LA CTF 2026 Writeup (Reflection)

1. Challenge Overview#

reflection이라는 Rust 바이너리가 주어진다. 실행하면 인자와 무관하게 항상 panic 메시지가 출력된다:

$ ./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.

어떤 값을 넘기든 출력은 동일하다. 디컴파일러에서 reflection::main을 확인하면 무조건 panic!()만 호출한다. .text 어디에도 플래그 검증 로직이 없고, 바이너리가 입력을 어떻게 받는지도 알 수 없다.

0.png

1.jpg

여기서 드는 두 가지 의문:

  1. 검증 로직은 어디에 있는가?
  2. 바이너리는 플래그를 어떻게 읽는가?

.text에 검증 코드가 없다면, 검증은 메타데이터에 숨겨져 있어야 한다. Rust에서 panic!()은 스택 Unwinding을 트리거하며, 이 과정에서 .eh_frame(임의의 DWARF 바이트코드를 포함할 수 있는 섹션)을 읽는다. 여기가 다음으로 살펴볼 곳이다.

참고: 후에 기술하겠지만, 플래그는 실제로 커맨드라인 인자로 전달된다. ./reflection "lactf{...}" 정확한 로직은 DWARF 표현식을 분석해야 알 수 있으며, 해당 표현식은 Rust 내부 전역 변수에서 argcargv를 직접 읽는다.


2. Background — Stack Unwinding, .eh_frame#

2.1 Rust Panic: Unwind vs. Abort#

참고: https://os.phil-opp.com/freestanding-rust-binary/

Rust 프로그램이 panic!()을 만나면, 컴파일 시점에 Cargo.toml로 설정된 두 가지 전략 중 하나가 실행된다:

Abort 모드 (panic = "abort"): 프로세스가 즉시 종료된다. 스택 정리도, 소멸자 호출도 없다. 더 단순하고 바이너리 크기가 작아 임베디드, 베어메탈 환경에서 흔히 사용된다.

[profile.release]
panic = "abort" # 그냥 프로세스를 죽임

Unwind 모드 (기본값): 런타임이 콜 스택을 역방향으로 순회하며, 살아있는 각 지역 변수의 소멸자를 호출하고, catch_unwind를 통한 복구를 허용한다:

use std::panic;
let result = panic::catch_unwind(|| {
panic!("something went wrong");
});
// result는 Err — 프로그램은 계속 실행됨

Unwinding에는 각 스택 프레임에 대한 메타데이터가 필요하다 — 구체적으로 DWARF Call Frame Information이 포함된 .eh_frame 섹션이다. 이 메타데이터는 Unwinder한테 레지스터 복원 방법과 이전 프레임을 찾는 방법을 알려준다.

이 챌린지 바이너리는 기본 Unwind 모드를 사용한다. 다음을 통해 확인할 수 있다:

  • panic 출력에 백트레이스 힌트(RUST_BACKTRACE=1)가 포함되어 있으며, 이는 Unwinding에서만 동작함
  • 바이너리에 큰 .eh_frame 섹션이 존재함
  • panic 도중 libgcc_s.so.1_Unwind_RaiseException이 호출됨

즉, panic!()이 Unwinding을 트리거하고, Unwinding이 .eh_frame DWARF 표현식을 평가하므로, 출제자는 해당 표현식 안에 완전한 검증 프로그램을 숨겨놓은 것이다!

2.2 Unwinding 동작 원리: 2-Phase 메커니즘#

참고: gcc/libgcc/unwind.inc(https://github.com/gcc-mirror/gcc/tree/master/libgcc)

Rust panic! 이 발생하면, Unwinder는 콜 스택을 역방향으로 순회하며 각 프레임의 저장된 레지스터를 복원하고 정리 코드를 실행한다. Linux x86-64에서 Unwinder는 libgcc_s.so.1에 위치한다. 진입점은 _Unwind_RaiseException()이며, 2단계로 동작한다:

// libgcc/unwind.inc 요약
_Unwind_RaiseException(exc) {
// Phase 1 — 검색:
// 프레임을 위로 순회하며 핸들러(catch_unwind)를 찾음.
// 아직 아무것도 변경하지 않음. 표현식은 실행되지만 결과는 폐기됨.
while (1) {
uw_frame_state_for(&context, &fs); // FDE 찾기, CFI 파싱
if (fs.personality)
personality(..._UA_SEARCH_PHASE...); // 질의: 정리? 캐치?
uw_update_context(&context, &fs); // CFI 규칙 적용
}
cur_context = this_context; // ★ 초기 상태로 리셋
// Phase 2 — 정리:
// 처음부터 프레임을 다시 순회.
// 각 프레임에서: 레지스터 복원, 정리 코드 실행.
// 핸들러 프레임에 도달하면 점프.
while (1) {
uw_frame_state_for(&context, &fs);
if (fs.personality)
personality(..._UA_CLEANUP_PHASE...);
uw_update_context(&context, &fs); // ★ 표현식 실행, RIP 결정
}
}

핵심 함수는 uw_update_context()이다. .eh_frame의 CFI 규칙을 읽고 이전 프레임의 레지스터 값을 계산한다. 규칙에 DWARF 표현식이 포함되어 있으면, libgcc 내부의 스택 기반 바이트코드 VM인 execute_stack_op()이 해당 표현식을 평가한다. 두 Phase 모두 동일한 프레임을 순회하므로, 모든 DWARF 표현식은 두 번 실행된다(각 Phase에서 한 번씩.) 그러나 Phase 1은 이후 컨텍스트를 리셋하므로 Phase 2의 결과만 주의깊게 보면 된다.

2.3 .eh_frame section: CIE, FDE#

참고: https://refspecs.linuxfoundation.org/LSB_3.0.0/LSB-Core-generic/LSB-Core-generic/ehframechpt.html

.eh_frame 섹션은 하나 이상의 Call Frame Information(CFI) 레코드를 포함해야 한다.

레코드 개수는 섹션 헤더에 명시된 섹션 크기에 따라 결정된다.

각 CFI 레코드는 다음으로 구성된다:

  • 하나의 Common Information Entry (CIE)
  • 그 뒤에 하나 이상의 Frame Description Entry (FDE)

CIE와 FDE는 모두 주소 단위 크기의 경계에 정렬(aligned)되어야 한다.

CIE (Common Information Entry) — 여러 함수가 공유하는 템플릿:

FieldUsage
Augmentation String기능 플래그: "zR" (기본), "zPLR" (personality 함수 포함)
Code Alignment factor명령어 정렬 인수 (x86-64에서 1)
Data Alignment factor스택 슬롯 인수 (x86-64에서 -8)
Return Address Register복귀 주소 레지스터 (16 = RIP)
Initial Instructions모든 FDE가 상속하는 기본 CFI 규칙

FDE (Frame Description Entry) — 함수당 하나:

FieldUsage
PC Begin함수 시작 주소
PC Range함수 크기(바이트)
Call Frame Instructions스택 프레임 변화를 기술하는 CFI 바이트코드

2.png

2.4 두 가지 바이트코드 시스템: DW_CFA vs. DW_OP#

참고: 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는 두 종류의 바이트코드 시스템을 포함한다:

  • DWARF Expressions (DW_OP_*) — 스택 기반 표현식 평가 언어
  • Call Frame Instructions (DW_CFA_*) — 레지스터 복원 규칙을 생성하는 상태 머신
구분DWARF ExpressionDW_CFA Instruction
정의스택 기반 표현식 평가기콜 프레임 기술 언어
모델스택 기반 가상 평가기복원 규칙을 생성하는 상태 머신
목적값/주소 계산CFA 및 레지스터 복원 규칙

대부분의 CFI 명령어는 콜 프레임 상태 복원 방법을 기술하는 규칙 테이블을 단순히 업데이트한다. 그러나 특정 DW_CFA 명령어는 DWARF 표현식을 피연산자로 내장할 수 있다:

CFI InstructionMeaning
DW_CFA_def_cfa_expression표현식 E를 평가하여 CFA를 계산
DW_CFA_expression(reg)표현식 E가 레지스터 reg이 저장된 주소를 계산
DW_CFA_val_expression(reg)표현식 E가 레지스터 reg의 값을 계산

uw_update_context() (Unwinder 함수)가 이러한 명령어를 만나면, libgcc/unwind-dw2.cexecute_stack_op() — 산술, 메모리 접근, 제어 흐름, 레지스터 읽기를 지원하는 스택 기반 DWARF VM — 을 호출한다.

이 챌린지에서 출제자는 12,888바이트짜리 검증 프로그램DW_CFA_val_expression 안에 심었다.


3. 숨겨진 코드 찾기#

3.1 Expressions 스캔#

참고: https://man7.org/linux/man-pages/man1/readelf.1.html

readelf의 --debug-dump=frames 옵션을 이용해 보면,

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)

6개 FDE에 걸쳐 7개의 DWARF 표현식이 있는 것을 알 수 있다. 바이너리의 대부분의 FDE는 단순한 CFI 규칙만 갖고 있다. 이 7개가 의심스럽다.

이때 readelf가 두 개의 r16 (rip) 표현식을 파싱하지 못하고 (Unknown location op)으로 보고하고 있는데, 이는 readelf가 지원하지 않는 funcrel|uleb128 인코딩의 DW_OP_GNU_encoded_addr (0xF1)을 사용하기 때문이다. 0xF1 이후 바이트를 잘못 파싱하고 포기한다.

실제 표현식은 115바이트와 12,888바이트로, 일반 CFI에 비해 훨씬 크다. raw 바이트는 다른 방법으로 추출해야 한다.

3.2 GDB 디버깅 — Expressions 추출#

참고: gcc/libgcc/unwind-dw2.의 execute_stack_op() 함수(https://github.com/gcc-mirror/gcc/tree/705d87ba8e987cb8e0ca8b6d400c0aff8739eb23/libgcc)

readelf가 이 표현식들을 완전히 파싱할 수 없으므로, 런타임에서 추출해야 한다.

핵심 인사이트: libgcc의 execute_stack_op()은 raw 표현식 바이트를 인자로 받는다.

libgcc/unwind-dw2.c
static _Unwind_Word
execute_stack_op(const unsigned char *op_ptr, // 표현식 시작
const unsigned char *op_end, // 표현식 끝
struct _Unwind_Context *context,
_Unwind_Word initial)

이 함수에 브레이크포인트를 걸면 실행되는 모든 DWARF 표현식을 검사할 수 있다:

Terminal window
$ gdb ./reflection
(gdb) set args "AAAA"
(gdb) start # libgcc_s.so.1 로드
(gdb) break execute_stack_op # DWARF 표현식 VM 진입점
(gdb) continue

브레이크포인트에 도달할 때마다, op_end - op_ptr로 정확한 표현식 크기를 알 수 있고, x/<size>xb $rdi로 원시 바이트를 덤프할 수 있다. Python GDB 스크립트(reflection_dump.py)로 이를 자동화한다: 각 호출마다 inferior.read_memory()로 원시 바이트를 읽고, .bin 파일로 저장하며, 컨텍스트 레지스터 상태를 기록한다.

2-phase Unwinding으로 인해, 각 표현식은 두 번 실행된다 (Phase 1 + Phase 2). op_ptr 주소로 중복을 제거하고, Phase 2 실행만 유지하도록 스크립트를 작성했다:

  • 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()

이때 1차 검증 로직에 따라 올바른 47바이트 입력(lactf{si + 38*A + })을 주고 덤프하면, Phase 2 실행 순서로 6개의 expressions가 나타난다:

# 크기 op_ptr 주소 설명
1 9 bytes 0x55555559e567 val_expression(RCX) = 상수 대입
2 4 bytes 0x5555555a4ea9 expression(R8) = 주소 인코딩
3 115 bytes 0x55555559cbf1 val_expression(RIP) — 1차 검증
4 9 bytes 0x5555555a1ba8 val_expression(R12) = 상수 대입
5 9 bytes 0x5555555a4e10 val_expression(R13) = 상수 대입
6 12888 bytes 0x5555555a1bb5 val_expression(RIP) — 2차 검증 VM

표현식 #4~#6은 115바이트 사전 검사(#3)를 통과할 때만 나타난다.

입력이 잘못되면, Unwinding은 정상 복귀 경로를 타고 이 표현식들은 실행되지 않는다. 이 경우 프로세스는 총 6번의 호출(Phase당 3개 × 2 Phase, 표현식 #1~#3만)로 종료된다.

3.3 Expression 체인#

정리하면, 전체 로직은 6개 표현식이 순서대로 실행되면서, 앞의 표현식이 설정한 레지스터 값을 뒤의 표현식이 읽는 구조다:

❶ val_expression(RCX) = 0x53AA9 → ARGV 전역 변수 접근 기준점
❷ expression(R8) = 0x555555590d94 → .text 영역 주소. 역참조하면 0x0f10478b4808578b
(코드 바이트를 해시 상수 겸 XOR 키로 재활용)
❸ val_expression(RIP) [115B] → 1차 검증: argc, strlen, 해시, '}' 확인
통과 시 RIP를 리다이렉트 → ❹❺❻ 실행
실패 시 정상 복귀 → ❹❺❻ 미실행
❹ val_expression(R12) = 0x3E06666E84F11F86 → 2차 VM용 상수 키
❺ val_expression(R13) = 0x5155422E88E856B5 → 2차 VM용 상수 키
❻ val_expression(RIP) [12,888B] → 2차 검증: 38개 블록으로 나머지 플래그 검증

❹❺❻은 같은 FDE에 속해 있어 Unwinder가 해당 FDE를 처리할 때 세 표현식이 함께 실행된다. ❸의 1차 검증이 실패하면 이 FDE에 도달하지 않으므로 ❹❺❻은 실행되지 않는다.

나의 경우는 lactf{test} 같은 문자열을 줬을 때 ❸까지만 멈춘 걸 보고 해당 표현식 바이트를 덤프해서 로직을 분석하고 초기 입력 검증 조건을 알아냈다.

3.4 115바이트 입력값 검증#

이게 표현식 ❸의 Phase2 115바이트 덤프이다

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

여기서 바이너리가 플래그를 읽는 방식이 드러난다. 사전 검사 표현식은 표현식 ❶에서 0x53AA9로 설정된 RCX 기준 상대 주소를 계산하는 것으로 시작한다. 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}"

이 표현식은 Rust 런타임이 내부적으로 저장하는 raw 전역 변수에서 argv를 읽는다. 이로써 플래그가 ./reflection "lactf{...}"로 전달된다는 것이 확실해졌다.

DWARF op코드가 *(RCX-89) == 2 (카운트가 2인지 확인)와 *(*(RCX-81)+8) (포인터 배열을 따라가 두 번째 요소에 접근)이라는 접근 패턴을 보여주었다. 이것이 argc/argv처럼 보였고, GDB가 해당 주소에서 ARGC.0ARGV.0이라는 심볼 이름을 보여줌으로써 확인되었다.

사전 검사는 115바이트 DWARF 표현식을 통해 네 가지 조건을 검증한다:

블록 A — argc 검사 (오프셋 0–24):

GNU_encoded_addr(funcrel, 6) → 0x3046 ; NOP 패딩 바이트 주소
deref → *(0x3046) & 0xFF = 0x66
lit13; minus → 0x66 - 13 = 89 ; ARGC까지의 오프셋 계산
reg2 (RCX); minus → RCX - 89 = ARGC_addr
deref → *(ARGC_addr) = argc
lit2; ne → (argc ≠ 2)?
bra(+86) → argc ≠ 2이면 탈출

블록 B — argv[1] 포인터 (오프셋 25–33):

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

블록 C — strlen 루프 (오프셋 34–52):

LOOP:
dup; deref → 현재 바이트
const1u(0xFF); and → 8비트로 마스킹
lit0; eq → 널 검사
bra(+9) → 널이면 탈출
plus_uconst(1) → ptr++
swap; plus_uconst(1); swap → count++
skip(-19) → 루프 복귀

블록 D — 3중 검증 (오프셋 53–96):

검증 1: (count ^ 75) != 100 → strlen ≠ 47 → 실패
검증 2: (first8_le + 0x0000FFFFFFFFFFFF) ^ 0x66643CED3C6B36E0 != R8 → 해시 실패
검증 3: last_byte != 0x7D ('}') → 실패

성공/실패 분기 (오프셋 97–114):

모든 검사 통과 → RIP = GNU_encoded_addr(funcrel, 236513) = 0x3CC21 → 가상 프레임
검사 실패 → RIP = *(CFA-8) = 정상 복귀 주소 → 종료

처음 8바이트에 대한 해시 검사를 역산할 수 있다:

first8 + 0x0000FFFFFFFFFFFF = R8_val ^ 0x66643CED3C6B36E0
= 0x0f10478b4808578b ^ 0x66643CED3C6B36E0
= 0x69747B667463616B
first8 = 0x69747B667463616B - 0x0000FFFFFFFFFFFF
= 0x69737B667463616C
LE → ASCII: 6C 61 63 74 66 7B 73 69 = "lactf{si"

이로써 플래그 접두사가 확인되고, 위치 0–7이 lactf{si임을 알 수 있다.


4. 12KB DWARF VM 리버싱#

4.1 바이트코드 추출 및 디코딩#

참고: https://github.com/gcc-mirror/gcc/tree/705d87ba8e987cb8e0ca8b6d400c0aff8739eb23/libgcc

메인 표현식은 가상 프레임 FDE의 12,888바이트 val_expression(RIP)이다.

마찬가지로 앞서 작성한 GDB 덤프 스크립트로 통째로 덤프했다.

이후 libgcc/unwind-dw2.cexecute_stack_op()libgcc/unwind-pe.h를 기반으로 한 커스텀 디코더(dwarf_decoder.py)가 이를 9,195개의 명령어로 디스어셈블했다.

  • 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()

디코더는 바이트코드에서 사용된 모든 DW_OP Op코드를 처리하며, 다양한 인코딩 형식(funcrel|uleb128, datarel|udata4 등)의 GNU 확장 DW_OP_GNU_encoded_addr(0xF1)도 포함한다.

$ 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 제약 조건 요약#

12,888바이트 VM은 38개의 검증 블록으로 구성된다. 각 블록은 결과로 0 또는 1을 반환하고, 전체가 AND되어 하나라도 실패하면 최종 결과는 0이다.

각 블록의 검증 방식은 이렇다: 플래그 바이트를 2진수로 펼친 뒤, 1이 연속으로 붙어있는 덩어리만 골라 각 덩어리의 크기를 기록한다. 0은 구분자일 뿐이고, 1 덩어리의 크기를 왼쪽부터 나열한 것이 그 바이트의 “시그니처”다:

'e' = 01100101
0 [11] 00 [1] 0 [1] → 시그니처 = [2, 1, 1]
'_' = 01011111
0 [1] 0 [11111] → 시그니처 = [1, 5]

VM은 플래그의 각 글자에 대해 이 시그니처를 계산하고, 미리 정해둔 정답 시그니처와 같은지 비교한다.

블록은 두 유형이다:

  • 바이트 단위 (31개): 한 글자의 시그니처만 검증. 각 위치를 독립적으로 제약
  • 교차 바이트 (7개): 여러 글자에 걸쳐 특정 비트 위치의 연속 패턴을 검증. 위치 간 의존성이 생기므로 한 글자씩 분석해서는 풀 수 없음

38개 블록에서 총 152개의 eq 비교가 수행된다. 모든 상수는 R8 레지스터 값으로 XOR 난독화되어 있으며, 디코딩 패턴 매칭으로 추출했다.


5. Solution#

디코딩한 바이트코드의 구조를 파악했으니, 이를 기반으로 솔버를 작성하면 된다.

LLM이 소화할 만큼 문제를 잘게 분해했으니 더 이상 “ANTHROPIC MAGIC STRING”을 두려워하지 않아도 된다 ㅇㅅㅇ

Solve Code#

  • 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_fast.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_fast.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;
}

최종적으로 정리하면, 풀이에 쓰인 코드는 크게 네가지 아래와 같다.

FileDescription
dwarf_decoder.pyDWARF 표현식 디스어셈블러 (GNU 확장 지원 DW_OP_* 디코더)
solve.py메인 솔버: 바이트코드 파싱 + 제약 조건 추출 + C VM 인터페이스 + SA
vm_emulator.cC VM 가속기 (libgcc execute_stack_op 재구현)
reflection_dump.py자동 표현식 추출을 위한 GDB Python 스크립트

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}
============================================================

