Logo
Overview
[CLUB LEAGUE] Go Mixer writeup

[CLUB LEAGUE] Go Mixer writeup

October 11, 2025
5 min read

TL;DR

Go 1.22.2 바이너리 VM

Analysis

Default

주요 패키지: Go_Mixer/internal/vvvv, Go_Mixer/internal/oracle

prompt \rightarrow must \rightarrow main 흐름은 크게 다음과 같다.

  1. prompt() 로 문자열 받기
  2. strings.TrimSpace로 개행 정리
  3. Go_Mixer/internal/deriv 함수 3개 호출로 VM 초기 데이터 복호화
  4. vvvv. (*Machine). Run 으로 VM 실행
  5. 정답 테이블과 비교해 일치하면 플래그 출력

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 0x531420
0x531420: 0x7233765f79723376 0x7433726333735f79
0x531430: 0x7230665f79336b5f 0x6861685f7530795f

preloadInputBERev 단계에서 DjNv3ry_v3ry_s3cr3t_k3y_f0r_y0u_hah이 메모리에 적재됐다.

(gdb) x/16gx 0x531660
0x531660: 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 = 0x4E6A44B9
  • machine.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 부근을 덤프하면 된다.

Terminal window
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))) & 0xFFFFFFFF

param & 0xFF 는 실제 메모리 인덱스로 쓰인다. 전체 명령어를 훑어보면 인덱스는 0~127 범위에서 93개가 등장했다.

핸들러

Go 함수 이름이 난수이지만, 기능은 다음과 같다.

idxaddressdesc
10x486b20mem[param & 0xFF] push (값과 bitwise NOT 두 개를 쌍으로 저장)
20x486cc0pop \rightarrow nibble 치환 \rightarrow MUL 상수 곱 \rightarrow push
30x486c00pop \rightarrow mem[param & 0xFF]에 저장
50x486fa0(flag=0) 좌회전, (flag=1) 우회전
70x487320pop 두 개 \rightarrow 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 instructions

gdb로 메모리를 덤프해서 추출한 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 치환 \rightarrow 곱셈 \rightarrow rotate \rightarrow 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 0x531660
0x531660: 0x82ce0803 0xed0a0ade 0x5eb83efd 0xdd86d41a
0x531670: 0xc635b860 0x2115b7f1 0xf57d3092 0x17a52348
0x531680: 0x223c75ae 0xdf525a75 0x3773e5f4 0xfd0e81a6
0x531690: 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 struct
import typing as t
from z3 import *
SEED = 0x13579BDF
MASK32 = 0xFFFFFFFF
SBOX = [0x03, 0x0E, 0x01, 0x0A, 0x04, 0x09, 0x05, 0x06,
0x08, 0x0B, 0x0F, 0x02, 0x0D, 0x0C, 0x00, 0x07]
MUL = 0x4E6A44B9
PROGRAM_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.py
VM output words for input 'abcd':
00: 0x354e4c3a
01: 0x95b11f95
02: 0xc3854e0b
03: 0xf7dd4eb1
04: 0x23a9583c
05: 0xb8ef8d79
06: 0x82d967b8
07: 0xaa56bd40
08: 0x0fbf5fad
09: 0xdbbd1af3
10: 0x67a236f0
11: 0xc604dab0
12: 0x86246b49
13: 0x473f3e13
14: 0x949e7ce2
15: 0xa0e5cb7f
Solved flag: hspace{yeah_y0u_h4v3_br0ken_g0_vm_h4ha_thats_it_gggg}