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

문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | 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
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'
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]))](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-mix-compare-writeup%2F04_objdump_check.png&w=3840&q=75)
▶🕳️ 삽질 — 앞 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 호출](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-mix-compare-writeup%2F05_objdump_check_add.png&w=3840&q=75)
정리하면 64바이트는 이렇게 나뉜다.
| 함수 | 담당 구간(i) | 변환식 |
|---|---|---|
check (인라인) | 0 ~ 15 | 바이트마다 다른 상수 연산 |
check_not | 16 ~ 25 | NOT(c) + i |
check_add | 26 ~ 35 | c + i |
check_dec | 36 ~ 45 | c - i |
check_mul | 46 ~ 55 | c * i |
check_la | 56 ~ 63 | c + 100 - i |
비교 대상인 result[64]는 함수가 계산해서 만드는 값이 아니라 .data 섹션에 애초에 정수 배열로 박혀 있다 — 그러니 실행 없이도 그대로 읽을 수 있다.
4020 39000000 9bffffff 2c000000 c6000000
4030 59000000 58000000 39000000 ab000000
...
4120 24000000![클릭하여 확대 objdump -s .data — result[] 64개 int32가 정적으로 박혀 있다](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-mix-compare-writeup%2F06_data_dump.png&w=3840&q=75)
🎯 풀이 — 구간별로 역연산
각 변환은 전부 역함수가 뻔한 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()}}}")
복원된 값을 실제 바이너리에 넣어 재확인한다.
./chall < input.txt # input.txt = 복원된 64바이트
정적으로 계산한 값과 실행 결과가 정확히 일치한다.
정적 분석만으로 뽑아낸 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
i=46, 47, 48 세 지점 모두 계산값과 result[i]가 정확히 일치했다 — 정적 분석과 런타임 실측이 어긋나지 않는다는 걸 확인한 뒤에야 최종 flag를 제출했다.
📝 결론
검증 로직을 여러 함수로 쪼개는 건 정적분석 난이도를 올리는 흔한 수법이다.
한 함수가 64바이트를 전부 처리했다면 한 번에 다 읽혔을 텐데, 6개로 쪼개놓으니 "함수 하나만 보고 끝났다"는 착각이 들기 쉽다. 함수 이름과 호출 체인을 먼저 추적해서 전체 그림(몇 바이트를 누가 담당하는지)을 그리는 게 먼저다.
비교 대상이 정적 데이터면 실행 없이도 다 풀린다.
이 문제는 result[]가 계산되는 값이 아니라 .data에 박힌 상수였다. 계산이 아니라 상수라는 걸 알아채면, 굳이 프로그램을 실행하거나 디버거로 추적할 필요 없이 objdump -s로 값만 뽑아 역산 스크립트에 넣으면 끝난다. 각 변환이 전부 역함수가 존재하는 가역 연산(덧셈/뺄셈/NOT/곱셈)이었다는 점도 컸다 — 해시나 비가역 연산이 하나라도 섞였다면 무차별 대입 없이는 못 풀었을 것이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…