$ 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.
$ cat /etc/rate-limit
Rate limit reached (20 reads/hour per IP). Showing preview only — full content returns at the next hour roll-over.
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:
...
$ grep --similar