[🥈 Silver 1] 상쇄되는 연산 14개와 미끼 키 2개 — DreamHack Ransom 풀이

2026-07-28·1분 읽기·

[🥈 Silver 1] 상쇄되는 연산 14개와 미끼 키 2개 — DreamHack Ransom 풀이

UPX 로 포장된 32비트 PE 가 flag.txt 를 두 번 암호화한다. 진짜 main 은 암호화 함수를 둘 부르는데, 하나만 보고 뒤집으면 아무것도 안 나온다. 2단계는 고정 인덱스 연산이 15개나 붙어 있지만 14개가 역연산 짝이라 상쇄되고 실질은 하나뿐이고, .rdata 의 키처럼 생긴 문자열 셋 중 둘은 스택에 복사만 되고 읽히지 않는 미끼다. 키가 파일보다 한 바이트 짧아 마지막 바이트는 초기화 안 된 스택을 읽는다.

문제: DreamHack — Ransom 분류: reversing 난이도: 🥈 Silver 1 FLAG: DH{Do_y0u_Know_Oni?}

문제 설명은 짧다. "드림이가 FLAG를 가져왔어요. 어라, FLAG가 이상하네요."

받은 건 ransom.exe 와 flag.txt 둘뿐이다. flag.txt 를 열어 보면 글자가 아니라 바이너리 쓰레기라, 저 exe 가 파일을 암호화했고 우리는 그걸 되돌려야 한다는 뜻이다.


문제 개요

항목내용
문제명Ransom
난이도🥈 Silver 1
분류reversing
제공 파일public.Zip → ransom.exe (PE32 i386, UPX), flag.txt (20바이트)
서버없음 (오프라인)
핵심암호화 함수가 둘이다. 하나만 뒤집으면 안 나온다
곁가지고정 인덱스 연산 15개 중 14개 상쇄 · 미끼 키 2개 · 키가 파일보다 1바이트 짧음


🔬 정찰 — 뭘 받았나

zip 을 열어 보고 파일 종류와 암호문을 먼저 확인한다.

unzip -l extracted/public.Zip; file extracted/ransom.exe; xxd extracted/flag.txt

zip 내용물과 파일 종류, flag.txt 의 hexdump — ransom.exe 는 UPX 로 눌린 32비트 PE 이고 암호문은 정확히 20바이트다
zip 내용물과 파일 종류, flag.txt 의 hexdump — ransom.exe 는 UPX 로 눌린 32비트 PE 이고 암호문은 정확히 20바이트다

ransom.exe 는 32비트 PE 인데 UPX compressed 가 붙어 있고 섹션이 3개다. flag.txt 는 정확히 20바이트.

플래그가 DH{...} 형식이고 20바이트라면 중괄호 안이 16글자라는 뜻이다. 이 길이는 나중에 검산할 때 쓴다.

UPX 벗기기

UPX 는 자체 포맷이라 upx -d 한 방이면 끝난다. 손으로 덤프 뜰 일이 없다.

upx -d -o ransom_unpacked.exe ransom_packed.exe; file ransom_unpacked.exe

upx -d 로 언패킹한 결과 — 8192바이트가 11776바이트로 풀리고 섹션이 3개에서 4개로 늘어 정상 PE 가 됐다
upx -d 로 언패킹한 결과 — 8192바이트가 11776바이트로 풀리고 섹션이 3개에서 4개로 늘어 정상 PE 가 됐다

8,192바이트가 11,776바이트로 풀렸고 섹션이 3개에서 4개로 늘었다. 이제 정상적인 PE 라 objdump 로 읽을 수 있다.

문자열부터

풀린 바이너리의 문자열을 보면 이 문제가 뭘 할지 대충 보인다.

strings -a -n 5 ransom_unpacked.exe | sed -n "10,28p"

풀린 바이너리의 strings — Detect it 메시지와 키처럼 생긴 문자열 셋, 그리고 flag.txt 파일명이 보인다
풀린 바이너리의 strings — Detect it 메시지와 키처럼 생긴 문자열 셋, 그리고 flag.txt 파일명이 보인다

세 가지가 눈에 띈다.

[+] Detect it!! 이 여러 번 나온다. 분석 도구를 탐지하면 뱉는 메시지다.

키처럼 생긴 문자열이 셋이다. abc!@#qwe012fgh456, 1@!@#a134234, zBNdlwi102394. 셋 다 쓰는지, 하나만 쓰는지는 코드를 봐야 안다.

그리고 flag.txt 와 [+] Good!. 탐지에 안 걸리면 Good 을 찍고 파일을 건드린다는 얘기다.


🧭 진입점 — 함수가 몇 개나 파일을 건드리나

objdump 로 진입점부터 따라가면 진짜 main 이 나온다. 하는 일이 딱 세 줄이다.

objdump -d --no-show-raw-insn -M intel \
  --start-address=0x4019a0 --stop-address=0x4019b6 ransom_unpacked.exe | tail -n +8 | sed -f annot.sed

objdump 로 본 main — 탐지 함수를 부른 뒤 flag.txt 를 여는 함수가 둘이나 이어진다. 암호화가 한 번이 아니다
objdump 로 본 main — 탐지 함수를 부른 뒤 flag.txt 를 여는 함수가 둘이나 이어진다. 암호화가 한 번이 아니다

0x401080 은 프로세스 이름을 훑어 분석 도구를 찾는 함수라 파일과는 무관하다. 문제는 그 뒤다.

0x401610 과 0x4011d0, 둘 다 flag.txt 를 연다. 암호화가 한 번이 아니라 두 번이라는 뜻이고, 이걸 놓치면 아무리 정확히 뒤집어도 결과가 안 나온다. (실제로 놓쳤다 — 아래 삽질 참고.)


🔎 1단계 — 0x401610

먼저 키로 XOR 을 건다.

objdump -d --no-show-raw-insn -M intel \
  --start-address=0x4017a4 --stop-address=0x4017d7 ransom_unpacked.exe | tail -n +8 | sed -f annot.sed

objdump 로 본 1단계 — div 로 i 를 16으로 나눈 나머지를 키 인덱스로 쓴다. 키는 18글자인데 16개만 쓰인다
objdump 로 본 1단계 — div 로 i 를 16으로 나눈 나머지를 키 인덱스로 쓴다. 키는 18글자인데 16개만 쓰인다

mov ecx,0x10 다음 div ecx 라 나머지는 i % 16 이다. 키 문자열은 18글자인데 16개만 쓴다. 뒤의 5, 6 두 글자는 한 번도 안 들어간다. 이런 건 상수를 눈으로 세는 것보다 나눗셈 명령의 제수를 확인하는 게 확실하다.

그 다음 루프가 조금 이상하게 생겼다.

objdump -d --no-show-raw-insn -M intel \
  --start-address=0x401804 --stop-address=0x4018c8 ransom_unpacked.exe | tail -n +8 \
  | grep -E "add|xor|div|cmp" | sed -f annot.sed

objdump 로 본 1단계 연산 — 짝수 자리에 1, 홀수 자리에 2 를 더하고 고정 인덱스끼리 XOR 하는 줄이 두 개 붙는다
objdump 로 본 1단계 연산 — 짝수 자리에 1, 홀수 자리에 2 를 더하고 고정 인덱스끼리 XOR 하는 줄이 두 개 붙는다

i % 2 로 갈라서 짝수 자리엔 1, 홀수 자리엔 2를 더한다. 여기까진 평범하다.

문제는 그 아래 두 줄이다. buf[0] ^= buf[3] 과 buf[2] ^= buf[7] 인데, 인덱스가 i 가 아니라 고정 상수다. 그런데 위치는 루프 안이라 20번 다 돈다. 게다가 buf[3] 은 i == 3 회차에서 값이 바뀌므로, 회차마다 XOR 되는 값이 같지 않다.

닫힌 식으로 정리하려 들면 골치 아프다. 어차피 각 연산이 전부 가역이니 그대로 시뮬레이션하고 역순으로 되돌리면 된다.

정리하면 1단계는 이렇다.

for i in range(n):
    buf[i] ^= key1[i % 16]
for i in range(n):
    buf[i] += 1 if i % 2 == 0 else 2
    buf[0] ^= buf[3]
    buf[2] ^= buf[7]

🐛 삽질

▶🐛 삽질 1 — 키가 셋인데 어느 걸 쓰는지 한참 봤다

.rdata 에 키처럼 생긴 문자열이 셋이나 있고, 1단계 함수는 그 셋을 전부 스택에 복사한다. 그래서 처음엔 세 개를 번갈아 쓰거나 이어 붙여 쓰는 줄 알았다.

