[🥈 Silver 3] 2MB 바이너리에서 코드는 509바이트뿐이었다 — DreamHack Branches and Leaves 풀이

2026-08-19·1분 읽기·

[🥈 Silver 3] 2MB 바이너리에서 코드는 509바이트뿐이었다 — DreamHack Branches and Leaves 풀이

문제 설명이 시 네 줄뿐인 리버싱. 2.1MB짜리 stripped ELF를 열어 보면 코드는 509바이트고 나머지 전부가 배열로 접은 깊이 16 이진트리다. 입력 4글자가 16비트로 펴져 트리를 16번 내려가고, 그 리프 65536개가 서로 겹치지 않아서 기대값마다 역상이 정확히 하나로 정해진다.

문제: DreamHack — Branches and Leaves 분류: reversing 난이도: 🥈 Silver 3 FLAG: DH{d8f794325872bab95cfaa117545c6c0b77059a74f4dd31d8c2bf130a59745d77}

문제 설명에 힌트가 없다. 시 네 줄이 전부다.

Branches in twilight, a silhouette's embrace, Leaves in the moonlight, a shimmering grace.

가지와 잎사귀. 나중에 보니 이게 힌트이긴 했는데, 처음 받았을 땐 그냥 분위기 글인 줄 알았다.

받은 파일은 main 하나. 2,111,704바이트짜리 stripped PIE ELF다. 이 크기면 보통 Go나 Rust로 정적 링크한 물건을 의심하는데, 심볼 테이블을 열면 그게 아니라는 게 바로 드러난다.

file extracted/main; readelf -W --dyn-syms extracted/main
file 과 readelf --dyn-syms 출력 — 2.1MB stripped PIE 인데 임포트는 strlen 과 printf 계열, exit 을 포함해 다섯 개뿐이다
file 과 readelf --dyn-syms 출력 — 2.1MB stripped PIE 인데 임포트는 strlen 과 printf 계열, exit 을 포함해 다섯 개뿐이다

임포트가 strlen, __printf_chk, exit 그리고 시작·종료 훅까지 다섯 개다. 2MB를 차지하는 게 코드일 리가 없다.


문제 개요

항목내용
문제명Branches and Leaves
난이도🥈 Silver 3
분류reversing
제공 파일main (2,111,704B, stripped PIE, x86-64)
서버없음 — 파일만 준다
핵심 기법배열로 접은 깊이 16 이진트리 + 리프가 전단사라 역상이 유일

프로그램은 인자 하나를 받아 검증하고, 통과하면 그 인자를 그대로 플래그 서식에 끼워 출력한다. 즉 입력이 곧 플래그다. 64글자를 맞히면 끝이다.



🔬 정찰 — 코드가 509바이트다

섹션 크기부터 본다. 파일이 2MB인데 어디가 2MB인지 알아야 한다.

ls -l extracted/main; readelf -S -W extracted/main | grep -E "\[Nr\]|\.text|\.rodata|\.data|\.bss"
readelf -S 로 본 섹션 크기 — 코드인 .text 는 0x1fd 즉 509바이트뿐이고 .data 가 0x200060 으로 약 2MB 를 통째로 차지한다
readelf -S 로 본 섹션 크기 — 코드인 .text 는 0x1fd 즉 509바이트뿐이고 .data 가 0x200060 으로 약 2MB 를 통째로 차지한다

.text 가 0x1fd, 509바이트다. .rodata 는 36바이트. 그리고 .data 가 0x200060, 2,097,248바이트.

파일의 99% 이상이 데이터다. 로직은 500바이트 안에 다 들어 있고 나머지는 전부 그 로직이 읽을 표라는 뜻이다. 이 시점에서 디컴파일러를 붙일 필요가 없다는 것도 정해진다 — 509바이트면 손으로 읽는 게 빠르다.

문자열도 마찬가지로 짧다.

objdump -s -j .rodata extracted/main; strings -a -n 8 extracted/main | grep -aE "use|DH"
objdump -s -j .rodata 출력 — 사용법 안내 한 줄과 검증 통과 시 입력을 그대로 끼워 넣는 플래그 서식 하나가 전부다
objdump -s -j .rodata 출력 — 사용법 안내 한 줄과 검증 통과 시 입력을 그대로 끼워 넣는 플래그 서식 하나가 전부다

How to use: %s <input> 과 플래그 서식 하나. 서식이 입력을 그대로 받아 찍는 형태라, 플래그가 파일 어딘가에 암호화돼 숨어 있는 게 아니다. 검증을 통과하는 입력 자체가 답이다.


