Behind the Curtain
Behind the Curtain
Platform: Uiuctf 2026 | Category: Reversing | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-08-08 | Status: Solved Techniques: embedded_bytecode_carving, luajit_upvalue_introspection, dynamic_operation_tracing, reversible_circuit_inversion
Summary
Task: A stripped x86-64 ELF embeds LuaJIT bytecode and a virtualized finite-field flag checker. Solution: Carve and inspect the bytecode, invert finale, trace its fixed F_257 circuit, and replay all operations backward.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
UIUCTF 2026| ID:20260808_uiuc2026_behind_the_curtain - Tags: elf, finite_field, luajit, wasm2lua, virtualized_checker
- Indicators: stripped x86-64 ELF containing LuaJIT runtime strings, embedded chunk name @curtain, wasm2lua export named finale, input-independent helper call schedule, 72-element state over F_257
- Source:
20260808_uiuc2026_behind_the_curtain.md
Foothold
Vulnerability / Misconfiguration
- Embedded_bytecode_carving
- Luajit_upvalue_introspection
- Dynamic_operation_tracing
- Reversible_circuit_inversion
<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
- embedded_bytecode_carving
- luajit_upvalue_introspection
- dynamic_operation_tracing
- reversible_circuit_inversion
- Tags: elf, finite_field, luajit, wasm2lua, virtualized_checker
Original Writeup
<details><summary>Click to expand original content</summary>Description
Sparkle has reserved the stage for one final performance. Every mask hides another stage, and every actor moves with the whole cast. Find the face that earns the audience's applause.
The supplied stripped ELF accepts one line and prints an applause message only for the correct input. The objective is to recover that input despite several layers of LuaJIT, wasm2lua, and virtualized finite-field logic.
Analysis
Carving the embedded LuaJIT layer
Initial inspection identified a 64-bit PIE ELF containing LuaJIT 2.1 runtime strings and the chunk name @curtain. The embedded bytecode starts at file offset 442592; carving 131403 bytes produces a clean chunk containing 115 prototypes:
from pathlib import Path
elf = Path("sparkle").read_bytes()
Path("curtain_clean.ljbc").write_bytes(elf[442592:442592 + 131403])
Disassembling it with the locally built LuaJIT and decompiling it showed wasm2lua-generated code. The module exports a function named finale. Its bytecode, preserved in func6_bytecode.txt, rejects any length other than 72 and compares 72 transformed words with a data segment at linear-memory offset 4096.
tools_luajit/src/luajit -bl curtain_clean.ljbc > curtain_bytecode.txt
The static inversion in solve2.lua reverses the 16 finale rounds and their word permutation. This yields required_u16.bin: 72 little-endian field values (144 storage bytes) that the outer layer must place in memory before calling finale(0, 72). Thus the apparent 72-byte requirement belongs to finale, not directly to stdin.
Exposing the outer checker
LuaJIT debug upvalue introspection made the otherwise hidden runtime objects reachable. The important pattern is:
local bc = assert(io.open("curtain_clean.ljbc", "rb")):read("*a")
local checker = assert(loadstring(bc, "@curtain"))()
local _, vm = debug.getupvalue(checker, 1)
local _, objects = debug.getupvalue(vm, 3)
local finale = objects[349][1]
local state = objects[349][10]
Dynamic tracing established that state is a 72-element vector over the prime field F_257. The wrapper copies exactly 70 stdin bytes into the first 70 cells and initializes the final two cells to 256. It then executes a fixed schedule; changing the input changes state values but not helper identities or arguments.
The complete trace is in array_ops.txt. After removing initialization and non-mutating helper calls, it contains 3,432 reversible H3/H4/H5 operations. Chosen-state experiments in field_probe.txt, followed by checks against zero, one, ramp, quadratic, and high-valued traces, identify the helpers exactly. All arithmetic below is modulo 257:
- H3
(t,s,clo,chi,klo,khi,_):A[t] <- A[t] + (clo + 256*chi)*A[s] + (klo + 256*khi). - H4
(t,i,j,c1,c2,klo,khi):A[t] <- A[t] + c1*A[i]*A[j] + c2*A[i] + (klo + 256*khi). - H5
(i,j,...): swapA[i]andA[j].
H3 and H4 never target either source cell referenced by the same operation. Their inverses therefore require only subtraction; no nonlinear equation solving is necessary. The checker is a triangular reversible circuit rather than a hash.
Solution
Parse the fixed H3/H4/H5 schedule, initialize the state from the 72 values in required_u16.bin, and replay the schedule backward. The essential part of solve_outer.py is:
P = 257
def reverse(out, ops):
a = list(out)
for helper, x in reversed(ops):
if helper == 5:
i, j = x[:2]
a[i], a[j] = a[j], a[i]
elif helper == 3:
t, s, clo, chi, klo, khi, _ = x
c, k = clo + 256 * chi, klo + 256 * khi
a[t] = (a[t] - c * a[s] - k) % P
elif helper == 4:
t, i, j, c1, c2, klo, khi = x
k = klo + 256 * khi
a[t] = (a[t] - c1 * a[i] * a[j] - c2 * a[i] - k) % P
return a
required = low_u16(ROOT / "required_u16.bin")
initial = reverse(required, load_ops())
assert initial[70:] == [256, 256]
assert all(0 <= value <= 255 for value in initial[:70])
candidate = bytes(initial[:70])
assert forward(candidate, load_ops()) == required
(ROOT / "candidate.bin").write_bytes(candidate)
The recovered padding [256, 256] is a strong structural check. solve_outer.py also forward-replays a known 70-byte sample against mapped_input.bin, reverses that mapping back to the sample, and finally verifies that the recovered candidate maps exactly to required_u16.bin.
Run the complete recovery and test the resulting binary input against the original amd64 ELF:
python3 solve_outer.py docker run --rm -i --platform linux/amd64 \ -v "$PWD":/w -w /w ubuntu:22.04 ./sparkle < candidate.bin
The original checker responds:
The audience erupts in applause.
Reproduction Artifacts
notes.md— investigation history and final proven claims.func6_bytecode.txt,solve2.lua,compare_forward.lua— finale disassembly, inversion, and differential validation.field_probe.txt,array_ops.txt— chosen-state formula evidence and complete fixed operation schedule.solve_outer.py— final forward/reverse circuit implementation.candidate.bin— recovered 70-byte input used for the successful ELF verification.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR