1. Challenge Overview
reflection이라는 Rust 바이너리가 주어진다. 실행하면 인자와 무관하게 항상 panic 메시지가 출력된다:
$ ./reflectionthread 'main' panicked at src/main.rs:2:5:Come close to me and I'll make you regret it.note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ ./reflection athread 'main' panicked at src/main.rs:2:5:Come close to me and I'll make you regret it.어떤 값을 넘기든 출력은 동일하다. 디컴파일러에서 reflection::main을 확인하면 무조건 panic!()만 호출한다. .text 어디에도 플래그 검증 로직이 없고, 바이너리가 입력을 어떻게 받는지도 알 수 없다.


여기서 드는 두 가지 의문:
- 검증 로직은 어디에 있는가?
- 바이너리는 플래그를 어떻게 읽는가?
.text에 검증 코드가 없다면, 검증은 메타데이터에 숨겨져 있어야 한다. Rust에서 panic!()은 스택 Unwinding을 트리거하며, 이 과정에서 .eh_frame(임의의 DWARF 바이트코드를 포함할 수 있는 섹션)을 읽는다. 여기가 다음으로 살펴볼 곳이다.
참고: 후에 기술하겠지만, 플래그는 실제로 커맨드라인 인자로 전달된다.
./reflection "lactf{...}"정확한 로직은 DWARF 표현식을 분석해야 알 수 있으며, 해당 표현식은 Rust 내부 전역 변수에서argc와argv를 직접 읽는다.
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) — 여러 함수가 공유하는 템플릿:
| Field | Usage |
|---|---|
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) — 함수당 하나:
| Field | Usage |
|---|---|
PC Begin | 함수 시작 주소 |
PC Range | 함수 크기(바이트) |
Call Frame Instructions | 스택 프레임 변화를 기술하는 CFI 바이트코드 |

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 Expression | DW_CFA Instruction |
|---|---|---|
| 정의 | 스택 기반 표현식 평가기 | 콜 프레임 기술 언어 |
| 모델 | 스택 기반 가상 평가기 | 복원 규칙을 생성하는 상태 머신 |
| 목적 | 값/주소 계산 | CFA 및 레지스터 복원 규칙 |
대부분의 CFI 명령어는 콜 프레임 상태 복원 방법을 기술하는 규칙 테이블을 단순히 업데이트한다. 그러나 특정 DW_CFA 명령어는 DWARF 표현식을 피연산자로 내장할 수 있다:
| CFI Instruction | Meaning |
|---|---|
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.c의 execute_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 옵션을 이용해 보면,
$ 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 표현식 바이트를 인자로 받는다.
static _Unwind_Wordexecute_stack_op(const unsigned char *op_ptr, // 표현식 시작 const unsigned char *op_end, // 표현식 끝 struct _Unwind_Context *context, _Unwind_Word initial)이 함수에 브레이크포인트를 걸면 실행되는 모든 DWARF 표현식을 검사할 수 있다:
$ 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.pyLACTF reflection - DWARF expression dump + analysis
Usage: gdb -x reflection_dump.py ./reflectionOutput: expr_p{1,2}_N_{size}b.bin (Phase_CallNumber_Size)
Examples: expr_p1_1_9b.bin Phase 1, call #1, 9 bytes (RCX constant) expr_p1_3_115b.bin Phase 1, call #3, 115 bytes (trigger) expr_p2_7_12888b.bin Phase 2, call #7, 12888 bytes (main VM)"""
import gdbimport structimport os
FLAG_INPUT = "lactf{si" + "A" * 38 + "}" # 8 + 38 + 1 = 47OUTDIR = "./reflection_exprs"PRINT_LIMIT = 256 # Max bytes for terminal output (truncates to head/tail if exceeded)PRINT_HEAD = 128PRINT_TAIL = 64
call_num = 0phase = 1phase1_ctx = Noneseen = {} # op_ptr -> (call_num, size, data, ctx_addr) deduplication map
def read_bytes(addr, size): """Read raw bytes from memory""" inferior = gdb.selected_inferior() return bytes(inferior.read_memory(addr, size))
def get_reg(ctx_addr, idx): """Return context register (by_value, value)""" try: bv = int(gdb.parse_and_eval( f"((struct _Unwind_Context*){ctx_addr:#x})->by_value[{idx}]")) val = int(gdb.parse_and_eval( f"(unsigned long)((struct _Unwind_Context*){ctx_addr:#x})->reg[{idx}]")) return bv, val except: return -1, 0
def get_cfa(ctx_addr): try: return int(gdb.parse_and_eval( f"(unsigned long)((struct _Unwind_Context*){ctx_addr:#x})->cfa")) except: return 0
def hexdump(data, addr, limit=None): """Pretty hex dump. Shows only head/tail if limit is exceeded.""" if limit and len(data) > limit: # Head section for i in range(0, PRINT_HEAD, 16): chunk = data[i:i+16] hex_str = " ".join(f"{b:02x}" for b in chunk) ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) print(f" {addr+i:012x} {hex_str:<48s} {ascii_str}") print(f" ... ({len(data) - PRINT_HEAD - PRINT_TAIL} bytes omitted) ...") # Tail section start = len(data) - PRINT_TAIL start = (start // 16) * 16 for i in range(start, len(data), 16): chunk = data[i:i+16] hex_str = " ".join(f"{b:02x}" for b in chunk) ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) print(f" {addr+i:012x} {hex_str:<48s} {ascii_str}") else: for i in range(0, len(data), 16): chunk = data[i:i+16] hex_str = " ".join(f"{b:02x}" for b in chunk) ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) print(f" {addr+i:012x} {hex_str:<48s} {ascii_str}")
def save_bin(data, filename): """Save as binary file""" path = os.path.join(OUTDIR, filename) with open(path, "wb") as f: f.write(data) return path
def save_context(ctx_addr, filename): """Save context registers as JSON-like text""" regs = {} for idx, name in [(2, "rcx"), (8, "r8"), (12, "r12"), (13, "r13"), (16, "rip")]: bv, val = get_reg(ctx_addr, idx) regs[name] = {"by_value": bv, "value": val} # Dereference if by_value=0 and value is non-zero if bv == 0 and val != 0: try: deref = int(gdb.parse_and_eval(f"*(unsigned long*){val:#x}")) regs[name]["deref"] = deref except: pass regs["cfa"] = get_cfa(ctx_addr)
path = os.path.join(OUTDIR, filename) with open(path, "w") as f: for name, info in regs.items(): if isinstance(info, dict): bv = info["by_value"] val = info["value"] deref = info.get("deref") line = f"{name}: by_value={bv} val={val:#018x}" if deref is not None: line += f" *val={deref:#018x}" f.write(line + "\n") else: f.write(f"{name}: {info:#018x}\n") return path
class ExprBreakpoint(gdb.Breakpoint): def stop(self): global call_num, phase, phase1_ctx, seen
try: op_ptr = int(gdb.parse_and_eval("(unsigned long)$rdi")) op_end = int(gdb.parse_and_eval("(unsigned long)$rsi")) ctx = int(gdb.parse_and_eval("(unsigned long)$rdx")) initial = int(gdb.parse_and_eval("(unsigned long)$rcx")) except: return True
call_num += 1 size = op_end - op_ptr
# Phase detection if call_num == 1: phase1_ctx = ctx phase = 1 elif ctx != phase1_ctx and phase == 1: phase = 2
# Read raw bytes data = read_bytes(op_ptr, size)
# Dedup check: overwrite if same op_ptr (Phase 2 takes precedence) is_new = op_ptr not in seen seen[op_ptr] = { "call_num": call_num, "phase": phase, "size": size, "data": data, "ctx": ctx, "initial": initial, "op_ptr": op_ptr, }
# Output dup_tag = "" if is_new else " [overwrite]" print() print(f"{'='*60}") print(f" #{call_num} | Phase {phase} | {size} bytes{dup_tag}") print(f"{'='*60}")
# Register summary for idx, name in [(2, "RCX"), (8, "R8"), (12, "R12"), (13, "R13")]: bv, val = get_reg(ctx, idx) tag = "val" if bv == 1 else "ptr" if bv == 0 else "?" extra = "" if bv == 0 and val != 0: try: deref = int(gdb.parse_and_eval(f"*(unsigned long*){val:#x}")) extra = f" → {deref:#x}" except: pass print(f" {name:>3}: [{tag}] {val:#x}{extra}") print(f" CFA: {get_cfa(ctx):#x}") print()
# Hex dump hexdump(data, op_ptr, limit=PRINT_LIMIT)
# Extra info for 115-byte expressions if size == 115: bv_rcx, val_rcx = get_reg(ctx, 2) if bv_rcx == 1 and val_rcx != 0: print() try: argc = int(gdb.parse_and_eval(f"*(unsigned long*){val_rcx - 89:#x}")) argv_ptr = int(gdb.parse_and_eval(f"*(unsigned long*){val_rcx - 81:#x}")) argv1 = int(gdb.parse_and_eval(f"*(unsigned long*){argv_ptr + 8:#x}")) argv1_s = gdb.execute(f"x/s {argv1:#x}", to_string=True).split(":", 1)[1].strip() print(f" argc={argc}, argv[1]={argv1_s}") except: pass
print(f"{'='*60}")
return False # Auto-continue
def main(): os.makedirs(OUTDIR, exist_ok=True) # Clean up existing files for f in os.listdir(OUTDIR): os.remove(os.path.join(OUTDIR, f))
print(f"\n[*] LACTF reflection expression dumper") print(f"[*] Input: {FLAG_INPUT}") print(f"[*] Output: {OUTDIR}/") print()
gdb.execute(f'set args "{FLAG_INPUT}"') gdb.execute("set pagination off") gdb.execute("set confirm off") gdb.execute("start")
bp = ExprBreakpoint("execute_stack_op") bp.silent = True
print(f"\n[*] Continuing to panic...\n") gdb.execute("continue")
# Save only deduplicated expressions print(f"\n{'='*60}") print(f" {call_num} total calls, {len(seen)} unique expressions") print(f" Output directory: {OUTDIR}/") print()
# Sort by execution order (call_num) sorted_exprs = sorted(seen.values(), key=lambda x: x["call_num"])
for i, info in enumerate(sorted_exprs, 1): size = info["size"] bin_name = f"expr_{i}_{size}b.bin" ctx_name = f"expr_{i}_{size}b_ctx.txt"
# Save .bin save_bin(info["data"], bin_name)
# Save context (Phase 2 values) save_context(info["ctx"], ctx_name)
# Description labels labels = {9: "constant assign", 4: "address encoding", 115: "1st validation", 12888: "2nd validation VM"} label = labels.get(size, f"{size}b")
fpath = os.path.join(OUTDIR, bin_name) print(f" {bin_name:>30s} ({os.path.getsize(fpath):>6d} bytes)" f" #{info['call_num']:>2d} @{info['op_ptr']:#x} {label}")
print(f"{'='*60}")
main()이때 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] 0x555555590d94 → 0xf10478b4808578b R12: [ptr] 0x7fffffffdf80 → 0x0 R13: [ptr] 0x7fffffffdf88 → 0x1000 CFA: 0x7fffffffe050
55555559cbf1 f1 41 06 06 08 ff 1a 3d 1c 0a 05 0a 16 52 16 1c .A.....=.....R.. 55555559cc01 16 13 12 06 32 2e 28 56 00 23 08 06 23 08 06 12 ....2.(V.#..#... 55555559cc11 30 16 12 06 08 ff 1a 30 29 28 09 00 23 01 16 23 0......0)(..#..# 55555559cc21 01 16 2f ed ff 17 08 4b 27 08 64 2e 16 06 30 31 ../....K'.d...01 55555559cc31 1c 40 25 22 0e dc 66 8d a7 9d 87 cc 0c 33 24 27 .@%"..f......3$' 55555559cc41 58 2e 21 16 31 1c 06 08 ff 1a 08 7d 2e 21 28 08 X.!.1......}.!(. 55555559cc51 00 f1 41 e1 b7 0e 2f 03 00 38 1c 06 2f 04 00 13 ..A.../..8../... 55555559cc61 38 1c 06 8..여기서 바이너리가 플래그를 읽는 방식이 드러난다. 사전 검사 표현식은 표현식 ❶에서 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.0과 ARGV.0이라는 심볼 이름을 보여줌으로써 확인되었다.
사전 검사는 115바이트 DWARF 표현식을 통해 네 가지 조건을 검증한다:
블록 A — argc 검사 (오프셋 0–24):
GNU_encoded_addr(funcrel, 6) → 0x3046 ; NOP 패딩 바이트 주소deref → *(0x3046) & 0xFF = 0x66lit13; minus → 0x66 - 13 = 89 ; ARGC까지의 오프셋 계산reg2 (RCX); minus → RCX - 89 = ARGC_addrderef → *(ARGC_addr) = argclit2; ne → (argc ≠ 2)?bra(+86) → argc ≠ 2이면 탈출블록 B — argv[1] 포인터 (오프셋 25–33):
plus_uconst(8); deref → *(ARGC_addr + 8) = ARGV_ptrplus_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.c의 execute_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, structfrom collections import Counter
# x86-64 register name mappingREG_NAMES = { 0:"rax", 1:"rdx", 2:"rcx", 3:"rbx", 4:"rsi", 5:"rdi", 6:"rbp", 7:"rsp", 8:"r8", 9:"r9", 10:"r10", 11:"r11", 12:"r12", 13:"r13", 14:"r14", 15:"r15", 16:"rip"}
def regname(n): return REG_NAMES.get(n, f"r{n}")
# ═══════════════════════════════════════════════════# LEB128 readers# ═══════════════════════════════════════════════════
def read_uleb128(data, pos): result = shift = 0 while pos < len(data): b = data[pos]; pos += 1 result |= (b & 0x7f) << shift; shift += 7 if not (b & 0x80): break return result, pos
def read_sleb128(data, pos): result = shift = 0; b = 0 while pos < len(data): b = data[pos]; pos += 1 result |= (b & 0x7f) << shift; shift += 7 if not (b & 0x80): break if shift < 64 and (b & 0x40): result |= -(1 << shift) if result >= (1 << 63): result -= (1 << 64) return result, pos
# ═══════════════════════════════════════════════════# GNU encoded address decoder# ═══════════════════════════════════════════════════
MOD_NAMES = {0x00:"abs", 0x10:"pcrel", 0x20:"textrel", 0x30:"datarel", 0x40:"funcrel", 0x50:"aligned"}FMT_NAMES = {0x00:"ptr", 0x01:"uleb", 0x02:"u2", 0x03:"u4", 0x04:"u8", 0x09:"sleb", 0x0a:"s2", 0x0b:"s4", 0x0c:"s8"}
def decode_encoded_addr(data, pos): """Decode DW_OP_GNU_encoded_addr per libgcc/unwind-pe.h""" enc = data[pos]; pos += 1 fmt = enc & 0x0f; modifier = enc & 0x70; indirect = enc & 0x80
if fmt == 0x00: val = int.from_bytes(data[pos:pos+8], 'little'); pos += 8 elif fmt == 0x01: val, pos = read_uleb128(data, pos) elif fmt == 0x02: val = int.from_bytes(data[pos:pos+2], 'little'); pos += 2 elif fmt == 0x03: val = int.from_bytes(data[pos:pos+4], 'little'); pos += 4 elif fmt == 0x04: val = int.from_bytes(data[pos:pos+8], 'little'); pos += 8 elif fmt == 0x09: val, pos = read_sleb128(data, pos) elif fmt == 0x0a: val = int.from_bytes(data[pos:pos+2], 'little', signed=True); pos += 2 elif fmt == 0x0b: val = int.from_bytes(data[pos:pos+4], 'little', signed=True); pos += 4 elif fmt == 0x0c: val = int.from_bytes(data[pos:pos+8], 'little', signed=True); pos += 8 else: val = 0
enc_str = f"{MOD_NAMES.get(modifier,'?')}|{FMT_NAMES.get(fmt,'?')}" if indirect: enc_str += "|indirect" return val, enc_str, pos
# ═══════════════════════════════════════════════════# Main decoder# ═══════════════════════════════════════════════════
def decode_dwarf_expr(data, func_base=0x3cc20): """Decode DWARF expression bytecode into (offset, name, description, metadata) list.""" pos = 0; insns = []
while pos < len(data): off = pos; op = data[pos]; pos += 1
# DW_OP_lit0..lit31 (0x30..0x4f) if 0x30 <= op <= 0x4f: v = op - 0x30 insns.append((off, f"lit{v}", f"push {v}", [])) # DW_OP_reg0..reg31 (0x50..0x6f) elif 0x50 <= op <= 0x6f: r = op - 0x50 insns.append((off, f"reg{r}", f"push {regname(r)}", [])) # DW_OP_breg0..breg31 (0x70..0x8f) elif 0x70 <= op <= 0x8f: r = op - 0x70; sv, pos = read_sleb128(data, pos) insns.append((off, f"breg{r}", f"push {regname(r)}+({sv})", []))
elif op == 0x03: # DW_OP_addr v = int.from_bytes(data[pos:pos+8], 'little'); pos += 8 insns.append((off, "addr", f"push 0x{v:x}", [])) elif op == 0x06: insns.append((off, "deref", "pop a; push *(uint64*)a", [])) elif op == 0x08: v = data[pos]; pos += 1 insns.append((off, "const1u", f"push {v} (0x{v:02x})", [])) elif op == 0x09: v = struct.unpack_from('b', data, pos)[0]; pos += 1 insns.append((off, "const1s", f"push {v}", [])) elif op == 0x0a: v = int.from_bytes(data[pos:pos+2], 'little'); pos += 2 insns.append((off, "const2u", f"push {v} (0x{v:04x})", [])) elif op == 0x0b: v = int.from_bytes(data[pos:pos+2], 'little', signed=True); pos += 2 insns.append((off, "const2s", f"push {v}", [])) elif op == 0x0c: v = int.from_bytes(data[pos:pos+4], 'little'); pos += 4 insns.append((off, "const4u", f"push 0x{v:08x}", [])) elif op == 0x0e: v = int.from_bytes(data[pos:pos+8], 'little'); pos += 8 insns.append((off, "const8u", f"push 0x{v:016x}", [])) elif op == 0x0f: v = int.from_bytes(data[pos:pos+8], 'little', signed=True); pos += 8 insns.append((off, "const8s", f"push {v}", [])) elif op == 0x10: v, pos = read_uleb128(data, pos) insns.append((off, "constu", f"push {v} (0x{v:x})", [])) elif op == 0x11: v, pos = read_sleb128(data, pos) insns.append((off, "consts", f"push {v}", []))
elif op == 0x12: insns.append((off, "dup", "push TOS", [])) elif op == 0x13: insns.append((off, "drop", "pop TOS", [])) elif op == 0x14: insns.append((off, "over", "push stack[-2]", [])) elif op == 0x15: idx = data[pos]; pos += 1 insns.append((off, "pick", f"push stack[-{idx+1}]", [])) elif op == 0x16: insns.append((off, "swap", "swap TOS and TOS-1", [])) elif op == 0x17: insns.append((off, "rot", "rotate top 3: [a,b,c]->[c,a,b]", [])) elif op == 0x19: insns.append((off, "abs", "TOS = |TOS|", []))
elif op == 0x1a: insns.append((off, "and", "pop b,a; push a & b", [])) elif op == 0x1b: insns.append((off, "div", "pop b,a; push a / b", [])) elif op == 0x1c: insns.append((off, "minus", "pop b,a; push a - b", [])) elif op == 0x1d: insns.append((off, "mod", "pop b,a; push a % b", [])) elif op == 0x1e: insns.append((off, "mul", "pop b,a; push a * b", [])) elif op == 0x1f: insns.append((off, "neg", "TOS = -TOS", [])) elif op == 0x20: insns.append((off, "not", "TOS = ~TOS", [])) elif op == 0x21: insns.append((off, "or", "pop b,a; push a | b", [])) elif op == 0x22: insns.append((off, "plus", "pop b,a; push a + b", [])) elif op == 0x23: v, pos = read_uleb128(data, pos) insns.append((off, "plus_uconst", f"TOS += {v}", [])) elif op == 0x24: insns.append((off, "shl", "pop b,a; push a << b", [])) elif op == 0x25: insns.append((off, "shr", "pop b,a; push a >> b (unsigned)", [])) elif op == 0x26: insns.append((off, "shra", "pop b,a; push a >> b (signed)", [])) elif op == 0x27: insns.append((off, "xor", "pop b,a; push a ^ b", []))
elif op == 0x28: # DW_OP_bra (conditional branch) soff = int.from_bytes(data[pos:pos+2], 'little', signed=True); pos += 2 tgt = pos + soff insns.append((off, "bra", f"if TOS!=0 goto {tgt} (offset {soff:+d})", [("target", tgt)]))
elif op == 0x29: insns.append((off, "eq", "pop b,a; push (a == b)", [])) elif op == 0x2a: insns.append((off, "ge", "pop b,a; push (a >= b)", [])) elif op == 0x2b: insns.append((off, "gt", "pop b,a; push (a > b)", [])) elif op == 0x2c: insns.append((off, "le", "pop b,a; push (a <= b)", [])) elif op == 0x2d: insns.append((off, "lt", "pop b,a; push (a < b)", [])) elif op == 0x2e: insns.append((off, "ne", "pop b,a; push (a != b)", []))
elif op == 0x2f: # DW_OP_skip (unconditional branch) soff = int.from_bytes(data[pos:pos+2], 'little', signed=True); pos += 2 tgt = pos + soff insns.append((off, "skip", f"goto {tgt} (offset {soff:+d})", [("target", tgt)]))
elif op == 0x90: # DW_OP_regx v, pos = read_uleb128(data, pos) insns.append((off, "regx", f"push {regname(v)} (reg {v})", [])) elif op == 0x91: # DW_OP_fbreg sv, pos = read_sleb128(data, pos) insns.append((off, "fbreg", f"push CFA+({sv})", [])) elif op == 0x93: # DW_OP_piece v, pos = read_uleb128(data, pos) insns.append((off, "piece", f"piece({v} bytes)", [])) elif op == 0x9f: insns.append((off, "stack_value", "TOS is the value (not addr)", []))
elif op == 0xf1: # DW_OP_GNU_encoded_addr val, enc_str, pos = decode_encoded_addr(data, pos) if "funcrel" in enc_str: resolved = func_base + val insns.append((off, "GNU_encoded_addr", f"({enc_str}, {val}) -> 0x{resolved:x}", [])) elif "datarel" in enc_str: insns.append((off, "GNU_encoded_addr", f"({enc_str}, {val}) -> dbase+{val}", [])) else: insns.append((off, "GNU_encoded_addr", f"({enc_str}, {val})", []))
elif op == 0x00: insns.append((off, "nop", "", [])) else: insns.append((off, f"UNKNOWN_0x{op:02x}", "???", []))
return insns
# ═══════════════════════════════════════════════════# Main# ═══════════════════════════════════════════════════
def main(): if len(sys.argv) != 2: print(f"Usage: python3 {sys.argv[0]} <dwarf_vm.bin>") sys.exit(1)
data = open(sys.argv[1], "rb").read() print(f"Loaded {len(data)} bytes of DWARF expression bytecode\n")
insns = decode_dwarf_expr(data, func_base=0x3cc20)
# Collect branch targets for annotation targets = {v for _, _, _, meta in insns for k, v in meta if k == "target"}
# Print disassembly print(f"{'OFF':>5} {'OPCODE':<22} {'DESCRIPTION'}") print("=" * 80) for off, name, desc, _ in insns: prefix = ">>>" if off in targets else " " print(f"{prefix}{off:5d} {name:<22} {desc}")
print(f"\nTotal: {len(insns)} instructions decoded from {len(data)} bytes")
# Top opcodes counts = Counter(name.split('(')[0] for _, name, _, _ in insns) print(f"\nTop opcodes:") for name, count in counts.most_common(20): print(f" {name}: {count}")
if __name__ == "__main__": main()디코더는 바이트코드에서 사용된 모든 DW_OP Op코드를 처리하며, 다양한 인코딩 형식(funcrel|uleb128, datarel|udata4 등)의 GNU 확장 DW_OP_GNU_encoded_addr(0xF1)도 포함한다.
$ python3 dwarf_decoder.py dwarf_vm.binLoaded 12888 bytes of DWARF expression bytecode
OFF OPCODE DESCRIPTION================================================================================ 0 GNU_encoded_addr (datarel|uleb, 8) → dbase+8 3 breg2 push rcx+(-81) 6 deref pop a; push *(uint64*)a 7 plus pop b,a; push a + b 8 deref pop a; push *(uint64*)a 9 const2u push 5377 (0x1501) ... 12884 GNU_encoded_addr (funcrel|uleb, 14000) → 0x402d0
Total: 9195 instructions decoded from 12888 bytes4.2 제약 조건 요약
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;}최종적으로 정리하면, 풀이에 쓰인 코드는 크게 네가지 아래와 같다.
| File | Description |
|---|---|
dwarf_decoder.py | DWARF 표현식 디스어셈블러 (GNU 확장 지원 DW_OP_* 디코더) |
solve.py | 메인 솔버: 바이트코드 파싱 + 제약 조건 추출 + C VM 인터페이스 + SA |
vm_emulator.c | C 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}============================================================최종 검증을 위해 바이너리에 플래그를 넣으면…

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 문자열(?)이 들어있는 리버싱 문제는 처음이라 신박했다. (내가 먼저 써먹으려고 했는데!)
아무튼 재미있었다.