🧩 배경 — 배열로 접은 이진트리

트리를 포인터로 만들면 노드마다 주소가 필요하고, 그 주소는 PIE에서 재배치 대상이 된다. 그래서 표로 굳혀 넣을 땐 보통 배열로 접는다.

노드에 번호를 매기고 자식 = 표[2 * 노드번호 + 방향] 으로 색인하면 포인터가 필요 없다. 힙(heap)을 배열로 다루는 방식과 같은 착상인데, 다른 점은 번호를 순서대로 안 줘도 된다는 것이다. 아무 번호나 주고 표를 그만큼 크게 잡으면 그만이다.

이 문제의 표는 그렇게 만들어졌다. 뒤에서 세어 보겠지만, 실제로 쓰는 노드는 65,535개인데 번호는 262,139번까지 흩어져 있다. 그 차이가 곧 파일 크기다.



💣 검증 함수 — 루프 두 개가 전부

main 은 인자 개수를 세고 0x11e0 의 함수를 부른 뒤 서식을 출력하는 게 전부다. 판정은 전부 그 함수 안에 있다.

읽기 편하게 objdump 출력에 주석을 얹었다. 치환 규칙은 작업 폴더의 annot.sed 에 그대로 있다.

s|\(cmp    rax,0x40\)|\1                 <-- 입력은 정확히 64글자|
s|\(lea    r10,\[rip+0x2e14\]\).*|\1        <-- 0x4020  expected[16]|
s|\(lea    rdi,\[rip+0x2e5b\]\).*|\1        <-- 0x4060  tree[524288]|
s|\(shl    edx,0x4\)|\1                   <-- 니블을 위에서부터 쌓는다|
s|\(cmp    sil,0x9\)|\1                   <-- '0'-'9' 인가|
s|\(cmp    sil,0x5\)|\1                   <-- 'a'-'f' 인가 (대문자 불가)|
s|\(mov    ecx,0x10\)|\1                  <-- 트리를 16번 내려간다|
s|\(sar    edx,1\)|\1                     <-- 다음 비트로 (LSB 부터)|
s|\(and    esi,0x1\)|\1                   <-- 이번 비트|
s|\(lea    rax,\[rsi+rax\*2\]\)|\1          <-- 자식 위치 = 2*node + bit|
s|\(movsxd rax,DWORD PTR \[rdi+rax\*4\]\)|\1  <-- node = tree[...]|
s|\(cmp    eax,0xffffffff\)|\1            <-- -1 은 막다른 가지|
s|\(cmp    eax,0x3ffff\)|\1               <-- 내부노드 상한 262143|
s|\(cmp    eax,DWORD PTR \[r10+rdx\*4\]\)|\1  <-- 16스텝 뒤 값 == expected[i] ?|
s|\(cmp    r9d,0x40\)|\1                  <-- 16덩어리를 다 봤나|

앞쪽 절반부터.

objdump -d --start-address=0x11e0 --stop-address=0x1240 -M intel extracted/main | sed -f annot.sed
objdump 로 자른 검증 함수 앞부분 — strlen 결과를 0x40 과 비교해 64글자를 강제하고 네 글자를 16진수로 접어 edx 에 쌓는다
objdump 로 자른 검증 함수 앞부분 — strlen 결과를 0x40 과 비교해 64글자를 강제하고 네 글자를 16진수로 접어 edx 에 쌓는다

첫 관문은 길이다. strlen 결과를 0x40 과 비교하니 정확히 64글자.

그다음 r8 을 rbx+4 로 잡고 4글자씩 끊는다. shl edx,0x4 다음에 add edx,esi — 전형적인 16진수 파서다. 숫자는 -0x30, 그게 아니면 0x1280 으로 튀어서 -0x57 을 뺀다. 대문자 분기는 아예 없다. 'A' - 'a' 는 음수고 그 비교가 unsigned(ja)라 그대로 튕긴다.

C로 옮기면 이렇다.

unsigned edx = 0;
for (char *c = chunk; c < chunk + 4; c++) {
    edx <<= 4;
    if      ((unsigned char)(*c - '0') <= 9) edx += *c - '0';
    else if ((unsigned char)(*c - 'a') <= 5) edx += *c - 'a' + 10;
    else exit(1);
}

입력은 소문자 16진수 64글자이고, 4글자마다 16비트 정수가 하나씩 나온다. 총 16개.

이제 뒤쪽 절반.

