Logo
Overview
[CLUB LEAGUE] Go Mixer writeup

[CLUB LEAGUE] Go Mixer writeup

October 11, 2025
4 min read

TL;DR

problem.exe (32-bit Windows PE) obfus.h로 난독화로 가상화된 산술 연산, 흐름 교란, 문자열 은닉

Analysis

  1. Obfh_VirtualMachine 호출과 각종 FPU 연산이 반복적으로 등장.
  2. fcn.0040145d에서 입력 문자열을 %255s로 읽은 후 0x401805 함수(이름을 임의로 validator)에서 복잡한 계산을 수행.
  3. validator 내부에는 다음 패턴이 반복된다.
    • 입력 바이트를 로드 \rightarrow 여러 imul, fild, fstp \rightarrow Obfh_VirtualMachine \rightarrow cmp eax, imm8.
  4. cmp 인근의 add eax, <offset> 등으로 입력 인덱스를 역추적 가능.

가상화 우회 전략

obfus.h의 가상화된 수식을 직접 복호화하는 대신 에뮬레이션으로 연산을 그대로 실행하여 결과만 관찰한다.

Unicorn

  1. PE 전체(.text, .data, .obfh)를 Unicorn 메모리에 로드.
  2. 스택/입력 버퍼 구성
    • 스택: 0x900000 영역에 0x20000바이트 맵핑.
    • 입력 문자열: 0x800000에 할당, 인자로 전달되도록 ESP 조작.
  3. validator 시작 주소 0x401805에서 0x4028a4까지 에뮬레이션.
  4. cmp 직전의 EAX 값을 후킹하여 기록.

애뮬레이팅 내용

.text:00401804 ; [00000001 BYTES: COLLAPSED FUNCTION nullsub_1]
.text:00401805 ; ---------------------------------------------------------------------------
.text:00401805 push ebp
.text:00401806 mov ebp, esp
.text:00401808 sub esp, 0C8h
.text:0040180E nop
...
.text:00402872 sub esp, 8
.text:00402878 fstp qword ptr [esp+0]
.text:0040287C call sub_4032F1
.text:00402881 add esp, 8
.text:00402884 and eax, 0FFh
.text:0040288A cmp eax, 36h ; '6'
.text:0040288D jz loc_40289D
.text:00402893 mov eax, 0
.text:00402898 jmp locret_4028A2
.text:0040289D ; ---------------------------------------------------------------------------
.text:0040289D
.text:0040289D loc_40289D: ; CODE XREF: .text:0040288D↑j
.text:0040289D mov eax, 1
.text:004028A2
.text:004028A2 locret_4028A2: ; CODE XREF: .text:004018B3↑j
.text:004028A2 ; .text:00401959↑j ...
.text:004028A2 leave
.text:004028A3 retn
.text:004028A4

문자 추출 방법

  1. 0~255 단일 바이트를 25자리 모두에 채운 입력을 실행.
  2. 25개의 cmp 결과를 수집하여 “문자 \rightarrow 비교 결과” 매핑 구성.
  3. 실제 상수 테이블과 매핑을 역추적하면 각 위치의 문자 결정.
  4. 모든 위치에서 유일하게 매칭되는 문자가 존재.
