← Back to Writeups
HTBN/AReversing

Behind the Curtain

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

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

  1. Embedded_bytecode_carving
  2. Luajit_upvalue_introspection
  3. Dynamic_operation_tracing
  4. 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

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

Flags

FlagLocationValue
flagREDACTED

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,...): swap A[i] and A[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.
</details>

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

signed by XESXOR