objdump -d --start-address=0x1240 --stop-address=0x129d -M intel extracted/main | sed -f annot.sed
objdump 로 자른 검증 함수 뒷부분 — 비트를 하나씩 꺼내 표를 읽는 5줄 루프를 16번 돌고 그 결과를 expected 와 비교한다
objdump 로 자른 검증 함수 뒷부분 — 비트를 하나씩 꺼내 표를 읽는 5줄 루프를 16번 돌고 그 결과를 expected 와 비교한다

mov ecx,0x10 이 반복 횟수 16이다. 본체는 다섯 줄뿐이다.

mov    esi,edx              ; esi = 지금 값
sar    edx,1                ; 값은 오른쪽으로 한 칸
and    esi,0x1              ; 방금 밀려난 자리의 비트
lea    rax,[rsi+rax*2]      ; 색인 = 2 * node + bit
movsxd rax,[rdi+rax*4]      ; node = tree[색인]

rdi 는 .data+0x60(표), r10 은 .data+0x20(기대값)이다. 비트를 LSB부터 하나씩 꺼내 표를 따라 내려간다. 앞의 배경 그대로다.

루프 앞머리의 두 검사가 경계다. 값이 -1 이면 죽고, 0x3ffff 를 넘어도 죽는다. 즉 내부 노드 번호의 상한이 262,143이다. 이 검사는 매 반복의 시작에 있으므로 마지막 16번째로 읽은 값에는 안 걸린다. 그 값이 곧 리프다.

16스텝이 끝나면 cmp eax,[r10+rdx*4] 로 r9d/4 번째 기대값과 맞춘다. 16덩어리를 전부 통과해야 ret 이다.

전체를 C로 옮기면 이만큼이다.

for (int i = 0; i < 16; i++) {
    unsigned v = parse4(input + i * 4);
    int node = 0;
    for (int s = 0; s < 16; s++) {
        if (s && (node == -1 || node > 0x3ffff)) exit(1);
        node = tree[2 * node + (v & 1)];
        v >>= 1;
    }
    if (node != expected[i]) exit(1);
}

여기서 결정적인 게 하나 있다. 덩어리 16개가 서로 아무 상관이 없다. 앞 덩어리의 상태가 뒤로 이어지지 않고, 매번 node = 0 에서 다시 시작한다.

그래서 탐색 공간은 16의 64승이 아니라 2의 16승짜리 문제 16개다. 이걸 알아채면 문제가 사실상 끝난다.



🌲 표를 열어 본다

기대값 16개와 표 앞머리를 직접 꺼냈다.

#!/usr/bin/env python3
"""main 의 .data 에서 두 표를 꺼내 본다: expected[16] 과 tree[524288] 의 앞머리."""
import struct
from pathlib import Path
 
blob = (Path(__file__).with_name("extracted") / "main").read_bytes()
off = lambda va: va - 0x4000 + 0x3000          # .data vaddr 0x4000 -> file 0x3000
 
expected = struct.unpack("<16i", blob[off(0x4020):off(0x4020) + 64])
print("expected[16] @ .data+0x20")
for i in range(0, 16, 4):
    print("  [%2d..%2d]  %s" % (i, i + 3, "  ".join("0x%08x" % (x & 0xFFFFFFFF) for x in expected[i:i + 4])))
 
head = struct.unpack("<32i", blob[off(0x4060):off(0x4060) + 128])
print("\ntree[0..31] @ .data+0x60   (2*node+bit 로 색인, -1 은 빈 슬롯)")
for i in range(0, 32, 8):
    print("  [%2d..%2d]  %s" % (i, i + 7, " ".join("%9d" % x for x in head[i:i + 8])))
 
print("\n루트의 두 자식 = tree[0]=%d, tree[1]=%d" % (head[0], head[1]))
print("파일 전체에서 0xff 바이트 비율 = %.1f%%  (빈 슬롯 -1 이 그만큼 많다)"
      % (100 * blob.count(0xFF) / len(blob)))
python3 dump_tables.py
dump_tables.py 출력 — expected 16개 값과 표 앞머리 32칸. 루트의 두 자식은 72254 와 116475 이고 파일의 74.5퍼센트가 0xff 다
dump_tables.py 출력 — expected 16개 값과 표 앞머리 32칸. 루트의 두 자식은 72254 와 116475 이고 파일의 74.5퍼센트가 0xff 다

-1 이 눈에 밟힌다. 앞 32칸 중 절반 이상이 -1 이고, 파일 전체 바이트의 74.5%가 0xff 다. 표가 성기게 차 있다는 뜻인데, 얼마나 성긴지는 세어 봐야 한다.

루트에서 실제로 닿는 노드만 깊이별로 세는 스크립트를 만들었다.

#!/usr/bin/env python3
"""Branches and Leaves — .data 에 박힌 표가 정말 '트리'인지 세어 본다."""
import struct
from collections import Counter
from pathlib import Path
 
BIN = Path(__file__).with_name("extracted") / "main"
DATA_VADDR, DATA_OFF = 0x4000, 0x3000
TREE_VA, TREE_N = 0x4060, (0x204060 - 0x4060) // 4
NODE_MAX = 0x3FFFF
 
blob = BIN.read_bytes()
o = TREE_VA - DATA_VADDR + DATA_OFF
tree = struct.unpack("<%di" % TREE_N, blob[o:o + TREE_N * 4])
 
print("[table] 엔트리 %d개 = %.1f MB" % (TREE_N, TREE_N * 4 / 1048576))
print("[table] 값 범위 min=%d max=%d" % (min(tree), max(tree)))
print("[table] 음수 엔트리 %d개, 그중 -1 이 %d개"
      % (sum(1 for x in tree if x < 0), sum(1 for x in tree if x == -1)))
print("[table] 내부노드로 쓸 수 있는 값(0..0x3ffff) %d개" % sum(1 for x in tree if 0 <= x <= NODE_MAX))
 
# 루트 0 에서 실제로 닿는 노드만 깊이별로 센다
level, reach, leaves = {0}, set(), []
for depth in range(16):
    nxt = set()
    for n in level:
        reach.add(n)
        for bit in (0, 1):
            child = tree[2 * n + bit]
            if depth == 15:
                leaves.append(child)
            else:
                nxt.add(child)
    print("[walk ] depth %2d: 노드 %6d개 -> 자식 %6d개" % (depth, len(level), len(nxt)))
    level = nxt
 
used = len(reach) * 2
print("[reach] 실제로 읽히는 내부노드 %d개 (엔트리 %d개)" % (len(reach), used))
print("[reach] 한 번도 안 읽히는 엔트리 %d개 = %.1f%% 가 미끼" % (TREE_N - used, 100 * (TREE_N - used) / TREE_N))
print("[reach] 쓰인 노드 인덱스 min=%d max=%d (0x3ffff 까지 흩어놓았다)" % (min(reach), max(reach)))
print("[leaf ] 리프 %d개 중 서로 다른 값 %d개" % (len(leaves), len(set(leaves))))
print("[leaf ] 리프 최소 0x%08x / 최대 0x%08x — 전부 0x3ffff 초과라 경계검사에 걸린다"
      % (min(leaves) & 0xFFFFFFFF, max(leaves) & 0xFFFFFFFF))
dup = [v for v, c in Counter(leaves).items() if c > 1]
print("[leaf ] 중복 리프 %d개 -> 전단사이므로 각 기대값의 역상은 유일하다" % len(dup))
python3 analyze_tree.py
analyze_tree.py 출력 — 깊이마다 노드가 정확히 두 배로 늘어 65535 개가 되고 리프 65536 개가 전부 서로 다르며 표의 75퍼센트는 한 번도 안 읽힌다
analyze_tree.py 출력 — 깊이마다 노드가 정확히 두 배로 늘어 65535 개가 되고 리프 65536 개가 전부 서로 다르며 표의 75퍼센트는 한 번도 안 읽힌다

읽어 낸 것 네 가지.

  • 깊이마다 노드 수가 정확히 두 배로 늘어난다. 1, 2, 4, ... 32768. 합치는 가지가 하나도 없는 완전 이진트리다.

  • 실제로 읽히는 내부 노드는 65,535개(엔트리 131,070칸). 나머지 393,218칸은 전부 -1 이고 한 번도 안 읽힌다. 표의 75%가 그냥 빈칸이라 파일의 74.5%가 0xff 였던 것이다.

  • 노드 번호는 0부터 262,139까지 흩어져 있다. 순서대로 채웠으면 65,535번까지로 충분했다. 상한 근처까지 일부러 뿌려 놔서 표가 4배로 부푼 것이고, 그게 2MB의 정체다.

  • 리프 65,536개가 전부 서로 다르다.

마지막 줄이 이 문제의 답이다. 16비트 값에서 리프로 가는 사상이 전단사면, 기대값 하나마다 그걸 만드는 덩어리가 정확히 하나 있다. 후보가 갈릴 일이 없다.

덩어리 하나가 실제로 어떻게 내려가는지 그려 보면 이렇다.

덩어리 d8f7 이 16비트로 펴진 뒤 루트 0 에서 116475 와 110380 을 거쳐 16스텝 만에 리프 0x6e42db36 에 닿아 기대값 0번과 맞는 경로 그림
덩어리 d8f7 이 16비트로 펴진 뒤 루트 0 에서 116475 와 110380 을 거쳐 16스텝 만에 리프 0x6e42db36 에 닿아 기대값 0번과 맞는 경로 그림


🐛 트리가 전단사라서 생기는 함정

▶🐛 삽질 — 후보가 하나 나왔다는 건 아무 보증도 아니다

전단사라는 성질이 편하기만 한 건 아니다. 읽기를 틀려도 후보가 똑같이 하나씩 나온다.

비트 순서나 니블 순서를 잘못 잡아도 그건 그냥 "다른 라벨링"일 뿐이라 여전히 전단사다. 그래서 실패 신호가 안 뜬다. 후보 0개로 죽어 주면 오히려 고맙다.

실제로 두 가지를 틀리게 만들어 돌려 봤다.

#!/usr/bin/env python3
"""읽기를 한 군데씩 틀리면 어떻게 되는지 — 실제로 돌려 본다.
 
세 모델 모두 '후보 없음'으로 죽지 않는다. 트리가 전단사라서 **틀린 모델도
64글자짜리 그럴듯한 답을 하나씩 만들어 낸다.** 그래서 답이 나왔다는 사실은
검증이 못 된다 — 바이너리에 넣어 봐야 안다.
"""
import struct
import subprocess
from pathlib import Path
 
HERE = Path(__file__).parent
BIN = HERE / "extracted" / "main"
blob = BIN.read_bytes()
off = lambda va: va - 0x4000 + 0x3000
expected = struct.unpack("<16i", blob[off(0x4020):off(0x4020) + 64])
tree = struct.unpack("<524288i", blob[off(0x4060):off(0x4060) + 524288 * 4])
 
 
def build(bit_msb_first):
    """v -> 16스텝 뒤 leaf. bit_msb_first 면 상위 비트부터 소비한다."""
    table = {}
    for v in range(1 << 16):
        node, x = 0, v
        for _ in range(16):
            bit = (x >> 15) & 1 if bit_msb_first else x & 1
            node = tree[2 * node + bit]
            x = (x << 1) & 0xFFFF if bit_msb_first else x >> 1
        table[node] = v
    return table
 
 
def answer(table, nibble_reversed):
    out = []
    for want in expected:
        v = table.get(want)
        if v is None:
            return None
        s = "%04x" % v
        out.append(s[::-1] if nibble_reversed else s)
    return "".join(out)
 
 
def run(s):
    r = subprocess.run([str(BIN), s], capture_output=True, text=True)
    return r.returncode, r.stdout.strip()
 
 
lsb, msb = build(False), build(True)
for label, cand in (
    ("A. 비트를 MSB 부터 소비", answer(msb, False)),
    ("B. 한 덩어리 안 니블 순서를 뒤집음", answer(lsb, True)),
    ("C. 제대로 읽은 것", answer(lsb, False)),
):
    rc, out = run(cand)
    print("%-28s %s" % (label, cand))
    print("%-28s -> exit=%d  stdout=%s" % ("", rc, out if out else "(없음)"))
python3 traps.py
traps.py 출력 — 비트 순서와 니블 순서를 틀리게 읽어도 64글자 답이 나오지만 바이너리는 둘 다 exit 1 로 거절하고 제대로 읽은 것만 플래그를 낸다
traps.py 출력 — 비트 순서와 니블 순서를 틀리게 읽어도 64글자 답이 나오지만 바이너리는 둘 다 exit 1 로 거절하고 제대로 읽은 것만 플래그를 낸다

A는 sar edx,1 의 방향을 반대로 읽은 것이고, B는 shl edx,0x4 가 니블을 어느 쪽부터 쌓는지 뒤집은 것이다. 둘 다 소문자 16진수 64글자가 멀쩡하게 나온다. 눈으로는 정답과 구분이 안 된다.

바이너리에 넣어야 갈린다. A와 B는 exit=1, 제대로 읽은 C만 플래그를 낸다.

그래서 solve 스크립트가 "candidates=1" 을 16번 찍는 것은 읽기가 맞다는 근거가 아니다. 검증은 문제 바이너리한테 시켜야 한다.


🎯 풀이 — 65536 경로를 한 번 훑는다

역방향으로 트리를 되짚을 필요가 없다. 덩어리 하나가 16비트뿐이니 65,536개를 전부 앞으로 굴려 리프 → 값 역인덱스를 만들고, 기대값 16개를 조회하면 그만이다. 표 접근이 100만 번쯤인데 파이썬으로도 순식간이다.

