문제: 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
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\{")건 ---"
배너와 등급 문구는 전부 평문으로 나온다. [!] 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"
scanf 로 받고 atof 로 숫자를 만든다. 여기까지는 계산기니까 당연하다.
눈에 걸리는 건 strlen 과 putchar 다.
당도를 재는 데 문자열 길이가 왜 필요한가. 그리고 등급 문구는 puts 로 통째로 찍으면 되는데
왜 putchar 로 한 글자씩 찍는 경로가 따로 있나. 이 두 개가 숨은 분기의 흔적이다.
IAT 주소도 같이 적어 둔다. 스트립된 바이너리라 objdump 는 call 0x403ca8 처럼
썽크 주소만 보여 주는데, 썽크가 읽는 IAT 슬롯을 이 표와 맞추면 이름이 복원된다.
0x40821c 가 strlen, 0x408200 이 putchar, 0x40820c 가 scanf 다.
🧩 .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
.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바이트
.rdata 쪽 00002041 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
가설 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
구조가 그대로 보인다.
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 로 풀면 이렇게 된다.
| 암호문 | 12 | 3c | 23 | 30 | 18 | 30 | 03 | 3c | 21 | 34 | 38 | 3c | 3b |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ^ 0x55 | 47 | 69 | 76 | 65 | 4d | 65 | 56 | 69 | 74 | 61 | 6d | 69 | 6e |
| ASCII | G | i | v | e | M | e | V | i | t | a | m | i | n |
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
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
quitWINEPREFIX="$HOME/.wine_brix" WINEDEBUG=-all winedbg ./brix_calc.exe < ../dbg_check.txt
brix_calc+0x4020 이 12 3c 23 30 18 30 03 3c 21 34 38 3c 3b,
brix_calc+0x4040 이 35 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
암호는 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
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
길이가 하나만 틀려도, 글자가 하나만 틀려도 에러 분기로 떨어진다.
숫자 쪽은 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
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…