TL;DR
Go 1.22.2 바이너리 VM
Analysis
Default
주요 패키지: Go_Mixer/internal/vvvv, Go_Mixer/internal/oracle
prompt must main 흐름은 크게 다음과 같다.
- prompt() 로 문자열 받기
- strings.TrimSpace로 개행 정리
- Go_Mixer/internal/deriv 함수 3개 호출로 VM 초기 데이터 복호화
- vvvv. (*Machine). Run 으로 VM 실행
- 정답 테이블과 비교해 일치하면 플래그 출력
fmt.Fprintln 부분부터 추적했다.
주요 함수
Go_Mixer/internal/vvvv.(*Machine).preloadInputBERev(0x4861c0)Go_Mixer/internal/vvvv.localMap(0x486300)Go_Mixer/internal/vvvv.(*Machine).Run(0x486400)Go_Mixer/internal/vvvv.adsfmdslfalf(0x486a60)Go_Mixer/internal/vvvv.cxzcxz(0x486b20)Go_Mixer/internal/vvvv.udsmlkf(0x486c00)Go_Mixer/internal/vvvv.ccxvl(0x486fa0)Go_Mixer/internal/vvvv.xuwml(0x487320)Go_Mixer/internal/vvvv.xvcmxlvx(0x487620)Go_Mixer/internal/vvvv.quwerw(0x4874a0)main.must(0x487e60)
VM Machine
main.main에서 runtime_newobject(&RTYPE_vvvv_Machine, ...) 호출 이후 구조체 필드를 채우는 부분이 있다.
(gdb) x/4gx 0x5314200x531420: 0x7233765f79723376 0x7433726333735f790x531430: 0x7230665f79336b5f 0x6861685f7530795fpreloadInputBERev 단계에서 DjNv3ry_v3ry_s3cr3t_k3y_f0r_y0u_hah이 메모리에 적재됐다.
(gdb) x/16gx 0x5316600x531660: 0x82ce0803 0xed0a0ade 0x5eb83efd 0xdd86d41a..VM이 최종 비교에 사용하는 16개의 32비트 값.
AES-CTR
Go_Mixer/internal/deriv.xorCTR 를 보면 키 문자열을 두 덩이(16바이트 nonce + 16바이트 key)로 쪼개 crypto/aes + cipher.NewCTR를 사용한다. VM용 S-Box와 곱셈 상수를 복호화한다.
vvvv.Machine에는 다음 값이 채워진다.
machine.sbox= 키 앞 16바이트machine.mul=0x4E6A44B9machine.key= 전체 32바이트machine.mem= 길이 128짜리 uint32 배열
sbox 030e010a04090506080b0f020d0c0007 mul 1315587257 target ['0x82ce0803', '0xed0a0ade', '0x5eb83efd', '0xdd86d41a', '0xc635b860', '0x2115b7f1', '0xf57d3092', '0x17a52348', '0x223c75ae', '0xdf525a75', '0x3773e5f4', '0xfd0e81a6', '0x87f325a8', '0x5cd21a47', '0x2290027e', '0x74d1bfed']VM Instruction
바이너리 안에는 opcode 스트림이 그대로 들어 있다. 주소 0x535DA0 부근을 덤프하면 된다.
gdb -batch -ex 'dump binary memory instructions.bin 0x535da0 0x535da0+0x133f' ./gomixer파일 구조는 [seed:uint32][length:uint16][body:length bytes]
seed 값은 0x13579bdf, 길이는 0x1335 (4917)이다.
remap 테이블
Go_Mixer/internal/vvvv.localMap 함수는 seed를 기반으로 16개짜리 테이블을 만든다.
LFSR처럼 난수를 섞어 0~10까지의 숫자를 shuffle 한 후 나머지는 0xFF로 채우는 구조였다.
def compute_remap(seed): s = seed & 0xFFFFFFFF arr = list(range(11)) for i in range(10, 0, -1): s = ((s << 13) ^ s) & 0xFFFFFFFF s = (s ^ (s >> 7)) & 0xFFFFFFFF s = (s ^ (s << 17)) & 0xFFFFFFFF j = s % (i + 1) arr[i], arr[j] = arr[j], arr[i] return arr + [0xFF] * (16 - len(arr))하위 nibble을 remap 테이블에 통과시켜 handler index로 사용한다. 이 테이블을 적용하지 않으면 엉뚱한 함수가 호출되어 디코딩이 틀어진다.
param 계산 규칙
opcode가 추가 파라미터를 필요로 하면 한 바이트를 더 읽는다. 이 바이트를 두 nibble로 나눠 S-box 인덱스로 사용한 뒤 seed와 xor한다.
low = sbox[arg & 0xF]high = sbox[arg >> 4]param = (seed ^ (seed >> 7) ^ (low | (high << 4))) & 0xFFFFFFFFparam & 0xFF 는 실제 메모리 인덱스로 쓰인다. 전체 명령어를 훑어보면 인덱스는 0~127 범위에서 93개가 등장했다.
핸들러
Go 함수 이름이 난수이지만, 기능은 다음과 같다.
| idx | address | desc |
|---|---|---|
| 1 | 0x486b20 | mem[param & 0xFF] push (값과 bitwise NOT 두 개를 쌍으로 저장) |
| 2 | 0x486cc0 | pop nibble 치환 MUL 상수 곱 push |
| 3 | 0x486c00 | pop mem[param & 0xFF]에 저장 |
| 5 | 0x486fa0 | (flag=0) 좌회전, (flag=1) 우회전 |
| 7 | 0x487320 | pop 두 개 XOR |
- push/pop 시 값과 bitwise NOT을 함께 관리한다. XOR 해서 0xFFFFFFFF가 아니면 panic.
fdsk는 4바이트를 nibble 단위로 치환한 후 0x4E6A44B9와 곱한다.- rotate의 경우 flag는 토글 명령어 0xEE를 만나야 1이 되는데, 명령어 스트림에 0xEE가 없어서 항상 0이다.
- add/multiply 등 다른 핸들러는 등록만 되어 있고 사용되지 않는다.
이제 이걸 토대로 에뮬레이터를 구성해보자.
Emulator
renmap 테이블
def compute_remap(seed: int) -> t.List[int]: mask = 0xFFFFFFFF state = seed & mask arr = list(range(11)) for i in range(10, 0, -1): state = ((state << 13) ^ state) & mask state = (state ^ (state >> 7)) & mask state = (state ^ (state << 17)) & mask j = state % (i + 1) arr[i], arr[j] = arr[j], arr[i] return arr + [0xFF] * (16 - len(arr))Go 바이너리의 localMap 함수를 그대로 재현했다.
seed 기반 LFSR로 11개 숫자를 섞고 나머지는 0xFF로 채우는 구조다.
하위 nibble을 remap해야 handler index가 올바르게 나온다. (Remap을 무시하면 완전히 다른 함수가 호출된다.)
opcode 파싱, param 복호화
def load_program(path: str) -> t.List[t.Tuple[t.Union[int, str], int]]: blob = open(path, "rb").read() seed_hdr, length = struct.unpack_from("<IH", blob, 0) if seed_hdr != SEED: raise ValueError("unexpected seed header") prog = blob[6: 6 + length]
pc = 0 instructions: t.List[t.Tuple[t.Union[int, str], int]] = [] while pc < len(prog): opcode = prog[pc]; pc += 1 if opcode == 0xEE: instructions.append(("toggle", 0)) continue if opcode == 0xF0: instructions.append(("ret", 0)) break
idx = REMAPPED[opcode & 0xF] if idx == 0xFF: raise ValueError("bad opcode") if idx in NEEDS_PARAM: arg = prog[pc]; pc += 1 low = SBOX[arg & 0xF] high = SBOX[arg >> 4] param = (SEED ^ (SEED >> 7) ^ (low | (high << 4))) & 0xFFFFFFFF else: param = 0 instructions.append((idx, param)) return instructionsgdb로 메모리를 덤프해서 추출한 instructions.bin은 [seed][length][body] 구조다. seed가 일치하는지 검증해 안전하게 파싱한다.
파라미터는 nibble 단위 S-Box를 거친 뒤 seed와 XOR해야 Go VM과 동일한 값이 나온다. 이는 gdb에서 원본 함수 로그를 따라가며 확인했다.
![[instruction.bin.png]]
VM 실행 로직
def run_vm(bytes_vec: t.Sequence[BitVecRef]) -> t.List[BitVecRef]: mem: t.List[BitVecRef] = [BitVecVal(0, 32) for _ in range(128)] rev = list(reversed(bytes_vec)) for index in range(16): block = rev[index * 4:(index + 1) * 4] while len(block) < 4: block.append(BitVecVal(0, 8)) value = (ZeroExt(24, block[0]) << 24) \ | (ZeroExt(24, block[1]) << 16) \ | (ZeroExt(24, block[2]) << 8) \ | ZeroExt(24, block[3]) mem[32 + index] = value & BitVecVal(0xFFFFFFFF, 32)
stack: t.List[BitVecRef] = [] flag = False for opcode, param in PROGRAM: if opcode == "ret": break if opcode == "toggle": flag = not flag continue if opcode == 1: stack.append(mem[param & 0xFF]) elif opcode == 2: value = stack.pop() mem[param & 0xFF] = value & BitVecVal(0xFFFFFFFF, 32) elif opcode == 3: value = stack.pop() acc = BitVecVal(0, 32) for i in range(4): byte = Extract(8 * i + 7, 8 * i, value) low = sbox4(Extract(3, 0, byte)) high = sbox4(Extract(7, 4, byte)) new_byte = Concat(high, low) acc = acc | (ZeroExt(24, new_byte) << (8 * i)) acc = (acc * BitVecVal(MUL, 32)) & BitVecVal(0xFFFFFFFF, 32) stack.append(acc) elif opcode == 5: value = stack.pop() amt = param & 0x1F rotated = rol32(value, amt if not flag else (32 - amt)) stack.append(rotated) elif opcode == 7: a = stack.pop(); b = stack.pop() stack.append(a ^ b) else: raise NotImplementedError return [mem[i] for i in range(16)]입력을 역순으로 4바이트씩 묶어 mem[32:]에 저장하는 이유는 Go 코드에서 preloadInputBERev가 big-endian 역순으로 데이터를 적재하기 때문이다.
push/pop 시 bitwise NOT 검사는 Go 런타임이 panic 여부를 확인하기 위해 사용하므로 그대로 구현했다.
nibble 치환 곱셈 rotate xor 순서를 추적하여 재현했다.
bytes_vec가 Z3 변수(BitVec)일 수도 있어서 ZeroExt를 사용해 정수 연산을 유지했다.
solver
def solve_flag() -> None: target_words = [...] flag_len = 64 chars = [BitVec(f"c{i}", 8) for i in range(flag_len)] words = run_vm(chars) solver = Solver() for word_val, target in zip(words[:16], target_words): solver.add(word_val == BitVecVal(target, 32)) if solver.check() != sat: print("Solver failed to find a solution.") return model = solver.model() flag_bytes = bytes(model[ch].as_long() for ch in chars) print(f"Solved flag: {flag_bytes.decode()}")초기 시도에서는 ASCII / 길이 제약을 추가했지만 padding 때문에 unsat이 나왔다. 그래서 핵심 제약(워드 배열 일치)만 남기고 나머지는 제거했다.
길이는 넉넉히 64로 두어 padding이 잘 반영되도록 했다. 모델에서 추출한 결과 앞부분은 \x00으로 채워져 있었고, 뒤쪽 ASCII 영역이 실제 플래그였다.
6.5 sanity check 예제
if __name__ == "__main__": sample = b"abcd" concrete_bytes = [BitVecVal(b, 8) for b in sample] output = run_vm(concrete_bytes) print("VM output words for input 'abcd':") for idx, word in enumerate(output): simplified = simplify(word) print(f"{idx:02d}: {simplified.as_long():#010x}") solve_flag()이렇게 실행하면 먼저 "abcd"에 대한 워드가 gdb와 일치하는지 확인하고, 바로 이어서 solver가 flag를 계산한다.
Exploit
타깃 워드 추출
정답 비교 배열은 0x531660 근처에 있다. gdb에서 바로 확인한다.
(gdb) x/16wx 0x5316600x531660: 0x82ce0803 0xed0a0ade 0x5eb83efd 0xdd86d41a0x531670: 0xc635b860 0x2115b7f1 0xf57d3092 0x17a523480x531680: 0x223c75ae 0xdf525a75 0x3773e5f4 0xfd0e81a60x531690: 0x87f325a8 0x5cd21a47 0x2290027e 0x74d1bfed이 16개가 목표 값이다.
Solver 구성
flag_len = 64 # padding까지 고려해서 넉넉히 설정chars = [BitVec(f"c{i}", 8) for i in range(flag_len)]words = run_vm(chars)for w, target in zip(words, target_words): solver.add(w == BitVecVal(target, 32))초기에 ASCII 제약과 길이 제약을 넣었더니 unsat이 나왔다. padding 영역까지 연산에 사용되기 때문. 제약을 제거하니 즉시 해를 얻을 수 있었다.
전체 코드
import structimport typing as t
from z3 import *
SEED = 0x13579BDFMASK32 = 0xFFFFFFFFSBOX = [0x03, 0x0E, 0x01, 0x0A, 0x04, 0x09, 0x05, 0x06, 0x08, 0x0B, 0x0F, 0x02, 0x0D, 0x0C, 0x00, 0x07]MUL = 0x4E6A44B9PROGRAM_PATH = "instructions.bin"
def compute_remap(seed: int) -> t.List[int]: mask = MASK32 state = seed & mask arr = list(range(11)) for i in range(10, 0, -1): state = ((state << 13) ^ state) & mask state = (state ^ (state >> 7)) & mask state = (state ^ (state << 17)) & mask j = state % (i + 1) arr[i], arr[j] = arr[j], arr[i] return arr + [0xFF] * (16 - len(arr))
REMAPPED = compute_remap(SEED)NEEDS_PARAM = {0, 1, 2, 5, 6, 9}
def load_program(path: str) -> t.List[t.Tuple[t.Union[int, str], int]]: blob = open(path, "rb").read() seed_hdr, length = struct.unpack_from("<IH", blob, 0) if seed_hdr != SEED: raise ValueError(f"unexpected seed header: {seed_hdr:#x}") prog = blob[6: 6 + length]
pc = 0 instructions: t.List[t.Tuple[t.Union[int, str], int]] = [] while pc < len(prog): opcode = prog[pc] pc += 1
if opcode == 0xEE: instructions.append(("toggle", 0)) continue if opcode == 0xF0: instructions.append(("ret", 0)) break
idx = REMAPPED[opcode & 0xF] if idx == 0xFF: raise ValueError(f"opcode {opcode:#x} remapped to invalid handler")
if idx in NEEDS_PARAM: arg = prog[pc] pc += 1 low = SBOX[arg & 0xF] high = SBOX[(arg >> 4) & 0xF] sub = low | (high << 4) param = (SEED ^ (SEED >> 7) ^ sub) & MASK32 else: param = 0 instructions.append((idx, param)) return instructions
PROGRAM = load_program(PROGRAM_PATH)
def sbox4(nibble: BitVecRef) -> BitVecRef: result = BitVecVal(SBOX[0], 4) for i in range(1, 16): result = If(nibble == BitVecVal(i, 4), BitVecVal(SBOX[i], 4), result) return result
def rol32(value: BitVecRef, amount: int) -> BitVecRef: amount %= 32 if amount == 0: return value & BitVecVal(MASK32, 32) return ((value << amount) | LShR(value, 32 - amount)) & BitVecVal(MASK32, 32)
def run_vm(bytes_vec: t.Sequence[BitVecRef]) -> t.List[BitVecRef]: mem: t.List[BitVecRef] = [BitVecVal(0, 32) for _ in range(128)]
rev = list(reversed(bytes_vec)) for index in range(16): block = rev[index * 4:(index + 1) * 4] while len(block) < 4: block.append(BitVecVal(0, 8)) value = (ZeroExt(24, block[0]) << 24) \ | (ZeroExt(24, block[1]) << 16) \ | (ZeroExt(24, block[2]) << 8) \ | ZeroExt(24, block[3]) mem[32 + index] = value & BitVecVal(MASK32, 32)
stack: t.List[BitVecRef] = [] flag = False
for opcode, param in PROGRAM: if opcode == "ret": break if opcode == "toggle": flag = not flag continue
if opcode == 1: # load stack.append(mem[param & 0xFF]) elif opcode == 2: # store value = stack.pop() mem[param & 0xFF] = value & BitVecVal(MASK32, 32) elif opcode == 3: # fdsk variant (nibble substitution + multiply) value = stack.pop() acc = BitVecVal(0, 32) for i in range(4): byte = Extract(8 * i + 7, 8 * i, value) low = sbox4(Extract(3, 0, byte)) high = sbox4(Extract(7, 4, byte)) new_byte = Concat(high, low) acc = acc | (ZeroExt(24, new_byte) << (8 * i)) acc = (acc * BitVecVal(MUL, 32)) & BitVecVal(MASK32, 32) stack.append(acc) elif opcode == 5: # rotation value = stack.pop() amt = param & 0x1F rotated = rol32(value, amt if not flag else (32 - amt)) stack.append(rotated) elif opcode == 7: # xor a = stack.pop() b = stack.pop() stack.append(a ^ b) else: raise NotImplementedError(f"Unhandled opcode index {opcode}")
return [mem[i] for i in range(16)]
def main() -> None: # sanity-check: emulate using concrete ASCII bytes "abcd" sample = b"abcd" concrete_bytes: t.List[BitVecRef] = [BitVecVal(b, 8) for b in sample] output = run_vm(concrete_bytes) print("VM output words for input 'abcd':") for idx, word in enumerate(output): simplified = simplify(word) if is_bv_value(simplified): value = simplified.as_long() print(f"{idx:02d}: {value:#010x}") else: print(f"{idx:02d}: {simplified}")
def solve_flag() -> None: target_words = [ 0x82CE0803, 0xED0A0ADE, 0x5EB83EFD, 0xDD86D41A, 0xC635B860, 0x2115B7F1, 0xF57D3092, 0x17A52348, 0x223C75AE, 0xDF525A75, 0x3773E5F4, 0xFD0E81A6, 0x87F325A8, 0x5CD21A47, 0x2290027E, 0x74D1BFED, ]
flag_len = 64 chars = [BitVec(f"c{i}", 8) for i in range(flag_len)] padded_input: t.List[BitVecRef] = chars + [BitVecVal(0, 8) for _ in range(64 - flag_len)]
words = run_vm(padded_input)
solver = Solver()
for word_val, target in zip(words[:16], target_words): solver.add(simplify(word_val) == BitVecVal(target, 32))
if solver.check() != sat: print("Solver failed to find a solution.") return
model = solver.model() flag_bytes = bytes(model[ch].as_long() for ch in chars) print(f"Solved flag: {flag_bytes.decode()}")
if __name__ == "__main__": main() solve_flag()실행 결과
andsopwn@meow:~/ctftemp/hspace/rev/gomixer$ python3 vm_solver.pyVM output words for input 'abcd':00: 0x354e4c3a01: 0x95b11f9502: 0xc3854e0b03: 0xf7dd4eb104: 0x23a9583c05: 0xb8ef8d7906: 0x82d967b807: 0xaa56bd4008: 0x0fbf5fad09: 0xdbbd1af310: 0x67a236f011: 0xc604dab012: 0x86246b4913: 0x473f3e1314: 0x949e7ce215: 0xa0e5cb7fSolved flag: hspace{yeah_y0u_h4v3_br0ken_g0_vm_h4ha_thats_it_gggg}