[🥉 Bronze 1] 64바이트를 6개 함수가 나눠 검증하는 crackme — DreamHack mix-compare 풀이

2026-07-20·1분 읽기·

[🥉 Bronze 1] 64바이트를 6개 함수가 나눠 검증하는 crackme — DreamHack mix-compare 풀이

64바이트 입력을 check → check_not → check_add → check_dec → check_mul → check_la 여섯 함수가 10바이트 안팎씩 나눠 맡아 각자 다른 산술 변환(NOT·덧셈·뺄셈·곱셈·역방향 덧셈)으로 검증한다. 비교 대상 배열은 .data에 평문 정수로 그대로 박혀 있어서, 각 구간의 변환을 거꾸로 풀기만 하면 64바이트 전체가 그대로 복원된다.

문제: DreamHack — mix-compare 분류: Reversing 난이도: 🥉 Bronze 1 FLAG: DH{0d0c70a91ccd9b4fda8eedc657580618c37d08dbfbdc9a426c8f9d1674e0dbf0}

Dreamhack CTF Season 4 Round #6(Div2)에 출제됐던 문제다. 이름 그대로 "mix"(여러 연산을 섞은) "compare"(비교) — 입력 64바이트를 함수 여섯 개가 구간별로 나눠서 각자 다른 연산으로 검증한다. 이름값 하는 구성이라, 함수 하나만 읽고 끝내면 절반도 못 푼다.

DreamHack 문제 페이지 — Season 4 Round #6 Div2 출제, 64바이트 입력 검증형 crackme
DreamHack 문제 페이지 — Season 4 Round #6 Div2 출제, 64바이트 입력 검증형 crackme

문제 개요

항목내용
문제명mix-compare
난이도🥉 Bronze 1
분류Reversing (x86-64 ELF)
제공 파일chall (PIE, not stripped, canary 없음)
핵심 취약점 / 기법구간별로 다른 연산을 쓰는 검증 함수 체인을 각각 역산

scanf("%s")로 64바이트를 정확히 받아 check()를 부르고, 통과하면 그 입력을 그대로 DH{...}에 감싸 출력한다. check() 안에서 check_not → check_add → check_dec → check_mul → check_la로 이어지는 함수 체인이 각자 맡은 구간을 검증한다.



🔬 정찰 — 함수 이름이 이미 힌트다

기본 정보부터 확인한다.

이 글의 명령은 모두 extracted/ 에서 실행한다.

file chall && checksec --file=chall
file + checksec — PIE, not stripped, canary 없음
file + checksec — PIE, not stripped, canary 없음

not stripped라 nm으로 함수 이름이 그대로 보인다.

nm chall | grep -E ' T | D | B ' | grep -viE 'frame_dummy|register_tm|_start|_edata|_end |data_start|bss_start|TMC_END'
nm — check/check_not/check_add/check_dec/check_mul/check_la, result는 초기화된 데이터(D)
nm — check/check_not/check_add/check_dec/check_mul/check_la, result는 초기화된 데이터(D)

check, check_not, check_add, check_dec, check_mul, check_la 여섯 함수와 전역 배열 result(D — 이미 초기값이 박힌 데이터)가 보인다. 이름만 봐도 "not/add/dec/mul" 연산을 구간별로 적용하는 구조라는 게 짐작된다.


💣 핵심 — 여섯 함수가 나눠 맡은 64바이트

check()는 앞 16바이트(i=0~15)를 루프 없이 하나씩 풀어서, 바이트마다 서로 다른 상수 연산(+9, NOT, -4, ×2, +0x22 …)을 적용해 result[i]와 비교한다.

lea    0x9(%rax),%edx        <-- input[0]+9
mov    0x2e54(%rip),%eax     # 4020 <result>
cmp    %eax,%edx
jne    13ad <check+0x204>
...
not    %eax                  <-- NOT(input[1])
mov    0x2e38(%rip),%eax     # 4024 <result+0x4>
cmp    %eax,%edx
objdump — check()의 앞 두 바이트 검증(input[0]+9, NOT(input[1]))
objdump — check()의 앞 두 바이트 검증(input[0]+9, NOT(input[1]))
▶🕳️ 삽질 — 앞 16바이트는 루프가 없어서 하나씩 손으로 다 읽어야 했다

check_not부터 check_la까지 다섯 함수는 전부 반복문 하나로 구간을 처리해서, 어셈블리를 한 번만 읽으면 변환식이 나온다. 그런데 check() 자신이 담당하는 앞 16바이트는 반복문이 아예 없다 — 컴파일러가 루프를 풀어(unroll) 16개의 서로 다른 즉석 상수 연산을 순서대로 늘어놓은 형태였다. input[0]+9, NOT(input[1]), input[2]+4, input[3]*2(정확히는 input[3]/2의 역연산)... 이런 식으로 바이트마다 계산식 자체가 다르다.

처음엔 "어차피 패턴이 있겠지"라고 생각하고 두세 개만 보고 나머지를 추측했다가, 실제 값이 안 맞아서 결과가 깨졌다. 결국 objdump로 16개 블록을 전부 하나씩, 빠짐없이 손으로 옮겨 적어야 했다 — 지루하지만 지름길이 없는 구간이었다. 이렇게 만든 const_ops 딕셔너리(아래 solve 스크립트의 0~15 항목)가 정확한지는, 뒤에서 gdb로 실제 실행 중인 값과 대조해 최종 확인했다.

16바이트를 다 통과하면 check_not(input)을 부르고, 그 함수는 반복문으로 i=16~25 구간을 NOT(input[i]) + i로 검증한 뒤 check_add를 부른다. 이런 식으로 다음 구간에게 넘기는 패턴이 끝까지 반복된다.

loc:
    movzbl (%rax),%eax
    movsbl %al,%edx
    lea    (%rdx,%rax,1),%ecx    ; input[i] + i
    ...
    cmp    %eax,%ecx
    jne    <fail>
    addl   $0x1,-0x4(%rbp)       ; i++
    cmpl   $0x23,-0x4(%rbp)      ; i <= 0x23(35) 까지
    jle    <loop>
    call   <check_dec>            ; 다음 구간으로 체인
objdump — check_add() 전체, i=26~35 구간을 input[i]+i로 검증하고 check_dec 호출
objdump — check_add() 전체, i=26~35 구간을 input[i]+i로 검증하고 check_dec 호출

정리하면 64바이트는 이렇게 나뉜다.

함수담당 구간(i)변환식
check (인라인)0 ~ 15바이트마다 다른 상수 연산
check_not16 ~ 25NOT(c) + i
check_add26 ~ 35c + i
check_dec36 ~ 45c - i
check_mul46 ~ 55c * i
check_la56 ~ 63c + 100 - i

비교 대상인 result[64]는 함수가 계산해서 만드는 값이 아니라 .data 섹션에 애초에 정수 배열로 박혀 있다 — 그러니 실행 없이도 그대로 읽을 수 있다.

4020 39000000 9bffffff 2c000000 c6000000
4030 59000000 58000000 39000000 ab000000
...
4120 24000000
objdump -s .data — result[] 64개 int32가 정적으로 박혀 있다
objdump -s .data — result[] 64개 int32가 정적으로 박혀 있다


🎯 풀이 — 구간별로 역연산

각 변환은 전부 역함수가 뻔한 1:1 연산이라(덧셈↔뺄셈, NOT↔NOT, 곱셈↔나눗셈), result[i]에서 input[i]를 바로 계산할 수 있다.

# check()/check_not()/check_add()/check_dec()/check_mul()/check_la() 가 64바이트 입력을
# 8바이트씩(정확히는 16+10+10+10+10+8) 서로 다른 산술 변환으로 result[i]와 비교한다.
#   i= 0..15 (check 자체, 각기 다른 상수 변환)
#   i=16..25 (check_not): NOT(c) + i        == result[i]
#   i=26..35 (check_add): c + i             == result[i]
#   i=36..45 (check_dec): c - i             == result[i]
#   i=46..55 (check_mul): c * i             == result[i]
#   i=56..63 (check_la) : c + 100 - i       == result[i]
# result[] 는 .data 섹션(0x4020부터 64개 int32)에 초기값으로 그대로 박혀 있다.
import re
import struct
import subprocess
 