#!/usr/bin/env python3
"""Branches and Leaves (DreamHack, Silver 3, reversing) — solver.
 
check() at 0x11e0 splits the 64-char argv[1] into 16 chunks of 4 hex digits,
turns each chunk into a 16-bit integer v, then walks an array-encoded binary
tree stored in .data: node = tree[2*node + bit], consuming the bits of v from
LSB to MSB, exactly 16 times.  The node reached after the 16th step must equal
expected[chunk] (16 int32 at .data+0x20).
 
Since every chunk is independent and only 16 bits wide, we replay all 65536
paths forward and index them by the leaf they reach.
"""
import struct
import sys
from pathlib import Path
 
BIN = Path(__file__).with_name("extracted") / "main"
 
DATA_VADDR = 0x4000     # .data virtual address
DATA_OFF = 0x3000       # .data file offset
EXPECT_VA = 0x4020      # int32 expected[16]
TREE_VA = 0x4060        # int32 tree[524288]
TREE_N = (0x204060 - 0x4060) // 4
DEPTH = 16              # bits consumed per chunk == loop counter in ecx
NODE_MAX = 0x3FFFF      # `cmp eax,0x3ffff; jg fail` bound on internal nodes
 
 
def load():
    blob = BIN.read_bytes()
    off = lambda va: va - DATA_VADDR + DATA_OFF
    expected = struct.unpack("<16i", blob[off(EXPECT_VA):off(EXPECT_VA) + 64])
    tree = struct.unpack("<%di" % TREE_N, blob[off(TREE_VA):off(TREE_VA) + TREE_N * 4])
    return expected, tree
 
 
def walk(tree, v):
    """Replay the 0x124c loop: 16 x  node = tree[2*node + (v & 1)], v >>= 1."""
    node = 0
    for step in range(DEPTH):
        if step and not (-1 < node <= NODE_MAX):   # bounds check at 0x1240
            return None
        node = tree[2 * node + (v & 1)]
        v >>= 1
    return node
 
 
def main():
    expected, tree = load()
 
    leaves = {}
    for v in range(1 << DEPTH):
        leaf = walk(tree, v)
        if leaf is None:
            continue
        leaves.setdefault(leaf, []).append(v)
 
    print("[*] tree entries      : %d" % TREE_N)
    print("[*] distinct leaves   : %d reached out of 65536 paths" % len(leaves))
 
    chunks = []
    for i, want in enumerate(expected):
        cands = leaves.get(want, [])
        print("[%2d] expected=0x%08x  candidates=%d  ->  %s"
              % (i, want & 0xFFFFFFFF, len(cands),
                 " ".join("%04x" % c for c in cands) or "NONE"))
        if len(cands) != 1:
            sys.exit("chunk %d is not uniquely determined" % i)
        chunks.append("%04x" % cands[0])
 
    key = "".join(chunks)
    print("[+] input : %s" % key)
    print("[+] flag  : DH{%s}" % key)
 
 
if __name__ == "__main__":
    main()
python3 solve.py
solve.py 출력 — 65536 경로를 한 번 훑어 만든 역인덱스로 기대값 16개를 조회하면 덩어리마다 후보가 정확히 하나씩 나온다
solve.py 출력 — 65536 경로를 한 번 훑어 만든 역인덱스로 기대값 16개를 조회하면 덩어리마다 후보가 정확히 하나씩 나온다

distinct leaves : 65536 reached out of 65536 paths — 경로가 하나도 안 죽고 리프도 안 겹친다. 앞의 통계와 같은 말이다.

기대값 16개가 전부 candidates=1 로 떨어지고, 이어 붙이면 64글자다. 실측 0.21초.



🧪 런타임으로 한 번 더 확인

정적으로 읽은 게 실행 중에도 맞는지 본다. 비교 지점 0x1269 에 브레이크를 걸고 eax(16스텝 뒤 값)와 expected[i] 를 같이 찍었다.

PIE라 베이스 주소는 관례로 가정하지 않고 info proc mappings 에서 실측해 쓴다.

set pagination off
set confirm off
starti
python
import gdb
base = None
for line in gdb.execute("info proc mappings", to_string=True).splitlines():
    if line.rstrip().endswith("/main"):
        base = int(line.split()[0], 16)
        break
