LA CTF 2026 Writeup (Reflection)

1. Challenge Overview#

We’re given a Rust binary called reflection. When run, it always prints a panic message regardless of any arguments:

$ ./reflection
thread '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 a
thread '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.

0.png

1.jpg

Two questions arise:

  1. Where is the validation logic?
  2. 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 read argc and argv from 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 process

Unwind 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 running

Unwinding 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_frame section
  • _Unwind_RaiseException from libgcc_s.so.1 is 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:

FieldUsage
Augmentation StringFeature flags: "zR" (basic), "zPLR" (with personality function)
Code Alignment FactorInstruction alignment factor (1 on x86-64)
Data Alignment FactorStack slot factor (-8 on x86-64)
Return Address RegisterReturn address register (16 = RIP)
Initial InstructionsDefault CFI rules inherited by all FDEs

FDE (Frame Description Entry) — one per function:

FieldUsage
PC BeginFunction start address
PC RangeFunction size (bytes)
Call Frame InstructionsCFI bytecode describing stack frame changes

2.png

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
AspectDWARF ExpressionDW_CFA Instruction
What it isStack-based expression evaluatorCall frame description language
ModelStack-based virtual evaluatorState machine generating recovery rules
PurposeValue/address computationCFA & 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 InstructionMeaning
DW_CFA_def_cfa_expressionCFA 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:

Terminal window
$ 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.

libgcc/unwind-dw2.c
static _Unwind_Word
execute_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:

Terminal window
$ gdb ./reflection
(gdb) set args "AAAA"
(gdb) start # Load libgcc_s.so.1
(gdb) break execute_stack_op # DWARF expression VM entry point
(gdb) continue

Each 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.py
LACTF reflection - DWARF expression dump + analysis
Usage: gdb -x reflection_dump.py ./reflection
Output: 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 gdb
import struct
import os
FLAG_INPUT = "lactf{si" + "A" * 38 + "}" # 8 + 38 + 1 = 47
OUTDIR = "./reflection_exprs"
PRINT_LIMIT = 256 # Max bytes for terminal output (truncates to head/tail if exceeded)
PRINT_HEAD = 128
PRINT_TAIL = 64
call_num = 0
phase = 1
phase1_ctx = None
seen = {} # 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 Description
1 9 bytes 0x55555559e567 val_expression(RCX) = constant assignment
2 4 bytes 0x5555555a4ea9 expression(R8) = address encoding
3 115 bytes 0x55555559cbf1 val_expression(RIP) — 1st validation
4 9 bytes 0x5555555a1ba8 val_expression(R12) = constant assignment
5 9 bytes 0x5555555a4e10 val_expression(R13) = constant assignment
6 12888 bytes 0x5555555a1bb5 val_expression(RIP) — 2nd validation VM

Expressions #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] 0x555555590d940xf10478b4808578b
R12: [ptr] 0x7fffffffdf800x0
R13: [ptr] 0x7fffffffdf880x1000
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 address
deref → *(0x3046) & 0xFF = 0x66
lit13; minus → 0x66 - 13 = 89 ; Compute offset to ARGC
reg2 (RCX); minus → RCX - 89 = ARGC_addr
deref → *(ARGC_addr) = argc
lit2; ne → (argc ≠ 2)?
bra(+86) → if argc ≠ 2, bail out

Block B — argv[1] pointer (offset 25–33):

plus_uconst(8); deref → *(ARGC_addr + 8) = ARGV_ptr
plus_uconst(8); deref → *(ARGV_ptr + 8) = argv[1] = flag_ptr

Block 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 back

Block D — Triple validation (offset 53–96):

Check 1: (count ^ 75) != 100 → strlen ≠ 47 → fail
Check 2: (first8_le + 0x0000FFFFFFFFFFFF) ^ 0x66643CED3C6B36E0 != R8 → hash fail
Check 3: last_byte != 0x7D ('}') → fail

Success/failure branching (offset 97–114):

All checks pass → RIP = GNU_encoded_addr(funcrel, 236513) = 0x3CC21 → virtual frame
Any check fails → RIP = *(CFA-8) = normal return address → exit

The 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, struct
from collections import Counter
# x86-64 register name mapping
REG_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.bin
Loaded 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 bytes

4.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#

3.png

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:

FileDescription
dwarf_decoder.pyDWARF expression disassembler (DW_OP_* decoder with GNU extension support)
solve.pyMain solver: bytecode parsing + constraint extraction + C VM interface + SA
vm_emulator.cC VM accelerator (reimplementation of libgcc’s execute_stack_op)
reflection_dump.pyGDB 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…

4.png

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 exits

The 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.

5.gif

LA CTF 2026 Writeup (Reflection)
https://ma4the.github.io/en/posts/rev-reflection-lactf2026/
Author
m@the
Published at
2026-02-13
License
CC BY-NC-SA 4.0