[🥉 Bronze 1] 64바이트를 6개 함수가 나눠 검증하는 crackme — DreamHack mix-compare 풀이 — ZINO
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 CTF Season 4 Round #6(Div2)에 출제됐던 문제다. 이름 그대로 "mix"(여러 연산을 섞은) "compare"(비교) — 입력 64바이트를 함수 여섯 개가 구간별로 나눠서 각자 다른 연산으로 검증한다. 이름값 하는 구성이라, 함수 하나만 읽고 끝내면 절반도 못 푼다.
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로 이어지는 함수 체인이 각자 맡은 구간을 검증한다.
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]와 비교한다.
각 변환은 전부 역함수가 뻔한 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 reimport structimport subprocessraw = subprocess.run(["objdump", "-s", "-j", ".data", "chall"], capture_output=True, text=True, check=True).stdoutmem = {}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 = 0x4020data = bytes(mem[base + i] for i in range(64 * 4))result = struct.unpack("<64i", data)flag_chars = [0] * 64const_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] - 1for i in range(26, 36): flag_chars[i] = result[i] - ifor i in range(36, 46): flag_chars[i] = result[i] + ifor i in range(46, 56): flag_chars[i] = result[i] // ifor i in range(56, 64): flag_chars[i] = result[i] - 100 + irecovered = bytes(flag_chars)print("recovered input:", recovered.decode())print(f"FLAG: DH{{{recovered.decode()}}}")
python3 solve.py 실행 — 64바이트 입력을 그대로 복원
복원된 값을 실제 바이너리에 넣어 재확인한다.
./chall < input.txt # input.txt = 복원된 64바이트
복원한 입력을 실제 바이너리에 그대로 넣어 확인 — Nice! + flag 출력
정적으로 계산한 값과 실행 결과가 정확히 일치한다.
🚀 확인 — flag 제출
python3 dreamhack.py wg 961 --flag 'DH{0d0c70a91ccd9b4fda8eedc657580618c37d08dbfbdc9a426c8f9d1674e0dbf0}'
dreamhack.py CLI로 flag 제출 — RESULT: 정답! 해결 처리됨
문제 페이지에서도 확인된다.
DreamHack 문제 페이지 — 정답 확인 및 해결 처리
📝 결론
검증 로직을 여러 함수로 쪼개는 건 정적분석 난이도를 올리는 흔한 수법이다.
한 함수가 64바이트를 전부 처리했다면 한 번에 다 읽혔을 텐데, 6개로 쪼개놓으니 "함수 하나만 보고 끝났다"는 착각이 들기 쉽다. 함수 이름과 호출 체인을 먼저 추적해서 전체 그림(몇 바이트를 누가 담당하는지)을 그리는 게 먼저다.
비교 대상이 정적 데이터면 실행 없이도 다 풀린다.
이 문제는 result[]가 계산되는 값이 아니라 .data에 박힌 상수였다. 계산이 아니라 상수라는 걸 알아채면, 굳이 프로그램을 실행하거나 디버거로 추적할 필요 없이 objdump -s로 값만 뽑아 역산 스크립트에 넣으면 끝난다. 각 변환이 전부 역함수가 존재하는 가역 연산(덧셈/뺄셈/NOT/곱셈)이었다는 점도 컸다 — 해시나 비가역 연산이 하나라도 섞였다면 무차별 대입 없이는 못 풀었을 것이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…