webfreehard

gas-giant

b01lersc

Task: a Jupyter-like SPA (b01lerLite) that renders base64-encoded notebooks through a Pyodide worker, with an admin bot holding the flag in a cookie scoped to localhost. Solution: bypass worker output sanitization by calling `self.postMessage` directly from Python in Pyodide, make the main thread render attacker-controlled `text/html` through `dangerouslySetInnerHTML` (trusted defaults to true), and feed the bot a http://localhost:3000/render?d=... URL so the cookie is same-origin; the XSS fetches it to a webhook.

$ ls tags/ techniques/
xss_via_dangerously_set_inner_htmlpyodide_worker_post_message_bypasscross_origin_bot_via_localhost_redirectadmin_bot_cookie_exfiltrationipynb_zod_schema_bypass

$ cat /etc/rate-limit

Rate limit reached (20 reads/hour per IP). Showing preview only — full content returns at the next hour roll-over.

gas-giant — b01lers CTF 2026

Description

b01lerLite — a minimal Jupyter clone. Submit a notebook URL and our admin will open it for you. Don't steal their cookies.

POST /report {"url": "..."} — puppeteer opens the URL with the flag cookie and clicks the first Run button.

Given: full source of a React + Express app. A notebook (.ipynb) is encoded in base64 and rendered at /render?d=<b64>. Python runs client-side in a Pyodide Web Worker. There's a /report endpoint that drives a puppeteer bot; the bot holds the flag in document.cookie with domain=localhost.

Goal: make the bot exfiltrate its own cookie to us.

Analysis

Three layers of defence, all of them subtly broken.

Layer 1 — worker output is sanitized

src/lib/pyodideWorker.mjs monkey-patches the kernel's output callbacks before running user code:

ipykernel.displayDataCallback = (data, metadata, transient) => { // replace whatever Python produced with a harmless plain-text string postMessage({ ..., data: { 'text/plain': 'output disabled' } }); }; ipykernel.publishExecutionResult = (prompt, data, metadata) => { /* same */ };

So IPython.display.HTML(...) or display({'text/html': ...}) can never reach the main thread — the worker rewrites data before posting.

But the patch is applied only to the ipykernel-level callbacks. The Worker's own self.postMessage is still reachable, and Pyodide exposes it to Python via the js module:

from js import self as _self # WorkerGlobalScope _self.postMessage(arbitrary_msg) # goes straight to main thread

The main-thread listener in JupyterNotebook.tsx doesn't check the shape or origin of the message — it only filters e.data.id !== currId. If we match currId, our fabricated output object is treated as if the kernel produced it.

...