raw = subprocess.run(["objdump", "-s", "-j", ".data", "chall"],
                      capture_output=True, text=True, check=True).stdout
 
mem = {}
for line in raw.splitlines():
    m = re.match(r"^\s*([0-9a-f]{4,8})\s+((?:[0-9a-f]{2,8}\s*){1,4})", line)
    if not m:
        continue
    addr = int(m.group(1), 16)
    words = m.group(2).split()
    off = 0
    for w in words:
        b = bytes.fromhex(w)
        for i, byte in enumerate(b):
            mem[addr + off + i] = byte
        off += len(b)
 
base = 0x4020
data = bytes(mem[base + i] for i in range(64 * 4))
result = struct.unpack("<64i", data)
 
flag_chars = [0] * 64
const_ops = {
    0: lambda r: r - 9,       1: lambda r: -r - 1,      2: lambda r: r + 4,
    3: lambda r: r // 2,      4: lambda r: r - 0x22,    5: lambda r: r - 0x28,
    6: lambda r: r + 0x28,    7: lambda r: r // 3,      8: lambda r: -r - 1,
    9: lambda r: r // 2,      10: lambda r: r // 4,     11: lambda r: r // 4,
    12: lambda r: 0x13 - r,   13: lambda r: r - 0x11,   14: lambda r: r - 0x1e,
    15: lambda r: r,
}
for i in range(16):
    flag_chars[i] = const_ops[i](result[i])
for i in range(16, 26):
    flag_chars[i] = i - result[i] - 1
for i in range(26, 36):
    flag_chars[i] = result[i] - i
for i in range(36, 46):
    flag_chars[i] = result[i] + i
for i in range(46, 56):
    flag_chars[i] = result[i] // i
for i in range(56, 64):
    flag_chars[i] = result[i] - 100 + i
 
recovered = bytes(flag_chars)
print("recovered input:", recovered.decode())
print(f"FLAG: DH{{{recovered.decode()}}}")
python3 solve.py 실행 — 64바이트 입력을 그대로 복원
python3 solve.py 실행 — 64바이트 입력을 그대로 복원

복원된 값을 실제 바이너리에 넣어 재확인한다.

./chall < input.txt   # input.txt = 복원된 64바이트
복원한 입력을 실제 바이너리에 그대로 넣어 확인 — Nice! + flag 출력
복원한 입력을 실제 바이너리에 그대로 넣어 확인 — Nice! + flag 출력

정적으로 계산한 값과 실행 결과가 정확히 일치한다.

정적 분석만으로 뽑아낸 const_ops(특히 앞서 삽질했던 check()의 16개 상수 연산)가 정말 맞는지, gdb로 check_mul 내부의 비교 직전 지점에 브레이크포인트를 걸고 몇 바이트를 직접 대조했다.

gdb -q -batch -ex 'break *(check_mul+0x49)' -ex 'run < input.txt' \
    -ex 'printf "i=%d input[i]*i=%d result[i]=%d\n", *(int*)($rbp-0x4), $edx, $eax' \
    -ex continue -ex continue ./chall
gdb로 check_mul 내부 cmp 직전에 브레이크포인트를 걸고 3번 연속 정지시켜 i=46,47,48 각각의 input[i]*i 계산값과 result[i] 기댓값이 2392/2350/2592로 정확히 일치함을 실측한 캡쳐

i=46, 47, 48 세 지점 모두 계산값과 result[i]가 정확히 일치했다 — 정적 분석과 런타임 실측이 어긋나지 않는다는 걸 확인한 뒤에야 최종 flag를 제출했다.

