1. Challenge Overview
We’re given a Rust binary called reflection. When run, it always prints a panic message regardless of any arguments:
$ ./reflectionthread 'main' panicked at src/main.rs:2:5:Come close to me and I'll make you regret it.note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ ./reflection athread 'main' panicked at src/main.rs:2:5:Come close to me and I'll make you regret it.No matter what value you pass, the output is identical. Looking at reflection::main in a decompiler, it unconditionally calls panic!(). There’s no flag validation logic anywhere in .text, and it’s unclear how the binary even reads input.


Two questions arise:
- Where is the validation logic?
- How does the binary read the flag?
If there’s no validation code in .text, the verification must be hidden in metadata. In Rust, panic!() triggers stack unwinding, which reads .eh_frame — a section that can contain arbitrary DWARF bytecode. That’s where we look next.
Note: As we’ll see later, the flag is actually passed as a command-line argument:
./reflection "lactf{...}". The exact logic can be understood by analyzing the DWARF expressions, which directly readargcandargvfrom Rust’s internal global variables.
2. Background — Stack Unwinding, .eh_frame
2.1 Rust Panic: Unwind vs. Abort
Ref: https://os.phil-opp.com/freestanding-rust-binary/
When a Rust program hits panic!(), one of two strategies — configured at compile time via Cargo.toml — is executed:
Abort mode (panic = "abort"): The process terminates immediately. No stack cleanup, no destructor calls. Simpler and produces smaller binaries, commonly used in embedded and bare-metal environments.
[profile.release]panic = "abort" # Just kill the processUnwind mode (the default): The runtime walks the call stack backwards, calling destructors for each live local variable, and allows recovery via catch_unwind:
use std::panic;
let result = panic::catch_unwind(|| { panic!("something went wrong");});// result is Err — the program continues runningUnwinding requires metadata for each stack frame — specifically the .eh_frame section containing DWARF Call Frame Information. This metadata tells the unwinder how to restore registers and locate the previous frame.
This challenge binary uses the default unwind mode. This is confirmed by:
- The panic output includes a backtrace hint (
RUST_BACKTRACE=1), which only works with unwinding - The binary contains a large
.eh_framesection _Unwind_RaiseExceptionfromlibgcc_s.so.1is called during the panic
This is the crux of the entire challenge: panic!() triggers unwinding, unwinding evaluates .eh_frame DWARF expressions, and the challenge author has hidden a complete validation program inside those expressions!
2.2 How Unwinding Works: The 2-Phase Mechanism
Ref: gcc/libgcc/unwind.inc (https://github.com/gcc-mirror/gcc/tree/master/libgcc)
When a Rust panic! occurs, the unwinder walks the call stack backwards, restoring saved registers for each frame and executing cleanup code. On Linux x86-64, the unwinder lives in libgcc_s.so.1. The entry point is _Unwind_RaiseException(), which operates in two phases:
// Summary of libgcc/unwind.inc_Unwind_RaiseException(exc) { // Phase 1 — Search: // Walk frames upward looking for a handler (catch_unwind). // Nothing is modified yet. Expressions execute but results are discarded. while (1) { uw_frame_state_for(&context, &fs); // Find FDE, parse CFI if (fs.personality) personality(..._UA_SEARCH_PHASE...); // Query: cleanup? catch? uw_update_context(&context, &fs); // Apply CFI rules }
cur_context = this_context; // ★ Reset to initial state
// Phase 2 — Cleanup: // Walk frames again from the beginning. // At each frame: restore registers, run cleanup code. // When the handler frame is reached, jump to it. while (1) { uw_frame_state_for(&context, &fs); if (fs.personality) personality(..._UA_CLEANUP_PHASE...); uw_update_context(&context, &fs); // ★ Execute expressions, determine RIP }}The key function is uw_update_context(). It reads CFI rules from .eh_frame and computes previous-frame register values. When a rule contains a DWARF expression, it calls execute_stack_op() — a stack-based bytecode VM inside libgcc that supports arithmetic, memory access, control flow, and register reads. Since both phases walk the same frames, every DWARF expression is executed twice — once per phase. However, Phase 1 resets the context afterward, so only the Phase 2 results matter.
2.3 .eh_frame Section: CIE, FDE
Ref: https://refspecs.linuxfoundation.org/LSB_3.0.0/LSB-Core-generic/LSB-Core-generic/ehframechpt.html
The .eh_frame section must contain one or more Call Frame Information (CFI) records.
The number of records is determined by the section size specified in the section header.
Each CFI record consists of:
- One Common Information Entry (CIE)
- Followed by one or more Frame Description Entries (FDEs)
Both CIEs and FDEs must be aligned to an address-unit-sized boundary.
CIE (Common Information Entry) — a template shared by multiple functions:
| Field | Usage |
|---|---|
Augmentation String | Feature flags: "zR" (basic), "zPLR" (with personality function) |
Code Alignment Factor | Instruction alignment factor (1 on x86-64) |
Data Alignment Factor | Stack slot factor (-8 on x86-64) |
Return Address Register | Return address register (16 = RIP) |
Initial Instructions | Default CFI rules inherited by all FDEs |
FDE (Frame Description Entry) — one per function:
| Field | Usage |
|---|---|
PC Begin | Function start address |
PC Range | Function size (bytes) |
Call Frame Instructions | CFI bytecode describing stack frame changes |

2.4 Two Bytecode Systems: DW_CFA vs. DW_OP
Ref: https://dwarfstd.org/doc/DWARF5.pdf
DWARF is a debugging information file format used by many compilers and debuggers to support source level debugging. It addresses the requirements of a number of procedural languages, such as C, C++, and Fortran, and is designed to be extensible to other languages. DWARF is architecture independent and applicable to any processor or operating system. It is widely used on Unix, Linux and other operating systems, as well as in stand-alone environments.
DWARF contains two distinct bytecode systems:
- DWARF Expressions (DW_OP_*) — a stack-based expression evaluation language
- Call Frame Instructions (DW_CFA_*) — a state machine that generates register recovery rules
| Aspect | DWARF Expression | DW_CFA Instruction |
|---|---|---|
| What it is | Stack-based expression evaluator | Call frame description language |
| Model | Stack-based virtual evaluator | State machine generating recovery rules |
| Purpose | Value/address computation | CFA & register recovery rules |
Most CFI instructions simply update rule tables describing how to recover the call frame state. However, certain DW_CFA instructions can embed DWARF expressions as operands:
| CFI Instruction | Meaning |
|---|---|
DW_CFA_def_cfa_expression | CFA is computed by evaluating expression E |
DW_CFA_expression(reg) | Expression E computes the address where register reg is saved |
DW_CFA_val_expression(reg) | Expression E computes the value of register reg |
When uw_update_context() (the unwinder function) encounters these instructions, it calls execute_stack_op() from libgcc/unwind-dw2.c — a stack-based DWARF VM supporting arithmetic, memory access, control flow, and register reads.
In this challenge, the author embedded a 12,888-byte validation program inside a DW_CFA_val_expression. This is completely valid DWARF!
3. Finding the Hidden Code
3.1 Scanning for Expressions
Ref: https://man7.org/linux/man-pages/man1/readelf.1.html
Using readelf’s --debug-dump=frames option:
$ readelf --debug-dump=frames ./reflection | grep expression
DW_CFA_def_cfa_expression (DW_OP_breg7 (rsp): 8; ...) DW_CFA_val_expression: r16 (rip) (DW_OP_GNU_encoded_addr: ... (Unknown location op 0x5)) DW_CFA_val_expression: r2 (rcx) (DW_OP_const8u: 342697) DW_CFA_val_expression: r12 (r12) (DW_OP_const8u: 4469372305074626438) DW_CFA_val_expression: r16 (rip) (DW_OP_GNU_encoded_addr: ... (Unknown location op 0x1)) DW_CFA_val_expression: r13 (r13) (DW_OP_const8u: 5860663257725425333) DW_CFA_expression: r8 (r8) (DW_OP_GNU_encoded_addr: fmt:42 addr:0000000000000024)We can see 7 DWARF expressions across 6 FDEs. Most FDEs in the binary have only simple CFI rules. These 7 are the suspicious ones.
Notice that readelf fails to parse the two r16 (rip) expressions and reports them as (Unknown location op). This is because they use DW_OP_GNU_encoded_addr (0xF1) with funcrel|uleb128 encoding, which readelf doesn’t support — it misparses the bytes after 0xF1 and gives up.
The actual expressions are 115 bytes and 12,888 bytes — far larger than typical CFI. The raw bytes need to be extracted by other means.
3.2 GDB Debugging — Extracting Expressions
Ref: execute_stack_op() in gcc/libgcc/unwind-dw2.c (https://github.com/gcc-mirror/gcc/tree/705d87ba8e987cb8e0ca8b6d400c0aff8739eb23/libgcc)
Since readelf can’t fully parse these expressions, we need to extract them at runtime.
The key insight: libgcc’s execute_stack_op() receives the raw expression bytes as arguments.
static _Unwind_Wordexecute_stack_op(const unsigned char *op_ptr, // expression start const unsigned char *op_end, // expression end struct _Unwind_Context *context, _Unwind_Word initial)By setting a breakpoint on this function, we can inspect every DWARF expression as it executes:
$ gdb ./reflection(gdb) set args "AAAA"(gdb) start # Load libgcc_s.so.1(gdb) break execute_stack_op # DWARF expression VM entry point(gdb) continueEach time the breakpoint hits, op_end - op_ptr gives the exact expression size, and x/<size>xb $rdi dumps the raw bytes. A Python GDB script (reflection_dump.py) automates this: on each call it reads raw bytes via inferior.read_memory(), saves them to .bin files, and records the context register state.
Due to the 2-phase unwinding, each expression executes twice (Phase 1 + Phase 2). The script deduplicates by op_ptr address and keeps only Phase 2 executions:
reflection_dump.py
246 collapsed lines
"""reflection_dump.pyLACTF reflection - DWARF expression dump + analysis
Usage: gdb -x reflection_dump.py ./reflectionOutput: expr_p{1,2}_N_{size}b.bin (Phase_CallNumber_Size)
Examples: expr_p1_1_9b.bin Phase 1, call #1, 9 bytes (RCX constant) expr_p1_3_115b.bin Phase 1, call #3, 115 bytes (trigger) expr_p2_7_12888b.bin Phase 2, call #7, 12888 bytes (main VM)"""
import gdbimport structimport os
FLAG_INPUT = "lactf{si" + "A" * 38 + "}" # 8 + 38 + 1 = 47OUTDIR = "./reflection_exprs"PRINT_LIMIT = 256 # Max bytes for terminal output (truncates to head/tail if exceeded)PRINT_HEAD = 128PRINT_TAIL = 64
call_num = 0phase = 1phase1_ctx = Noneseen = {} # op_ptr -> (call_num, size, data, ctx_addr) deduplication map
def read_bytes(addr, size): """Read raw bytes from memory""" inferior = gdb.selected_inferior() return bytes(inferior.read_memory(addr, size))
def get_reg(ctx_addr, idx): """Return context register (by_value, value)""" try: bv = int(gdb.parse_and_eval( f"((struct _Unwind_Context*){ctx_addr:#x})->by_value[{idx}]")) val = int(gdb.parse_and_eval( f"(unsigned long)((struct _Unwind_Context*){ctx_addr:#x})->reg[{idx}]")) return bv, val except: return -1, 0
def get_cfa(ctx_addr): try: return int(gdb.parse_and_eval( f"(unsigned long)((struct _Unwind_Context*){ctx_addr:#x})->cfa")) except: return 0
def hexdump(data, addr, limit=None): """Pretty hex dump. Shows only head/tail if limit is exceeded.""" if limit and len(data) > limit: # Head section for i in range(0, PRINT_HEAD, 16): chunk = data[i:i+16] hex_str = " ".join(f"{b:02x}" for b in chunk) ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) print(f" {addr+i:012x} {hex_str:<48s} {ascii_str}") print(f" ... ({len(data) - PRINT_HEAD - PRINT_TAIL} bytes omitted) ...") # Tail section start = len(data) - PRINT_TAIL start = (start // 16) * 16 for i in range(start, len(data), 16): chunk = data[i:i+16] hex_str = " ".join(f"{b:02x}" for b in chunk) ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) print(f" {addr+i:012x} {hex_str:<48s} {ascii_str}") else: for i in range(0, len(data), 16): chunk = data[i:i+16] hex_str = " ".join(f"{b:02x}" for b in chunk) ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) print(f" {addr+i:012x} {hex_str:<48s} {ascii_str}")
def save_bin(data, filename): """Save as binary file""" path = os.path.join(OUTDIR, filename) with open(path, "wb") as f: f.write(data) return path
def save_context(ctx_addr, filename): """Save context registers as JSON-like text""" regs = {} for idx, name in [(2, "rcx"), (8, "r8"), (12, "r12"), (13, "r13"), (16, "rip")]: bv, val = get_reg(ctx_addr, idx) regs[name] = {"by_value": bv, "value": val} # Dereference if by_value=0 and value is non-zero if bv == 0 and val != 0: try: deref = int(gdb.parse_and_eval(f"*(unsigned long*){val:#x}")) regs[name]["deref"] = deref except: pass regs["cfa"] = get_cfa(ctx_addr)
path = os.path.join(OUTDIR, filename) with open(path, "w") as f: for name, info in regs.items(): if isinstance(info, dict): bv = info["by_value"] val = info["value"] deref = info.get("deref") line = f"{name}: by_value={bv} val={val:#018x}" if deref is not None: line += f" *val={deref:#018x}" f.write(line + "\\n") else: f.write(f"{name}: {info:#018x}\\n") return path
class ExprBreakpoint(gdb.Breakpoint): def stop(self): global call_num, phase, phase1_ctx, seen
try: op_ptr = int(gdb.parse_and_eval("(unsigned long)$rdi")) op_end = int(gdb.parse_and_eval("(unsigned long)$rsi")) ctx = int(gdb.parse_and_eval("(unsigned long)$rdx")) initial = int(gdb.parse_and_eval("(unsigned long)$rcx")) except: return True
call_num += 1 size = op_end - op_ptr
# Phase detection if call_num == 1: phase1_ctx = ctx phase = 1 elif ctx != phase1_ctx and phase == 1: phase = 2
# Read raw bytes data = read_bytes(op_ptr, size)
# Dedup check: overwrite if same op_ptr (Phase 2 takes precedence) is_new = op_ptr not in seen seen[op_ptr] = { "call_num": call_num, "phase": phase, "size": size, "data": data, "ctx": ctx, "initial": initial, "op_ptr": op_ptr, }
# Output dup_tag = "" if is_new else " [overwrite]" print() print(f"{'='*60}") print(f" #{call_num} | Phase {phase} | {size} bytes{dup_tag}") print(f"{'='*60}")
# Register summary for idx, name in [(2, "RCX"), (8, "R8"), (12, "R12"), (13, "R13")]: bv, val = get_reg(ctx, idx) tag = "val" if bv == 1 else "ptr" if bv == 0 else "?" extra = "" if bv == 0 and val != 0: try: deref = int(gdb.parse_and_eval(f"*(unsigned long*){val:#x}")) extra = f" → {deref:#x}" except: pass print(f" {name:>3}: [{tag}] {val:#x}{extra}") print(f" CFA: {get_cfa(ctx):#x}") print()
# Hex dump hexdump(data, op_ptr, limit=PRINT_LIMIT)
# Extra info for 115-byte expressions if size == 115: bv_rcx, val_rcx = get_reg(ctx, 2) if bv_rcx == 1 and val_rcx != 0: print() try: argc = int(gdb.parse_and_eval(f"*(unsigned long*){val_rcx - 89:#x}")) argv_ptr = int(gdb.parse_and_eval(f"*(unsigned long*){val_rcx - 81:#x}")) argv1 = int(gdb.parse_and_eval(f"*(unsigned long*){argv_ptr + 8:#x}")) argv1_s = gdb.execute(f"x/s {argv1:#x}", to_string=True).split(":", 1)[1].strip() print(f" argc={argc}, argv[1]={argv1_s}") except: pass
print(f"{'='*60}")
return False # Auto-continue
def main(): os.makedirs(OUTDIR, exist_ok=True) # Clean up existing files for f in os.listdir(OUTDIR): os.remove(os.path.join(OUTDIR, f))
print(f"\\n[*] LACTF reflection expression dumper") print(f"[*] Input: {FLAG_INPUT}") print(f"[*] Output: {OUTDIR}/") print()
gdb.execute(f'set args "{FLAG_INPUT}"') gdb.execute("set pagination off") gdb.execute("set confirm off") gdb.execute("start")
bp = ExprBreakpoint("execute_stack_op") bp.silent = True
print(f"\\n[*] Continuing to panic...\\n") gdb.execute("continue")
# Save only deduplicated expressions print(f"\\n{'='*60}") print(f" {call_num} total calls, {len(seen)} unique expressions") print(f" Output directory: {OUTDIR}/") print()
# Sort by execution order (call_num) sorted_exprs = sorted(seen.values(), key=lambda x: x["call_num"])
for i, info in enumerate(sorted_exprs, 1): size = info["size"] bin_name = f"expr_{i}_{size}b.bin" ctx_name = f"expr_{i}_{size}b_ctx.txt"
# Save .bin save_bin(info["data"], bin_name)
# Save context (Phase 2 values) save_context(info["ctx"], ctx_name)
# Description labels labels = {9: "constant assign", 4: "address encoding", 115: "1st validation", 12888: "2nd validation VM"} label = labels.get(size, f"{size}b")
fpath = os.path.join(OUTDIR, bin_name) print(f" {bin_name:>30s} ({os.path.getsize(fpath):>6d} bytes)" f" #{info['call_num']:>2d} @{info['op_ptr']:#x} {label}")
print(f"{'='*60}")
main()When we dump with a valid 47-byte input (lactf{si + 38*A + }) that passes the first validation, 6 expressions appear in Phase 2 execution order:
# Size op_ptr Address Description1 9 bytes 0x55555559e567 val_expression(RCX) = constant assignment2 4 bytes 0x5555555a4ea9 expression(R8) = address encoding3 115 bytes 0x55555559cbf1 val_expression(RIP) — 1st validation4 9 bytes 0x5555555a1ba8 val_expression(R12) = constant assignment5 9 bytes 0x5555555a4e10 val_expression(R13) = constant assignment6 12888 bytes 0x5555555a1bb5 val_expression(RIP) — 2nd validation VMExpressions #4–#6 only appear when the 115-byte pre-check (#3) passes.
If the input is incorrect, unwinding takes the normal return path and these expressions never execute. In that case, the process completes with a total of 6 calls (3 per Phase x 2 Phases, expressions #1–#3 only).
3.3 Expression Chain
In summary, the entire logic consists of 6 expressions executing in sequence, where earlier expressions set register values that later expressions read:
❶ val_expression(RCX) = 0x53AA9 → Base address for accessing ARGV globals❷ expression(R8) = 0x555555590d94 → .text region address. Dereferencing yields 0x0f10478b4808578b (code bytes repurposed as hash constant / XOR key)❸ val_expression(RIP) [115B] → 1st validation: argc, strlen, hash, '}' check Pass → redirects RIP → ❹❺❻ execute Fail → normal return → ❹❺❻ skipped❹ val_expression(R12) = 0x3E06666E84F11F86 → Constant key for the 2nd VM❺ val_expression(R13) = 0x5155422E88E856B5 → Constant key for the 2nd VM❻ val_expression(RIP) [12,888B] → 2nd validation: 38 blocks verify the remaining flag❹❺❻ belong to the same FDE, so all three expressions execute together when the unwinder processes that FDE. If ❸’s first validation fails, this FDE is never reached and ❹❺❻ don’t execute.
In my case, I noticed that passing a string like lactf{test} stopped at ❸. I dumped those expression bytes, analyzed the logic, and figured out the initial input validation conditions.
3.4 The 115-Byte Pre-Check — How Input Is Read
Here’s the Phase 2 dump of expression ❸ (115 bytes):
============================================================ #9 | Phase 2 | 115 bytes [overwrite]============================================================ RCX: [val] 0x5555555a7aa9 R8: [ptr] 0x555555590d94 → 0xf10478b4808578b R12: [ptr] 0x7fffffffdf80 → 0x0 R13: [ptr] 0x7fffffffdf88 → 0x1000 CFA: 0x7fffffffe050
55555559cbf1 f1 41 06 06 08 ff 1a 3d 1c 0a 05 0a 16 52 16 1c .A.....=.....R.. 55555559cc01 16 13 12 06 32 2e 28 56 00 23 08 06 23 08 06 12 ....2.(V.#..#... 55555559cc11 30 16 12 06 08 ff 1a 30 29 28 09 00 23 01 16 23 0......0)(..#..# 55555559cc21 01 16 2f ed ff 17 08 4b 27 08 64 2e 16 06 30 31 ../....K'.d...01 55555559cc31 1c 40 25 22 0e dc 66 8d a7 9d 87 cc 0c 33 24 27 .@%"..f......3$' 55555559cc41 58 2e 21 16 31 1c 06 08 ff 1a 08 7d 2e 21 28 08 X.!.1......}.!(. 55555559cc51 00 f1 41 e1 b7 0e 2f 03 00 38 1c 06 2f 04 00 13 ..A.../..8../... 55555559cc61 38 1c 06 8..This reveals how the binary reads the flag. The pre-check expression begins by computing a relative address from RCX, which was set to 0x53AA9 by expression ❶. In GDB:
(gdb) x/gx (0x5555555a7aa9 - 89)0x5555555a7a50 <std::sys::args::unix::imp::ARGC.0>: 0x0000000000000002
(gdb) x/gx (0x5555555a7aa9 - 81)0x5555555a7a58 <std::sys::args::unix::imp::ARGV.0>: 0x00007fffffffe268
(gdb) set $argv = *(unsigned long*)(0x5555555a7aa9 - 81)(gdb) x/s *(char**)($argv + 8)0x7fffffffe516: "lactf{test}"The expression reads argv from the raw global variables that the Rust runtime stores internally. This confirms that the flag is passed via ./reflection "lactf{...}".
The DWARF opcodes showed the access patterns *(RCX-89) == 2 (checking that the count is 2) and *(*(RCX-81)+8) (following the pointer array to access the second element). This looked like argc/argv, and GDB confirmed it by showing the symbol names ARGC.0 and ARGV.0 at those addresses.
The pre-check validates four conditions through the 115-byte DWARF expression:
Block A — argc check (offset 0–24):
GNU_encoded_addr(funcrel, 6) → 0x3046 ; NOP padding byte addressderef → *(0x3046) & 0xFF = 0x66lit13; minus → 0x66 - 13 = 89 ; Compute offset to ARGCreg2 (RCX); minus → RCX - 89 = ARGC_addrderef → *(ARGC_addr) = argclit2; ne → (argc ≠ 2)?bra(+86) → if argc ≠ 2, bail outBlock B — argv[1] pointer (offset 25–33):
plus_uconst(8); deref → *(ARGC_addr + 8) = ARGV_ptrplus_uconst(8); deref → *(ARGV_ptr + 8) = argv[1] = flag_ptrBlock C — strlen loop (offset 34–52):
LOOP: dup; deref → current byte const1u(0xFF); and → mask to 8 bits lit0; eq → null check bra(+9) → if null, exit loop plus_uconst(1) → ptr++ swap; plus_uconst(1); swap → count++ skip(-19) → loop backBlock D — Triple validation (offset 53–96):
Check 1: (count ^ 75) != 100 → strlen ≠ 47 → failCheck 2: (first8_le + 0x0000FFFFFFFFFFFF) ^ 0x66643CED3C6B36E0 != R8 → hash failCheck 3: last_byte != 0x7D ('}') → failSuccess/failure branching (offset 97–114):
All checks pass → RIP = GNU_encoded_addr(funcrel, 236513) = 0x3CC21 → virtual frameAny check fails → RIP = *(CFA-8) = normal return address → exitThe hash check on the first 8 bytes can be reversed:
first8 + 0x0000FFFFFFFFFFFF = R8_val ^ 0x66643CED3C6B36E0 = 0x0f10478b4808578b ^ 0x66643CED3C6B36E0 = 0x69747B667463616B
first8 = 0x69747B667463616B - 0x0000FFFFFFFFFFFF = 0x69737B667463616C
LE → ASCII: 6C 61 63 74 66 7B 73 69 = "lactf{si"This confirms the flag prefix and tells us positions 0–7 are lactf{si.
4. Reversing the 12KB DWARF VM
4.1 Bytecode Extraction and Decoding
Ref: https://github.com/gcc-mirror/gcc/tree/705d87ba8e987cb8e0ca8b6d400c0aff8739eb23/libgcc
The main expression is the 12,888-byte val_expression(RIP) in the virtual frame FDE.
I dumped it in its entirety using the GDB dump script described earlier.
Then a custom decoder (dwarf_decoder.py) — based on execute_stack_op() from libgcc/unwind-dw2.c and libgcc/unwind-pe.h — disassembled it into 9,195 instructions.
dwarf_decoder.py
238 collapsed lines
#!/usr/bin/env python3"""DWARF Expression VM Decoder============================Decodes DW_OP_* opcodes from raw .eh_frame bytecode,including GNU extension DW_OP_GNU_encoded_addr (0xF1).
Based on libgcc/unwind-dw2.c execute_stack_op() and libgcc/unwind-pe.h
Usage: python3 dwarf_decoder.py <dwarf_vm.bin>"""import sys, structfrom collections import Counter
# x86-64 register name mappingREG_NAMES = { 0:"rax", 1:"rdx", 2:"rcx", 3:"rbx", 4:"rsi", 5:"rdi", 6:"rbp", 7:"rsp", 8:"r8", 9:"r9", 10:"r10", 11:"r11", 12:"r12", 13:"r13", 14:"r14", 15:"r15", 16:"rip"}
def regname(n): return REG_NAMES.get(n, f"r{n}")
# ═══════════════════════════════════════════════════# LEB128 readers# ═══════════════════════════════════════════════════
def read_uleb128(data, pos): result = shift = 0 while pos < len(data): b = data[pos]; pos += 1 result |= (b & 0x7f) << shift; shift += 7 if not (b & 0x80): break return result, pos
def read_sleb128(data, pos): result = shift = 0; b = 0 while pos < len(data): b = data[pos]; pos += 1 result |= (b & 0x7f) << shift; shift += 7 if not (b & 0x80): break if shift < 64 and (b & 0x40): result |= -(1 << shift) if result >= (1 << 63): result -= (1 << 64) return result, pos
# ═══════════════════════════════════════════════════# GNU encoded address decoder# ═══════════════════════════════════════════════════
MOD_NAMES = {0x00:"abs", 0x10:"pcrel", 0x20:"textrel", 0x30:"datarel", 0x40:"funcrel", 0x50:"aligned"}FMT_NAMES = {0x00:"ptr", 0x01:"uleb", 0x02:"u2", 0x03:"u4", 0x04:"u8", 0x09:"sleb", 0x0a:"s2", 0x0b:"s4", 0x0c:"s8"}
def decode_encoded_addr(data, pos): """Decode DW_OP_GNU_encoded_addr per libgcc/unwind-pe.h""" enc = data[pos]; pos += 1 fmt = enc & 0x0f; modifier = enc & 0x70; indirect = enc & 0x80
if fmt == 0x00: val = int.from_bytes(data[pos:pos+8], 'little'); pos += 8 elif fmt == 0x01: val, pos = read_uleb128(data, pos) elif fmt == 0x02: val = int.from_bytes(data[pos:pos+2], 'little'); pos += 2 elif fmt == 0x03: val = int.from_bytes(data[pos:pos+4], 'little'); pos += 4 elif fmt == 0x04: val = int.from_bytes(data[pos:pos+8], 'little'); pos += 8 elif fmt == 0x09: val, pos = read_sleb128(data, pos) elif fmt == 0x0a: val = int.from_bytes(data[pos:pos+2], 'little', signed=True); pos += 2 elif fmt == 0x0b: val = int.from_bytes(data[pos:pos+4], 'little', signed=True); pos += 4 elif fmt == 0x0c: val = int.from_bytes(data[pos:pos+8], 'little', signed=True); pos += 8 else: val = 0
enc_str = f"{MOD_NAMES.get(modifier,'?')}|{FMT_NAMES.get(fmt,'?')}" if indirect: enc_str += "|indirect" return val, enc_str, pos
# ═══════════════════════════════════════════════════# Main decoder# ═══════════════════════════════════════════════════
def decode_dwarf_expr(data, func_base=0x3cc20): """Decode DWARF expression bytecode into (offset, name, description, metadata) list.""" pos = 0; insns = []
while pos < len(data): off = pos; op = data[pos]; pos += 1
# DW_OP_lit0..lit31 (0x30..0x4f) if 0x30 <= op <= 0x4f: v = op - 0x30 insns.append((off, f"lit{v}", f"push {v}", [])) # DW_OP_reg0..reg31 (0x50..0x6f) elif 0x50 <= op <= 0x6f: r = op - 0x50 insns.append((off, f"reg{r}", f"push {regname(r)}", [])) # DW_OP_breg0..breg31 (0x70..0x8f) elif 0x70 <= op <= 0x8f: r = op - 0x70; sv, pos = read_sleb128(data, pos) insns.append((off, f"breg{r}", f"push {regname(r)}+({sv})", []))
elif op == 0x03: # DW_OP_addr v = int.from_bytes(data[pos:pos+8], 'little'); pos += 8 insns.append((off, "addr", f"push 0x{v:x}", [])) elif op == 0x06: insns.append((off, "deref", "pop a; push *(uint64*)a", [])) elif op == 0x08: v = data[pos]; pos += 1 insns.append((off, "const1u", f"push {v} (0x{v:02x})", [])) elif op == 0x09: v = struct.unpack_from('b', data, pos)[0]; pos += 1 insns.append((off, "const1s", f"push {v}", [])) elif op == 0x0a: v = int.from_bytes(data[pos:pos+2], 'little'); pos += 2 insns.append((off, "const2u", f"push {v} (0x{v:04x})", [])) elif op == 0x0b: v = int.from_bytes(data[pos:pos+2], 'little', signed=True); pos += 2 insns.append((off, "const2s", f"push {v}", [])) elif op == 0x0c: v = int.from_bytes(data[pos:pos+4], 'little'); pos += 4 insns.append((off, "const4u", f"push 0x{v:08x}", [])) elif op == 0x0e: v = int.from_bytes(data[pos:pos+8], 'little'); pos += 8 insns.append((off, "const8u", f"push 0x{v:016x}", [])) elif op == 0x0f: v = int.from_bytes(data[pos:pos+8], 'little', signed=True); pos += 8 insns.append((off, "const8s", f"push {v}", [])) elif op == 0x10: v, pos = read_uleb128(data, pos) insns.append((off, "constu", f"push {v} (0x{v:x})", [])) elif op == 0x11: v, pos = read_sleb128(data, pos) insns.append((off, "consts", f"push {v}", []))
elif op == 0x12: insns.append((off, "dup", "push TOS", [])) elif op == 0x13: insns.append((off, "drop", "pop TOS", [])) elif op == 0x14: insns.append((off, "over", "push stack[-2]", [])) elif op == 0x15: idx = data[pos]; pos += 1 insns.append((off, "pick", f"push stack[-{idx+1}]", [])) elif op == 0x16: insns.append((off, "swap", "swap TOS and TOS-1", [])) elif op == 0x17: insns.append((off, "rot", "rotate top 3: [a,b,c]->[c,a,b]", [])) elif op == 0x19: insns.append((off, "abs", "TOS = |TOS|", []))
elif op == 0x1a: insns.append((off, "and", "pop b,a; push a & b", [])) elif op == 0x1b: insns.append((off, "div", "pop b,a; push a / b", [])) elif op == 0x1c: insns.append((off, "minus", "pop b,a; push a - b", [])) elif op == 0x1d: insns.append((off, "mod", "pop b,a; push a % b", [])) elif op == 0x1e: insns.append((off, "mul", "pop b,a; push a * b", [])) elif op == 0x1f: insns.append((off, "neg", "TOS = -TOS", [])) elif op == 0x20: insns.append((off, "not", "TOS = ~TOS", [])) elif op == 0x21: insns.append((off, "or", "pop b,a; push a | b", [])) elif op == 0x22: insns.append((off, "plus", "pop b,a; push a + b", [])) elif op == 0x23: v, pos = read_uleb128(data, pos) insns.append((off, "plus_uconst", f"TOS += {v}", [])) elif op == 0x24: insns.append((off, "shl", "pop b,a; push a << b", [])) elif op == 0x25: insns.append((off, "shr", "pop b,a; push a >> b (unsigned)", [])) elif op == 0x26: insns.append((off, "shra", "pop b,a; push a >> b (signed)", [])) elif op == 0x27: insns.append((off, "xor", "pop b,a; push a ^ b", []))
elif op == 0x28: # DW_OP_bra (conditional branch) soff = int.from_bytes(data[pos:pos+2], 'little', signed=True); pos += 2 tgt = pos + soff insns.append((off, "bra", f"if TOS!=0 goto {tgt} (offset {soff:+d})", [("target", tgt)]))
elif op == 0x29: insns.append((off, "eq", "pop b,a; push (a == b)", [])) elif op == 0x2a: insns.append((off, "ge", "pop b,a; push (a >= b)", [])) elif op == 0x2b: insns.append((off, "gt", "pop b,a; push (a > b)", [])) elif op == 0x2c: insns.append((off, "le", "pop b,a; push (a <= b)", [])) elif op == 0x2d: insns.append((off, "lt", "pop b,a; push (a < b)", [])) elif op == 0x2e: insns.append((off, "ne", "pop b,a; push (a != b)", []))
elif op == 0x2f: # DW_OP_skip (unconditional branch) soff = int.from_bytes(data[pos:pos+2], 'little', signed=True); pos += 2 tgt = pos + soff insns.append((off, "skip", f"goto {tgt} (offset {soff:+d})", [("target", tgt)]))
elif op == 0x90: # DW_OP_regx v, pos = read_uleb128(data, pos) insns.append((off, "regx", f"push {regname(v)} (reg {v})", [])) elif op == 0x91: # DW_OP_fbreg sv, pos = read_sleb128(data, pos) insns.append((off, "fbreg", f"push CFA+({sv})", [])) elif op == 0x93: # DW_OP_piece v, pos = read_uleb128(data, pos) insns.append((off, "piece", f"piece({v} bytes)", [])) elif op == 0x9f: insns.append((off, "stack_value", "TOS is the value (not addr)", []))
elif op == 0xf1: # DW_OP_GNU_encoded_addr val, enc_str, pos = decode_encoded_addr(data, pos) if "funcrel" in enc_str: resolved = func_base + val insns.append((off, "GNU_encoded_addr", f"({enc_str}, {val}) -> 0x{resolved:x}", [])) elif "datarel" in enc_str: insns.append((off, "GNU_encoded_addr", f"({enc_str}, {val}) -> dbase+{val}", [])) else: insns.append((off, "GNU_encoded_addr", f"({enc_str}, {val})", []))
elif op == 0x00: insns.append((off, "nop", "", [])) else: insns.append((off, f"UNKNOWN_0x{op:02x}", "???", []))
return insns
# ═══════════════════════════════════════════════════# Main# ═══════════════════════════════════════════════════
def main(): if len(sys.argv) != 2: print(f"Usage: python3 {sys.argv[0]} <dwarf_vm.bin>") sys.exit(1)
data = open(sys.argv[1], "rb").read() print(f"Loaded {len(data)} bytes of DWARF expression bytecode\\n")
insns = decode_dwarf_expr(data, func_base=0x3cc20)
# Collect branch targets for annotation targets = {v for _, _, _, meta in insns for k, v in meta if k == "target"}
# Print disassembly print(f"{'OFF':>5} {'OPCODE':<22} {'DESCRIPTION'}") print("=" * 80) for off, name, desc, _ in insns: prefix = ">>>" if off in targets else " " print(f"{prefix}{off:5d} {name:<22} {desc}")
print(f"\\nTotal: {len(insns)} instructions decoded from {len(data)} bytes")
# Top opcodes counts = Counter(name.split('(')[0] for _, name, _, _ in insns) print(f"\\nTop opcodes:") for name, count in counts.most_common(20): print(f" {name}: {count}")
if __name__ == "__main__": main()The decoder handles all DW_OP opcodes used in the bytecode, including the GNU extension DW_OP_GNU_encoded_addr (0xF1) with various encoding formats (funcrel|uleb128, datarel|udata4, etc.).
$ python3 dwarf_decoder.py dwarf_vm.binLoaded 12888 bytes of DWARF expression bytecode
OFF OPCODE DESCRIPTION================================================================================ 0 GNU_encoded_addr (datarel|uleb, 8) → dbase+8 3 breg2 push rcx+(-81) 6 deref pop a; push *(uint64*)a 7 plus pop b,a; push a + b 8 deref pop a; push *(uint64*)a 9 const2u push 5377 (0x1501) ... 12884 GNU_encoded_addr (funcrel|uleb, 14000) → 0x402d0
Total: 9195 instructions decoded from 12888 bytes4.2 Constraint Summary
The 12,888-byte VM consists of 38 validation blocks. Each block returns either 0 or 1, and the results are ANDed together — if any single block fails, the final result is 0.
Each block’s validation works as follows: it expands a flag byte into binary, picks out only the contiguous groups of 1-bits, and records the length of each group. Zeros serve merely as separators, and listing the 1-group lengths from left to right produces the byte’s “signature”:
'e' = 01100101 0 [11] 00 [1] 0 [1] → signature = [2, 1, 1]
'_' = 01011111 0 [1] 0 [11111] → signature = [1, 5]The VM computes this signature for each flag character and compares it against a predetermined expected signature.
There are two types of blocks:
- Per-byte (31 blocks): Validates only a single character’s signature. Constrains each position independently.
- Cross-byte (7 blocks): Validates consecutive patterns at specific bit positions across multiple characters. This creates dependencies between positions, making it impossible to solve by analyzing one character at a time.
A total of 152 eq comparisons are performed across the 38 blocks. All constants are XOR-obfuscated with the R8 register value, and were extracted via pattern matching during decoding.
5. Solution

Now that we understand the structure of the decoded bytecode, we can write a solver based on it.
We’ve decomposed the problem into pieces small enough for an LLM to digest, and we’re no longer afraid of the ANTHROPIC MAGIC STRING.
”HEY CLAUDE, SOLVE THIS CHALLENGE”
solve.py
404 collapsed lines
#!/usr/bin/env python3"""LACTF 'reflection' exploit solver==================================Extracts constraints from DWARF Expression VM bytecode in .eh_frame,then recovers the flag via Simulated Annealing + Coordinate Descent.
Usage: python3 solves.py <dwarf_vm.bin> <vm_emulator.so>"""import struct, sys, ctypes, random, time, math
FLAG_PREFIX = "lactf{si"FLAG_LEN = 47
# ═══════════════════════════════════════════════════# 1. Bytecode loader + DWARF disassembler# ═══════════════════════════════════════════════════
def load_bytecode(path): with open(path, "rb") as f: return f.read()
def read_uleb128(data, pos): result = shift = 0 while pos < len(data): b = data[pos]; pos += 1 result |= (b & 0x7f) << shift; shift += 7 if not (b & 0x80): break return result, pos
def read_sleb128(data, pos): result = shift = 0; b = 0 while pos < len(data): b = data[pos]; pos += 1 result |= (b & 0x7f) << shift; shift += 7 if not (b & 0x80): break if shift < 64 and (b & 0x40): result |= -(1 << shift) if result >= (1 << 63): result -= (1 << 64) return result, pos
def parse_instructions(data): """Decode raw DWARF expression bytecode into (offset, mnemonic, operand) list.""" pos = 0; ops = [] while pos < len(data): off = pos; op = data[pos]; pos += 1 if 0x30 <= op <= 0x4f: ops.append((off, 'lit', op - 0x30)) elif 0x50 <= op <= 0x6f: ops.append((off, 'reg', op - 0x50)) elif 0x70 <= op <= 0x8f: _, pos = read_sleb128(data, pos); ops.append((off, 'breg', op - 0x70)) elif op == 0x06: ops.append((off, 'deref', None)) elif op == 0x08: v = data[pos]; pos += 1; ops.append((off, 'const1u', v)) elif op == 0x09: v = struct.unpack_from('b', data, pos)[0]; pos += 1; ops.append((off, 'const1s', v)) elif op == 0x0a: v = int.from_bytes(data[pos:pos+2], 'little'); pos += 2; ops.append((off, 'const2u', v)) elif op == 0x0e: v = int.from_bytes(data[pos:pos+8], 'little'); pos += 8; ops.append((off, 'const8u', v)) elif op == 0x10: v, pos = read_uleb128(data, pos); ops.append((off, 'constu', v)) elif op == 0x12: ops.append((off, 'dup', None)) elif op == 0x13: ops.append((off, 'drop', None)) elif op == 0x14: ops.append((off, 'over', None)) elif op == 0x15: v = data[pos]; pos += 1; ops.append((off, 'pick', v)) elif op == 0x16: ops.append((off, 'swap', None)) elif op == 0x17: ops.append((off, 'rot', None)) elif op == 0x1a: ops.append((off, 'and', None)) elif op == 0x1b: ops.append((off, 'div', None)) elif op == 0x1c: ops.append((off, 'minus', None)) elif op == 0x1d: ops.append((off, 'mod', None)) elif op == 0x1e: ops.append((off, 'mul', None)) elif op == 0x1f: ops.append((off, 'neg', None)) elif op == 0x20: ops.append((off, 'not', None)) elif op == 0x21: ops.append((off, 'or', None)) elif op == 0x22: ops.append((off, 'plus', None)) elif op == 0x23: v, pos = read_uleb128(data, pos); ops.append((off, 'plus_uconst', v)) elif op == 0x24: ops.append((off, 'shl', None)) elif op == 0x25: ops.append((off, 'shr', None)) elif op == 0x26: ops.append((off, 'shra', None)) elif op == 0x27: ops.append((off, 'xor', None)) elif op == 0x28: soff = int.from_bytes(data[pos:pos+2], 'little', signed=True); pos += 2 ops.append((off, 'bra', pos + soff)) elif op == 0x29: ops.append((off, 'eq', None)) elif op == 0x2a: ops.append((off, 'ge', None)) elif op == 0x2b: ops.append((off, 'gt', None)) elif op == 0x2c: ops.append((off, 'le', None)) elif op == 0x2d: ops.append((off, 'lt', None)) elif op == 0x2e: ops.append((off, 'ne', None)) elif op == 0x2f: soff = int.from_bytes(data[pos:pos+2], 'little', signed=True); pos += 2 ops.append((off, 'skip', pos + soff)) elif op == 0x90: v, pos = read_uleb128(data, pos); ops.append((off, 'regx', v)) elif op == 0xf1: enc = data[pos]; pos += 1; fmt = enc & 0x0f if fmt == 0x01: v, pos = read_uleb128(data, pos) elif fmt == 0x02: v = int.from_bytes(data[pos:pos+2], 'little'); pos += 2 elif fmt == 0x03: v = int.from_bytes(data[pos:pos+4], 'little'); pos += 4 elif fmt == 0x04: v = int.from_bytes(data[pos:pos+8], 'little'); pos += 8 else: v = 0 ops.append((off, 'encoded_addr', (enc, enc & 0x70, v))) elif op == 0x00: ops.append((off, 'nop', None)) else: ops.append((off, f'unk_{op:02x}', None)) return ops
# ═══════════════════════════════════════════════════# 2. Constraint extraction# ═══════════════════════════════════════════════════R8 = 0x8578b # constant key stored in r8
def xr(X, shift): """XOR decode: X ^ ((r8 >> shift) & 0xFF)""" return X ^ ((R8 >> shift) & 0xFF)
def bit_groups_of(byte_val): """Return list of consecutive 1-bit group lengths (MSB to LSB).""" if byte_val == 0: return [] groups = []; p = 7 while p >= 0 and not (byte_val & (1 << p)): p -= 1 while p >= 0: cnt = 0 while p >= 0 and (byte_val & (1 << p)): cnt += 1; p -= 1 if cnt > 0: groups.append(cnt) while p >= 0 and not (byte_val & (1 << p)): p -= 1 return groups
def find_constraint_eq_offsets(ops): """Find bytecode offsets of all 'eq' ops that follow the XOR-decode pattern.""" eq_offsets = set() for i in range(len(ops)): off, name, operand = ops[i] if name not in ('const1u', 'lit') or i + 7 >= len(ops): continue n1 = ops[i+1] if not ((n1[1] == 'regx' and n1[2] == 8) or (n1[1] == 'reg' and n1[2] == 8)): continue n2 = ops[i+2] if n2[1] not in ('lit', 'const1u'): continue if ops[i+3][1] != 'shr': continue if ops[i+4][1] != 'const1u' or ops[i+4][2] != 255: continue if ops[i+5][1] != 'and': continue if ops[i+6][1] != 'xor': continue if ops[i+7][1] == 'eq': eq_offsets.add(ops[i+7][0]) return eq_offsets
def find_block_ranges(ops): """Detect constraint block boundaries via rot/drop/drop/and sequences.""" markers = [] for i in range(len(ops) - 3): if (ops[i][1] == 'rot' and ops[i+1][1] == 'drop' and ops[i+2][1] == 'drop' and ops[i+3][1] == 'and'): markers.append(ops[i][0]) markers.sort() ranges = [] prev = 9 for m in markers: ranges.append((prev, m)) prev = m + 4 return ranges
def extract_per_byte_constraints(ops, block_ranges): """From single-deref blocks, extract {flag_pos: expected_bit_groups}.""" per_byte = {} for bstart, bend in block_ranges: block_ops = [(o, n, p) for o, n, p in ops if bstart <= o < bend] if sum(1 for _, n, _ in block_ops if n == 'deref') > 1: continue # skip cross-byte blocks
brs, gcs = [], [] for i in range(len(ops)): off, name, operand = ops[i] if not (bstart <= off < bend): continue if name not in ('const1u', 'lit') or i + 7 >= len(ops): continue X = operand n1 = ops[i+1] if not ((n1[1] == 'regx' and n1[2] == 8) or (n1[1] == 'reg' and n1[2] == 8)): continue n2 = ops[i+2] if n2[1] in ('lit', 'const1u'): shift = n2[2] else: continue if ops[i+3][1] != 'shr': continue if ops[i+4][1] != 'const1u' or ops[i+4][2] != 255: continue if ops[i+5][1] != 'and': continue if ops[i+6][1] != 'xor': continue val = xr(X, shift) if ops[i+7][1] == 'plus': brs.append(val) elif ops[i+7][1] == 'eq': gcs.append(val)
if brs: per_byte[brs[0]] = gcs return per_byte
def compute_per_byte_candidates(per_byte): """Build candidate char set for each flag position from per-byte constraints.""" candidates = {} for pos in range(FLAG_LEN): if pos < len(FLAG_PREFIX): candidates[pos] = [ord(FLAG_PREFIX[pos])] elif pos == FLAG_LEN - 1: candidates[pos] = [ord('}')] elif pos in per_byte: expected = per_byte[pos] cands = [v for v in range(32, 127) if bit_groups_of(v) == expected] candidates[pos] = cands if cands else list(range(32, 127)) else: candidates[pos] = list(range(32, 127)) return candidates
# ═══════════════════════════════════════════════════# 3. C VM interface (ctypes wrapper)# ═══════════════════════════════════════════════════class CVM: def __init__(self, so_path, bytecode, constraint_offsets, r8=R8): self.lib = ctypes.CDLL(so_path) self.bc = (ctypes.c_uint8 * len(bytecode))(*bytecode) self.bc_len = len(bytecode) self.r8 = ctypes.c_uint64(r8) self.n_con = len(constraint_offsets) c_offsets = (ctypes.c_int * self.n_con)(*sorted(constraint_offsets)) self.lib.setup_constraints(c_offsets, self.n_con)
def run_quick(self, flag_bytes): """Execute VM with given flag -> (result, total_passes)""" c_flag = (ctypes.c_uint8 * len(flag_bytes))(*flag_bytes) c_out = ctypes.c_int(0) passes = self.lib.vm_run_quick( self.bc, self.bc_len, c_flag, len(flag_bytes), self.r8, ctypes.byref(c_out) ) return c_out.value, passes
# ═══════════════════════════════════════════════════# 4. Coordinate Descent solver# ═══════════════════════════════════════════════════def solve_coordinate_descent(vm, candidates, n_constraints, max_rounds=20): """Greedy per-position optimization: pick the value that maximizes pass count.""" flag = [candidates[p][0] for p in range(FLAG_LEN)] _, best_score = vm.run_quick(flag)
for rnd in range(max_rounds): improved = False positions = list(range(len(FLAG_PREFIX), FLAG_LEN - 1)) random.shuffle(positions)
for pos in positions: orig = flag[pos]; best_val = orig for v in candidates[pos]: if v == orig: continue flag[pos] = v _, score = vm.run_quick(flag) if score > best_score: best_score = score; best_val = v; improved = True flag[pos] = best_val
result, _ = vm.run_quick(flag) flag_str = ''.join(chr(b) for b in flag) print(f" [CD round {rnd+1}] score={best_score}/{n_constraints} flag={flag_str}") if result != 0: return flag, True if not improved: break
return flag, False
# ═══════════════════════════════════════════════════# 5. Simulated Annealing solver# ═══════════════════════════════════════════════════def solve_simulated_annealing(vm, candidates, n_constraints, max_iter=2000000, T_start=8.0, T_end=0.001): """SA: probabilistically accepts worse moves at high T to escape local optima.""" mutable = [p for p in range(len(FLAG_PREFIX), FLAG_LEN - 1) if len(candidates[p]) > 1]
# random init flag = [random.choice(candidates[p]) if len(candidates[p]) > 1 else candidates[p][0] for p in range(FLAG_LEN)] _, cur_score = vm.run_quick(flag) best_score = cur_score; best_flag = list(flag) log_interval = max_iter // 20
for i in range(max_iter): T = T_start * (T_end / T_start) ** (i / max_iter)
pos = random.choice(mutable) old_val = flag[pos] new_val = random.choice(candidates[pos]) if new_val == old_val: continue
flag[pos] = new_val result, new_score = vm.run_quick(flag)
if result != 0: print(f" [SA iter {i}] FOUND! score={new_score}/{n_constraints}") return flag, True
delta = new_score - cur_score if delta > 0 or random.random() < math.exp(delta / max(T, 1e-10)): cur_score = new_score if new_score > best_score: best_score = new_score; best_flag = list(flag) else: flag[pos] = old_val
if i % log_interval == 0 and i > 0: flag_str = ''.join(chr(b) for b in best_flag) print(f" [SA {i//1000}K/{max_iter//1000}K] T={T:.3f} best={best_score}/{n_constraints} " f"flag={flag_str}")
return best_flag, False
def solve_sa_with_restarts(vm, candidates, n_constraints, n_restarts=10, iter_per_restart=2000000): """Run SA multiple times with random restarts.""" best_overall = 0; best_flag = None
for r in range(n_restarts): print(f"\\n --- SA restart {r+1}/{n_restarts} ---") flag, found = solve_simulated_annealing( vm, candidates, n_constraints, max_iter=iter_per_restart, T_start=6.0, T_end=0.001 ) if found: return flag, True
_, score = vm.run_quick(flag) if score > best_overall: best_overall = score; best_flag = list(flag) flag_str = ''.join(chr(b) for b in flag) print(f" [SA restart {r+1}] score={score}/{n_constraints} " f"(best={best_overall}) flag={flag_str}")
# if very close, brute-force one position at a time if score >= n_constraints - 2: print(f" [*] Near-solution ({score}/{n_constraints}), trying single-pos bruteforce...") for pos in range(len(FLAG_PREFIX), FLAG_LEN - 1): for v in range(32, 127): trial = list(flag); trial[pos] = v result, _ = vm.run_quick(trial) if result != 0: print(f" FOUND via pos {pos} = '{chr(v)}'") return trial, True
return best_flag, False
# ═══════════════════════════════════════════════════# 6. Main# ═══════════════════════════════════════════════════def main(): if len(sys.argv) != 3: print(f"Usage: python3 {sys.argv[0]} <dwarf_vm.bin> <vm_emulator.so>") sys.exit(1)
bin_path = sys.argv[1] so_path = sys.argv[2]
# --- Parse bytecode --- data = load_bytecode(bin_path) ops = parse_instructions(data) print(f"[*] Loaded {len(data)} bytes -> {len(ops)} instructions")
# --- Extract constraints --- block_ranges = find_block_ranges(ops) eq_offsets = find_constraint_eq_offsets(ops) n_constraints = len(eq_offsets) per_byte = extract_per_byte_constraints(ops, block_ranges) candidates = compute_per_byte_candidates(per_byte) print(f"[*] Blocks: {len(block_ranges)}, Constraints: {n_constraints}, Per-byte: {len(per_byte)}")
# --- Per-position candidate summary --- print(f"\\n[*] Per-position candidates:") fixed = 0 for pos in range(len(FLAG_PREFIX), FLAG_LEN - 1): cands = candidates[pos] if len(cands) == 1: print(f" pos {pos:>2}: FIXED -> '{chr(cands[0])}'") fixed += 1 elif len(cands) <= 10: cand_str = ' '.join(f"'{chr(c)}'" for c in cands) print(f" pos {pos:>2}: [{len(cands):>2}] {cand_str}") else: print(f" pos {pos:>2}: [{len(cands):>2}] (unconstrained)") n_search = (FLAG_LEN - 1) - len(FLAG_PREFIX) print(f" Fixed: {fixed}, Need search: {n_search - fixed}")
# --- Init C VM --- vm = CVM(so_path, data, eq_offsets)
# --- Phase 1: Simulated Annealing --- print(f"\\n[*] Phase 1: Simulated Annealing") random.seed(42) flag, found = solve_sa_with_restarts(vm, candidates, n_constraints, n_restarts=10, iter_per_restart=2000000) if found: print(f"\\n{'='*60}\\n FLAG: {''.join(chr(b) for b in flag)}\\n{'='*60}") return
# --- Phase 2: Coordinate Descent polish --- print(f"\\n[*] Phase 2: Coordinate Descent") flag, found = solve_coordinate_descent(vm, candidates, n_constraints) if found: print(f"\\n{'='*60}\\n FLAG: {''.join(chr(b) for b in flag)}\\n{'='*60}") return
_, best_score = vm.run_quick(flag) print(f"\\n[-] Not solved. Best: {best_score}/{n_constraints}") print(f" {''.join(chr(b) for b in flag)}")
if __name__ == "__main__": t0 = time.time() main() print(f"\\n[*] Elapsed: {time.time()-t0:.1f}s")vm_emulator.c
188 collapsed lines
/* * DWARF Expression VM – C accelerator for the solver * * Exports: * setup_constraints(offsets, n) – register 'eq' bytecode offsets * vm_run_quick(data, bc_len, flag, flag_len, r8, out_result) * -> returns total constraint passes; *out_result = final TOS * * Build: * gcc -O3 -shared -fPIC -o vm_fast2.so vm_fast2.c */
#include <stdint.h>#include <string.h>
/* ── Tunables ─────────────────────────────────────── */#define MAX_STACK 4096#define MAX_STEPS 2000000#define MAX_CONSTRAINTS 256#define MEM_SIZE 128
/* ── Helpers ──────────────────────────────────────── */
static inline int64_t to_signed(uint64_t v) { return (int64_t)v; }
static int read_uleb128(const uint8_t *d, int len, int p, uint64_t *out) { uint64_t r = 0; int s = 0; while (p < len) { uint8_t b = d[p++]; r |= (uint64_t)(b & 0x7f) << s; s += 7; if (!(b & 0x80)) break; } *out = r; return p;}
static int read_sleb128(const uint8_t *d, int len, int p, int64_t *out) { int64_t r = 0; int s = 0; uint8_t b = 0; while (p < len) { b = d[p++]; r |= (int64_t)(b & 0x7f) << s; s += 7; if (!(b & 0x80)) break; } if (s < 64 && (b & 0x40)) r |= -(1LL << s); *out = r; return p;}
/* ── Constraint offset -> index map ───────────────── */
static int con_map[16384]; /* offset -> constraint index (-1 = none) */static int n_con = 0;
void setup_constraints(const int *offsets, int n) { n_con = n; memset(con_map, 0xFF, sizeof(con_map)); for (int i = 0; i < n && i < MAX_CONSTRAINTS; i++) if (offsets[i] >= 0 && offsets[i] < 16384) con_map[offsets[i]] = i;}
/* ── Flat memory (flag bytes mapped at flag_ptr) ──── */
static uint8_t mem[MEM_SIZE];
static uint64_t mem_read_u64(uint64_t addr, uint64_t base) { uint64_t v = 0; for (int i = 0; i < 8; i++) { int idx = (int)(addr + i - base); uint8_t b = (idx >= 0 && idx < MEM_SIZE) ? mem[idx] : 0; v |= (uint64_t)b << (i * 8); } return v;}
/* ── VM execution ─────────────────────────────────── */
int vm_run_quick(const uint8_t *data, int bc_len, const uint8_t *flag, int flag_len, uint64_t r8, int *out_result){ const uint64_t flag_ptr = 0x400000ULL;
/* Load flag into flat memory */ memset(mem, 0, MEM_SIZE); for (int i = 0; i < flag_len && i < MEM_SIZE; i++) mem[i] = flag[i];
uint64_t stk[MAX_STACK]; int sp = 0; stk[sp++] = flag_ptr; /* initial TOS = pointer to flag */ int pos = 9; /* skip 9-byte prologue */ int steps = 0, passes = 0;
while (pos < bc_len) { if (++steps > MAX_STEPS) { *out_result = -1; return passes; }
int pc = pos; uint8_t op = data[pos++];
/* ── Compact opcode ranges ── */ if (op >= 0x30 && op <= 0x4f) { stk[sp++] = (uint64_t)(op - 0x30); continue; } /* lit0..lit31 */ if (op >= 0x50 && op <= 0x6f) { stk[sp++] = ((op-0x50)==8) ? r8 : 0; continue; } /* reg0..reg31 */ if (op >= 0x70 && op <= 0x8f) { /* breg0..breg31 */ int64_t dummy; pos = read_sleb128(data, bc_len, pos, &dummy); stk[sp++] = 0; continue; }
switch (op) {
/* ── Memory / constants ── */ case 0x06: { uint64_t a = stk[--sp]; stk[sp++] = mem_read_u64(a, flag_ptr); break; } /* deref */ case 0x08: { stk[sp++] = data[pos++]; break; } /* const1u */ case 0x09: { int8_t v = (int8_t)data[pos++]; stk[sp++] = (uint64_t)(int64_t)v; break; } /* const1s */ case 0x0a: { uint16_t v = data[pos]|((uint16_t)data[pos+1]<<8); pos+=2; stk[sp++]=v; break; }/* const2u */ case 0x0e: { uint64_t v=0; for(int i=0;i<8;i++) v|=(uint64_t)data[pos+i]<<(i*8); pos+=8; stk[sp++]=v; break; } /* const8u */ case 0x10: { uint64_t v; pos=read_uleb128(data,bc_len,pos,&v); stk[sp++]=v; break; } /* constu */
/* ── Stack manipulation ── */ case 0x12: stk[sp]=stk[sp-1]; sp++; break; /* dup */ case 0x13: sp--; break; /* drop */ case 0x14: stk[sp]=stk[sp-2]; sp++; break; /* over */ case 0x15: { uint8_t i=data[pos++]; stk[sp]=stk[sp-1-i]; sp++; break; } /* pick */ case 0x16: { uint64_t t=stk[sp-1]; stk[sp-1]=stk[sp-2]; stk[sp-2]=t; break; } /* swap */ case 0x17: { uint64_t a=stk[sp-1],b=stk[sp-2],c=stk[sp-3]; /* rot */ stk[sp-1]=b; stk[sp-2]=c; stk[sp-3]=a; break; }
/* ── Arithmetic ── */ case 0x1a: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=a&b; break; } /* and */ case 0x1b: { uint64_t b=stk[--sp],a=stk[--sp]; int64_t sb=to_signed(b); /* div */ stk[sp++]=sb?(uint64_t)(to_signed(a)/sb):0; break; } case 0x1c: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=a-b; break; } /* minus */ case 0x1d: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=b?a%b:0; break; } /* mod */ case 0x1e: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=a*b; break; } /* mul */ case 0x1f: { uint64_t a=stk[--sp]; stk[sp++]=(uint64_t)(-to_signed(a)); break; } /* neg */ case 0x20: { uint64_t a=stk[--sp]; stk[sp++]=~a; break; } /* not */ case 0x21: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=a|b; break; } /* or */ case 0x22: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=a+b; break; } /* plus */ case 0x23: { uint64_t v; pos=read_uleb128(data,bc_len,pos,&v); /* plus_uconst */ stk[sp-1]+=v; break; } case 0x24: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=a<<(b&63); break; } /* shl */ case 0x25: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=a>>(b&63); break; } /* shr */ case 0x26: { uint64_t b=stk[--sp],a=stk[--sp]; /* shra */ stk[sp++]=(uint64_t)(to_signed(a)>>(b&63)); break; } case 0x27: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=a^b; break; } /* xor */
/* ── Branching ── */ case 0x28: { int16_t off=(int16_t)(data[pos]|((uint16_t)data[pos+1]<<8)); pos+=2; /* bra */ if(stk[--sp]) pos+=off; break; } case 0x2f: { int16_t off=(int16_t)(data[pos]|((uint16_t)data[pos+1]<<8)); pos+=2; /* skip */ pos+=off; break; }
/* ── Comparisons ── */ case 0x29: { uint64_t b=stk[--sp],a=stk[--sp]; /* eq — constraint check */ int ok=(a==b); stk[sp++]=ok; if(pc<16384 && con_map[pc]>=0 && ok) passes++; break; } case 0x2a: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=(to_signed(a)>=to_signed(b)); break; } /* ge */ case 0x2b: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=(to_signed(a)> to_signed(b)); break; } /* gt */ case 0x2c: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=(to_signed(a)<=to_signed(b)); break; } /* le */ case 0x2d: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=(to_signed(a)< to_signed(b)); break; } /* lt */ case 0x2e: { uint64_t b=stk[--sp],a=stk[--sp]; stk[sp++]=(a!=b); break; } /* ne */
/* ── Register extended ── */ case 0x90: { uint64_t v; pos=read_uleb128(data,bc_len,pos,&v); /* regx */ if (v==8) stk[sp++]=r8; else if (v==12) stk[sp++]=0x3E06666E84F11F86ULL; /* r12 constant */ else if (v==13) stk[sp++]=0x5155422E88E856B5ULL; /* r13 constant */ else stk[sp++]=0; break; }
/* ── GNU encoded address ── */ case 0xf1: { uint8_t enc=data[pos++]; uint8_t fmt=enc&0x0f; uint8_t mod=enc&0x70; uint64_t v=0; if (fmt==0x01) { pos=read_uleb128(data,bc_len,pos,&v); } else if (fmt==0x02) { v=data[pos]|((uint64_t)data[pos+1]<<8); pos+=2; } else if (fmt==0x03) { for(int i=0;i<4;i++) v|=(uint64_t)data[pos+i]<<(i*8); pos+=4; } else if (fmt==0x04) { for(int i=0;i<8;i++) v|=(uint64_t)data[pos+i]<<(i*8); pos+=8; } stk[sp++] = (mod==0x40) ? 0x3cc20ULL+v : v; /* funcrel -> base+v */ break; }
case 0x00: break; /* nop */ default: *out_result = -2; return passes; }
if (sp < 0 || sp >= MAX_STACK) { *out_result = -3; return passes; } }
*out_result = (sp > 0) ? (int)stk[sp-1] : -4; return passes;}To summarize, the four main files used in the solution are:
| File | Description |
|---|---|
dwarf_decoder.py | DWARF expression disassembler (DW_OP_* decoder with GNU extension support) |
solve.py | Main solver: bytecode parsing + constraint extraction + C VM interface + SA |
vm_emulator.c | C VM accelerator (reimplementation of libgcc’s execute_stack_op) |
reflection_dump.py | GDB Python script for automated expression extraction |
Flag
./solve.py dwarf_vm.bin vm.so[*] Loaded 12888 bytes -> 9195 instructions[*] Blocks: 38, Constraints: 152, Per-byte: 31
[*] Per-position candidates: pos 8: FIXED -> 'k' pos 9: [ 4] '5' 'e' 'i' 'j' pos 10: FIXED -> '_' pos 11: [95] (unconstrained) pos 12: [95] (unconstrained) pos 13: [ 4] '5' 'e' 'i' 'j' pos 14: [ 3] ';' 's' 'v' pos 15: [95] (unconstrained) pos 16: [95] (unconstrained) pos 17: [ 3] ';' 's' 'v' pos 18: FIXED -> '_' pos 19: [ 7] '1' '2' '4' 'a' 'b' 'd' 'h' pos 20: [ 5] '3' '6' 'c' 'f' 'l' pos 21: [ 5] '9' ':' 'q' 'r' 't' pos 22: FIXED -> 'u' pos 23: [ 7] '1' '2' '4' 'a' 'b' 'd' 'h' pos 24: [ 5] '3' '6' 'c' 'f' 'l' pos 25: [ 5] '3' '6' 'c' 'f' 'l' pos 26: [ 3] '=' 'y' 'z' pos 27: FIXED -> '_' pos 28: [ 7] '1' '2' '4' 'a' 'b' 'd' 'h' pos 29: FIXED -> '_' pos 30: [ 2] '8' 'p' pos 31: FIXED -> 'u' pos 32: FIXED -> 'm' pos 33: [ 7] '1' '2' '4' 'a' 'b' 'd' 'h' pos 34: [ 4] '5' 'e' 'i' 'j' pos 35: [ 5] '9' ':' 'q' 'r' 't' pos 36: [95] (unconstrained) pos 37: [95] (unconstrained) pos 38: [ 4] '5' 'e' 'i' 'j' pos 39: [ 5] '3' '6' 'c' 'f' 'l' pos 40: [ 4] '5' 'e' 'i' 'j' pos 41: [ 5] '9' ':' 'q' 'r' 't' pos 42: [ 4] '5' 'e' 'i' 'j' pos 43: [95] (unconstrained) pos 44: [ 5] '3' '6' 'c' 'f' 'l' pos 45: [ 4] '5' 'e' 'i' 'j' Fixed: 8, Need search: 30
[*] Phase 1: Simulated Annealing
--- SA restart 1/10 --- [SA 300K/2000K] T=1.627 best=130/152 flag=lactf{siki_<lj;Qms_hltublfz_a_8um2e:G*e6ere43e} [SA 500K/2000K] T=0.682 best=143/152 flag=lactf{sike_this_es_actu1lly_a_pumb5rWBef5:5.c5} [SA 600K/2000K] T=0.441 best=146/152 flag=lactf{sike_tiesSa;_af9udlly_a_pumber_re6e:5n3e} [SA 900K/2000K] T=0.120 best=147/152 flag=lactf{sike_tae;[a;_af9udlly_a_pumber_re6e:enc5} [SA 1100K/2000K] T=0.050 best=147/152 flag=lactf{sike_tae;[a;_af9udlly_a_pumber_re6e:enc5} [SA 1200K/2000K] T=0.032 best=147/152 flag=lactf{sike_tae;[a;_af9udlly_a_pumber_re6e:enc5} [SA 1300K/2000K] T=0.021 best=147/152 flag=lactf{sike_tae;[a;_af9udlly_a_pumber_re6e:enc5} [SA 1400K/2000K] T=0.014 best=147/152 flag=lactf{sike_tae;[a;_af9udlly_a_pumber_re6e:enc5} [SA 1600K/2000K] T=0.006 best=147/152 flag=lactf{sike_tae;[a;_af9udlly_a_pumber_re6e:enc5} [SA 1700K/2000K] T=0.004 best=147/152 flag=lactf{sike_tae;[a;_af9udlly_a_pumber_re6e:enc5} [SA 1800K/2000K] T=0.002 best=147/152 flag=lactf{sike_tae;[a;_af9udlly_a_pumber_re6e:enc5} [SA 1900K/2000K] T=0.002 best=147/152 flag=lactf{sike_tae;[a;_af9udlly_a_pumber_re6e:enc5} [SA restart 1] score=147/152 (best=147) flag=lactf{sike_tae;[a;_af9udlly_a_pumber_re6e:enc5}
--- SA restart 2/10 --- [SA 100K/2000K] T=3.884 best=125/152 flag=lactf{siki_Qhe;;B;_b39u16fy_h_8umb5q[ej6et56le} [SA 200K/2000K] T=2.514 best=125/152 flag=lactf{siki_Qhe;;B;_b39u16fy_h_8umb5q[ej6et56le} [SA 300K/2000K] T=1.627 best=130/152 flag=lactf{sike_de5s_hs_h39u1fly_h_pumb5:[>j6itja6j} [SA 400K/2000K] T=1.053 best=136/152 flag=lactf{sik5_tai;[%s_aftual6y_a_puma5:Wb56e:e.ce} [SA iter 535145] FOUND! score=152/152
============================================================ FLAG: lactf{sike_this_is_actually_a_pumber_reference}============================================================When we pass the flag to the binary for final verification…

The catch_unwind handler receives the panic, the expression returns a non-zero value, and the program terminates normally!
6. Execution Flow
./reflection "lactf{...}"│├─ main() → sets up catch_unwind│ └─ __rust_begin_short_backtrace()│ └─ reflection::main()│ └─ panic!("Come close to me and I'll make you regret it.")│ └─ panic_fmt() → panic_with_hook() → rust_panic()│ └─ _Unwind_RaiseException() — validation begins here│├─ [Phase 1] Search: walk frames, find catch_unwind handler in main()│ └─ Expressions execute but context is reset afterward│├─ [Phase 2] Cleanup: walk frames again, execute DWARF expressions│ ├─ ❶ panic_with_hook FDE → RCX = 0x53AA9 (near ARGV globals)│ ├─ ❷ panic_handler FDE → R8 = code region address (hash value)│ ├─ ❸ __rust_begin_short_backtrace FDE → 115B expression:│ │ argc==2? strlen==47? hash(first8)? last=='}'?│ │ ├─ Pass → RIP = 0x3CC21 (enter virtual frame)│ │ └─ Fail → RIP = normal return (skip validation)│ ├─ ❹ Virtual frame FDE → R12 = 0x3E06666E84F11F86 (constant key)│ ├─ ❺ Virtual frame FDE → R13 = 0x5155422E88E856B5 (constant key)│ └─ ❻ Virtual frame FDE → 12,888B expression:│ 38 blocks verify remaining characters via bit-group signatures│ ├─ All pass → VM returns non-zero → RIP = success path│ └─ Any fail → VM returns 0 → RIP = failure path│└─ catch_unwind receives result → program exitsThe unwinder blindly trusts the CFI to restore registers, which is exactly what makes this kind of manipulation possible. WoW
7. Conclusion
These days, so many challenges are easily solved by LLMs, which can feel a bit anticlimactic. So it was a genuine treat to run into a challenge that contained an Anti-LLM string (?).
Thanks for creating such an great challenge! BGM was also great too.