최종 검증을 위해 바이너리에 플래그를 넣으면…

4.png

catch_unwind 핸들러가 panic을 받고, expression이 0이 아닌 값을 반환하여 프로그램이 정상 종료된다!


6. 실행 흐름#

./reflection "lactf{...}"
├─ main() → 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() — 여기서 검증 시작
├─ [Phase 1] 검색: 프레임 순회, main()의 catch_unwind 핸들러 발견
│ └─ 표현식이 실행되지만 컨텍스트는 이후 리셋됨
├─ [Phase 2] 정리: 프레임을 다시 순회, DWARF 표현식 실행
│ ├─ ❶ panic_with_hook FDE → RCX = 0x53AA9 (ARGV 전역 변수 근처)
│ ├─ ❷ panic_handler FDE → R8 = 코드 영역 주소 (해시 값)
│ ├─ ❸ __rust_begin_short_backtrace FDE → 115B 표현식:
│ │ argc==2? strlen==47? hash(first8)? last=='}'?
│ │ ├─ 통과 → RIP = 0x3CC21 (가상 프레임 진입)
│ │ └─ 실패 → RIP = 정상 복귀 (검증 건너뜀)
│ ├─ ❹ 가상 프레임 FDE → R12 = 0x3E06666E84F11F86 (상수 키)
│ ├─ ❺ 가상 프레임 FDE → R13 = 0x5155422E88E856B5 (상수 키)
│ └─ ❻ 가상 프레임 FDE → 12,888B 표현식:
│ 38개 블록이 비트 그룹 시그니처로 나머지 문자 검증
│ ├─ 모두 통과 → VM이 0이 아닌 값 반환 → RIP = 성공 경로
│ └─ 하나라도 실패 → VM이 0 반환 → RIP = 실패 경로
└─ catch_unwind가 결과를 받음 → 프로그램 종료

Unwinder는 CFI를 신뢰해 레지스터를 복원하는데, 바로 이 점이 이러한 조작을 가능하게 한다. 와!


7. Conclusion#

요즘 많은 문제들이 LLM으로 쉽게 풀리다 보니 허무할 때도 있는데, Anti-LLM 문자열(?)이 들어있는 리버싱 문제는 처음이라 신박했다. (내가 먼저 써먹으려고 했는데!)

아무튼 재미있었다.

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