0 CTF 2025 Writeup (1Zwasm)

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() 시스콜이 호출됨을 알 수 있었다.

1.png

Terminal window
(gdb) set follow-fork-mode child
(gdb) r
Starting program: /home/noonchi/ctf/1Zwαsm
Catchpoint 1 (call to syscall memfd_create), 0x00007ffff7ffe3c7 in ?? ()
(gdb) c
Continuing.
...
[Attaching after process 108066 fork to child process 108619]
[New inferior 2 (process 108619)]
process 108619 is executing new program: /proc/108619/exe
Enter the flag:hey
:(
[Inferior 2 (process 108619) exited normally]

2. 바이너리 언패킹 및 덤프#

UPX 패커가 실행할 때 memfd_create() 시스콜로 파일 두 개를 생성하고 있어서 런타임에 덤프를 떴다.

Terminal window
pgrep -f 1Zw | xargs -I{} sh -c 'echo "=== PID {} ===" && ls -la /proc/{}/fd/ 2>/dev/null'
=== PID 65619 ===
합계 0
dr-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/16
lrwx------ 1 noonchi noonchi 64 12월 20 15:39 1 -> /dev/pts/16
lrwx------ 1 noonchi noonchi 64 12월 20 15:39 2 -> /dev/pts/16
lrwx------ 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가 손상된 것을 알 수 있다:

Terminal window
wasm-objdump -h chall_wasm.bin
chall_wasm.bin: file format wasm 0x1
0000253: error: init expression must end with END opcode
000028b: error: unfinished section (expected end: 0x2e1)
000e8f2: error: ref.null type must be a reference type
module 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]_startEntryPoint
func[11]chall::main메인 함수
func[12]chall::crypto::apply_sbox_layerS-box 치환
func[23]sha1::compress::compressSHA1 압축
..등등…

추가로 데이터 세그먼트에서 정적 데이터를 추출했다:

  • 플래그 포맷: 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이 원형임을 알 수 있었다.

Terminal window
./chall
Usage:
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=VAR
Examples:
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로 넘기는 파싱 로직이 보였다.

2.png

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/maps
555576ff5000-555577016000 rw-p 00000000 00:00 0 [heap]
7cf924fec000-7cf9250fc000 rw-p 00000000 00:00 0
7cf925200000-7cf925228000 r--p 00000000 fc:00 2118957 /usr/lib/x86_64-linux-gnu/libc.so.6
7cf925228000-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 디스패처 함수를 찾는다.

3.png

코어 덤프 뜰 때마다 베이스 주소는 달라질 수 있기 때문에 i64.eqz 같은 문자열 검색해서 xref를 찾아서 타고 올라가다 보면 찾을 수 있다. (사실 expr.c 핸들러를 찾는 게 목표니 기존 expr.c 소스코드에 있던 "Unimplemented instructions" 같은 문자열 기준으로 찾으면 더 쉽다)

추출한 opcode 매핑은 아래와 같다:

64 collapsed lines
# 전체 Opcode 매핑 테이블
# base=0x7cf099725fe0 stride=32
0x00 i64.eqz 0x01 i64.sub 0x02 i64.load16_s
0x03 i64.gt_u 0x04 nop 0x05 i64.const
0x06 select 0x07 else 0x08 i64.load16_u
0x09 i64.ge_u 0x0a i64.load8_u 0x0b f64.lt
0x0c i32.gt_s 0x0d i32.lt_s 0x0e i32.load8_u
0x0f i32.rem_u 0x10 i64.store 0x11 br
0x12 f32.ne 0x13 i64.load8_s 0x14 i32.or
0x15 f32.lt 0x16 i32.add 0x17 i32.clz
0x18 i32.shr_u 0x1a f32.eq 0x1b i64.load32_u
0x1c global.get 0x1d i32.eqz 0x20 select_t
0x21 i32.div_s 0x22 i32.shr_s 0x23 i32.store8
0x24 i32.gt_u 0x25 i32.store 0x26 i64.clz
0x28 local.set 0x29 i64.le_s 0x2a f32.const
0x2b i64.le_u 0x2d f64.ge 0x2e if
0x2f i32.rotl 0x30 i32.eq 0x31 i64.load
0x33 i64.store8 0x34 f32.ge 0x35 i64.eq
0x36 global.set 0x37 i32.xor 0x38 local.get
0x39 drop 0x3a i64.gt_s 0x3d table_set
0x3e call 0x3f memory.size 0x40 f32.gt
0x41 i64.ne 0x42 local.tee 0x43 f64.ne
0x44 f64.store 0x45 i32.const 0x46 i32.ctz
0x47 i32.mul 0x48 i32.shl 0x49 return
0x4a f32.le 0x4c i32.load8_s 0x4d br_if
0x4e i32.popcnt 0x4f i32.load16_u 0x50 i32.sub
0x51 i32.lt_u 0x53 table_get 0x54 br_table
0x55 end 0x56 i64.lt_s 0x57 call_indirect
0x58 i64.lt_u 0x59 i32.load 0x5a i32.load16_s
0x5b block 0x5c unreachable 0x5d i64.mul
0x5e f64.le 0x5f i32.le_u 0x62 f64.const
0x63 f32.store 0x64 f32.load 0x65 f64.eq
0x66 i64.ctz 0x67 i32.ne 0x68 i64.add
0x69 i64.popcnt 0x6a i64.div_s 0x6b i32.ge_u
0x6d i32.rotr 0x6f i32.rem_s 0x70 memory.grow
0x71 i64.store16 0x72 i32.ge_s 0x73 i32.div_u
0x74 i64.store32 0x75 f64.load 0x76 i64.ge_s
0x77 i32.and 0x78 i64.load32_s 0x7a loop
0x7c i32.store16 0x7e f64.gt 0x7f i32.le_s
0x80 ref.null 0x81 fd 0x82 f32.convert_i32_s
0x84 i64.rem_s 0x85 i64.extend8_s 0x86 f64.ceil
0x87 i64.xor 0x88 f32.convert_i32_u 0x8a i64.trunc_f64_u
0x8e i64.extend_i32_u 0x90 f64.convert_i32_s 0x91 f32.neg
0x93 i64.trunc_f64_s 0x99 i64.shr_s 0x9b f32.convert_i64_u
0x9c f64.div 0x9e f32.copysign 0x9f f32.sub
0xa0 f64.floor 0xa3 f64.convert_i64_u 0xa5 f32.demote_f64
0xa7 i64.rotr 0xa8 f64.sub 0xaa i64.extend16_s
0xac f64.trunc 0xae i64.or 0xb1 i32.trunc_f32_u
0xb2 i64.shr_u 0xb3 i32.reinterpret_f32 0xb5 ref.is_null
0xb6 f64.min 0xb8 f64.add 0xb9 f64.neg
0xbb f32.div 0xbc i64.rotl 0xbd f64.copysign
0xbe f32.nearest 0xbf f64.reinterpret_i64 0xc1 ref.func
0xc2 f64.mul 0xc4 f64.convert_i64_s 0xc5 i32.extend16_s
0xc9 f32.trunc 0xcb f32.max 0xcc i64.extend_i32_s
0xcd f32.ceil 0xcf i64.trunc_f32_u 0xd2 f32.add
0xd3 f64.max 0xd4 f32.floor 0xd5 f32.abs
0xd7 f32.sqrt 0xd9 i32.trunc_f32_s 0xda i64.trunc_f32_s
0xdb i64.rem_u 0xdc i64.div_u 0xe0 f64.nearest
0xe1 f32.min 0xe2 i64.extend32_s 0xe3 f64.convert_i32_u
0xe4 i32.trunc_f64_s 0xe8 fc 0xec i64.shl
0xee f64.abs 0xf0 i32.extend8_s 0xf1 i64.reinterpret_f64
0xf4 f32.reinterpret_i32 0xf5 i32.trunc_f64_u 0xf8 f32.convert_i64_s
0xf9 f64.sqrt 0xfa i64.and 0xfb i32.wrap_i64
0xfc f64.promote_f32 0xfd f32.mul

실제 WASM 표준 테이블과 비교해보면, 표준 WASM에선 end가 항상 0x0B인데 여기선 end0x55로 매핑돼 있고, 반대로 표준 테이블에서 i64.sub0x7D인데 추출 테이블에선 0x01i64.sub로 되어있는 식이다.

정리#

  1. chall을 실행하면 커스텀된 toywasm이 메모리 상에 풀린 상태로 동작함
  2. 그 상태에서 프로세스 코어덤프를 뜨면, toywasm의 opcode → mnemonic 문자열 포인터 테이블도 같이 메모리에 남음
  3. core 바이너리 리버싱해서 opcode 핸들러 함수와 매핑 테이블을 찾을 수 있음
  4. (덤) 테이블 베이스를 잡고 IDA Python으로 0..255를 순회해서 0x?? mnemonic을 출력하는 식으로 자동화도 가능

7. Opcode 패치 및 디컴파일#

알아낸 매핑대로 WASM 바이너리를 패치했다. 아래는 WASM을 포맷대로 파싱해서 opcode 위치만 치환하는 코드이다:

362 collapsed lines
# Opcode 패치 스크립트
import argparse
import os
import 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 -> mnemonic
FC_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을 설치하고 패치된 바이너리를 열어보면:

4.png

코드가 보인다!

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

5.png


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] * 16
for i, v in enumerate(SBOX):
INV_SBOX[v] = i
MASK32 = 0xFFFFFFFF
MASK64 = (1 << 64) - 1
C1 = (-0x4508329480000000) & MASK64
C2 = (-0x77fccd388a106529) & MASK64
C0 = 0x225d686a
M32 = 0x1e35a7bd
ROUND_CONSTS = [
0x6bdfe07b,
0xaf92329f,
0x32553697,
0x45b21761,
0xa45b1a4b,
0xff580251,
0xd3f278fc,
0x0945406a,
0x9826331a,
0x3d0b5dce,
0x52b238a8,
0x3c67752c,
0xeb51d479,
0xf3c6b787,
0x7f7d729b,
]
SHIFT = (C1 & -C1).bit_length() - 1
K1 = C1 >> SHIFT
MOD_K1 = 1 << (64 - SHIFT)
INV_K1 = pow(K1, -1, MOD_K1)
MOD31 = 1 << 31
INV_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

0 CTF 2025 Writeup (1Zwasm)
https://ma4the.github.io/posts/rev-1zwasm-0ctf2026/
Author
m@the
Published at
2026-01-05
License
CC BY-NC-SA 4.0