디스어셈블리를 눈으로 훑는 대신 범위를 잘라 세어 봤다. 스택 오프셋 기준으로 "읽는" 명령이 몇 개인지만 보면 된다.

#!/usr/bin/env python3
"""1단계 함수(0x401610~0x40194e) 안에서 세 키 버퍼가 실제로 '읽히는지' 센다."""
import pathlib
import re
 
BUFS = {"key1 (abc!@#qwe012fgh456)": "-0x28",
        "key2 (1@!@#a134234)":       "-0x14",
        "key3 (zBNdlwi102394)":      "-0x38"}
LO, HI = 0x401610, 0x40194e
 
lines = pathlib.Path("disasm.txt").read_text().splitlines()
for name, off in BUFS.items():
    reads = writes = 0
    for ln in lines:
        m = re.match(r"\s+([0-9a-f]+):", ln)
        if not m or not (LO <= int(m.group(1), 16) <= HI):
            continue
        if f"{off}]" not in ln:
            continue
        # 'mov [ebp-off], reg' 는 쓰기, 그 외(movsx reg,[...] 등)는 읽기
        if re.search(r"mov\s+(BYTE|WORD|DWORD) PTR \[ebp[^\]]*" + re.escape(off) + r"\],", ln):
            writes += 1
        else:
            reads += 1
    verdict = "실제로 쓰인다" if reads else "★ 복사만 하고 한 번도 안 읽는다 = 미끼"
    print(f"{name:28} 읽기 {reads:2}회 (시작오프셋 쓰기 {writes}회)   {verdict}")

키 버퍼 세 개의 읽기 횟수를 센 결과 — 실제로 읽히는 건 하나뿐이고 나머지 둘은 스택에 얹히기만 하는 미끼였다
키 버퍼 세 개의 읽기 횟수를 센 결과 — 실제로 읽히는 건 하나뿐이고 나머지 둘은 스택에 얹히기만 하는 미끼였다

읽기가 있는 건 key1 하나뿐이다. 나머지 둘은 스택에 얹히기만 하고 아무도 안 본다.

"복사되니까 쓰이겠지"라고 넘겨짚으면 안 된다는 얘기다. 쓰기와 읽기를 나눠 세면 5초면 끝난다.

▶🐛 삽질 2 — 1단계만 뒤집고 결과가 안 나와 한참 헤맸다

처음엔 0x401610 을 main 으로 착각했다. 그 안에 fopen("flag.txt") 도 있고 암호화 루프도 있고 [+] Good! 출력도 있어서, 이게 전부라고 봤다.

그래서 1단계만 역산했더니 이런 게 나왔다.

[+] 복호 결과: b'\x108S"\x03\xab1}\xe5\xef;.]2\x04>fpO}'

거의 다 쓰레기인데 마지막 바이트만 } 였다. 이게 힌트였다. 마지막 자리에 대해서는 내 모델이 맞다는 뜻이고, 나머지가 틀렸다는 건 어딘가 변환이 더 있다는 뜻이다.

키 인덱스를 % 18 로 바꿔 보고, 덧셈 상수를 뒤바꿔 보고, 고정 XOR 을 빼 보고 — 조합을 다 돌려도 인쇄 가능한 문자가 20개 중 14개를 넘지 않았다.

막힌 김에 처음으로 돌아가 "flag.txt 를 여는 함수가 정말 하나뿐인가"를 확인했더니, flag.txt 문자열이 .rdata 에 두 벌 있었다. 0x4031d4 와 0x40323c. 참조 지점을 따라가니 함수가 둘이었고, 그제서야 진짜 main 인 0x4019a0 이 보였다.

문자열이 중복으로 박혀 있으면 그걸 쓰는 곳도 둘일 수 있다. 처음 찾은 참조에서 멈추면 안 된다.


💣 2단계 — 0x4011d0

2단계는 키를 .rdata 에서 읽어오지 않는다. 스택에 한 바이트씩 직접 박아 넣는다.

objdump -d --no-show-raw-insn -M intel \
  --start-address=0x4011e3 --stop-address=0x401288 ransom_unpacked.exe | tail -n +8 | sed -f annot.sed

objdump 디스어셈블 — IsDebuggerPresent 이후 스택에 키 19바이트를 한 바이트씩 채운다
objdump 디스어셈블 — IsDebuggerPresent 이후 스택에 키 19바이트를 한 바이트씩 채운다

IsDebuggerPresent 를 먼저 부르고, 통과하면 mov BYTE PTR [ebp-0x420], 0x50 부터 한 바이트씩 채운다. 19개다. 파일은 20바이트인데 키는 19바이트다. 이 어긋남은 뒤에서 다시 나온다.

문자열 상수로 두지 않고 이렇게 박으면 strings 에 안 잡힌다. 아까 문자열 목록에 키가 셋만 보였던 이유이기도 하다.

이어지는 루프 몸통이 이 문제의 볼거리다.

objdump -d --no-show-raw-insn -M intel \
  --start-address=0x401362 --stop-address=0x401573 ransom_unpacked.exe | tail -n +8 \
  | grep -E "(add|sub|xor)    e[a-d]x,0x|xor    ecx,edx" | sed -f annot.sed

objdump 로 본 2단계 — 고정 인덱스 연산 15개가 붙는데 그중 14개가 서로 역연산 짝이라 사실상 상쇄된다
objdump 로 본 2단계 — 고정 인덱스 연산 15개가 붙는데 그중 14개가 서로 역연산 짝이라 사실상 상쇄된다

buf[i] ^= key2[i] 다음에 고정 인덱스 연산이 15개 줄줄이 붙는다. 상수도 제각각이라 처음 보면 복잡한 라운드 함수처럼 보인다.

그런데 짝을 맞춰 보면 이렇다.

앞뒤결과
buf[0] -= 0x15buf[0] += 0x15상쇄
buf[3] ^= 0x18buf[3] ^= 0x18상쇄
buf[8] -= 0x33buf[8] += 0x33상쇄
buf[11] ^= 0x44buf[11] ^= 0x44상쇄
buf[9] += 0x47buf[9] -= 0x47상쇄
buf[4] ^= 0x88buf[4] ^= 0x88상쇄
buf[7] ^= 0x68buf[7] ^= 0x68상쇄
buf[5] += 0x11—남는다

14개가 서로를 지우고 buf[5] += 0x11 하나만 살아남는다. 전부 즉시값 연산이라 인덱스끼리 얽히지도 않아서, 중간에 다른 연산이 끼어도 상쇄는 그대로 성립한다.

그러니까 눈에 보이는 분량의 대부분이 장식이다. 다만 이걸 믿고 코드에서 지워 버리면 나중에 검산이 안 되니, 역산기에는 15개를 다 적어 두고 시뮬레이션으로 돌렸다.

두 단계가 flag.txt 를 어떻게 통과시키는지, 복호는 왜 역순인지
두 단계가 flag.txt 를 어떻게 통과시키는지, 복호는 왜 역순인지

키가 파일보다 한 바이트 짧다

키는 19바이트인데 루프는 i 가 0부터 19까지 돈다. 20번째에서 읽는 key2[19] 는 초기화한 적이 없는 스택이다.

값이 얼마인지는 코드만 봐서는 알 수 없다. 그래서 일단 0으로 두고 풀되, 나중에 0~255 를 전부 훑어 검산했다.



🚀 복호

두 단계를 그대로 구현하고, 순서를 뒤집어 되돌린다. 2단계 → 1단계 순이다.

