[💎 Platinum 3] 암호화된 RISC-V 멀티-VM을 복호해 flag를 역산하다 — DreamHack BumpyMM 풀이

2026-10-11·1분 읽기·

[💎 Platinum 3] 암호화된 RISC-V 멀티-VM을 복호해 flag를 역산하다 — DreamHack BumpyMM 풀이

qemu-system-i386로 부팅하는 드라이브 네 장이 전부다. drive0는 MBR에서 보호모드로 넘어가 RISC-V(RV32) 에뮬레이터를 띄우고, 그 위에서 세 개의 VM이 돈다. 둘은 디스크에서 암호화돼 있고 실행될 때만 블록 단위로 복호되는 anti-dump 구조라 메모리 덤프로는 한 블록밖에 못 본다. 평문으로 남은 세 번째 VM이 복호기였고, 거기서 커스텀 블록암호를 그대로 전사해 코드를 전부 복호한 뒤, 검증기의 XTEA-CBC 변환을 역산해 64바이트 입력을 복원했다.

문제: 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 커맨드가 들어 있고 drive0는 DOS/MBR 부트섹터, drive1 drive2 drive3는 각각 1.44MB data로 식별된다. IDE 인덱스 1·2·3에 플로피 세 장을 물린 구성이다
run.sh에는 qemu-system-i386 커맨드가 들어 있고 drive0는 DOS/MBR 부트섹터, drive1 drive2 drive3는 각각 1.44MB data로 식별된다. IDE 인덱스 1·2·3에 플로피 세 장을 물린 구성이다

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'
SeaBIOS와 iPXE 배너가 지나간 뒤 Booting from Hard Disk 메시지가 뜨고 곧바로 command 콜론 프롬프트가 출력된다. 부팅이 끝나면 무언가 명령을 입력받으려 기다리는 셸 같은 인터페이스가 올라온다
SeaBIOS와 iPXE 배너가 지나간 뒤 Booting from Hard Disk 메시지가 뜨고 곧바로 command 콜론 프롬프트가 출력된다. 부팅이 끝나면 무언가 명령을 입력받으려 기다리는 셸 같은 인터페이스가 올라온다

부팅하면 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'
objdump 출력에서 현재 명령 바이트를 읽어 0x7f로 마스킹하고 0x33과 비교하는 분기가 보인다. 이어서 명령의 비트필드를 잘라 0x12800 베이스의 배열을 인덱싱하는데 이것이 RISC-V 레지스터 파일이다. R타입 산술명령을 해석하는 전형적인 인터프리터 코드다
objdump 출력에서 현재 명령 바이트를 읽어 0x7f로 마스킹하고 0x33과 비교하는 분기가 보인다. 이어서 명령의 비트필드를 잘라 0x12800 베이스의 배열을 인덱싱하는데 이것이 RISC-V 레지스터 파일이다. R타입 산술명령을 해석하는 전형적인 인터프리터 코드다

명령 바이트를 & 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
scan_floppies.py 실행 결과. drive1 drive2는 하위 2비트가 11인 비율이 0.11 안팎이고 drive3만 1.00이다. drive3는 평문 RV32 코드, 나머지는 암호화됐다. 문자열도 command size data와 correct와 YOUHAVESECUREKEY로 갈린다
scan_floppies.py 실행 결과. drive1 drive2는 하위 2비트가 11인 비율이 0.11 안팎이고 drive3만 1.00이다. drive3는 평문 RV32 코드, 나머지는 암호화됐다. 문자열도 command size data와 correct와 YOUHAVESECUREKEY로 갈린다

결과가 셋의 역할을 단번에 알려 준다.

  • 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 += 4
python3 disasm.py extracted/drive3 extracted/drive3 0x148c 0x1530
drive3 복호 래퍼의 디스어셈블. 16바이트 단위 루프 안에서 키 주소 0x15d4 YOUHAVESECUREKEY와 상수 0x15e8을 인자로 내부 블록암호를 호출하고, 블록 인덱스를 데이터 오프셋을 16으로 나눈 값으로 계산한다. CTR 모드의 전형적인 구조다
drive3 복호 래퍼의 디스어셈블. 16바이트 단위 루프 안에서 키 주소 0x15d4 YOUHAVESECUREKEY와 상수 0x15e8을 인자로 내부 블록암호를 호출하고, 블록 인덱스를 데이터 오프셋을 16으로 나눈 값으로 계산한다. CTR 모드의 전형적인 구조다

