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

2026-08-17·1분 읽기·

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

당도를 입력받는 척하는 32비트 PE 안에, 관리자 암호와 플래그가 각각 단일바이트 XOR 한 겹만 쓴 채 .data 에 나란히 누워 있다. 키가 코드 안 즉치값이라 strings 로는 아무것도 안 잡히고, 두 블롭을 키·암호문 쌍으로 넘겨짚으면 더 헤맨다. 디스어셈블리에서 0x55 와 0x77 을 읽어내면 끝난다.

문제: DreamHack — Gyul Brix Calculator 분류: reversing 난이도: 🥉 Bronze 2 FLAG: B1N4RY{sweet_G@mGyu1_1s_delicious}

문제 설명은 딱 한 줄이다.

귤은 자고로 달달~한 게 최고 아니겠습니까?

받은 건 brix_calc.exe 하나. 소스도 Dockerfile 도 없다. 제1회 감귤 CTF 출제작이 드림핵으로 넘어온 것이라 플래그 형식도 DH{...} 가 아니다. 이걸 모르고 시작하면 삽질 하나를 공짜로 얻는다.

file extracted/brix_calc.exe && md5sum extracted/brix_calc.exe && objdump -h extracted/brix_calc.exe

file 과 objdump -h 로 본 brix_calc.exe 기본 정보 — PE32 콘솔 실행파일이고 i386, 심볼은 외부 PDB 로 빠져 스트립 상태다. 섹션은 여덟 개이며 .text 가 0x401000, .data 가 0x404000, .rdata 가 0x405000 에 매핑된다
file 과 objdump -h 로 본 brix_calc.exe 기본 정보 — PE32 콘솔 실행파일이고 i386, 심볼은 외부 PDB 로 빠져 스트립 상태다. 섹션은 여덟 개이며 .text 가 0x401000, .data 가 0x404000, .rdata 가 0x405000 에 매핑된다

32비트 PE32 콘솔 프로그램이고 심볼은 없다. 섹션이 여덟 개인데 크기는 전부 자잘하다. .text 가 0x2e44, .data 는 0x98 바이트뿐이다. 파일 전체가 21KB 도 안 된다.


문제 개요

항목내용
문제명Gyul Brix Calculator
난이도🥉 Bronze 2
분류reversing
제공 파일brix_calc.exe (PE32, MinGW GCC 6.3.0, 20,992 B)
서버없음 — 완전 오프라인
핵심 취약점관리자 암호와 플래그가 .data 에 단일바이트 XOR 한 겹만 쓰고 상주

흐름은 짧다. 프로그램이 브릭스(당도) 값을 입력받아 등급을 매기는 척하지만, 입력이 특정 13글자와 정확히 일치하면 숨은 분기로 빠져 플래그를 출력한다. 그 13글자도, 플래그도 .data 안에 있다. 키는 코드에 박힌 즉치값 두 개다.



🔬 정찰 — 문자열부터 훑는다

리버싱 브론즈에서 가장 먼저 하는 건 strings 다. 화면에 뜨는 문구가 다 보이면 분기 구조를 절반은 짐작할 수 있다.