#!/usr/bin/env python3
"""Ransom (DreamHack, Silver 1 / reversing) — flag.txt 복호
 
UPX 를 벗기면 진짜 main 은 0x4019a0 이고, 파일을 두 번 건드린다.
 
    main:  call 0x401080   # 프로세스 이름으로 분석도구 탐지 (파일과 무관)
           call 0x401610   # 1단계
           call 0x4011d0   # 2단계 (IsDebuggerPresent 먼저)
 
1단계 (0x401610) — key1 은 .rdata 0x4031e0 의 "abc!@#qwe012fgh456"
 
    for i in range(n): buf[i] ^= key1[i % 16]      # ★ %16 이라 뒤 두 글자 '5','6' 은 안 쓰인다
    for i in range(n):
        buf[i] += 1 if i % 2 == 0 else 2
        buf[0] ^= buf[3]                           # 고정 인덱스, 매 회차 실행
        buf[2] ^= buf[7]
 
2단계 (0x4011d0) — key2 는 스택에 한 바이트씩 박아 넣은 19바이트
 
    for i in range(n):
        buf[i] ^= key2[i]                          # 나머지 연산 없이 인덱스 그대로
        <고정 인덱스 연산 15개>                      # 아래 OPS
 
OPS 는 15개인데 14개가 서로 역연산 짝이라 상쇄되고, 실질은 buf[5] += 0x11 하나뿐이다.
그래도 짝이 맞는지 눈으로 세는 것보다 그대로 시뮬레이션하는 쪽이 안전해서 전부 적어 뒀다.
 
.rdata 의 "1@!@#a134234" / "zBNdlwi102394" 는 0x401610 스택에 복사만 되고 읽히지 않는다(미끼).
key2 는 19바이트만 초기화되는데 파일은 20바이트라, 마지막 한 바이트는 초기화 안 된 스택을
읽는다. 실행 환경에서 0 이었다는 가정이 맞는지는 --scan 으로 확인한다.
 
    python3 solve.py                 # extracted/flag.txt 복호
    python3 solve.py --selftest      # 복호본을 다시 암호화해 원본과 대조
    python3 solve.py --scan          # key2 의 미초기화 마지막 바이트를 0~255 전수
"""
import argparse
import pathlib
import string
import sys
 
KEY1 = b"abc!@#qwe012fgh456"
KEY2 = bytes([0x50, 0x70, 0x20, 0x62, 0x74, 0x58, 0x48, 0x45, 0x90, 0x90,
              0x70, 0x40, 0x36, 0x45, 0x55, 0x71, 0x18, 0x19, 0x70])
 
# 2단계 루프 몸통의 고정 인덱스 연산 (0x401371 ~ 0x40156c 순서 그대로)
OPS = [(0, '-', 0x15), (3, '^', 0x18), (8, '-', 0x33), (11, '^', 0x44),
       (9, '+', 0x47), (4, '^', 0x88), (7, '^', 0x68), (0, '+', 0x15),
       (11, '^', 0x44), (9, '-', 0x47), (8, '+', 0x33), (5, '+', 0x11),
       (3, '^', 0x18), (4, '^', 0x88), (7, '^', 0x68)]
 
HERE = pathlib.Path(__file__).resolve().parent
 
 
def _apply(b, idx, op, v, inverse=False):
    if op == '^':
        b[idx] ^= v                       # XOR 은 자기 자신이 역연산
    elif (op == '+') != inverse:
        b[idx] = (b[idx] + v) & 0xFF
    else:
        b[idx] = (b[idx] - v) & 0xFF
 
 
def stage1(b):
    n = len(b)
    for i in range(n):
        b[i] ^= KEY1[i % 16]
    for i in range(n):
        b[i] = (b[i] + (1 if i % 2 == 0 else 2)) & 0xFF
        b[0] ^= b[3]
        b[2] ^= b[7]
 
 
def stage1_inv(b):
    n = len(b)
    for i in reversed(range(n)):
        b[2] ^= b[7]
        b[0] ^= b[3]
        b[i] = (b[i] - (1 if i % 2 == 0 else 2)) & 0xFF
    for i in range(n):
        b[i] ^= KEY1[i % 16]
 
 
def stage2(b, tail=0x00):
    key = KEY2 + bytes([tail]) * (len(b) - len(KEY2))
    for i in range(len(b)):
        b[i] ^= key[i]
        for idx, op, v in OPS:
            _apply(b, idx, op, v)
 
 
def stage2_inv(b, tail=0x00):
    key = KEY2 + bytes([tail]) * (len(b) - len(KEY2))
    for i in reversed(range(len(b))):
        for idx, op, v in reversed(OPS):
            _apply(b, idx, op, v, inverse=True)
        b[i] ^= key[i]
 
 
def encrypt(data: bytes, tail=0x00) -> bytes:
    b = bytearray(data)
    stage1(b)
    stage2(b, tail)
    return bytes(b)
 
 