import struct
from typing import Dict, List, Tuple
from unicorn import Uc, UC_ARCH_X86, UC_MODE_32, UC_HOOK_CODE, UcError
from unicorn.x86_const import UC_X86_REG_EAX, UC_X86_REG_EBP, UC_X86_REG_EIP, UC_X86_REG_ESP
import pefile
PE_PATH = "problem.exe"
CMP_INFO: List[Tuple[int, int, int]] = [
(0x4018A2, 0x98, 23),
(0x40194B, 0x30, 15),
(0x4019F1, 0xBC, 16),
(0x401A9A, 0xAE, 9),
(0x401B43, 0xE7, 20),
(0x401BEC, 0x6D, 5),
(0x401C92, 0x48, 24),
(0x401D38, 0xBA, 17),
(0x401DE1, 0x30, 2),
(0x401E87, 0xA8, 3),
(0x401F30, 0xBB, 4),
(0x401FD9, 0x8D, 22),
(0x402082, 0x0E, 11),
(0x402128, 0x05, 8),
(0x4021CE, 0x2F, 12),
(0x402272, 0x1E, 1),
(0x40231E, 0xC4, 7),
(0x4023CD, 0x25, 13),
(0x402479, 0xCD, 21),
(0x402528, 0x79, 19),
(0x4025D1, 0xF8, 0),
(0x402680, 0x17, 10),
(0x40272C, 0xAC, 18),
(0x4027DB, 0xB1, 14),
(0x40288A, 0x36, 6),
]
def load_binary() -> Tuple[Uc, int, bytes, List[pefile.SectionStructure]]:
pe = pefile.PE(PE_PATH)
base = pe.OPTIONAL_HEADER.ImageBase
sections = list(pe.sections)
with open(PE_PATH, "rb") as f:
blob = f.read()
max_va = 0
for sec in sections:
va = base + sec.VirtualAddress
size = sec.Misc_VirtualSize
limit = va + ((size + 0xFFF) & ~0xFFF)
max_va = max(max_va, limit)
mem_size = (max_va - base) + 0x1000
uc = Uc(UC_ARCH_X86, UC_MODE_32)
uc.mem_map(base, mem_size)
for sec in sections:
va = base + sec.VirtualAddress
raw = sec.PointerToRawData
size = sec.SizeOfRawData
uc.mem_write(va, blob[raw:raw + size])
return uc, base, blob, sections
def prepare_context(uc: Uc, base: int, flag: bytes) -> int:
stack_top = 0x900000
stack_size = 0x20000
uc.mem_map(stack_top - stack_size, stack_size)
inp_addr = 0x800000
uc.mem_map(inp_addr, 0x1000)
uc.mem_write(inp_addr, flag + b"\x00")
esp = stack_top - 0x10
uc.mem_write(esp, struct.pack("<I", 0xDEADBEEF))
uc.mem_write(esp + 4, struct.pack("<I", inp_addr))
uc.reg_write(UC_X86_REG_ESP, esp)
uc.reg_write(UC_X86_REG_EBP, 0)
uc.reg_write(UC_X86_REG_EIP, 0x401805)
return inp_addr
def run_with_flag(flag: bytes, force_expected: bool = False) -> Dict[int, int]:
uc, base, _, _ = load_binary()
prepare_context(uc, base, flag)
cmp_map = {addr: expected for addr, expected, _ in CMP_INFO}
observed: Dict[int, int] = {}
def hook(emu: Uc, address: int, size: int, user_data: object) -> None:
if address in cmp_map and address not in observed:
eax = emu.reg_read(UC_X86_REG_EAX) & 0xFF
observed[address] = eax
if force_expected:
emu.reg_write(UC_X86_REG_EAX, cmp_map[address])
uc.hook_add(UC_HOOK_CODE, hook)
try:
uc.emu_start(0x401805, 0x4028A4)
except UcError:
pass
return observed
def brute_force_flag() -> str:
cmp_order = [addr for addr, _, _ in CMP_INFO]
mapping: List[Dict[int, int]] = [dict() for _ in CMP_INFO]
for byte in range(256):
flag = bytes([byte]) * 25
observed = run_with_flag(flag, force_expected=True)
values = [observed.get(addr, None) for addr in cmp_order]
if None in values:
raise RuntimeError("Failed to record all cmp values.")
for idx, val in enumerate(values):
mapping[idx][val] = byte
recovered = ["?"] * 25
for idx, (addr, expected, pos) in enumerate(CMP_INFO):
guess = mapping[idx].get(expected)
if guess is None:
raise RuntimeError(f"No byte matched cmp at {addr:#x}.")
recovered[pos] = chr(guess)
return "".join(recovered)
def main() -> None:
flag = brute_force_flag()
print("Recovered flag:", flag)
observed = run_with_flag(flag.encode(), force_expected=False)
for addr, expected, _ in CMP_INFO:
if observed.get(addr) != expected:
raise RuntimeError("Validation failed.")
print("Validation passed.")
if __name__ == "__main__":
main()

복원된 플래그

python3 solve.py
Recovered flag: hspace{d8a928b2043db77e3}
Validation passed.

길이 25바이트로 문제 조건을 만족한다.