gdb.execute("set $base = %d" % base)
print("[gdb] PIE base = 0x%x  (info proc mappings 로 실측)" % base)
end
break *($base + 0x1269)
commands
silent
printf "chunk %2d: 16스텝 뒤 node=0x%08x   expected[%d]=0x%08x   %s\n", $r9d/4, $eax, $r9d/4, *(int*)($r10 + ($r9d>>2)*4), ($eax == *(int*)($r10 + ($r9d>>2)*4)) ? "일치" : "불일치"
continue
end
continue
gdb -q -batch -x cmds.gdb --args ./extracted/main d8f794325872bab95cfaa117545c6c0b77059a74f4dd31d8c2bf130a59745d77
gdb 배치 실행 — PIE 베이스를 info proc mappings 로 실측해 비교 지점에 브레이크를 걸었고 16덩어리 전부 기대값과 일치한 뒤 플래그가 출력된다
gdb 배치 실행 — PIE 베이스를 info proc mappings 로 실측해 비교 지점에 브레이크를 걸었고 16덩어리 전부 기대값과 일치한 뒤 플래그가 출력된다

베이스는 0x555555554000, 브레이크포인트는 0x555555555269. 16덩어리가 전부 일치하고 그대로 플래그가 나온다. 정적 분석과 실행이 어긋나는 곳은 없다.



🚀 재현

경계 조건도 같이 확인한다. 한 글자만 바꾸거나, 대문자로 쓰거나, 63글자로 줄이면 전부 exit=1 이어야 한다.

#!/usr/bin/env python3
"""solve.py 가 뽑은 입력을 문제 바이너리에 그대로 넣어 본다."""
import subprocess
from pathlib import Path
 
BIN = Path(__file__).with_name("extracted") / "main"
KEY = "d8f794325872bab95cfaa117545c6c0b77059a74f4dd31d8c2bf130a59745d77"
 
for label, arg in (("정답", KEY),
                   ("한 글자 바꿈", KEY[:-1] + ("0" if KEY[-1] != "0" else "1")),
                   ("대문자로", KEY.upper()),
                   ("63글자", KEY[:-1])):
    r = subprocess.run([str(BIN), arg], capture_output=True, text=True)
    print("%-12s exit=%d  stdout=%s" % (label, r.returncode, r.stdout.strip() or "(없음)"))
python3 verify.py
verify.py 출력 — 정답만 exit 0 으로 플래그를 내고 한 글자를 바꾸거나 대문자로 쓰거나 63글자로 줄이면 전부 exit 1 이다
verify.py 출력 — 정답만 exit 0 으로 플래그를 내고 한 글자를 바꾸거나 대문자로 쓰거나 63글자로 줄이면 전부 exit 1 이다

대문자가 튕기는 게 눈에 보인다. 앞에서 읽은 'a'-'f' 전용 분기 그대로다.

마지막으로 배포 바이너리만 가지고 처음부터 끝까지 도는 스크립트를 남긴다. 표를 다시 읽어 입력을 복원하고, 그 입력을 바이너리에 되먹여 기대 플래그와 대조까지 한다.

#!/usr/bin/env bash
# Branches and Leaves (DreamHack Silver 3, reversing) — 한 방 재현.
# 배포 바이너리의 .data 에서 트리를 읽어 입력을 복원하고, 그 입력을 바이너리에 다시 먹여 flag 를 받는다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
 
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
 
chmod +x extracted/main
KEY=$(timeout 300 python3 solve.py | sed -n 's/^\[+\] input : //p')
if [ -z "$KEY" ]; then
  echo "❌ FAIL (solve.py 가 입력을 못 뽑았다)"; exit 1
fi
echo "복원한 입력: $KEY"
 
OUT=$(./extracted/main "$KEY" || true)
echo "$OUT"
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
 
