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/
$ 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.
...