def decrypt(data: bytes, tail=0x00) -> bytes:
    b = bytearray(data)
    stage2_inv(b, tail)
    stage1_inv(b)
    return bytes(b)
 
 
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("path", nargs="?", default=str(HERE / "extracted" / "flag.txt"))
    ap.add_argument("--selftest", action="store_true")
    ap.add_argument("--scan", action="store_true",
                    help="key2 의 미초기화 마지막 바이트를 0~255 전수해 후보를 본다")
    a = ap.parse_args()
 
    ct = pathlib.Path(a.path).read_bytes()
    print(f"[*] 암호문 {len(ct)}바이트: {ct.hex()}")
 
    if a.scan:
        ok = set(string.printable[:-5].encode())
        hits = [(t, decrypt(ct, t)) for t in range(256)]
        hits = [(t, p) for t, p in hits if all(c in ok for c in p)]
        for t, pt in hits[:4]:
            print(f"    tail=0x{t:02x} → {pt.decode()}")
        closed = [t for t, p in hits if p.endswith(b"}")]
        print(f"    ... 인쇄가능 후보 {len(hits)}개")
        print(f"    그중 '}}' 로 끝나는 것: {['0x%02x' % t for t in closed]}")
        return
 
    pt = decrypt(ct)
    print(f"[+] 복호 결과: {pt!r}")
    try:
        print(f"[+] FLAG: {pt.decode()}")
    except UnicodeDecodeError:
        print("[-] ASCII 로 안 떨어진다 — --scan 으로 마지막 키 바이트를 훑어 볼 것")
        sys.exit(1)
 
    if a.selftest:
        again = encrypt(pt)
        ok = again == ct
        print(f"[{'+' if ok else '-'}] 재암호화 대조: {again.hex()} {'일치' if ok else '불일치'}")
        sys.exit(0 if ok else 1)
 
 
if __name__ == "__main__":
    main()

solve.py 실행 — 복호한 평문을 다시 암호화해 배포본 20바이트와 대조하는 자체 검산까지 통과한 화면이다
solve.py 실행 — 복호한 평문을 다시 암호화해 배포본 20바이트와 대조하는 자체 검산까지 통과한 화면이다

--selftest 는 복호한 평문을 다시 암호화해서 배포본 20바이트와 대조한다. 복호만 하고 "글자로 읽히니 맞겠지" 하고 넘어가면, 자리 하나가 우연히 맞은 건지 알 수가 없다.

[+] FLAG: DH{Do_y0u_Know_Oni?}
[+] 재암호화 대조: 745c3705448a410c81e10b1e3c576d0c08142d5e 일치

🧪 바이너리로 확인 — wine 실행

PE 라 gdb 로 런타임을 찍기가 마땅치 않다. 대신 바이너리를 실제로 돌려서 확인할 수 있다. 복호로 얻은 평문을 flag.txt 에 넣고 ransom.exe 를 wine 으로 실행하면, 배포본과 같은 암호문이 나와야 한다.

#!/usr/bin/env bash
# 복호 결과가 맞는지 바이너리로 직접 확인한다.
# 평문(복호 결과)을 flag.txt 에 넣고 ransom.exe 를 실제로 돌려서,
# 배포본 flag.txt 와 바이트가 같아지는지 본다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
 
PLAIN=${1:-'DH{Do_y0u_Know_Oni?}'}
WORK=wine_run
rm -rf "$WORK"; mkdir -p "$WORK"
cp extracted/ransom.exe "$WORK/"
printf '%s' "$PLAIN" > "$WORK/flag.txt"
 
echo "[*] 실행 전 flag.txt (복호로 얻은 평문)"
xxd "$WORK/flag.txt"
 
export WINEPREFIX="$HOME/.wine" WINEDEBUG=-all
( cd "$WORK" && wine ransom.exe ) 2>&1 | grep -vE '^wine:|XDG_RUNTIME_DIR' || true
 
echo
echo "[*] 실행 후 flag.txt (ransom.exe 가 다시 암호화한 결과)"
xxd "$WORK/flag.txt"
echo "[*] 배포본 flag.txt"
xxd extracted/flag.txt
 
python3 - "$WORK/flag.txt" extracted/flag.txt <<'PY'
import sys, pathlib
got = pathlib.Path(sys.argv[1]).read_bytes()
want = pathlib.Path(sys.argv[2]).read_bytes()
head_ok = got[:19] == want[:19]
print()
print(f"앞 19바이트 : {'일치' if head_ok else '불일치'}")
print(f"20번째      : 이번 실행 0x{got[19]:02x} / 배포본 0x{want[19]:02x}"
      f"  → 미초기화 키 바이트 차이 0x{got[19] ^ want[19]:02x}")