구조는 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
kat.py 실행 결과. counter 0부터 5까지 여섯 블록 모두 라이브 덤프에서 추출한 실제 키스트림과 재구현한 키스트림이 완전히 일치한다. 마지막 줄에 ALL MATCH 블록암호 재구현 정확이라고 출력된다
kat.py 실행 결과. counter 0부터 5까지 여섯 블록 모두 라이브 덤프에서 추출한 실제 키스트림과 재구현한 키스트림이 완전히 일치한다. 마지막 줄에 ALL MATCH 블록암호 재구현 정확이라고 출력된다

여섯 블록이 전부 일치한다. 블록암호를 정확히 옮겼다는 뜻이고, 이제 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
복호한 VM2 검증기 0x774의 디스어셈블. 입력 크기가 0x40 64바이트인지 beq로 확인하고 아니면 0 반환. 통과하면 변환 함수 0x644를 호출한 뒤 결과를 rodata 0x3018의 64바이트 레퍼런스와 한 바이트씩 비교하는 루프를 돈다. 전부 일치하면 correct를 출력한다
복호한 VM2 검증기 0x774의 디스어셈블. 입력 크기가 0x40 64바이트인지 beq로 확인하고 아니면 0 반환. 통과하면 변환 함수 0x644를 호출한 뒤 결과를 rodata 0x3018의 64바이트 레퍼런스와 한 바이트씩 비교하는 루프를 돈다. 전부 일치하면 correct를 출력한다

검증기의 요구는 명확하다.

  1. 입력은 정확히 64바이트여야 한다(li a5,0x40; beq).
  2. 변환 함수 0x644를 거친 뒤,
  3. 그 결과가 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
변환에 쓰이는 블록암호 0x3c4의 디스어셈블. delta 상수 0x9e3779b9를 lui와 addi로 적재하고, v0와 v1에 대해 시프트와 XOR과 키 인덱싱을 4회 반복하는 전형적인 XTEA 암호화 루프다. 4라운드 XTEA임을 확인할 수 있다
변환에 쓰이는 블록암호 0x3c4의 디스어셈블. delta 상수 0x9e3779b9를 lui와 addi로 적재하고, v0와 v1에 대해 시프트와 XOR과 키 인덱싱을 4회 반복하는 전형적인 XTEA 암호화 루프다. 4라운드 XTEA임을 확인할 수 있다

정리하면 검증기가 하는 일은 — 입력 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
solve.py 실행 결과. 복원된 64바이트 입력은 DH로 시작하는 flag 문자열 뒤에 널 바이트 패딩이 붙은 형태이고, 정리하면 flag가 그대로 출력된다. 평문 플로피만으로 전부 오프라인 복원된다
solve.py 실행 결과. 복원된 64바이트 입력은 DH로 시작하는 flag 문자열 뒤에 널 바이트 패딩이 붙은 형태이고, 정리하면 flag가 그대로 출력된다. 평문 플로피만으로 전부 오프라인 복원된다

복원한 64바이트는 flag 문자열 + 널 패딩이었다. 이게 진짜 유일한 정답인지 확인하려면 역방향으로 다시 암호화해 레퍼런스와 같은지 보면 된다 — 검증기가 하는 바로 그 변환을 그대로 돌려서.

python3 verify.py
verify.py 실행 결과. 먼저 복호한 drive2 코드의 첫 네 명령이 정상적인 함수 프롤로그로 디스어셈블되고, 복원한 입력을 다시 CBC 암호화한 결과가 rodata 레퍼런스와 완전히 일치한다는 True가 찍힌다. 마지막 줄에 복원된 flag가 출력된다
verify.py 실행 결과. 먼저 복호한 drive2 코드의 첫 네 명령이 정상적인 함수 프롤로그로 디스어셈블되고, 복원한 입력을 다시 CBC 암호화한 결과가 rodata 레퍼런스와 완전히 일치한다는 True가 찍힌다. 마지막 줄에 복원된 flag가 출력된다

forward(복원입력) == rodata 레퍼런스? True. 복원한 입력을 검증기와 똑같이 암호화하면 임베드된 레퍼런스와 바이트 하나까지 일치한다 — 검증을 통과하는 입력은 이것뿐이고, 그 안의 문자열이 flag다.

