문제: DreamHack — BumpyMM 분류: Reversing 난이도: 💎 Platinum 3 FLAG:
DH{flat_memory_model_is_better_than_segmented_2hf8eg23}
문제 설명은 딱 한 줄이다. "Too Slow, Too Bumpy." 느리고 울퉁불퉁하다. 풀고 나니 이 한 줄이 문제의 성격을 정확히 요약한다는 걸 알았다 — RISC-V 에뮬레이터를 다시 소프트웨어로 얹었으니 느리고(Too Slow), 세그먼트로 메모리를 울퉁불퉁하게 쪼개 놨으니(Too Bumpy) 그렇다. 그리고 flag 자체가 "그 울퉁불퉁한 세그먼트 모델보다 평평한(flat) 메모리가 낫다"는 농담이다.
받은 것: 드라이브 네 장과 실행 스크립트
압축을 풀면 run.sh 하나와 드라이브 이미지 네 개가 전부다. 바이너리도, 소스도, ELF도 없다.
cat run.sh; file drive0 drive1 drive2 drive3
run.sh는 qemu-system-i386을 -nographic으로 띄운다. 즉 베어메탈이다. 운영체제가 없고
drive0를 그대로 부팅한다. drive0는 DOS/MBR boot sector, 나머지 세 장은 각각 정확히
1,474,560바이트 — 표준 1.44MB 플로피 크기의 data다. 시리얼이 stdio로 빠지고(-nographic),
-s로 gdbstub(1234)도 열려 있다.
처음엔 "펌웨어 문제인가" 싶었지만, -nographic으로 시리얼이 콘솔에 붙으니 일단 돌려 보는 게
가장 빠르다. 원본을 건드리지 않으려고 사본을 떠서 부팅만 시켜 봤다.
#!/usr/bin/env bash
cd "$(dirname "$0")/extracted"
timeout 8 qemu-system-i386 -enable-kvm -cpu host,+rdrand -m 512M \
-drive format=raw,file=drive0 \
-drive format=raw,file=drive1,if=ide,index=1 \
-drive format=raw,file=drive2,if=ide,index=2 \
-drive format=raw,file=drive3,if=ide,index=3 \
-nographic -monitor none 2>&1 | stdbuf -o0 sed $'s/\x1b\\[[0-9;?]*[JH]//g'
부팅하면 command: 프롬프트가 뜬다(화면 지움 이스케이프만 걷어내 부팅 로그를 남겼다).
뭔가 명령을 받는 작은 셸이 올라온 것이다. 그런데 이게 느리다. 글자 하나 처리하는 데도
눈에 띄게 뜸을 들인다 — "Too Slow"의 정체는 곧 드러난다.
drive0 정찰: MBR → 보호모드 → 32비트 코드
drive0의 첫 섹터는 전형적인 MBR이다. int 0x13 확장 읽기(AH=0x42)로 LBA 1부터 20섹터를
0x10000에 적재하고, GDT/IDT를 세운 뒤 cr0의 PE 비트를 켜고 0x08:0x7c71로 far jump해
보호모드(32비트 flat)로 넘어간다. 그리고 0x10000으로 점프한다. 즉 진짜 프로그램은 drive0의
오프셋 0x200부터(메모리 0x10000) 있는 32비트 코드다.
이 32비트 코드를 objdump로 뜯어보면 출발점이 보인다. 시리얼(COM1, 0x3f8) 초기화,
ATA PIO로 디스크를 읽는 rep insw, 그리고 문자열 Illegal instruction · Illegal ecall ·
EBREAK encountered · VM exited · VID: . ecall/EBREAK는 RISC-V 명령이다. 즉 이 32비트
프로그램은 RISC-V 에뮬레이터다. 명령 디코더를 보면 확신이 선다.
dd if=extracted/drive0 bs=512 skip=1 of=emu_main.bin status=none
objdump -D -b binary -mi386 -Maddr32,data32 --adjust-vma=0x10000 emu_main.bin \
| sed -n '/10bed:/,/10c2c:/p'
명령 바이트를 & 0x7f로 opcode를 뽑고 0x33(RISC-V R-type)과 비교한다. 레지스터는 0x12800
베이스의 32개 워드 배열, PC는 0x12880이다. 전형적인 RV32 인터프리터다. PC는 세그먼트
베이스에 더해지는 상대 오프셋이라, 메인 루프는 PC가 0x1fff를 넘으면 그 VM을 끝낸다
(코드 영역 = 8KB). "Too Slow"의 정체 — RISC-V를 소프트웨어로 한 번 더 돌리니 느릴 수밖에.
메인(main)은 drive1·drive2·drive3를 각각 VM 1·2·3으로 적재하고, VM마다 데이터
세그먼트 셀렉터를 (vmid+2)<<3으로 잡아 준다. GDT로 VM별 메모리를 격리하는 것 — 이게
"MM(Memory Management)"이고, 세그먼트 베이스를 rdrand로 흔들어(ASLR) 매 부팅 주소가 달라진다.
적재 뒤에는 bump 포인터를 0x1ff000으로 리셋한다. "BumpyMM"의 "Bumpy"는 이 bump 할당기 +
울퉁불퉁하게 나뉜 세그먼트를 가리킨다.
플로피 세 장의 성격이 다르다
각 VM의 프로그램은 플로피 그대로 메모리에 올라간다(ATA raw read). 그렇다면 플로피 = RISC-V
프로그램 이미지다. 그런데 세 장의 질감이 완전히 다르다. RV32 평문 코드는 32비트 워드의 하위
2비트가 항상 11(비압축 명령 opcode의 하위)이라, 그 비율만 세어도 "평문 코드"와 "암호화"를
가를 수 있다.
# scan_floppies.py — 워드 하위 2비트가 11인 비율로 평문/암호화를 가른다
import struct, re, os
HERE = os.path.dirname(os.path.abspath(__file__))
def dens(d, a, b):
c = t = 0
for o in range(a, b - 4, 4):
w = struct.unpack('<I', d[o:o + 4])[0]
if w == 0: continue
t += 1
if (w & 3) == 3: c += 1
return c / t if t else 0.0
for name in ('drive1', 'drive2', 'drive3'):
d = open(os.path.join(HERE, 'extracted', name), 'rb').read()
row = [f'{dens(d, b, b + 0x400):.2f}' for b in range(0, 0x1800, 0x400)]
strs = sorted({m.group().decode('latin1') for m in re.finditer(rb'[ -~]{5,}', d)})[:4]
print(f'{name}: op-bits=11 density/1KB {row} strings~{strs}')python3 scan_floppies.py
결과가 셋의 역할을 단번에 알려 준다.
- drive3: 밀도 1.00 → 평문 RV32 코드. 문자열
YOUHAVESECUREKEY(= 키). - drive1 / drive2: 밀도 0.11 안팎 → 암호화됨. 문자열은 평문 rodata(코드만 암호화)로 남아
drive1은
command:size:data:error vm2 response, drive2는correct.
역할을 정리하면 — **VM1(drive1)**은 사용자 입력을 받는 프론트엔드, **VM2(drive2)**는 입력을
검증해 correct를 돌려주는 검증기, **VM3(drive3)**는 평문이고 키를 가진 무언가다.
anti-dump: 코드는 실행될 때만 복호된다
VM1은 command:를 찍으며 멀쩡히 돈다. 그런데 그 코드는 디스크에서 암호화돼 있다. 그럼
메모리에선 복호돼 있을 테니 덤프하면 되겠지 — QEMU 모니터로 프롬프트 시점의 물리 메모리를
떠서 열어 봤다. 결과가 묘했다.
- VM2의 세그먼트: 첫 블록만 평문(스핀 대기 중인 그 블록). 나머지는 암호문.
- VM1의 세그먼트: 통째로 암호문. 프롬프트를 찍고 있는데도.
- VM3: 전체 평문.
즉 코드는 실행되는 순간에만, 그것도 현재 블록만 복호되고, 블록을 벗어나면 다시 암호화된다.
메모리 덤프로는 아무리 떠도 한 번에 한 블록밖에 못 본다 — 의도된 anti-dump다. 메인 루프를
다시 보면, PC가 현재 복호 윈도우 [0x12884, +0x12888)를 벗어나면 그 VM의 state를 1로 바꾸고
VM3로 전환한다. VM3가 요청받은 블록을 복호해 써 주는 페이저였던 것이다. 그래서 VM3만
평문이고, 키 YOUHAVESECUREKEY를 들고 있다.
🟠 삽질 기록 — 덤프로 암호를 "유도"하려다 샌 시간
처음엔 복호 알고리즘을 통째로 역설계하는 대신, "입력을 흘려 넣어 VM2가 검증 코드를 실행하게
만들면 그 블록이 복호돼 덤프에 남겠지"라고 생각했다. 그런데 VM1의 입력 프로토콜을 정확히
모르는 상태에서 시리얼로 찔러 넣으니 프레이밍이 어긋나 계속 command:만 반복됐다. 입력 읽기가
줄 단위인지 고정 길이인지, command가 숫자인지 등을 블랙박스로 맞추느라 시간을 흘렸다.
결론은 "에뮬레이터의 복호 루틴 자체를 읽는 게 정도(正道)"였다 — anti-dump는 한 블록의
known-plaintext만 있으면 오히려 역설계의 발판이 된다.
복호기 역설계: VM3의 커스텀 블록암호
VM3는 평문이라 그냥 읽으면 된다. capstone으로 RV32 디스어셈블러를 짜서 복호 경로를 따라갔다.
키 YOUHAVESECUREKEY와 상수 deadbeefcafebabe를 참조하는 함수가 복호 래퍼였다.
# disasm.py — capstone RV32 + auipc/addi 상대주소·rodata 문자열 주석
import sys, re, struct, capstone
code = open(sys.argv[1], 'rb').read()
full = open(sys.argv[2], 'rb').read()
start = int(sys.argv[3], 0); end = int(sys.argv[4], 0)
img = bytearray(full); img[0:len(code)] = code; img = bytes(img)
md = capstone.Cs(capstone.CS_ARCH_RISCV, capstone.CS_MODE_RISCV32)
strs = {0x3000 + m.start(): m.group().decode('latin1')
for m in re.finditer(rb'[ -~]{3,}', img[0x3000:0x3200])}
prev = {}
a = start
while a < end:
w = struct.unpack('<I', img[a:a + 4])[0]
ins = list(md.disasm(img[a:a + 4], a))
mn, op = (ins[0].mnemonic, ins[0].op_str) if ins else ('.word', '%#010x' % w)
note = ''
if (w & 0x7f) == 0x17:
imm = w & 0xfffff000; imm -= 0x100000000 if imm & 0x80000000 else 0
prev[(w >> 7) & 0x1f] = a + imm
elif (w & 0x7f) == 0x13 and ((w >> 12) & 7) == 0:
rs1 = (w >> 15) & 0x1f; rd = (w >> 7) & 0x1f
if rs1 in prev and rs1 == rd:
imm = w >> 20; imm -= 0x1000 if imm & 0x800 else 0
t = prev[rs1] + imm; note = '; =%#x' % t
if t in strs: note += ' "%s"' % strs[t]
print('%06x: %-7s %-22s %s' % (a, mn, op, note))
a += 4python3 disasm.py extracted/drive3 extracted/drive3 0x148c 0x1530
구조는 CTR 모드였다. 16바이트마다 블록암호로 키스트림 한 블록을 만들고 XOR한다. 카운터는
코드 오프셋 ÷ 16, nonce는 deadbeefcafebabe. 블록암호의 입력은 nonce(8) || counter로
조립된다. 안쪽 블록암호는 10라운드 커스텀 SPN이었다:
- SubBytes:
sbox(x) = rotl8((29*x + 0x25) & 0xff, 3)— 바이트마다 아핀 후 좌회전. - 라운드:
state = rotl128(state, (3r+7) % 128) XOR roundkey[r]— 128비트 좌회전 후 라운드키 XOR. - 키 스케줄: xorshift-128(
k ^= k<<13; k ^= k>>7; k ^= k<<17)으로 k를 갱신하며roundkey[r-1] = rotl128(k, (11*r) % 128).
이 전부를 RISC-V에서 한 줄씩 파이썬으로 옮긴 다음, 맞게 옮겼는지를 라이브 덤프에서 뽑은 실제 키스트림과 대조했다. 덤프에서 VM2의 복호된 첫 블록(평문)과 플로피의 암호문을 XOR하면 에뮬레이터가 실제로 쓴 키스트림이 나온다 — 그 6블록을 KAT로 썼다.
# cipher.py 핵심 — 재구현한 블록암호와 CTR 키스트림 (sbox/키스케줄/라운드는 본문 설명과 동일)
def block_encrypt(key, inp):
rks = key_schedule(key)
st = bytearray(inp)
for r in range(10):
for i in range(16): st[i] = sbox(st[i]) # SubBytes
w = rotl128(b2i(bytes(st)), (3 * r + 7) % 128) # ShiftRows 류
st = bytearray(i2b(w ^ rks[r])) # AddRoundKey
return bytes(st)
KEY, NONCE = b'YOUHAVESECUREKEY', bytes.fromhex('deadbeefcafebabe')
def keystream_block(counter): # 입력 블록 = nonce || ctr_hi(BE) || ctr_lo(BE)
hi, lo = (counter >> 32) & 0xffffffff, counter & 0xffffffff
return block_encrypt(KEY, NONCE + struct.pack('>I', hi) + struct.pack('>I', lo))python3 kat.py
여섯 블록이 전부 일치한다. 블록암호를 정확히 옮겼다는 뜻이고, 이제 drive1·drive2의 코드를 오프라인에서 완전히 복호할 수 있다(카운터 = 오프셋 ÷ 16).
함정 하나. 처음엔 블록0만 맞고 블록1부터 전부 어긋났다. 원인은 카운터의 바이트 순서 — 입력 블록이
nonce || ctr_hi(BE) || ctr_lo(BE)인데 hi/lo를 바꿔 넣었던 것. 카운터 0에서는 hi·lo가 모두 0이라 버그가 가려졌다가, 블록1에서야 드러났다. KAT를 블록 여러 개로 돌린 덕에 바로 잡혔다.
검증기 역설계: VM2의 XTEA-CBC
코드를 복호했으니 VM2의 검증 로직을 평문으로 읽을 수 있다. 메일박스(0x2000)에 매직
0x12345678이 들어오길 기다렸다가 입력을 받아 검증 함수 0x774로 넘긴다.
python3 disasm.py drive2_plain.bin extracted/drive2 0x774 0x814
검증기의 요구는 명확하다.
- 입력은 정확히 64바이트여야 한다(
li a5,0x40; beq). - 변환 함수
0x644를 거친 뒤, - 그 결과가 rodata
0x3018의 64바이트 레퍼런스와 바이트 단위로 전부 일치하면correct.
변환 0x644는 CBC 모드였다. 8바이트 블록마다 이전 암호문(첫 블록은 IV)과 XOR한 뒤 블록암호
0x3c4로 암호화해 피드백한다. 키는 rodata 0x3000(16바이트), IV는 0x3010에 있다. 그 블록암호
0x3c4를 보면 상수 0x9e3779b9 — XTEA다.
python3 disasm.py drive2_plain.bin extracted/drive2 0x3c4 0x468
정리하면 검증기가 하는 일은 — 입력 64바이트에 (XTEA 4라운드, 키·IV는 rodata) CBC 암호화를 적용하고, 그 결과가 임베드된 64바이트 레퍼런스와 같은지 보는 것이다. CBC도 XTEA도 가역이니, 레퍼런스를 역으로 복호하면 유일한 입력이 나온다.
flag 역산
CBC + XTEA를 역산한다. 레퍼런스를 8바이트 블록으로 끊어 XTEA 복호한 뒤, 직전 암호문 (첫 블록은 IV)과 XOR하면 원래 입력 블록이다. 데이터 워드는 빅엔디언, 키 워드는 리틀엔디언으로 읽힌다는 점만 디스어셈블 그대로 맞춰 주면 된다.
# solve.py (발췌) — CBC+XTEA 역산으로 64바이트 입력 복원
M = 0xffffffff; DELTA = 0x9e3779b9
def xtea_dec(v0, v1, key): # 0x500, 4 라운드
s = (DELTA * 4) & M
for _ in range(4):
v1 = (v1 - ((((v0 << 4) & M ^ (v0 >> 5)) + v0) ^ (s + key[(s >> 11) & 3]))) & M
s = (s - DELTA) & M
v0 = (v0 - ((((v1 << 4) & M ^ (v1 >> 5)) + v1) ^ (s + key[s & 3]))) & M
return v0, v1
def recover_flag(vm2_rodata):
key = list(struct.unpack('<4I', vm2_rodata[0x3000:0x3010])) # key: LE 워드
iv0, iv1 = struct.unpack('<2I', vm2_rodata[0x3010:0x3018]) # IV
ref = vm2_rodata[0x3018:0x3058] # 64바이트 레퍼런스
prev0, prev1 = iv0, iv1
out = bytearray()
for i in range(0, 64, 8): # CBC 역산, 워드는 BE
c0, c1 = struct.unpack('>2I', ref[i:i + 8])
d0, d1 = xtea_dec(c0, c1, key)
out += struct.pack('>2I', d0 ^ prev0, d1 ^ prev1)
prev0, prev1 = c0, c1
return bytes(out)python3 solve.py
복원한 64바이트는 flag 문자열 + 널 패딩이었다. 이게 진짜 유일한 정답인지 확인하려면 역방향으로 다시 암호화해 레퍼런스와 같은지 보면 된다 — 검증기가 하는 바로 그 변환을 그대로 돌려서.
python3 verify.py
forward(복원입력) == rodata 레퍼런스? True. 복원한 입력을 검증기와 똑같이 암호화하면 임베드된
레퍼런스와 바이트 하나까지 일치한다 — 검증을 통과하는 입력은 이것뿐이고, 그 안의 문자열이 flag다.
python3 dreamhack.py wg 2906 --flag 'DH{flat_memory_model_is_better_than_segmented_2hf8eg23}'
DH{flat_memory_model_is_better_than_segmented_2hf8eg23}.
전체 구조 한눈에
여기까지가 한 그림으로 요약된다. i386 베어메탈 위에 RISC-V 에뮬레이터, 그 위에 세 VM. 둘은 암호화돼 실행될 때만 VM3가 블록 단위로 복호하고, VM3의 블록암호를 전사해 코드를 복호한 뒤 검증기의 XTEA-CBC를 역산한다.

