Challenges: https://github.com/sajjadium/ctf-archives/tree/main/ctfs/0CTF/2025/rev/1Zwasm
1. Challenge Overview
우선 문제 바이너리를 열어보니 UPX로 패킹돼있었고, 툴로 언패킹이 불가능했다.
GDB로 붙어봤는데 이번엔 디버깅이 안 됐다. set follow-fork-mode child 명령어를 써서 트레이싱 해봤더니, memfd_create() 시스콜이 호출됨을 알 수 있었다.

(gdb) set follow-fork-mode child(gdb) rStarting program: /home/noonchi/ctf/1Zwαsm
Catchpoint 1 (call to syscall memfd_create), 0x00007ffff7ffe3c7 in ?? ()(gdb) cContinuing.
...
[Attaching after process 108066 fork to child process 108619][New inferior 2 (process 108619)]process 108619 is executing new program: /proc/108619/exeEnter the flag:hey
:([Inferior 2 (process 108619) exited normally]2. 바이너리 언패킹 및 덤프
UPX 패커가 실행할 때 memfd_create() 시스콜로 파일 두 개를 생성하고 있어서 런타임에 덤프를 떴다.
pgrep -f 1Zw | xargs -I{} sh -c 'echo "=== PID {} ===" && ls -la /proc/{}/fd/ 2>/dev/null'=== PID 65619 ===합계 0dr-x------ 2 noonchi noonchi 5 12월 20 15:39 .dr-xr-xr-x 9 noonchi noonchi 0 12월 20 15:30 ..lrwx------ 1 noonchi noonchi 64 12월 20 15:39 0 -> /dev/pts/16lrwx------ 1 noonchi noonchi 64 12월 20 15:39 1 -> /dev/pts/16lrwx------ 1 noonchi noonchi 64 12월 20 15:39 2 -> /dev/pts/16lrwx------ 1 noonchi noonchi 64 12월 20 15:39 3 -> '/memfd:chall (deleted)'lrwx------ 1 noonchi noonchi 64 12월 20 15:39 4 -> '/memfd:chall_wasm (deleted)'두 개의 파일이 메모리에 올라와 있다:
- 파일 1.
chall: toywasm 런타임 (WASM 인터프리터)cat /proc/65619/fd/3 > chall.bin
- 파일 2.
chall_wasm: Rust로 작성된 WASM 모듈cat /proc/65619/fd/4 > chall_wasm.bin
chall은 디컴파일러로 열면 UPX로 패킹돼있다고 뜨고, chall_wasm은 WASM 바이너리로 인식은 되지만 코드가 깨져 보인다.
3. WASM 바이너리 분석
chall_wasm 바이너리를 wasm-objdump로 보면, END를 비롯한 여러 opcode가 손상된 것을 알 수 있다:
wasm-objdump -h chall_wasm.bin
chall_wasm.bin: file format wasm 0x10000253: error: init expression must end with END opcode000028b: error: unfinished section (expected end: 0x2e1)000e8f2: error: ref.null type must be a reference typemodule name: <chall-7a8478e4c9508fe0.wasm>
Sections:
Type start=0x0000000b end=0x0000008c (size=0x00000081) count: 18 Import start=0x0000008f end=0x00000146 (size=0x000000b7) count: 5 Function start=0x00000149 end=0x00000237 (size=0x000000ee) count: 236 Table start=0x00000239 end=0x0000023e (size=0x00000005) count: 1 Memory start=0x00000240 end=0x00000243 (size=0x00000003) count: 1 Global start=0x00000245 end=0x00000253 (size=0x0000000e) count: 2 Export start=0x00000255 end=0x00000276 (size=0x00000021) count: 3 Elem start=0x00000278 end=0x000002e1 (size=0x00000069) count: 1 Code start=0x000002e5 end=0x0000e253 (size=0x0000df6e) count: 236 Data start=0x0000e256 end=0x00011490 (size=0x0000323a) count: 2 Custom start=0x00011493 end=0x00011537 (size=0x000000a4) "target_features" Custom start=0x0001153a end=0x000153f7 (size=0x00003ebd) "name" Custom start=0x000153fa end=0x000154ba (size=0x000000c0) "producers"마찬가지로 wasm-objdump로 확인한 함수 심볼 등으로 추측컨대, 전체 흐름이 대충 **“입력 문자[i] → apply_sbox_layer(???) → SHA1 → 타겟 해시[i]와 비교”**이고, 구체적인 로직을 알아내서 역연산을 해야 한다는 걸 알 수 있다.
| 함수 | 이름 | 설명 |
|---|---|---|
| func[6] | _start | EntryPoint |
| func[11] | chall::main | 메인 함수 |
| func[12] | chall::crypto::apply_sbox_layer | S-box 치환 |
| func[23] | sha1::compress::compress | SHA1 압축 |
| .. | 등등… | … |
추가로 데이터 세그먼트에서 정적 데이터를 추출했다:
- 플래그 포맷:
0ops{...} - 타겟 해시 테이블: 해시 40개
- S-box 테이블: 16바이트
0e 04 0d 01 02 0f 0b 08 03 0a 06 0c 05 09 00 07
4. toywasm 런타임 식별
덤프 뜬 chall을 실행해보니 toywasm이 원형임을 알 수 있었다.
./challUsage: toywasm [OPTIONS] [--] <MODULE> [WASI-ARGS...]Options: --allow-unresolved-functions --disable-jump-table --disable-localtype-cellidx --disable-resulttype-cellidx --invoke FUNCTION[ FUNCTION_ARGS...] --load MODULE_PATH --max-frames NUMBER_OF_FRAMES --max-memory MEMORY_LIMIT_IN_BYTES --max-stack-cells NUMBER_OF_CELLS --repl --repl-prompt STRING --print-build-options --print-stats --timeout TIMEOUT_MS --version --wasi --wasi-dir HOST_DIR[::GUEST_DIR] --wasi-env NAME=VARExamples: Run a wasi module toywasm --wasi module Load a module and invoke its function toywasm --load module --invoke "func arg1 arg2"원본 toywasm의 오픈소스 코드를 뜯어보니, opcode를 테이블로 해석하고 각 instruction handler로 넘기는 파싱 로직이 보였다.

toywasm expr.c — opcode 디스패치 테이블
이쯤에서 이 문제는 커스텀 opcode를 정의한 VM-like 문제가 아닐까 하는 생각을 했다.
5. 커스텀 Opcode 가설
근거 1. 모든 236개 함수가 0x55로 끝남
- Code 섹션을 WASM 포맷대로 파싱해서 각 함수 body의 마지막 1바이트 값을 확인해본 결과 모두
0x55로 끝남 - 표준 WASM에서는
0x0b(END)로 끝나야 하기 때문에,0x55 → 0x0b이렇게 치환된 게 아닐까 추측
근거 2. 바이트 빈도 분포가 비정상적임
0x38: 7193회 (원래f32.store인데 이렇게 많을 수 없음)0x45: 4441회 (원래i32.eqz)0x55: 1459회 (원래i64.gt_u)0x0b: 227회 (원래end인데 너무 적음!)
여기서 명백한 사실은, 커스텀 인터프리터는 커스텀 WASM 바이너리의 바이트코드를 올바르게 해석하고 실행하고 있다는 것이다.
즉, 멋대로 뒤섞인 opcode를 표준 opcode 테이블로 디스어셈블하면 니모닉이 틀리는 게 당연하고, 디스패처 역할을 하는 함수를 찾고 매핑 테이블을 알아내서 패치만 해주면 일반 디컴파일러로도 해석이 가능해질 것이다.
이 가설을 세우고 언패킹된 toywasm을 덤프 떠서 실제 사용되는 opcode 핸들러를 추적하고자 했다.
6. 코어 덤프를 통한 Opcode 매핑 추출
./chall --wasi ./chall_wasm 으로 실행한 뒤 프로세스 메모리 맵을 확인:
cat /proc/16222/maps555576ff5000-555577016000 rw-p 00000000 00:00 0 [heap]7cf924fec000-7cf9250fc000 rw-p 00000000 00:00 07cf925200000-7cf925228000 r--p 00000000 fc:00 2118957 /usr/lib/x86_64-linux-gnu/libc.so.67cf925228000-7cf9253b0000 r-xp 00028000 fc:00 2118957 /usr/lib/x86_64-linux-gnu/libc.so.6...7cf925572000-7cf9255b3000 r-xs 00000000 00:01 6031 /memfd:upx (deleted)...앞서 말했듯 chall은 UPX로 패킹돼있기 때문에 런타임에 덤프를 떠야 한다. gcore -o core <PID>로 전체 덤프를 뜨면 대강 이런 구조:
┌─────────────────────────────────────────────────────────────┐│ core.??? (2.2MB) - 프로세스 메모리 스냅샷││ 내용:│ ├── libc.so.6 (~1.5MB) - 시스템 라이브러리│ ├── ld-linux.so (~200KB) - 동적 링커│ ├── libm.so (~500KB) - 수학 라이브러리│ ├── memfd:upx (266KB) <- 언패킹된 toywasm│ ├── 힙, 스택│ └── WASM 메모리└─────────────────────────────────────────────────────────────┘바이너리를 분석해 opcode 디스패처 함수를 찾는다.

코어 덤프 뜰 때마다 베이스 주소는 달라질 수 있기 때문에 i64.eqz 같은 문자열 검색해서 xref를 찾아서 타고 올라가다 보면 찾을 수 있다. (사실 expr.c 핸들러를 찾는 게 목표니 기존 expr.c 소스코드에 있던 "Unimplemented instructions" 같은 문자열 기준으로 찾으면 더 쉽다)
추출한 opcode 매핑은 아래와 같다:
64 collapsed lines
# 전체 Opcode 매핑 테이블# base=0x7cf099725fe0 stride=320x00 i64.eqz 0x01 i64.sub 0x02 i64.load16_s0x03 i64.gt_u 0x04 nop 0x05 i64.const0x06 select 0x07 else 0x08 i64.load16_u0x09 i64.ge_u 0x0a i64.load8_u 0x0b f64.lt0x0c i32.gt_s 0x0d i32.lt_s 0x0e i32.load8_u0x0f i32.rem_u 0x10 i64.store 0x11 br0x12 f32.ne 0x13 i64.load8_s 0x14 i32.or0x15 f32.lt 0x16 i32.add 0x17 i32.clz0x18 i32.shr_u 0x1a f32.eq 0x1b i64.load32_u0x1c global.get 0x1d i32.eqz 0x20 select_t0x21 i32.div_s 0x22 i32.shr_s 0x23 i32.store80x24 i32.gt_u 0x25 i32.store 0x26 i64.clz0x28 local.set 0x29 i64.le_s 0x2a f32.const0x2b i64.le_u 0x2d f64.ge 0x2e if0x2f i32.rotl 0x30 i32.eq 0x31 i64.load0x33 i64.store8 0x34 f32.ge 0x35 i64.eq0x36 global.set 0x37 i32.xor 0x38 local.get0x39 drop 0x3a i64.gt_s 0x3d table_set0x3e call 0x3f memory.size 0x40 f32.gt0x41 i64.ne 0x42 local.tee 0x43 f64.ne0x44 f64.store 0x45 i32.const 0x46 i32.ctz0x47 i32.mul 0x48 i32.shl 0x49 return0x4a f32.le 0x4c i32.load8_s 0x4d br_if0x4e i32.popcnt 0x4f i32.load16_u 0x50 i32.sub0x51 i32.lt_u 0x53 table_get 0x54 br_table0x55 end 0x56 i64.lt_s 0x57 call_indirect0x58 i64.lt_u 0x59 i32.load 0x5a i32.load16_s0x5b block 0x5c unreachable 0x5d i64.mul0x5e f64.le 0x5f i32.le_u 0x62 f64.const0x63 f32.store 0x64 f32.load 0x65 f64.eq0x66 i64.ctz 0x67 i32.ne 0x68 i64.add0x69 i64.popcnt 0x6a i64.div_s 0x6b i32.ge_u0x6d i32.rotr 0x6f i32.rem_s 0x70 memory.grow0x71 i64.store16 0x72 i32.ge_s 0x73 i32.div_u0x74 i64.store32 0x75 f64.load 0x76 i64.ge_s0x77 i32.and 0x78 i64.load32_s 0x7a loop0x7c i32.store16 0x7e f64.gt 0x7f i32.le_s0x80 ref.null 0x81 fd 0x82 f32.convert_i32_s0x84 i64.rem_s 0x85 i64.extend8_s 0x86 f64.ceil0x87 i64.xor 0x88 f32.convert_i32_u 0x8a i64.trunc_f64_u0x8e i64.extend_i32_u 0x90 f64.convert_i32_s 0x91 f32.neg0x93 i64.trunc_f64_s 0x99 i64.shr_s 0x9b f32.convert_i64_u0x9c f64.div 0x9e f32.copysign 0x9f f32.sub0xa0 f64.floor 0xa3 f64.convert_i64_u 0xa5 f32.demote_f640xa7 i64.rotr 0xa8 f64.sub 0xaa i64.extend16_s0xac f64.trunc 0xae i64.or 0xb1 i32.trunc_f32_u0xb2 i64.shr_u 0xb3 i32.reinterpret_f32 0xb5 ref.is_null0xb6 f64.min 0xb8 f64.add 0xb9 f64.neg0xbb f32.div 0xbc i64.rotl 0xbd f64.copysign0xbe f32.nearest 0xbf f64.reinterpret_i64 0xc1 ref.func0xc2 f64.mul 0xc4 f64.convert_i64_s 0xc5 i32.extend16_s0xc9 f32.trunc 0xcb f32.max 0xcc i64.extend_i32_s0xcd f32.ceil 0xcf i64.trunc_f32_u 0xd2 f32.add0xd3 f64.max 0xd4 f32.floor 0xd5 f32.abs0xd7 f32.sqrt 0xd9 i32.trunc_f32_s 0xda i64.trunc_f32_s0xdb i64.rem_u 0xdc i64.div_u 0xe0 f64.nearest0xe1 f32.min 0xe2 i64.extend32_s 0xe3 f64.convert_i32_u0xe4 i32.trunc_f64_s 0xe8 fc 0xec i64.shl0xee f64.abs 0xf0 i32.extend8_s 0xf1 i64.reinterpret_f640xf4 f32.reinterpret_i32 0xf5 i32.trunc_f64_u 0xf8 f32.convert_i64_s0xf9 f64.sqrt 0xfa i64.and 0xfb i32.wrap_i640xfc f64.promote_f32 0xfd f32.mul실제 WASM 표준 테이블과 비교해보면, 표준 WASM에선 end가 항상 0x0B인데 여기선 end가 0x55로 매핑돼 있고, 반대로 표준 테이블에서 i64.sub는 0x7D인데 추출 테이블에선 0x01이 i64.sub로 되어있는 식이다.
정리
chall을 실행하면 커스텀된 toywasm이 메모리 상에 풀린 상태로 동작함- 그 상태에서 프로세스 코어덤프를 뜨면, toywasm의 opcode → mnemonic 문자열 포인터 테이블도 같이 메모리에 남음
- core 바이너리 리버싱해서 opcode 핸들러 함수와 매핑 테이블을 찾을 수 있음
- (덤) 테이블 베이스를 잡고 IDA Python으로 0..255를 순회해서
0x?? mnemonic을 출력하는 식으로 자동화도 가능
7. Opcode 패치 및 디컴파일
알아낸 매핑대로 WASM 바이너리를 패치했다. 아래는 WASM을 포맷대로 파싱해서 opcode 위치만 치환하는 코드이다:
362 collapsed lines
# Opcode 패치 스크립트import argparseimport osimport re
def uleb(d: bytes, o: int) -> tuple[int, int]: r = 0 s = 0 i = 0 while True: b = d[o + i] r |= (b & 0x7F) << s i += 1 if b < 0x80: return r, i s += 7 if i > 16: raise ValueError("uleb too long")
def read_uleb_bytes(d: bytes, o: int) -> tuple[bytes, int]: i = 0 while True: b = d[o + i] i += 1 if b < 0x80: return d[o : o + i], i if i > 16: raise ValueError("uleb bytes too long")
def read_sleb_bytes(d: bytes, o: int) -> tuple[bytes, int]: i = 0 while True: b = d[o + i] i += 1 if b < 0x80: return d[o : o + i], i if i > 16: raise ValueError("sleb bytes too long")
def encode_uleb(n: int) -> bytes: out = bytearray() while True: b = n & 0x7F n >>= 7 if n: out.append(b | 0x80) else: out.append(b) return bytes(out)
def parse_sections(wasm: bytes) -> list[tuple[int, int, int]]: if wasm[:4] != b"\0asm": raise ValueError("not wasm") o = 8 secs: list[tuple[int, int, int]] = [] while o < len(wasm): sid = wasm[o] o += 1 sz, n = uleb(wasm, o) o += n secs.append((sid, o, o + sz)) o += sz return secs
def sec_range(secs: list[tuple[int, int, int]], sid: int) -> tuple[int, int] | None: for s, a, b in secs: if s == sid: return a, b return None
def load_opdump(path: str) -> dict[int, str]: enc2name: dict[int, str] = {} with open(path, "r", encoding="utf-8", errors="ignore") as f: for line in f: line = line.strip() if not line or line.startswith("#"): continue k, name = line.split(None, 1) enc2name[int(k, 16)] = name.strip() return enc2name
_RE_INSN = re.compile(r'INSTRUCTION\(\s*(0x[0-9a-fA-F]+)\s*,\s*"([^"]+)"')
def load_std_maps(repo_root: str) -> tuple[dict[str, int], dict[str, int]]: def parse_hdr(p: str) -> dict[str, int]: m: dict[str, int] = {} with open(p, "r", encoding="utf-8", errors="ignore") as f: for ln in f: mo = _RE_INSN.search(ln) if mo: m[mo.group(2)] = int(mo.group(1), 0) return m
base_p = os.path.join(repo_root, "toywasm", "lib", "insn_list_base.h") fc_p = os.path.join(repo_root, "toywasm", "lib", "insn_list_fc.h") if not (os.path.exists(base_p) and os.path.exists(fc_p)): raise SystemExit("need toywasm headers: toywasm/lib/insn_list_base.h + insn_list_fc.h")
std = parse_hdr(base_p) std_fc = parse_hdr(fc_p) if "table.grew" in std_fc: std_fc["table.grow"] = std_fc.pop("table.grew") return std, std_fc
# encoded 0xfc subopcode -> mnemonicFC_ENC2NAME: dict[int, str] = { 7: "i64.trunc_sat_f32_u", 39: "memory.copy", 85: "memory.fill",}
def rewrite_init_expr(buf: bytes, enc2name: dict[int, str], std: dict[str, int]) -> tuple[bytes, int]: out = bytearray() i = 0 while i < len(buf): op = buf[i] i += 1 name = enc2name.get(op) if not name: raise ValueError(f"unknown encoded opcode 0x{op:02x} in init-expr") if name in ("fc", "fd"): raise ValueError("prefix in init-expr not supported")
out.append(std[name]) if name in ("i32.const", "i64.const"): raw, n = read_sleb_bytes(buf, i) i += n out += raw elif name == "f32.const": out += buf[i : i + 4] i += 4 elif name == "f64.const": out += buf[i : i + 8] i += 8 elif name == "global.get": raw, n = read_uleb_bytes(buf, i) i += n out += raw elif name == "ref.null": out.append(buf[i]) i += 1 elif name == "ref.func": raw, n = read_uleb_bytes(buf, i) i += n out += raw
if name == "end": return bytes(out), i raise ValueError("init-expr didn't end with end")
def rewrite_code(code: bytes, enc2name: dict[int, str], std: dict[str, int], std_fc: dict[str, int]) -> bytes: out = bytearray() i = 0 while i < len(code): op = code[i] i += 1 name = enc2name.get(op) if not name: raise ValueError(f"unknown encoded opcode 0x{op:02x} at +0x{i-1:x}")
if name == "fd": raise ValueError("0xfd(SIMD) not supported")
if name == "fc": enc_sub, _ = uleb(code, i) _raw, n = read_uleb_bytes(code, i) i += n sub_name = FC_ENC2NAME.get(enc_sub) if not sub_name: raise ValueError(f"unknown encoded 0xfc subopcode {enc_sub}") std_sub = std_fc.get(sub_name) if std_sub is None: raise ValueError(f"unknown std 0xfc mnemonic {sub_name!r}")
out.append(0xFC) out += encode_uleb(std_sub)
# fc immediates if 0x00 <= std_sub <= 0x07: pass elif sub_name == "memory.init": raw, n = read_uleb_bytes(code, i); i += n; out += raw raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name == "data.drop": raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name == "memory.copy": raw, n = read_uleb_bytes(code, i); i += n; out += raw raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name == "memory.fill": raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name == "table.init": raw, n = read_uleb_bytes(code, i); i += n; out += raw raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name == "elem.drop": raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name == "table.copy": raw, n = read_uleb_bytes(code, i); i += n; out += raw raw, n = read_uleb_bytes(code, i); i += n; out += raw elif sub_name in ("table.grow", "table.size", "table.fill"): raw, n = read_uleb_bytes(code, i); i += n; out += raw else: raise ValueError(f"unsupported 0xfc mnemonic {sub_name!r}") continue
out.append(std[name])
# immediates: length-only if name in ("block", "loop", "if"): b = code[i] if b in {0x40, 0x7F, 0x7E, 0x7D, 0x7C, 0x7B, 0x70, 0x6F}: out.append(b); i += 1 else: raw, n = read_sleb_bytes(code, i); i += n; out += raw elif name in ("br", "br_if", "call", "local.get", "local.set", "local.tee", "global.get", "global.set", "table_get", "table_set"): raw, n = read_uleb_bytes(code, i); i += n; out += raw elif name == "call_indirect": raw, n = read_uleb_bytes(code, i); i += n; out += raw out.append(code[i]); i += 1 elif name == "br_table": raw_cnt, n = read_uleb_bytes(code, i) cnt, _ = uleb(code, i) i += n out += raw_cnt for _ in range(cnt + 1): raw, n = read_uleb_bytes(code, i); i += n; out += raw elif name == "select_t": raw_cnt, n = read_uleb_bytes(code, i) cnt, _ = uleb(code, i) i += n out += raw_cnt out += code[i : i + cnt] i += cnt elif ".load" in name or ".store" in name: raw, n = read_uleb_bytes(code, i); i += n; out += raw raw, n = read_uleb_bytes(code, i); i += n; out += raw elif name in ("memory.size", "memory.grow"): out.append(code[i]); i += 1 elif name in ("i32.const", "i64.const"): raw, n = read_sleb_bytes(code, i); i += n; out += raw elif name == "f32.const": out += code[i : i + 4]; i += 4 elif name == "f64.const": out += code[i : i + 8]; i += 8 elif name == "ref.null": out.append(code[i]); i += 1 elif name == "ref.func": raw, n = read_uleb_bytes(code, i); i += n; out += raw
return bytes(out)
def rewrite_module(wasm: bytes, enc2name: dict[int, str], std: dict[str, int], std_fc: dict[str, int]) -> bytes: secs = parse_sections(wasm) out = bytearray(wasm)
# globals (6) gr = sec_range(secs, 6) if gr: gs, ge = gr p = gs gcount, n = uleb(out, p); p += n for _ in range(gcount): p += 2 rw, used = rewrite_init_expr(bytes(out[p:ge]), enc2name, std) out[p : p + used] = rw p += used
# elem (9) - active segments(flags 0/2)만 er = sec_range(secs, 9) if er: es, ee = er p = es segcnt, n = uleb(out, p); p += n for _ in range(segcnt): flags, n = uleb(out, p); p += n if flags not in (0, 2): raise ValueError(f"unsupported elem flags {flags}") if flags == 2: _, n = uleb(out, p); p += n rw, used = rewrite_init_expr(bytes(out[p:ee]), enc2name, std) out[p : p + used] = rw p += used cnt, n = uleb(out, p); p += n for _j in range(cnt): _, n = uleb(out, p); p += n
# data (11) dr = sec_range(secs, 11) if dr: ds, de = dr p = ds segcnt, n = uleb(out, p); p += n for _ in range(segcnt): flags, n = uleb(out, p); p += n if flags & 0x02: _, n = uleb(out, p); p += n if (flags & 0x01) == 0: rw, used = rewrite_init_expr(bytes(out[p:de]), enc2name, std) out[p : p + used] = rw p += used sz, n = uleb(out, p); p += n p += sz
# code (10) cr = sec_range(secs, 10) if not cr: raise ValueError("no code section") cs, _ce = cr p = cs fcnt, n = uleb(out, p); p += n for fi in range(fcnt): body_size, nsz = uleb(out, p); p += nsz bs = p be = p + body_size body = bytes(out[bs:be]) q = 0 lgc, nn = uleb(body, q); q += nn for _ in range(lgc): _, nn = uleb(body, q); q += nn q += 1 rw = rewrite_code(body[q:], enc2name, std, std_fc) if len(rw) != len(body) - q: raise ValueError(f"code size changed at func {fi}; need section rebuild") out[bs + q : be] = rw p = be
return bytes(out)
def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--wasm", required=True) ap.add_argument("--opdump", required=True) ap.add_argument("--out", required=True) args = ap.parse_args()
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) std, std_fc = load_std_maps(repo_root) enc2name = load_opdump(args.opdump) wasm = open(args.wasm, "rb").read() out = rewrite_module(wasm, enc2name, std, std_fc) open(args.out, "wb").write(out) print("[ok]", args.out, "size", len(out)) return 0
if __name__ == "__main__": raise SystemExit(main())Ghidra Wasm plugin을 설치하고 패치된 바이너리를 열어보면:

코드가 보인다!
제일 궁금했던 apply_sbox_layer 함수 로직은 64비트 값을 4비트 조각으로 쪼개 S-box 테이블(DAT_ram_00100abc)로 바꾼 뒤 다시 64비트로 합치는 로직이었다.

8. 솔브 코드
import hashlib
HASHES = [ "4462fc5ac9dda45e1f956bfa9159c148906ea2eb", "50afeeeba0e2674462f21f8286bb8fa20f91e882", "271d1c2edfb9d5c6506be73c695958b035a2fbb4", "82ef770e9de4f8ad66071b95e69cc80998cc6154", "5a6778089ec6ecf9abc84348af33e761e30f0368", "c839f2e7c4701924709bc0b9fa339cb7d361102e", "e93f555173093854f0588ae859de9caf686213d4", "3e0bd1fb0cc18b23941f3a015a0826071f9526a6", "1df3f80cd3b0c34360f9e7c56378fba862d05e0b", "6c144bf262c8e54719cd1e056ca6ede306161182", "983b5bc62c686e987e252de3548cd17ce2f0c998", "64e63929dc2c52e93a26ea72c6c5d00db839d2c4", "afb7023a362d94e0bfdb33e2a8dced45ae87aa0b", "c5b02ef1d8f7a31950ab219e6258355fd81a218e", "58511d70859435ea1e4a4a64ebab7aff01e8ccad", "2f58d45d2c6cdf5421d41af2b08778abf0e3d525", "deeb5ed18ce5d39b0f0be9a7f9ccd9bdf8221753", "8bae2558d08c0bc17e80192fbc398436e6934d55", "b6a03cd5819d84f9babf9e80291b08c6901cfa5d", "2d91a20a20fcaeb0ae60b5189b810bdf8481b1d7", "5a930b172e356b7e4d9e3e0560863ca7794a87d8", "72f676227f09ea59b4c176786b43d5638709e594", "4acf5137e8896d3de00cb2fe97cbbbd5c00ae443", "b6f116748b1a85fd0514666b220f3acd73c05863", "a5522b2b6e176d64dc1d064264d43704e31b7325", "1ea1c9dea1b08dc9cb3834d66e4ac48c28e3cf30", "f5b378b0e1eed620d0522de40427a59ecb1d1b06", "b6d3b5ebc1b4ed96d33702e18796846d4a07996c", "c4e45262d2f681f87ca7624dab7589df0daec44e", "fd1a1247655fccb5ccf1689f99789a30e69440fa", "4a7008477bfd6c0bb38b44cbfe1f578230f09c34", "2eebddd42c73f4df038bcedf24d2a9fda3e24b96", "416a237b97883bf34bcbf14f9b84bc8111d369dd", "814daf9e0d4106a254fe39a4019338377db99300", "c70544086b038f41391a84815e6a3cb5c8e7405a", "f36ef8a4827781edca736f121fab55d61ad2a902", "e737b3e6a9d2f5d36c930e0f825813ae1f86e2ee", "2789186ff38370bb23b45775f9bcf8b3bfb72ddc", "001c5fcd727e9e8a1af79b5ef966771b5b5008ef", "d685f409ea233df5eba3658a4547ddd78485d2fa",]
SBOX = [0x0e, 0x04, 0x0d, 0x01, 0x02, 0x0f, 0x0b, 0x08, 0x03, 0x0a, 0x06, 0x0c, 0x05, 0x09, 0x00, 0x07]INV_SBOX = [0] * 16for i, v in enumerate(SBOX): INV_SBOX[v] = i
MASK32 = 0xFFFFFFFFMASK64 = (1 << 64) - 1
C1 = (-0x4508329480000000) & MASK64C2 = (-0x77fccd388a106529) & MASK64C0 = 0x225d686aM32 = 0x1e35a7bdROUND_CONSTS = [ 0x6bdfe07b, 0xaf92329f, 0x32553697, 0x45b21761, 0xa45b1a4b, 0xff580251, 0xd3f278fc, 0x0945406a, 0x9826331a, 0x3d0b5dce, 0x52b238a8, 0x3c67752c, 0xeb51d479, 0xf3c6b787, 0x7f7d729b,]
SHIFT = (C1 & -C1).bit_length() - 1K1 = C1 >> SHIFTMOD_K1 = 1 << (64 - SHIFT)INV_K1 = pow(K1, -1, MOD_K1)MOD31 = 1 << 31INV_C2_31 = pow(C2 % MOD31, -1, MOD31)
def g(u8): return (SBOX[u8 >> 4] << 17) | (SBOX[u8 & 0xF] << 13)
def f32(u): return ((u * M32) ^ g(u & 0xFF)) & MASK32
def inv_sbox_layer(x): out = 0 for i in range(16): out |= INV_SBOX[(x >> (4 * i)) & 0xF] << (4 * i) return out
def bswap64(x): return int.from_bytes(x.to_bytes(8, "little"), "big")
def inv_mix(p1, u): t0 = ((p1 >> SHIFT) * INV_K1) % MOD_K1 a = (t0 * C2) & MASK64 k = (u - (a >> 33)) & (MOD31 - 1) k = (k * INV_C2_31) % MOD31 return (t0 + (k << (64 - SHIFT))) & MASK64
def recover_words(): table = {hashlib.sha1(v.to_bytes(2, "little")).hexdigest(): v for v in range(65536)} return [table[h] for h in HASHES]
def recover_block(out64): b = out64.to_bytes(8, "little") u = b[7] | (b[6] << 8) | (b[5] << 16) | ((b[4] & 0x7F) << 24) p1 = ((b[4] & 0x80) << 24) | (b[3] << 32) | (b[2] << 40) | (b[1] << 48) | (b[0] << 56)
t = inv_mix(p1, u) v = inv_sbox_layer(t) A = v & MASK32 B = (v >> 32) & MASK32
A ^= f32(B ^ ROUND_CONSTS[-1]) for i in range(len(ROUND_CONSTS) - 2, -1, -1): if i % 2 == 0: A ^= f32(B ^ ROUND_CONSTS[i]) else: B ^= f32(A ^ ROUND_CONSTS[i])
H = B ^ f32(A ^ C0) p1 = (A & 0x80000000) | (H << 32) u = A & 0x7FFFFFFF y = inv_mix(p1, u)
x = bswap64(inv_sbox_layer(y)) return x.to_bytes(8, "little")
def main(): w = recover_words() data = b"".join( recover_block(w[i] | (w[i + 1] << 16) | (w[i + 2] << 32) | (w[i + 3] << 48)) for i in range(0, len(w), 4) ) print(data.rstrip(b"\x00").decode())
if __name__ == "__main__": main()Flag
0ops{𝞃ĥ|ß_ǐ$_Ʀ³αIıу_Ą_Ślmp1ȩ_Ŵǟ5ɱ_cHªIIεnɠ𝛆()}
9. 결론
한번 코드가 노출되면 아무리 어려운 로직이어도 LLM이 쓱싹 풀어줄 수도 있는데, 여기에 포렌식 요소를 더해서 스크램블된 opcode를 패치해 바이너리를 복원하게끔 하는 출제 의도가 좋았다.
물론 CTF 서버에서 어떤 분이 LLM이 잘 풀어주더라며 코멘트 하기도 했지만, 적어도 원샷에 풀 수 있는 문제는 아니었다고 생각한다.
더 디깅하고 싶은데 시간이 없는 관계로 이 정도만 적을게요 그럼 20000