python3 dreamhack.py wg 2906 --flag 'DH{flat_memory_model_is_better_than_segmented_2hf8eg23}'
드림핵 자동화로 flag를 제출한 결과 화면. 문제 id 2906 BumpyMM에 복원한 flag를 넣자 RESULT 정답 해결 처리됨이 출력되어 정답으로 확인됐다. VM 크레딧은 0으로 오프라인 문제라 소모가 없다
드림핵 자동화로 flag를 제출한 결과 화면. 문제 id 2906 BumpyMM에 복원한 flag를 넣자 RESULT 정답 해결 처리됨이 출력되어 정답으로 확인됐다. VM 크레딧은 0으로 오프라인 문제라 소모가 없다

DH{flat_memory_model_is_better_than_segmented_2hf8eg23}.

전체 구조 한눈에

여기까지가 한 그림으로 요약된다. i386 베어메탈 위에 RISC-V 에뮬레이터, 그 위에 세 VM. 둘은 암호화돼 실행될 때만 VM3가 블록 단위로 복호하고, VM3의 블록암호를 전사해 코드를 복호한 뒤 검증기의 XTEA-CBC를 역산한다.

BumpyMM 전체 구조도. 맨 아래 i386 베어메탈과 RISC-V 인터프리터 루프 위에 세 개의 VM 박스가 놓인다. VM1과 VM2는 암호화된 프론트엔드와 검증기, VM3는 평문 페이저로 키를 갖고 VM1 VM2의 코드를 온디맨드로 복호한다. 아래에는 풀이 세 단계인 블록암호 전사와 검증기 분석과 CBC XTEA 역산이 정리돼 있다
BumpyMM 전체 구조도. 맨 아래 i386 베어메탈과 RISC-V 인터프리터 루프 위에 세 개의 VM 박스가 놓인다. VM1과 VM2는 암호화된 프론트엔드와 검증기, VM3는 평문 페이저로 키를 갖고 VM1 VM2의 코드를 온디맨드로 복호한다. 아래에는 풀이 세 단계인 블록암호 전사와 검증기 분석과 CBC XTEA 역산이 정리돼 있다

자체 완결 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

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 1] MixColumns 하나가 빠지면 AES 는 XOR 표로 무너진다 — DreamHack locked present 풀이
blog

[🥈 Silver 1] MixColumns 하나가 빠지면 AES 는 XOR 표로 무너진다 — DreamHack locked present 풀이

25바이트 비밀번호를 검사하는 stripped ELF 안에 5x5 블록 AES 변형이 통째로 들어 있다. S-box 도 Rcon 도 진짜 AES 인데 MixColumns 만 없다. 그 하나가 빠지자 확산이 사라져 평문 바이트 하나가 암호문 바이트 하나로 그대로 대응했고, 라운드가 10 이라 열 회전마저 상쇄돼 자리 순열까지 항등이 됐다. 재구현으로 되감아 비밀번호를 복구하고, 키를 전혀 모르는 상태에서도 같은 값이 나오는 두 가지 경로를 따로 실증했다.
#dreamhack#ctf#reversing+7
2026-08-19#dreamhack +4
[🥈 Silver 4] 3×10¹⁷번 세제곱을 안 기다리고 접기 — DreamHack power cube 풀이
blog

[🥈 Silver 4] 3×10¹⁷번 세제곱을 안 기다리고 접기 — DreamHack power cube 풀이

실행하면 "You've waited so long...."만 남기고 영원히 안 끝나는 바이너리 하나. 하는 일은 64비트 정수를 세제곱만 3.1×10¹⁷번 반복한 뒤 그 결과를 SHA256해서 플래그로 찍는 게 전부다. 세제곱을 쌓으면 지수가 3의 거듭제곱으로 자란다는 것과, 2⁶⁴ 곱셈군의 지수가 2⁶²라는 카마이클 정리 하나로 그 반복을 한 줄 pow로 접는다.
#dreamhack#ctf#reversing+5
2026-07-24#dreamhack +4
[🥉 브론즈 3] XOR 한 줄로 잠근 랜섬웨어, 키를 거꾸로 뽑아내다 — DreamHack darimchal_001 풀이
blog

[🥉 브론즈 3] XOR 한 줄로 잠근 랜섬웨어, 키를 거꾸로 뽑아내다 — DreamHack darimchal_001 풀이

입력한 복호화 키를 고정 KEY와 XOR해 JOKER 상수와 strncmp하는 랜섬웨어 콘셉트 문제. XOR은 자기역원이라 JOKER ⊕ KEY를 그대로 계산하면 통과 비밀번호 pa55uc0_가 나온다. 마지막에 플래그를 DH{} 없이·언더바를 빼고 pa55uc0로 내야 정답으로 인정되는 함정이 있었다.
#dreamhack#ctf#crypto+4
2026-07-13#dreamhack +4