전체 풀이 흐름 다이어그램 — check가 앞 16바이트를 상수연산으로, check_not/add/dec/mul/la가 차례로 10바이트씩 각기 다른 산술로 검증하는 체인 구조와 각 구간의 역연산, gdb로 check_mul 결과를 실측 검증하는 과정을 정리한 다이어그램

📝 결론

검증 로직을 여러 함수로 쪼개는 건 정적분석 난이도를 올리는 흔한 수법이다.

한 함수가 64바이트를 전부 처리했다면 한 번에 다 읽혔을 텐데, 6개로 쪼개놓으니 "함수 하나만 보고 끝났다"는 착각이 들기 쉽다. 함수 이름과 호출 체인을 먼저 추적해서 전체 그림(몇 바이트를 누가 담당하는지)을 그리는 게 먼저다.

비교 대상이 정적 데이터면 실행 없이도 다 풀린다.

이 문제는 result[]가 계산되는 값이 아니라 .data에 박힌 상수였다. 계산이 아니라 상수라는 걸 알아채면, 굳이 프로그램을 실행하거나 디버거로 추적할 필요 없이 objdump -s로 값만 뽑아 역산 스크립트에 넣으면 끝난다. 각 변환이 전부 역함수가 존재하는 가역 연산(덧셈/뺄셈/NOT/곱셈)이었다는 점도 컸다 — 해시나 비가역 연산이 하나라도 섞였다면 무차별 대입 없이는 못 풀었을 것이다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 1] 키는 GOT 뒤에 숨어 있었다 — DreamHack basic_CrackMe 풀이
blog

[🥉 Bronze 1] 키는 GOT 뒤에 숨어 있었다 — DreamHack basic_CrackMe 풀이

32비트 PIE ELF와 Windows PE가 세트로 나오는 리버싱 문제. 겉보기엔 흔한 XOR 크랙미인데 막상 열어보면 XOR 한 번이 아니라 자릿수마다 제곱·뺄셈·덧셈이 섞인 int64 배열 연산이라 손으로 따라가면 금방 헷갈린다. 진짜 함정은 알고리즘이 아니라 키 위치 — 키 문자열은 코드 어디에도 안 보이고 GOT 슬롯에 꽂힌 포인터 하나가 .rodata를 가리키고 있었다.
#dreamhack#ctf#reversing+3
2026-07-20#dreamhack +4
[🥉 Bronze 3] 미로를 풀지 않고 미로를 깨다 — DreamHack Tiny Maze 풀이
blog

[🥉 Bronze 3] 미로를 풀지 않고 미로를 깨다 — DreamHack Tiny Maze 풀이

WASD로 미로를 움직여 goal에 닿으면 플래그를 찍어 주는 작은 리버싱 문제다. 미로를 실제로 풀 수도 있지만, 플래그는 이미 바이너리 안에 encoded_flag로 들어 있고 print_flag가 그걸 0x5a로 XOR해 출력할 뿐이다. 그래서 .rodata에서 바이트를 꺼내 한 줄로 복원했고, 재미 삼아 미로도 BFS로 풀었다. 이때 move_from_key의 점프 테이블을 읽어 보면 WASD 키가 회전돼 있다는 함정이 드러난다.
#dreamhack#ctf#reversing+4
2026-10-08#dreamhack +4
[🥉 Bronze 2] 분기 미로 뒤에 숨은 상수 한 줄 — DreamHack Labyrinth 풀이
blog

[🥉 Bronze 2] 분기 미로 뒤에 숨은 상수 한 줄 — DreamHack Labyrinth 풀이

14KB 짜리 stripped PIE 하나가 전부인 리버싱 문제. ptrace 안티디버깅과 시드 %7 로 갈라지는 분기 미로가 겹겹이 깔려 있지만, 정답 입력은 .rodata 의 32바이트를 0x30 으로 XOR 한 것이 전부였다. flag 생성기까지 파이썬으로 재구현해 바이너리 출력과 바이트 단위로 대조했다.
#dreamhack#ctf#reversing+6
2026-08-20#dreamhack +4