if [ -n "$FLAG" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
  echo; echo "✅ PASS  $FLAG"; exit 0
fi
echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"; exit 1
./reproduce.sh
reproduce.sh 출력 — 배포 바이너리에서 표를 다시 읽어 입력을 복원하고 그 입력을 바이너리에 되먹여 기대 플래그와 일치하는지까지 확인한다
reproduce.sh 출력 — 배포 바이너리에서 표를 다시 읽어 입력을 복원하고 그 입력을 바이너리에 되먹여 기대 플래그와 일치하는지까지 확인한다

로컬 환경은 Ubuntu 25.10 x86_64, Python 3.13.7, GNU gdb 16.3, binutils objdump 2.45다. 빌드 과정은 없다 — 배포 바이너리를 그대로 읽고 그대로 실행한다.

최종 플래그.

DH{d8f794325872bab95cfaa117545c6c0b77059a74f4dd31d8c2bf130a59745d77}

📝 결론

파일이 크다고 어려운 문제가 아니다

2.1MB 중 1.5MB가 0xff 패딩이었다. 크기에 눌려서 "정적 링크 바이너리인가" 하고 디컴파일러부터 켰다면 한참 돌아갔을 것이다. 섹션 크기 한 줄이 그걸 먼저 정리해 준다. .text 가 509바이트면 손으로 읽는 게 제일 빠르다.

덩어리가 독립이면 탐색 공간이 무너진다

64글자 전체를 한 덩어리로 보면 경우의 수가 천문학적이지만, 매 덩어리가 node = 0 에서 다시 시작하는 순간 문제는 2의 16승짜리 16개로 쪼개진다. 검증 루프를 읽을 때 "앞의 상태가 뒤로 이어지는가"를 먼저 보는 습관이 값을 한다.

답이 하나 나온 것은 검증이 아니다

이 문제는 틀리게 읽어도 후보가 하나씩 나온다. 비트 순서를 뒤집은 답도 64글자 16진수로 그럴듯하게 생겼다. 최종 판정은 문제 바이너리한테 시키는 게 맞다. 그래서 reproduce.sh 가 복원한 입력을 바이너리에 되먹이는 것으로 끝나게 짰다.

만드는 쪽에서 보면

검사를 코드에서 데이터로 옮기면 디스어셈블러로 보이는 로직이 확 줄어든다. 여기서도 판정 전체가 60줄이 안 된다. 다만 옮겨 간 데이터가 구조를 그대로 드러낸다는 게 대가다. 깊이마다 노드가 두 배씩 늘고 리프가 안 겹친다는 사실은 표를 한 번 세는 것만으로 나오고, 그 순간 역산이 65,536회 순회로 끝난다.

노드 번호를 흩뿌려 표를 4배로 부풀린 것도 파일만 키웠지 분석을 막지는 못했다. 표 기반 검증을 정말 어렵게 만들려면 덩어리끼리 상태를 이어 붙이거나 리프를 여러 값에 겹치게 해서 역상이 갈리게 만들어야 한다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 4] 화면에 아무것도 안 뜨는 바이너리에서 flag 뽑아내기 — DreamHack fake 풀이
blog

[🥈 Silver 4] 화면에 아무것도 안 뜨는 바이너리에서 flag 뽑아내기 — DreamHack fake 풀이

실행해도 아무것도 출력하지 않는 ELF 바이너리 하나. strace로 확인해보니 read/write 계열 syscall이 단 한 번도 없다 — 임포트된 함수조차 malloc/free뿐이다. objdump로 뜯어보니 main()은 스택에 movabs 즉시값으로 23바이트 시드 버퍼를 조립해 malloc한 뒤, reverse → 인접 바이트 교환 → XOR라는 세 단계 변환만 거쳐 곧바로 free해버린다. 화면에 찍히는 건 없지만, 그 변환 결과를 파이썬으로 그대로 재현하고 gdb로 free() 직전 힙 버퍼를 직접 들여다보는 것으로 flag를 확인했다.
#dreamhack#ctf#reversing+4
2026-07-21#dreamhack +5
[🥈 Silver 1] 교차하는 선으로 숨긴 이미지 — DreamHack Crossing 풀이
blog

[🥈 Silver 1] 교차하는 선으로 숨긴 이미지 — DreamHack Crossing 풀이

이미지 대신 "또 다른 2D 표현"으로 flag.jpg를 숨긴 인코더를 리버싱했다. 바이트를 니블로 쪼개 getrandom으로 뽑은 행·열·방향에 0xff 구분 토큰으로 그려 넣는 구조를 파악하고, 격자의 모든 행과 열을 전수 스캔해 니블을 재조립하는 역변환으로 원본 JPEG을 복원했다.
#dreamhack#ctf#reversing+5
2026-10-09#dreamhack +5
[🥈 Silver 3] srand(0xBEEF) 하나로 6MB 사진을 되찾다 — DreamHack photographer 풀이
blog

[🥈 Silver 3] srand(0xBEEF) 하나로 6MB 사진을 되찾다 — DreamHack photographer 풀이

BMP 한 장을 통째로 바꿔 놓은 26KB짜리 C++ 바이너리. 시드가 time(NULL)이 아니라 상수 0xBEEF고, 바이트마다 rand()를 정확히 한 번씩만 쓰며, 세 갈래 변환이 모두 가역이다. 그 세 조건이 맞아떨어져서 역함수를 같은 순서로 한 번씩 걸면 6,220,854바이트가 그대로 돌아온다.
#dreamhack#ctf#reversing+5
2026-08-17#dreamhack +5