strings -n 6 extracted/brix_calc.exe | grep -nE "GAMHYUL|INPUT|GRANTED|recipe|Here is|Grade"; echo "--- 플래그 문자열 검색: $(strings -a extracted/brix_calc.exe | grep -cE "B1N4RY|DH\{")건 ---"

strings 와 grep 으로 뽑은 brix_calc.exe 의 UI 문구 — GAMHYUL BRIX 배너, ADMINISTRATOR ACCESS GRANTED, Here is the flag 와 Grade C·B·A·S 가 보인다. 마지막 줄은 플래그 문자열 검색이 0건이라는 뜻
strings 와 grep 으로 뽑은 brix_calc.exe 의 UI 문구 — GAMHYUL BRIX 배너, ADMINISTRATOR ACCESS GRANTED, Here is the flag 와 Grade C·B·A·S 가 보인다. 마지막 줄은 플래그 문자열 검색이 0건이라는 뜻

배너와 등급 문구는 전부 평문으로 나온다. [!] ADMINISTRATOR ACCESS GRANTED [!], [*] Decoding the hidden recipe..., Here is the flag: 까지 나란히 있다. 숨은 분기가 있다는 건 이걸로 확정이다.

그런데 정작 플래그는 0건이다. Here is the flag: 다음에 이어붙일 문자열이 파일 어디에도 평문으로 없다는 뜻이니, 출력 직전에 뭔가 계산이 들어간다.

다음은 임포트다. 어떤 CRT 함수를 쓰는지 보면 로직의 골격이 잡힌다.

rabin2 -i extracted/brix_calc.exe | grep -E "strlen|putchar|printf|puts|scanf|atof|getchar|fflush"

radare2 의 rabin2 -i 로 뽑은 msvcrt 임포트 목록 — atof, fflush, getchar, printf, putchar, puts, scanf, strlen, vfprintf 아홉 개가 IAT 주소와 함께 나열된다. strlen 은 0x40821c, putchar 는 0x408200 에 있다
radare2 의 rabin2 -i 로 뽑은 msvcrt 임포트 목록 — atof, fflush, getchar, printf, putchar, puts, scanf, strlen, vfprintf 아홉 개가 IAT 주소와 함께 나열된다. strlen 은 0x40821c, putchar 는 0x408200 에 있다

scanf 로 받고 atof 로 숫자를 만든다. 여기까지는 계산기니까 당연하다. 눈에 걸리는 건 strlenputchar 다.

당도를 재는 데 문자열 길이가 왜 필요한가. 그리고 등급 문구는 puts 로 통째로 찍으면 되는데 왜 putchar 로 한 글자씩 찍는 경로가 따로 있나. 이 두 개가 숨은 분기의 흔적이다.

IAT 주소도 같이 적어 둔다. 스트립된 바이너리라 objdumpcall 0x403ca8 처럼 썽크 주소만 보여 주는데, 썽크가 읽는 IAT 슬롯을 이 표와 맞추면 이름이 복원된다. 0x40821cstrlen, 0x408200putchar, 0x40820cscanf 다.



🧩 .data 에 뭔가 누워 있다

.data 가 0x98 바이트밖에 안 되니 통째로 떠 봐도 부담이 없다. 등급 임계값이 .rdata 에 float 로 들어 있을 것 같아 그쪽 네 워드도 같이 봤다.

objdump -s -j .data extracted/brix_calc.exe; objdump -s -j .rdata --start-address=0x405270 --stop-address=0x405280 extracted/brix_calc.exe | tail -2

objdump -s 로 뜬 .data 섹션 전체 hex 덤프 — 대부분 0 으로 채워져 있는데 0x404020 에 13바이트, 0x404040 에 34바이트짜리 비ASCII 블롭 두 개만 섬처럼 떠 있다. 아래 .rdata 0x405270 에는 float 10, 12, 14 에 해당하는 값이 연달아 있다
objdump -s 로 뜬 .data 섹션 전체 hex 덤프 — 대부분 0 으로 채워져 있는데 0x404020 에 13바이트, 0x404040 에 34바이트짜리 비ASCII 블롭 두 개만 섬처럼 떠 있다. 아래 .rdata 0x405270 에는 float 10, 12, 14 에 해당하는 값이 연달아 있다

.data 는 거의 다 0인데 두 군데만 값이 차 있다.

  • 0x404020 : 12 3c 23 30 18 30 03 3c 21 34 38 3c 3b — 13바이트
  • 0x404040 : 35 46 39 43 25 2e 0c 04 ... — 34바이트

.rdata00002041 00004041 00006041 은 리틀엔디언 float 로 각각 10.0, 12.0, 14.0 이다. 등급 임계값 세 개. 이건 예상대로다.

문제는 .data 의 두 블롭이다. 13이라는 길이가 strlen 과 겹치는 게 의심스럽고, 34바이트는 플래그 길이로 그럴듯하다.


🐛 삽질 — 두 블롭을 키·암호문 한 쌍으로 봤다

여기서 한 번 미끄러졌다. 짧은 블롭과 긴 블롭이 나란히 있으니 반사적으로 "앞의 13바이트가 반복 키, 뒤의 34바이트가 암호문" 이라고 생각했다. CTF 에서 워낙 흔한 배치라서 확인도 안 하고 그 가정으로 계산기를 두드렸다.

🐛 삽질 — 반복키 XOR 로 넘겨짚고 두 번 헛돌았다

세 가지 가정을 그대로 코드로 옮겨 돌려 봤다.

#!/usr/bin/env python3
"""삽질 재현 — .data 의 두 블롭을 "키 + 암호문" 한 쌍으로 넘겨짚었던 시도.
 
디스어셈블리를 읽기 전, 13바이트(0x404020)와 34바이트(0x404040)가 나란히 있는 걸 보고
"짧은 쪽이 반복 키겠지" 라고 가정했다. 결과는 전부 쓰레기였다.
아래 세 가설을 그대로 돌려 본다.
"""
import string
 
BLOB_A = bytes.fromhex("123c23301830033c2134383c3b")
BLOB_B = bytes.fromhex(
    "35463943252e0c04001212032830371a300e024628460428131"
    "21b1e141e1802040a"
)
 
PRINTABLE = set(string.printable[:-5].encode())
 
 
def show(label, data):
    ratio = sum(b in PRINTABLE for b in data) / len(data)
    txt = "".join(chr(b) if b in PRINTABLE else "." for b in data)
    print(f"  {label:<34} 출력가능 {ratio:5.0%}  {txt}")
 
 
print("[가설 1] 13B 블롭이 34B 블롭의 반복 키다")
show("B ^ cycle(A)", bytes(b ^ BLOB_A[i % len(BLOB_A)] for i, b in enumerate(BLOB_B)))
 
print("\n[가설 2] 플래그가 DH{ 로 시작한다 -> 앞 세 글자로 키를 뽑는다")
print("         (두 번째 줄은 정답을 알고 난 뒤 되짚어 본 것 — 키가 0x77 한 바이트였다)")
for want in (b"DH{", b"B1N"):
    key = bytes(c ^ p for c, p in zip(BLOB_B, want))
    show(f"B ^ cycle({key.hex()})",
         bytes(b ^ key[i % len(key)] for i, b in enumerate(BLOB_B)))
 
print("\n[가설 3] 그냥 단일바이트 키 256개를 전부 돌린다")
hits = 0
for k in range(256):
    dec = bytes(b ^ k for b in BLOB_B)
    if all(b in PRINTABLE for b in dec) and dec.count(b"{") == 1 and dec.endswith(b"}"):
        show(f"key=0x{k:02x}", dec)
        hits += 1
print(f"  -> 후보 {hits}개")
python3 sapjil_xorpair.py

sapjil_xorpair.py 실행 결과 — 가설 1 의 반복키 XOR 은 출력가능 문자 비율 65 퍼센트의 쓰레기가 나오고, DH 로 시작한다고 가정한 가설 2 도 82 퍼센트에서 깨진다. 마지막 단일바이트 키 256개 전수 시도만 100 퍼센트 출력가능한 후보 하나를 찾아낸다
sapjil_xorpair.py 실행 결과 — 가설 1 의 반복키 XOR 은 출력가능 문자 비율 65 퍼센트의 쓰레기가 나오고, DH 로 시작한다고 가정한 가설 2 도 82 퍼센트에서 깨진다. 마지막 단일바이트 키 256개 전수 시도만 100 퍼센트 출력가능한 후보 하나를 찾아낸다

가설 1은 65% 만 출력 가능한 글자로 나왔다. 완전한 쓰레기다.

가설 2도 어긋났다. 플래그가 DH{ 로 시작할 거라고 믿고 앞 세 바이트에서 키를 역산했는데 그 뒤가 안 이어진다. 감귤 CTF 출제작이라 접두사가 DH{ 가 아니었던 것인데, 그때는 그걸 몰랐으니 "키 길이 가정이 틀렸나" 쪽만 계속 의심했다.

마지막 가설 3은 성공한다. 단일바이트 키 256개를 전부 돌려 출력 가능한 문자만 나오는 후보를 걸렀더니 0x77 하나가 남았다. 이걸로 플래그는 나온다.

다만 이건 왜 되는지 모르는 채로 얻는 답이다. 13바이트 블롭이 무엇인지, 프로그램이 어떤 입력을 받아야 그 분기로 가는지는 여전히 설명하지 못한다. 결국 디스어셈블리를 읽어야 했다.



💣 핵심 — 관문은 두 개다

main 은 0x40148d 에서 시작한다. scanf 로 문자열을 받은 직후 구간을 잘라서 본다. 읽기 편하게 sed 규칙 파일 하나를 만들어 핵심 명령에 주석을 얹었다. 아래 화살표 주석은 도구가 원래 찍는 게 아니라 내가 붙인 것이다.

s|mov    DWORD PTR \[esp+0x90\],0xd|&        <-- 기대 길이 = 13|
s|call   0x403ca8|&                       <-- msvcrt!strlen|
s|add    edx,0x404020|&        <-- .data 의 암호 테이블|
s|xor    edx,0x55|&                       <-- 키 0x55 로 복호|
s|cmp    al,dl|&                          <-- 입력 한 글자 vs 복호 한 글자|
s|mov    DWORD PTR \[esp+0x9c\],0x0|&        <-- 하나라도 틀리면 플래그 0|
objdump -d -M intel --start-address=0x4014d2 --stop-address=0x401575 extracted/brix_calc.exe | sed -f annot_check.sed

objdump 로 뜬 0x4014d2 부터 0x401575 까지의 검사 루틴 디스어셈블리 — 기대 길이 13 을 스택에 넣고 strlen 결과와 비교한 뒤, 루프에서 입력 바이트와 0x404020 테이블 바이트를 0x55 로 XOR 한 값을 cmp al dl 로 한 글자씩 대조한다
objdump 로 뜬 0x4014d2 부터 0x401575 까지의 검사 루틴 디스어셈블리 — 기대 길이 13 을 스택에 넣고 strlen 결과와 비교한 뒤, 루프에서 입력 바이트와 0x404020 테이블 바이트를 0x55 로 XOR 한 값을 cmp al dl 로 한 글자씩 대조한다

구조가 그대로 보인다.

0x4014d2 에서 [esp+0x9c] 에 1을 넣는다. 이게 "아직 관리자다" 플래그다. 0x4014dd 에서 [esp+0x90] 에 0xd, 즉 13을 넣는다. 기대 길이다.

첫 관문은 길이다. strlen 결과와 13을 cmp 해서 다르면 곧장 [esp+0x9c] 를 0으로 밀고 루프를 건너뛴다.

둘째 관문이 본체다. 0x40151b 부터 도는 루프가 입력 i 번째 바이트를 al 에 담고, 0x404020 + i 의 바이트를 dl 에 담아 0x55 로 XOR 한 뒤 cmp al, dl 한다. 한 글자라도 어긋나면 역시 플래그가 0이 된다.

.data 의 13바이트는 키가 아니라 암호 자체였다. 키는 .data 에 없고 xor edx, 0x55 라는 즉치값으로 코드 안에 있었다. 삽질의 원인이 여기 있다.

그 13바이트를 0x55 로 풀면 이렇게 된다.

암호문123c23301830033c2134383c3b
^ 0x55476976654d65566974616d696e
ASCIIGiveMeVitamin

GiveMeVitamin. 13글자니까 길이 관문도 자연히 맞는다.

플래그를 뱉는 쪽

두 관문을 통과하면 0x401575 부터 관리자 메시지 세 줄을 찍고, 곧바로 0x401599 의 출력 루프로 들어간다. 여기도 같은 방식으로 주석을 얹어 봤다.

s|add    eax,0x404040|&           <-- .data 의 플래그 테이블|
s|xor    eax,0x77|&                       <-- 키 0x77 로 복호|
s|call   0x403ce0|&                       <-- msvcrt!putchar (한 글자씩 출력)|
s|cmp    eax,0x21|&                       <-- 0x21 까지, jbe 라서 34바이트|
s|mov    DWORD PTR \[esp\],0xa|&           <-- 마지막에 개행|
objdump -d -M intel --start-address=0x401599 --stop-address=0x4015e3 extracted/brix_calc.exe | sed -f annot_flag.sed

objdump 로 뜬 0x401599 부터 0x4015e3 까지의 플래그 출력 루프 — 0x404040 테이블에서 바이트를 읽어 0x77 로 XOR 한 뒤 putchar 로 한 글자씩 찍고, 인덱스를 0x21 과 cmp 한 다음 jbe 로 되돌아가므로 정확히 34바이트를 출력한다
objdump 로 뜬 0x401599 부터 0x4015e3 까지의 플래그 출력 루프 — 0x404040 테이블에서 바이트를 읽어 0x77 로 XOR 한 뒤 putchar 로 한 글자씩 찍고, 인덱스를 0x21 과 cmp 한 다음 jbe 로 되돌아가므로 정확히 34바이트를 출력한다

0x404040 + j 를 읽어 0x77 로 XOR 하고 putchar 에 넘긴다. 정찰에서 걸렸던 putchar 가 여기서 쓰인다.

길이는 종료 조건에서 읽는다. cmp eax, 0x21 다음이 jbe 인데, below or equal 이라 인덱스가 0x21(=33)일 때도 루프로 되돌아간다. 0부터 33까지 도니까 34바이트다. 앞의 암호 검사 루프가 jl 로 13 직전에서 멈춘 것과 대비된다. .data 블롭 크기와도 정확히 맞는다.

키가 두 개인 것도 이제 설명된다. 암호는 0x55, 플래그는 0x77. 서로 무관한 상수 두 개라 "짧은 블롭이 긴 블롭의 키" 라는 가정으로는 죽어도 안 풀린다.



🔎 런타임에서도 같은 값인가

정적으로 읽은 바이트가 실행 중 메모리에도 그대로 올라오는지 확인해 둔다. PE 라서 gdb 는 못 붙지만 wine 에 딸린 winedbg 로 배치 실행이 된다.

main 진입점에 브레이크를 걸고 두 블롭을 덤프하는 스크립트를 만들었다.

break *0x40148d
cont
x /13b 0x404020
x /34b 0x404040
kill
quit
WINEPREFIX="$HOME/.wine_brix" WINEDEBUG=-all winedbg ./brix_calc.exe < ../dbg_check.txt

winedbg 로 brix_calc.exe 를 main 진입점에서 멈춰 세운 뒤 메모리를 덤프한 화면 — 0x404020 에서 13바이트, 0x404040 에서 34바이트를 읽었고 값이 objdump 로 본 파일 내용과 한 바이트도 다르지 않다
winedbg 로 brix_calc.exe 를 main 진입점에서 멈춰 세운 뒤 메모리를 덤프한 화면 — 0x404020 에서 13바이트, 0x404040 에서 34바이트를 읽었고 값이 objdump 로 본 파일 내용과 한 바이트도 다르지 않다

brix_calc+0x402012 3c 23 30 18 30 03 3c 21 34 38 3c 3b, brix_calc+0x404035 46 39 43 .... 파일에서 읽은 것과 한 바이트도 다르지 않다.

당연한 결과지만 확인해 두면 좋다. 런타임에 테이블을 한 번 더 뒤섞는 초기화 루틴이 숨어 있었다면 여기서 값이 달라졌을 것이다.


🚀 Full solve

풀이는 exe 에서 두 블롭을 읽어 각각 XOR 하면 끝난다. 하드코딩 없이 PE 섹션 테이블을 직접 파싱해 VA 를 파일 오프셋으로 바꾼다. 다른 사람이 받은 배포본으로 돌려도 그대로 동작한다.

#!/usr/bin/env python3
"""
Gyul Brix Calculator (DreamHack Bronze 2, reversing) — solver
 
brix_calc.exe 는 PE32(MinGW) 콘솔 프로그램이다. main 이 하는 일:
  1) scanf("%99s", buf)
  2) strlen(buf) == 13 이고 buf[i] == (data[0x404020+i] ^ 0x55) 이면 관리자 모드
  3) 관리자 모드에서 data[0x404040 + j] ^ 0x77 을 j=0..0x21(34바이트) 만큼 putchar
 
즉 암호도, 플래그도 XOR 한 겹으로 .data 에 그대로 들어 있다.
이 스크립트는 exe 파일에서 직접 두 블롭을 읽어 복호한다 — 하드코딩 없음.
"""
import struct
import sys
 
EXE = sys.argv[1] if len(sys.argv) > 1 else "extracted/brix_calc.exe"
 
PASS_VA, PASS_LEN, PASS_KEY = 0x404020, 13, 0x55     # 0x4014dd: mov [esp+0x90],0xd / 0x40153b: xor edx,0x55
FLAG_VA, FLAG_LEN, FLAG_KEY = 0x404040, 0x21 + 1, 0x77  # 0x4015d2: cmp eax,0x21 / jbe → 0..0x21
 
 
def section_table(blob):
    """PE 섹션 테이블을 struct 로 직접 파싱해 (name, VA, vsize, raw_ptr) 목록을 만든다."""
    e_lfanew = struct.unpack_from("<I", blob, 0x3C)[0]
    assert blob[e_lfanew:e_lfanew + 4] == b"PE\0\0", "PE 시그니처 없음"
    n_sec = struct.unpack_from("<H", blob, e_lfanew + 6)[0]
    opt_size = struct.unpack_from("<H", blob, e_lfanew + 20)[0]
    image_base = struct.unpack_from("<I", blob, e_lfanew + 24 + 28)[0]
    off = e_lfanew + 24 + opt_size
    secs = []
    for i in range(n_sec):
        name, vsize, va, rsize, rptr = struct.unpack_from("<8sIIII", blob, off + i * 40)
        secs.append((name.rstrip(b"\0").decode(), image_base + va, vsize, rptr, rsize))
    return secs
 
 
def va_to_off(secs, va):
    for name, sva, vsize, rptr, rsize in secs:
        if sva <= va < sva + max(vsize, rsize):
            return rptr + (va - sva)
    raise ValueError(f"VA {va:#x} 가 어느 섹션에도 없음")
 
 
def xor_read(blob, secs, va, length, key):
    off = va_to_off(secs, va)
    return bytes(b ^ key for b in blob[off:off + length])
 
 
blob = open(EXE, "rb").read()
secs = section_table(blob)
 
print(f"[*] {EXE}  ({len(blob)} bytes)")
for name, va, vsize, rptr, rsize in secs:
    print(f"    {name:<9} VA={va:#010x} vsize={vsize:#07x} rawptr={rptr:#07x}")
 
raw_pass = blob[va_to_off(secs, PASS_VA):va_to_off(secs, PASS_VA) + PASS_LEN]
raw_flag = blob[va_to_off(secs, FLAG_VA):va_to_off(secs, FLAG_VA) + FLAG_LEN]
print(f"\n[*] .data {PASS_VA:#x} raw : {raw_pass.hex(' ')}")
print(f"[*] .data {FLAG_VA:#x} raw : {raw_flag.hex(' ')}")
 
password = xor_read(blob, secs, PASS_VA, PASS_LEN, PASS_KEY).decode()
flag = xor_read(blob, secs, FLAG_VA, FLAG_LEN, FLAG_KEY).decode()
 
print(f"\n[+] PASSWORD (^{PASS_KEY:#04x}, {PASS_LEN}B) = {password}")
print(f"[+] FLAG     (^{FLAG_KEY:#04x}, {FLAG_LEN}B) = {flag}")
python3 solve.py extracted/brix_calc.exe

solve.py 실행 결과 — PE 섹션 테이블 여덟 줄을 출력한 뒤 0x404020 과 0x404040 의 원본 바이트를 찍고, 마지막에 암호 GiveMeVitamin 과 B1N4RY 로 시작하는 34글자 플래그를 복호해 보여 준다
solve.py 실행 결과 — PE 섹션 테이블 여덟 줄을 출력한 뒤 0x404020 과 0x404040 의 원본 바이트를 찍고, 마지막에 암호 GiveMeVitamin 과 B1N4RY 로 시작하는 34글자 플래그를 복호해 보여 준다

암호는 GiveMeVitamin, 플래그는 B1N4RY{sweet_G@mGyu1_1s_delicious} 다. 34바이트라는 계산도 맞는다.



✅ 실제로 돌려서 대조

정적 분석만으로 답이 나왔지만 프로그램에 직접 먹여 봐야 확실하다. PE 라 리눅스에서는 wine 으로 돌린다. 파이프 대신 subprocess 로 stdin 을 넣는다.

#!/usr/bin/env python3
"""brix_calc.exe 를 wine 으로 실제 실행해 solve.py 가 뽑은 암호를 검증한다.
 
echo 파이프 대신 subprocess input 을 쓴다(메모리 rev_wine_setup).
인자로 넘긴 문자열을 그대로 stdin 에 흘려 넣는다 — 프로그램이 마지막에
getchar() 두 번을 하므로 개행을 넉넉히 붙인다.
"""
import os
import subprocess
import sys
 
answer = sys.argv[1] if len(sys.argv) > 1 else "GiveMeVitamin"
env = dict(os.environ, WINEPREFIX=os.path.expanduser("~/.wine_brix"), WINEDEBUG="-all")
r = subprocess.run(
    ["wine", "extracted/brix_calc.exe"],
    input=answer + "\n\n\n",
    capture_output=True, text=True, env=env, timeout=120,
)
print(r.stdout.strip())
python3 run_wine.py GiveMeVitamin

run_wine.py 로 wine 위에서 brix_calc.exe 를 실행한 화면 — GAMHYUL BRIX 배너가 뜨고 입력 프롬프트 다음에 ADMINISTRATOR ACCESS GRANTED 가 찍히며 Here is the flag 줄에 B1N4RY 로 시작하는 플래그가 그대로 출력된다
run_wine.py 로 wine 위에서 brix_calc.exe 를 실행한 화면 — GAMHYUL BRIX 배너가 뜨고 입력 프롬프트 다음에 ADMINISTRATOR ACCESS GRANTED 가 찍히며 Here is the flag 줄에 B1N4RY 로 시작하는 플래그가 그대로 출력된다

ADMINISTRATOR ACCESS GRANTED 를 지나 플래그가 그대로 나온다. 정적으로 계산한 값과 한 글자도 다르지 않다.

내친김에 관문 두 개와 등급 임계값도 실행으로 확인했다. 길이가 12나 14면 떨어지는지, 13글자인데 마지막 한 글자만 달라도 떨어지는지가 확인 대상이다.

#!/usr/bin/env python3
"""정적 분석으로 읽은 두 관문(길이 13 · 바이트별 비교)과 등급 임계값을
wine 으로 실제 실행해 확인한다. 입력마다 프로그램이 어느 분기로 갔는지 한 줄로 요약.
 
  - 길이 관문 : 0x4014dd 의 mov [esp+0x90],0xd
  - 바이트 관문: 0x40153b 의 xor edx,0x55 뒤 cmp al,dl
  - 등급 임계값: .rdata 0x405270/0x405274/0x405278 의 float 10 / 12 / 14
"""
import os
import subprocess
 
ENV = dict(os.environ, WINEPREFIX=os.path.expanduser("~/.wine_brix"), WINEDEBUG="-all")
 
CASES = [
    ("GiveMeVitami",  "12글자 — 길이 관문에서 탈락"),
    ("GiveMeVitaminX", "14글자 — 길이 관문에서 탈락"),
    ("GiveMeVitamiX",  "13글자·마지막 글자만 다름"),
    ("GiveMeVitamin",  "정답"),
    ("9.9",  "등급 임계 < 10"),
    ("10",   "등급 임계 10 <= x < 12"),
    ("12",   "등급 임계 12 <= x < 14"),
    ("14",   "등급 임계 14 <="),
    ("abc",  "atof 가 0.0 -> 에러 분기"),
]
 
 
def run(answer):
    r = subprocess.run(["wine", "extracted/brix_calc.exe"],
                       input=answer + "\n\n\n", capture_output=True,
                       text=True, env=ENV, timeout=120)
    for line in r.stdout.splitlines():
        line = line.strip()
        if line.startswith(("[!]", "[Result]", "[Error]")):
            return line
    return "(분기 없음)"
 
 
for answer, note in CASES:
    print(f"{answer:<15} | {run(answer):<48} | {note}")
python3 verify_gate.py

verify_gate.py 로 아홉 가지 입력을 wine 에서 실행해 분기를 대조한 표 — 12글자·14글자와 마지막 글자만 틀린 13글자는 모두 Invalid Brix value 로 떨어지고 정답만 통과한다. 9.9 부터 14 까지는 Grade C·B·A·S 로 갈린다
verify_gate.py 로 아홉 가지 입력을 wine 에서 실행해 분기를 대조한 표 — 12글자·14글자와 마지막 글자만 틀린 13글자는 모두 Invalid Brix value 로 떨어지고 정답만 통과한다. 9.9 부터 14 까지는 Grade C·B·A·S 로 갈린다

길이가 하나만 틀려도, 글자가 하나만 틀려도 에러 분기로 떨어진다. 숫자 쪽은 9.9 가 C, 10 이 B, 12 가 A, 14 가 S 로 갈린다. .rdata 에서 본 10/12/14 그대로다.

abc 처럼 숫자가 아닌 걸 넣으면 atof 가 0.0 을 돌려주는데, 0보다 큰지 먼저 보는 분기가 있어서 곧장 에러로 간다. 길이 관문에서 걸린 입력도 결국 이 경로로 흘러들어 온다. 그래서 화면에는 "길이가 틀렸다" 가 아니라 "유효한 숫자가 아니다" 라고만 뜬다.



📝 결론

키를 데이터 옆에 두지 않았다고 안전해지진 않는다

이 문제가 strings 를 피해 간 방법은 XOR 한 겹이 전부다. 그마저도 키는 .data 에 없고 코드 안 즉치값이었다. 덕분에 문자열 검색으로는 0건이 나온다.

하지만 그 대가로 키가 xor edx, 0x55 라는 명령 하나에 노출된다. 실행 파일은 키와 암호문을 같은 파일 안에 함께 배포할 수밖에 없고, 어디에 숨기든 코드를 읽으면 나온다. "찾기 어렵게" 는 "찾을 수 없게" 가 아니다.

형식을 가정하면 그 가정이 관문이 된다

DH{ 로 시작할 거라 믿고 앞 세 바이트에서 키를 역산한 게 이번 삽질의 절반이다. 드림핵 문제이긴 하지만 외부 대회에서 넘어온 출제작이라 접두사가 달랐다. 알려진 평문 공격은 그 평문이 진짜여야 성립한다. 접두사가 안 맞으면 키 길이나 알고리즘 쪽을 의심하기 전에 접두사부터 의심하는 게 빠르다.

단일바이트 XOR 은 256번이면 끝난다

가설 3처럼 키 256개를 전부 돌려 출력 가능한 문자만 남기는 필터는 비용이 거의 없다. 디스어셈블리를 열기 전에 이걸 먼저 돌렸다면 플래그는 몇 초 만에 나왔을 것이다. 다만 그렇게 얻은 답으로는 13바이트 블롭이 무엇인지 설명하지 못한다. 답만 필요하면 전수 대입, 구조를 알아야 하면 디스어셈블리 — 순서는 목적에 따라 다르다.

방어 쪽에서 보면

클라이언트에 비밀을 심고 클라이언트가 검사하게 만드는 구조 자체가 문제다. 비교 로직이 바이너리 안에 있으면 사용자는 그 비교를 읽을 수도, 건너뛸 수도 있다.

정말 감춰야 하는 값이라면 서버에서 검증하고, 클라이언트에는 검증 결과만 내려보내야 한다. 그게 불가능한 오프라인 프로그램이라면 그 값은 이미 공개된 값이라고 보는 편이 맞다.

이 글이 도움이 됐나요?

Comments

댓글

0

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

댓글 불러오는 중…

Related

관련 글

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

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

UPX 로 포장된 32비트 PE 가 flag.txt 를 두 번 암호화한다. 진짜 main 은 암호화 함수를 둘 부르는데, 하나만 보고 뒤집으면 아무것도 안 나온다. 2단계는 고정 인덱스 연산이 15개나 붙어 있지만 14개가 역연산 짝이라 상쇄되고 실질은 하나뿐이고, .rdata 의 키처럼 생긴 문자열 셋 중 둘은 스택에 복사만 되고 읽히지 않는 미끼다. 키가 파일보다 한 바이트 짧아 마지막 바이트는 초기화 안 된 스택을 읽는다.
#dreamhack#ctf#reversing+7
2026-07-28#dreamhack +6
[🥈 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 +6
[🥉 Bronze 3] stripped Rust 바이너리에서 스택 key 를 뽑아 XOR 역산 — DreamHack Rusty Gyul 풀이
blog

[🥉 Bronze 3] stripped Rust 바이너리에서 스택 key 를 뽑아 XOR 역산 — DreamHack Rusty Gyul 풀이

심볼을 모두 걷어낸 Rust ELF 하나가 "플래그를 맞춰봐"라고 한다. ltrace 에는 strcmp 하나 안 잡히고 main 은 껍데기뿐이다. 진짜 검사 루틴을 어셈블리에서 찾아 들어가 보면, 입력을 trim 한 뒤 스택에 실어둔 32바이트 key 와 한 바이트씩 XOR 해서 전부 0x55 인지만 본다. key 를 그대로 뽑아 0x55 로 되XOR 하면 플래그가 나온다.
#dreamhack#ctf#reversing+5
2026-07-16#dreamhack +5