if not head_ok:
    sys.exit(1)
print()
print("✅ 이 평문이 배포본 암호문을 만든다 (마지막 1바이트는 환경 의존이라 제외)")
PY

wine 으로 ransom.exe 를 직접 돌린 결과 — 복원한 평문이 배포본 암호문을 19바이트까지 똑같이 만들어 낸다
wine 으로 ransom.exe 를 직접 돌린 결과 — 복원한 평문이 배포본 암호문을 19바이트까지 똑같이 만들어 낸다

[+] Good! 이 찍히고, 나온 암호문이 배포본과 19바이트까지 똑같다. 마지막 한 바이트만 다르다.

이게 바로 아까 그 초기화 안 된 키 바이트다. 같은 바이너리라도 돌리는 환경에 따라 그 자리에 들어오는 스택 쓰레기가 달라지니, 마지막 바이트도 따라 달라진다. 출제자 PC 에서는 0 이었고, 이 wine 환경에서는 0x7b 였다(두 결과의 XOR 차이가 그대로 그 값이다).

실패가 아니라 오히려 분석이 맞다는 증거다. 실제로 solve.py 의 encrypt 에 tail=0x7b 를 넣으면 wine 이 만든 암호문이 그대로 재현된다.

마지막 바이트를 전수로 확인

값을 0 이라고 가정한 게 우연히 맞은 건 아닌지, 0~255 를 전부 넣어 봤다.

마지막 한 바이트를 전수 조사한 결과 — 인쇄 가능한 후보가 95개나 되지만 중괄호로 닫히는 건 딱 하나뿐이다
마지막 한 바이트를 전수 조사한 결과 — 인쇄 가능한 후보가 95개나 되지만 중괄호로 닫히는 건 딱 하나뿐이다

인쇄 가능한 문자열이 나오는 후보가 95개나 된다. 마지막 한 글자만 바뀌니 당연하다.

하지만 플래그가 } 로 닫혀야 한다는 조건을 걸면 0x00 하나만 남는다. 형식이 검산 도구가 되는 흔한 경우다.

재현

배포본만 있으면 바로 돌려볼 수 있게 한 방 스크립트를 남겼다. 서버가 필요 없는 문제라 이것만으로 진짜 플래그가 나온다.

#!/usr/bin/env bash
# Ransom (DreamHack, Silver 1) — 한 방 재현
#   ./reproduce.sh            복호 → flag 대조 (인자 없이 그냥 돌리면 된다)
#   ./reproduce.sh --wine     추가로 ransom.exe 를 wine 으로 실제 실행해 암호문 재현까지
set -eu
cd "$(dirname "$(readlink -f "$0")")"
 
EXPECT='DH{Do_y0u_Know_Oni?}'
 
# 배포본이 zip 안에 있으면 먼저 푼다
[ -f extracted/flag.txt ] || ( cd extracted && unzip -o -q public.Zip )
 
OUT=$(timeout 120 python3 solve.py --selftest 2>&1) || true
echo "$OUT" | tail -6
 
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
if [ "$FLAG" != "$EXPECT" ]; then
    echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"; exit 1
fi
if ! printf '%s' "$OUT" | grep -q '재암호화 대조.*일치'; then
    echo; echo "❌ FAIL — 복호는 됐는데 재암호화가 원본과 안 맞는다"; exit 1
fi
 
if [ "${1:-}" = "--wine" ]; then
    echo; echo "=== wine 실행 검증 ==="
    ./run_wine.sh "$EXPECT" | tail -6
fi
 
echo; echo "✅ PASS  $FLAG"

reproduce.sh 실행 — 복호와 재암호화 대조를 모두 거쳐 기대한 플래그와 일치하며 PASS 로 끝난다
reproduce.sh 실행 — 복호와 재암호화 대조를 모두 거쳐 기대한 플래그와 일치하며 PASS 로 끝난다



📝 결론

암호화 함수가 하나라고 가정하지 않는다

이 문제에서 제일 오래 잡아먹은 건 알고리즘이 아니라 "함수가 둘"이라는 사실이었다. 파일을 여는 코드를 찾았다고 거기서 멈추면, 그 뒤에 붙은 두 번째 변환을 통째로 놓친다.