자체 완결 solve
평문 플로피 세 장만 있으면 문제 VM 없이 전부 오프라인으로 재현된다. 블록암호 전사부터 flag 역산까지 한 파일로 묶었다.
#!/usr/bin/env python3
# BumpyMM — self-contained solver (문제 VM 불필요, 평문 플로피만으로 복원)
import struct, os
HERE = os.path.dirname(os.path.abspath(__file__))
def rd(p): return open(os.path.join(HERE, p), 'rb').read()
# 1) VM3 블록암호 (CTR 키스트림 생성기) — RISC-V 에서 그대로 전사
MASK = (1 << 128) - 1
def rotl8(b, n): return ((b << n) | (b >> (8 - n))) & 0xff
def sbox(x): return rotl8((29 * x + 0x25) & 0xff, 3)
def rotl128(x, n):
n &= 0x7f
return ((x << n) | (x >> (128 - n))) & MASK if n else x
def shl128(x, n): return (x << n) & MASK
def shr128(x, n): return x >> n
def b2i(b):
w = struct.unpack('<4I', b); return w[0] | (w[1] << 32) | (w[2] << 64) | (w[3] << 96)
def i2b(x):
return struct.pack('<4I', *[(x >> (32 * i)) & 0xffffffff for i in range(4)])
def key_schedule(key):
k = b2i(key); rks = []
for r in range(1, 11):
k ^= shl128(k, 13); k &= MASK
k ^= shr128(k, 7)
k ^= shl128(k, 17); k &= MASK
rks.append(rotl128(k, (11 * r) % 128))
return rks
def block_encrypt(key, inp):
rks = key_schedule(key)
st = bytearray(inp)
for r in range(10):
for i in range(16): st[i] = sbox(st[i]) # SubBytes
w = rotl128(b2i(bytes(st)), (3 * r + 7) % 128) # ShiftRows 류
st = bytearray(i2b(w ^ rks[r])) # AddRoundKey
return bytes(st)
KEY = b'YOUHAVESECUREKEY'
NONCE = bytes.fromhex('deadbeefcafebabe')
def keystream_block(counter): # nonce || ctr_hi(BE) || ctr_lo(BE)
hi, lo = (counter >> 32) & 0xffffffff, counter & 0xffffffff
return block_encrypt(KEY, NONCE + struct.pack('>I', hi) + struct.pack('>I', lo))
def decrypt_code(cipher, end=0x2000): # CTR, counter = off/16
d = bytearray(cipher)
for off in range(0, end, 16):
ks = keystream_block(off // 16)
for k in range(16): d[off + k] ^= ks[k]
return bytes(d)
# 2) VM2 검증기의 변환 역산 (CBC + XTEA) — 키·IV·레퍼런스는 VM2 rodata
M = 0xffffffff; DELTA = 0x9e3779b9
def xtea_dec(v0, v1, key):
s = (DELTA * 4) & M
for _ in range(4):
v1 = (v1 - ((((v0 << 4) & M ^ (v0 >> 5)) + v0) ^ (s + key[(s >> 11) & 3]))) & M
s = (s - DELTA) & M
v0 = (v0 - ((((v1 << 4) & M ^ (v1 >> 5)) + v1) ^ (s + key[s & 3]))) & M
return v0, v1
def recover_flag(vm2_rodata):
key = list(struct.unpack('<4I', vm2_rodata[0x3000:0x3010]))
iv0, iv1 = struct.unpack('<2I', vm2_rodata[0x3010:0x3018])
ref = vm2_rodata[0x3018:0x3058]
prev0, prev1 = iv0, iv1
out = bytearray()
for i in range(0, 64, 8):
c0, c1 = struct.unpack('>2I', ref[i:i + 8])
d0, d1 = xtea_dec(c0, c1, key)
out += struct.pack('>2I', d0 ^ prev0, d1 ^ prev1)
prev0, prev1 = c0, c1
return bytes(out)
if __name__ == '__main__':
drive2 = rd('extracted/drive2')
plain2 = decrypt_code(drive2)
assert plain2[:4] == b'\x13\x01\x01\xee', '복호 실패 — prologue 불일치'
vm2_rodata = bytearray(drive2); vm2_rodata[:0x2000] = plain2[:0x2000]
flag = recover_flag(bytes(vm2_rodata))
print('[*] recovered 64-byte input:', flag)
print('[+] FLAG:', flag.rstrip(b'\x00').decode())배운 것 / 방어 관점
이 문제는 "실행 중에만 복호되는 코드(anti-dump)"를 중심에 둔 리버싱이다. 몇 가지가 남는다.
- 온디맨드 복호는 한 블록의 known-plaintext면 뚫린다. 어떤 보호든 결국 CPU가 평문을 실행해야
하므로, 실행되는 그 순간 그 블록은 평문으로 메모리에 존재한다. 블록 하나만 떠도
암호문 ⊕ 평문 = 키스트림이 나오고, 그걸 KAT 삼아 복호 알고리즘 재구현의 정답지로 쓸 수 있었다. 난독화는 분석을 느리게 할 뿐 불가능하게 만들지 못한다. - 키가 바이너리 안에 있으면 끝이다.
YOUHAVESECUREKEY가 평문 VM3에 그대로 박혀 있었다. 이름부터가 농담이지만("너는 안전한 키를 가졌다"), 클라이언트에 키를 넣는 순간 그 암호는 장식이다. 검증기의 XTEA 키·IV·레퍼런스도 전부 rodata에 있었다. - CTR의 카운터가 예측 가능하면 재현도 쉽다. nonce는 고정, 카운터는 주소 ÷ 16이라 어떤 블록이든 키스트림을 바로 만들 수 있었다. 스트림 암호에서 nonce 재사용·예측 가능한 카운터는 전형적인 약점이다.
- 세그먼트 격리는 공격 표면을 늘리기도 한다. flag 메시지(
flat_memory_model_is_better_than_segmented) 가 역설적이다. 울퉁불퉁하게 쪼갠 세그먼트 MM이 "복잡해서 안전해 보이는" 설계였지만, 결국 복호 경계를 만드는 그 구조 자체가 known-plaintext를 떠먹여 주는 지점이 됐다. 복잡도는 보안이 아니다.
재현은 전부 오프라인이다(reproduce.sh): 플로피 성격 스캔 → 블록암호 KAT → 코드 복호 →
검증기 디스어셈블 → flag 역산까지 문제 VM 없이 돌아간다. qemu-system-i386과
python3-capstone만 있으면 된다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…