# How I accidentally set a DOMPurify 0 day as a national high school olympiad qualification challenge

Table of Contents

The Singapore National Cybersecurity Olympiad preliminary round has recently just concluded.

For the contest, I set 2 web challenges. Here’s how I accidentally set a 0 day DOMPurify vulnerability as a challenge.

The Challenge

Not sure if I can share the entire source, but the challenge revolves around this code snippet

const clean = DOMPurify.sanitize(content);
res.send("<div><noscript>"+clean+"</noscript></div>")

Disclaimer: I found this behaviour somewhere around November of 2025, and assumed it was intended behavior, and that the exploitability of this stemmed from the misuse of dompurify (lack of context) rather than an actual vulnerability. Therefore, I did not report this vulnerability at that point in time (trust me, I would’ve collected that CVE if I thought it could’ve been one). I only realised this was intended behaviour after the contest when I was notified of the CVE that had been released 2 days before the contest.

Oops…

Anyways, fast forward to the day of the contest when I received this message from the other web author - @halogen

Initial Message From Halogen

At this time, I was busy playing poker at a friend’s place. Quickly however, I whipped out my laptop to check if I had indeed committed a whoopsie :p

After updating dompurify and spinning up the challenge…yep, it seemed like the latest dompurify patch fixed my challenge. Thankfully, the docker image we were deploying at that time still had the outdated dompurify version!

Therefore, I didn’t think much of it until later.

Preliminary investigations

The CVE in question is CVE-2026-0540, a vulnerability in the sanitizer caused by a lack of rawtext elements in the SAFE_FOR_XML regex.

Before we go further, let me show you my intended solution to the challenge:

<div id="</noscript><img src=x onerror=eval(atob(''))>"></div>

Now let us try to see why this works

noscript…or yesscript?

noscript is a HTML tag that defines alternate content to be displayed when the user’s browser has javascript disabled. The way the browser handles this is by treating content starting from a <noscript> tag as literal raw text data till it hits another </noscript> tag. (in my personal opinion, the name “noscript” is extremely misleading to beginner developers who might use it as an XSS prevention, but webdev might be dead to claude anyways so…).

Actual analysis

Here’s how the bug actually worked.

DOMPurify mistakingly assumes that attribute boundaries are preserved across different HTML context.
What does one mean by this? Well, essentially DOMPurify assumes that things within an attribute stay within an attribute which is really really really not the case. For elements like noscript, xmp, iframe, noembed and noframes.

The HTML tokenizer treats these contexts differently as explained above depending on what the context is. Consider this snippet

<div kek="</noscript>">hehe</div>

This is perfectly fine behaviour. The attribute “kek” has the value of "". There were no opening noscript tags and therefore the HTML parser does not register a noscript context.

Now consider this snippet

<noscript><div kek="</noscript>normal_html">hehe</div>

Now this is a very different scenario. The HTML tokenizer sees the opening <noscript> tags and proceeds to treat everything after it as raw text. Therefore, <div kek=” becomes raw text. Then, it sees a </noscript> closing tag and exits out of the noscript context. Therefore, everything after this (normal_html) is parsed as, well, normal HTML.

Here is the code snippet responsible

/* Work around a security issue with comments inside attributes */
if (
SAFE_FOR_XML &&
regExpTest(/((--!?|])>)|<\/(style|title|textarea)/i, value)
) {
_removeAttribute(name, currentNode);
continue;
}

Interestingly enough, you can see remnants of a patch of (what I presume) was another XSS vulnerability involving HTML comments! This is a good example of why looking at past commits can be so insanely valuable in finding new bugs.

Anyhoo, the attribute guard fails to check for the noscript tags. The patch is as follows

if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|title|xmp|textarea|noscript|iframe|noembed|noframes)/i, value)) {

As you can see, the proper checks are now in place.

Visualization of what happens

So what actually happens in this challenge?

Well, the challenge involves concatenating a sanitized snippet into a noscript context

res.send("<div><noscript>"+clean+"</noscript></div>")

Consider the intended payload <div id="</noscript><img src=x onerror=eval(atob(''))>"></div>.

When this gets injected into the response, it becomes

<div><noscript><div id="</noscript><img src=x onerror=eval(atob(''))>"></div></noscript></div>

See what happens? We escape out of the initial noscript. The DOM looks like this

<div>
<noscript><div id="</noscript>
<img src=x onerror=eval(atob(''))>">
</div>
</noscript>
</div>

Our img tag is rendered fully and therefore we achieve XSS! Delicious…

In defense of myself + extra vibecoding thoughts

Now you may ask: if you found this all the way back in November why did you not report it? My defense is that I did not actually realise this was a vulnerability at all. I assumed that this was intended behaviour of DOMPurify and that this would be classified as “unintended usage due to lack of context given to DOMPurify (a known issue)” had I reported it.

I do not believe that using DOMPurify this way is smart.

Also it was probably 2am when I found this bug :p

RIP my CVE I guess…

Anyways, an interesting note is that this challenge was created around how vibecoding could introduce vulnerabilities to your code. The context was a webapp that was 100% vibecoded. Initially, I had prompted claude to “create a webapp for my ctf chall etc…don’t introduce the vulnerability yet, I’ll do this myself”. Surprisingly, it actually wrote the challenge (including the vulnerable code!) with a comment that said ’# Safe sanitization code for now, the user will introduce the vulnerability later’

Perhaps this should’ve told me that this was an actual vulnerability but I guess I was just too oblivious.

Conclusion

Anyhow, I hope you enjoyed this small analysis of the CVE. Hopefully I actually report bugs I find next time (or maybe not).

My avatar

Hopefully this was an interesting read! Feel free to check out my other posts or contact me via the social links in the footer.


More Posts