같은 문자열이 .rdata 에 두 벌 박혀 있으면 그걸 쓰는 곳도 둘일 수 있다. 참조를 전부 훑고 진입점부터 호출 순서를 확인하는 게 결국 빠르다.

분량과 실질은 다르다

2단계의 고정 연산 15개는 화면을 꽉 채우지만 실제로 남는 건 하나다. 상수가 제각각이라 복잡해 보일 뿐, 인덱스별로 묶으면 대부분이 자기를 지운다.

반대로 1단계의 두 줄짜리 buf[0] ^= buf[3] 은 짧은데 훨씬 성가시다. 루프 안에 있어서 20번 돌고, 참조하는 값이 도중에 바뀐다. 길이로 난이도를 재면 반대로 짚는다.

복호가 읽힌다고 끝내지 않는다

DH{ 로 시작하는 문자열이 나오면 거기서 손을 놓기 쉽다. 하지만 그건 앞자리 몇 개가 맞았다는 뜻일 뿐이다. 복호본을 다시 암호화해 원본 바이트와 대조하면 전 자리가 한꺼번에 검산된다.

이번엔 그 검산 덕분에 마지막 바이트가 환경 의존이라는 것까지 드러났다.

초기화 안 된 메모리를 읽는 코드는 결과가 기계마다 다르다

키 배열을 19개만 채워 놓고 20번 읽는 건 명백한 버그다. 문제 파일이 만들어진 PC 에서는 그 자리가 0이었지만, wine 에서 돌리면 다른 값이 나온다.

암호화 도구가 이러면 복호가 원본 기계에서만 되는 일이 생긴다. 랜섬웨어를 흉내 낸 문제에 이 버그가 들어 있는 게 공교롭다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 2] 암호도 플래그도 .data 에 누워 있다 — DreamHack Gyul Brix Calculator 풀이
blog

[🥉 Bronze 2] 암호도 플래그도 .data 에 누워 있다 — DreamHack Gyul Brix Calculator 풀이

당도를 입력받는 척하는 32비트 PE 안에, 관리자 암호와 플래그가 각각 단일바이트 XOR 한 겹만 쓴 채 .data 에 나란히 누워 있다. 키가 코드 안 즉치값이라 strings 로는 아무것도 안 잡히고, 두 블롭을 키·암호문 쌍으로 넘겨짚으면 더 헤맨다. 디스어셈블리에서 0x55 와 0x77 을 읽어내면 끝난다.
#dreamhack#ctf#reversing+6
2026-08-17#dreamhack +6
[🥈 Silver 2] 시리얼 검증 루프를 거꾸로 돌리기 — DreamHack babycmp 풀이
blog

[🥈 Silver 2] 시리얼 검증 루프를 거꾸로 돌리기 — DreamHack babycmp 풀이

SERIAL 한 줄만 받는 MFC 다이얼로그 앱. 입력 24글자를 int32 배열로 부풀린 뒤 .data 에 박아 둔 값 24개와 통째로 비교한다. 변환이 ror32 → XOR → ADD → +30000 넉 줄뿐이라 자리마다 후보가 하나로 떨어지고, 키는 .rdata 가 아니라 코드 즉치값으로 숨어 있다. 역산본은 바이너리의 루프 기계어를 Unicorn 으로 그대로 돌려 교차검증했다.
#dreamhack#ctf#reversing+6
2026-08-17#dreamhack +5
[🥈 Silver 4] 프로그램이 정말로 자기 이름에게 물었다 — DreamHack What is your name? 풀이
blog

[🥈 Silver 4] 프로그램이 정말로 자기 이름에게 물었다 — DreamHack What is your name? 풀이

zip 안에 exe가 11개, 전부 파일명이 무작위 3글자로 뒤섞여 있다. 실행해도 콘솔에 아무것도 안 뜨고, sha256도 전부 다르다. cmp -l로 두 파일을 겹쳐보니 123392바이트 중 딱 2바이트만 다르다는 걸 발견하면서 실마리가 풀렸다. 각 exe는 GetModuleFileNameA로 자기 자신의 파일명을 읽어 한 글자씩 밀어서 출력할 뿐이었고, 그 결과를 순서대로 이어붙이면 그대로 flag가 된다 — "What is your name?"이 은유가 아니라, 프로그램이 진짜로 자기 자신의 이름을 묻는 질문이었다.
#dreamhack#ctf#reversing+4
2026-07-21#dreamhack +5