[🥇 Gold 4] 파일 커서를 656번 흩뿌린 검증기 되감기 — DreamHack fverify 풀이

2026-08-17·1분 읽기·

[🥇 Gold 4] 파일 커서를 656번 흩뿌린 검증기 되감기 — DreamHack fverify 풀이

fverify 는 fseek(SEEK_CUR) 4949번과 1바이트 fread 656번으로만 이루어진 직선 코드다. 파일 오프셋이 코드 어디에도 안 적혀 있어 objdump 출력만으로는 무엇을 검사하는지 알 수 없다. 커서 이동을 그대로 재생해 슬롯과 파일 오프셋을 다시 이어 붙이면 656바이트 정답 파일이 통째로 복원된다.

문제: DreamHack — fverify 분류: reversing 난이도: 🥇 Gold 4 FLAG: DH{ce60d40e6fd2cc3303465d66559d021a5391a8245b65f517fda3bf61646bfe9a}

문제 설명은 두 줄이다.

fopen, fread, fwrite, fseek, ftell. And then there should be at least one fverify function.

받는 건 main 이라는 ELF 하나. 인자로 파일 하나를 받아 Correct! 또는 Wrong! 만 뱉는다. 정답 파일을 만들어 내는 게 목표고, 그 파일 안에 플래그가 들어 있다.

file extracted/main; readelf -SW extracted/main | grep -E " \.(text|rodata|data|bss) "

objdump 이전 단계의 정찰 — file 과 readelf -SW 로 본 fverify 바이너리. 64비트 PIE·stripped 이고 .text 가 0x299dd(170,973바이트)인데 .rodata 는 0x25(37바이트)뿐이다
objdump 이전 단계의 정찰 — file 과 readelf -SW 로 본 fverify 바이너리. 64비트 PIE·stripped 이고 .text 가 0x299dd(170,973바이트)인데 .rodata 는 0x25(37바이트)뿐이다

.text 가 171KB 인데 .rodata 는 37바이트다. 상수 테이블도 암호문 블롭도 없다는 뜻이다. 검사에 쓰이는 값이 전부 코드 안에 즉치값으로 박혀 있다는 얘기고, 실제로 그랬다.


문제 개요

항목내용
문제명fverify (id 2905)
난이도🥇 Gold 4
분류reversing
제공 파일main — ELF 64-bit LSB PIE, dynamically linked, stripped (182,408 B)
서버없음 (오프라인 문제)
핵심 취약점 / 기법상대 seek 로 뒤섞인 파일 커서를 정적 재생해 검사 오프셋 복원

풀이 흐름은 짧다. main 이 파일 크기를 656으로 못박고 fverify 로 넘긴다 → fverify 는 분기 하나 없이 fseek(SEEK_CUR) 와 1바이트 fread 만 반복한다 → objdump 출력에서 그 순서를 뽑아 커서를 시뮬레이션하면 어느 스택 슬롯이 파일 몇 번을 읽었는지 나온다 → 슬롯마다 붙어 있는 cmp 상수를 그 오프셋에 꽂으면 656바이트가 채워진다.



🧩 배경 — stdio 커서는 상태 하나로 굴러간다

FILE * 은 "지금 어디까지 읽었는가"를 내부에 들고 있다. fread 는 읽은 만큼 그 위치를 앞으로 민다. fseek 는 그 위치를 직접 옮기는데, 세 번째 인자 whence 로 기준이 갈린다.

fseek(fp, off, SEEK_SET);   // 파일 시작 기준 — off 가 곧 절대 위치
fseek(fp, off, SEEK_CUR);   // 현재 위치 기준 — 지금 어디인지 모르면 결과도 모른다
fseek(fp, off, SEEK_END);   // 파일 끝 기준
long ftell(FILE *fp);       // 현재 위치를 돌려준다

이 문제가 서 있는 자리가 두 번째다.

SEEK_SET 이면 디스어셈블리에 찍힌 즉치값이 그대로 파일 오프셋이라 읽기만 하면 끝난다. SEEK_CUR 이면 즉치값은 차분일 뿐이고, 실제 오프셋은 프로그램이 시작부터 지금까지 밟아 온 이동을 전부 더해야 나온다. 코드에는 결과가 아니라 과정만 적혀 있다.

fseek 에는 알아 둘 만한 성질이 둘 더 있다. 파일 끝을 넘어가는 위치로 옮기는 것은 정상이고(읽으면 EOF), 결과가 음수가 되는 이동만 EINVAL 로 실패하며 이때 커서는 제자리에 남는다. 뒤에서 커서를 재생할 때 이 두 가지를 그대로 흉내내야 한다.


🔬 정찰 — 심볼도 문자열도 거의 없다

stripped 라 nm 은 아무것도 안 준다. 남은 건 동적 심볼과 .rodata 37바이트뿐이다.

objdump -T extracted/main | grep UND; objdump -s -j .rodata extracted/main | tail -3

objdump -T 로 뽑은 동적 심볼과 objdump -s -j .rodata 덤프. 라이브러리 호출은 puts·fread·ftell·fseek·fopen 다섯뿐이고 문자열은 Wrong!·rb·size mismatch·Correct! 넷이 전부다
objdump -T 로 뽑은 동적 심볼과 objdump -s -j .rodata 덤프. 라이브러리 호출은 puts·fread·ftell·fseek·fopen 다섯뿐이고 문자열은 Wrong!·rb·size mismatch·Correct! 넷이 전부다

쓰는 라이브러리 함수가 puts · fread · ftell · fseek · fopen 다섯뿐이다. memcmp 도 strcmp 도 없고 malloc 조차 없다. 검사는 전부 인라인이라는 뜻이다.

여기서 문제 설명과 어긋나는 게 하나 보인다. 설명은 fwrite 를 같이 나열했는데 임포트 목록에 없다. 쓰기는 실제로 안 한다 — 설명의 그 줄은 분위기 잡는 문장이지 힌트가 아니었다.

문자열은 Wrong! · rb · size mismatch · Correct! 넷. 판정 경로가 셋뿐이라는 게 여기서 이미 드러난다.

main — 크기 656을 못박고 넘긴다

_start 가 __libc_start_main 에 넘기는 주소를 따라가면 main 은 0x11e9 다.

objdump -d -M intel --start-address=0x1233 --stop-address=0x12bc extracted/main | sed -f annot_main.sed

objdump 로 뜬 main 의 핵심 구간에 sed 로 주석을 얹은 화면. fopen 뒤 SEEK_END·ftell 로 크기를 재고 cmp QWORD PTR rbp-0x8,0x290 으로 656바이트인지 확인한 다음 0x12e3 의 fverify 를 호출한다
objdump 로 뜬 main 의 핵심 구간에 sed 로 주석을 얹은 화면. fopen 뒤 SEEK_END·ftell 로 크기를 재고 cmp QWORD PTR rbp-0x8,0x290 으로 656바이트인지 확인한 다음 0x12e3 의 fverify 를 호출한다

읽히는 대로 옮기면 이렇다.

FILE *fp = fopen(argv[1], "rb");
fseek(fp, 0, SEEK_END);
long size = ftell(fp);
fseek(fp, 0, SEEK_SET);          // 커서를 0 으로 되돌려 놓고
if (size != 0x290) { puts("size mismatch"); puts("Wrong!"); return 0; }
char ok = fverify(fp);           // 0x12e3
puts(ok ? "Correct!" : "Wrong!");

크기가 0x290 = 656으로 고정이고, fverify 에 들어갈 때 커서는 0 이다. 재생의 출발점이 정해졌다.



🧱 fverify — 35,257개 명령이 세 종류뿐

fverify 는 0x12e3 에서 시작해 0x2aadc 에서 끝난다. .text 171KB 가 사실상 이 함수 하나다. 함수 머리를 열어 보면 패턴이 바로 눈에 띈다.

objdump -d -M intel --start-address=0x1308 --stop-address=0x13c9 extracted/main | sed -f annot_fverify.sed

objdump 로 뜬 fverify 도입부에 sed 주석을 얹은 화면. ok=1 로 시작해 SEEK_CUR fseek 를 +62·+419·-52·+197·-531·+37 여섯 번 부른 뒤 rbp-0x299 슬롯으로 fread 를 한 번 호출한다
objdump 로 뜬 fverify 도입부에 sed 주석을 얹은 화면. ok=1 로 시작해 SEEK_CUR fseek 를 +62·+419·-52·+197·-531·+37 여섯 번 부른 뒤 rbp-0x299 슬롯으로 fread 를 한 번 호출한다

+62, +419, -52, +197, -531, +37. 여섯 번 움직이고 나서야 fread(&buf[rbp-0x299], 1, 1, fp) 가 한 번 나온다. 더해 보면 132다. 즉 이 묶음은 "파일 132번 바이트를 읽어라"를 여섯 조각으로 쪼개 놓은 것이다.

함수 끝은 대칭이다. 마지막 fread 뒤에 판정이 붙는다.

objdump -d -M intel --start-address=0x2aa89 --stop-address=0x2aadd extracted/main | sed -f annot_tail.sed

objdump 로 뜬 fverify 마지막 구간에 sed 주석을 얹은 화면. 656번째 fread 직후 cmp al,0x20 · sete · and eax,edx 로 누산기 rbp-0x9 를 갱신하고 그 값을 그대로 반환한다
objdump 로 뜬 fverify 마지막 구간에 sed 주석을 얹은 화면. 656번째 fread 직후 cmp al,0x20 · sete · and eax,edx 로 누산기 rbp-0x9 를 갱신하고 그 값을 그대로 반환한다

판정 코드가 이거다.

movzx  edx, BYTE PTR [rbp-0x9]   ; 지금까지의 ok
movzx  eax, BYTE PTR [rbp-0xa]   ; 방금 읽은 바이트
cmp    al, 0x20                  ; 기대값과 비교
sete   al
and    eax, edx                  ; ok &= (byte == 0x20)
mov    BYTE PTR [rbp-0x9], al

ok 는 rbp-0x9 한 바이트에 눌러 담기고, 마지막에 그대로 반환된다. 조기 탈출이 없다. 첫 바이트가 틀려도 나머지 655개를 끝까지 다 읽는다.

세 종류밖에 없다는 게 정말인지는 세어 보면 된다.

#!/usr/bin/env python3
# fverify 본문이 정말 "fseek/fread/cmp 세 종류뿐"인지 세어 본다.
# 니모닉에서 즉치값만 K 로 뭉개고 빈도를 뽑는다.
import re
import subprocess
from collections import Counter
from pathlib import Path
 
BIN = Path(__file__).resolve().parent / "extracted" / "main"
out = subprocess.run(["objdump", "-d", "-M", "intel", str(BIN)],
                     capture_output=True, text=True, check=True).stdout
 
body = []
for line in out.split("\n"):
    m = re.match(r"\s*([0-9a-f]+):\t([0-9a-f ]+)\t(.*)", line)
    if m and 0x12E3 <= int(m.group(1), 16) <= 0x2AADC:
        body.append(m.group(3).strip())
 
print(f"fverify 본문 명령 수: {len(body)}")
c = Counter(re.sub(r"#.*", "", re.sub(r"0x[0-9a-f]+", "K", t)).strip() for t in body)
print("--- 상위 20개 (즉치값은 K 로 뭉갬) ---")
for text, n in c.most_common(20):
    print(f"{n:6d}  {text}")
print("--- 조건분기(jcc)·산술 명령이 어디에 있나 ---")
for line in body:
    if re.match(r"j(?!mp)[a-z]+\s", line) or re.match(r"(add|sub|xor|imul|shl|shr|rol|ror)\s", line):
        print(f"        {line}")
print("        ^ 전부 프롤로그(스택 확보)·에필로그(스택 카나리)뿐이다.")
print("          본문에는 분기도 연산도 없다 — 읽고 비교하기만 한다.")
python3 stats.py

stats.py 실행 결과 — objdump 니모닉 히스토그램. fseek 호출 4949회·fread 656회·cmp al 656회이고 조건분기와 산술 명령은 프롤로그의 sub rsp와 에필로그 스택 카나리 넷뿐이다
stats.py 실행 결과 — objdump 니모닉 히스토그램. fseek 호출 4949회·fread 656회·cmp al 656회이고 조건분기와 산술 명령은 프롤로그의 sub rsp와 에필로그 스택 카나리 넷뿐이다

항목실측
본문 명령 수35,257
fseek 호출4,949 (전부 whence=1, 즉 SEEK_CUR)
fread 호출656 (전부 size=1, nmemb=1)
cmp al, imm656
조건분기 · 산술프롤로그 sub rsp,0x2b0 · xor eax,eax + 에필로그 카나리 검사, 그게 전부

fread 656번은 파일 크기 656과 정확히 같다. 목적지 슬롯은 rbp-0x299 부터 rbp-0xa 까지 656개가 전부 다르다. 한 슬롯에 한 바이트, 겹치지 않는다.

난독화도 연산도 없다. 그냥 크다. 그런데 그게 이 문제를 성가시게 만든다 — 어느 fread 가 파일의 몇 번을 읽는지가 코드 어디에도 안 적혀 있다.



🐛 여기서 두 번 헛돌았다

▶🐛 삽질 1 — cmp 상수 656개를 나온 순서대로 이어붙이면 되지 않나

cmp 상수가 656개고 파일도 656바이트다. 순서까지 같은 거 아닐까 싶어 그대로 붙여 봤다.

#!/usr/bin/env python3
# 삽질 1 — "cmp 상수 656개를 나온 순서대로 이어붙이면 그게 파일 아닌가?"
# fseek 를 무시하고 프로그램 순서 그대로 조립해 본다.
import re
import sys
from pathlib import Path
 
sys.path.insert(0, str(Path(__file__).resolve().parent))
import solve  # 파싱 루틴 재사용
 
ops = solve.extract_ops(solve.disassemble(solve.BIN))
consts = bytes(v[1] for k, v in ops if k == "cmp")
 
print(f"cmp 상수 개수: {len(consts)}  (파일 크기 {solve.SIZE} 와 일치)")
print("--- 프로그램에 나온 순서 그대로 이어붙인 결과 ---")
print(consts[:160].decode("utf-8", "replace"))
print("...")
print()
print("DH{ 있나?", "있음" if b"DH{" in consts else "없음")
print("ASCII 비율:", f"{sum(1 for b in consts if 0x20 <= b < 0x7f) * 100 // len(consts)}%",
      "— 글자는 다 있는데 순서가 뒤섞였다")
python3 naive.py

naive.py 실행 결과 — cmp 상수를 프로그램 순서대로 이어붙인 656바이트. ASCII 비율은 98%인데 hrtlne usfbdA oe 처럼 뜻이 없는 글자 나열이고 DH 로 시작하는 플래그 문자열도 나오지 않는다
naive.py 실행 결과 — cmp 상수를 프로그램 순서대로 이어붙인 656바이트. ASCII 비율은 98%인데 hrtlne usfbdA oe 처럼 뜻이 없는 글자 나열이고 DH 로 시작하는 플래그 문자열도 나오지 않는다

ASCII 비율 98%. 글자는 다 맞는데 배열이 엉망이다. DH{ 도 안 나온다.

이게 오히려 확증이 됐다. 상수 656개는 정답 파일의 바이트가 맞고, 순서만 뒤섞였다. 뒤섞은 주체는 하나뿐이다 — 무시하고 지나간 fseek 4,949개.

▶🐛 삽질 2 — 바이너리를 오라클 삼아 한 바이트씩 맞춰 나가면

정적 분석이 귀찮으면 실행으로 때우는 방법이 있다. 한 바이트씩 0~255를 넣어 보고 반응이 달라지는 값을 고르면 656 × 256 = 167,936번으로 끝난다. 될까 싶어 0번 오프셋만 실제로 돌려 봤다.

#!/usr/bin/env python3
# 삽질 2 — "한 바이트씩 바꿔 가며 브루트포스하면 되지 않나?"
# 0번 오프셋만 0~255 로 돌려 보고 나머지는 'A' 로 채운 파일 256개를 실제로 먹여 본다.
# 판정이 Correct!/Wrong! 둘뿐이라 부분 정답에 대한 신호가 전혀 없다는 것을 확인한다.
import subprocess
from collections import Counter
from pathlib import Path
 
HERE = Path(__file__).resolve().parent
BIN = HERE / "extracted" / "main"
TMP = HERE / "probe.bin"
SIZE = 0x290
 
results = Counter()
for b in range(256):
    blob = bytearray(b"A" * SIZE)
    blob[0] = b
    TMP.write_bytes(bytes(blob))
    out = subprocess.run([str(BIN), str(TMP)], capture_output=True, text=True).stdout.strip()
    results[out] += 1
 
print(f"0번 오프셋만 0~255 로 바꾼 파일 {sum(results.values())}개 실행 결과")
for msg, n in results.items():
    print(f"  {msg:12s} : {n}회")
print()
print("정답 바이트(0x54 'T')를 맞춘 경우에도 출력이 똑같다.")
print("→ 바이트 단위 오라클이 없다. 656 * 256 = 167,936 번을 돌려도 정보가 0비트다.")
TMP.unlink(missing_ok=True)
python3 probe_oracle.py

probe_oracle.py 실행 결과 — 0번 오프셋만 0부터 255까지 바꾼 파일 256개를 바이너리에 먹인 결과가 전부 Wrong! 하나로 동일해 바이트 단위 오라클이 존재하지 않음을 보여 준다
probe_oracle.py 실행 결과 — 0번 오프셋만 0부터 255까지 바꾼 파일 256개를 바이너리에 먹인 결과가 전부 Wrong! 하나로 동일해 바이트 단위 오라클이 존재하지 않음을 보여 준다

256번 전부 Wrong!. 정답 바이트 0x54('T')를 맞춘 회차도 출력이 똑같다.

당연한 결과였다. and eax,edx 로 전부 눌러 담고 마지막에 한 번만 뱉으니 부분 정답이 밖으로 새지 않는다. 조기 탈출이 없어 실행 시간 차이도 없다. 정적으로 푸는 것 말고 길이 없다.



💣 핵심 — 커서를 그대로 다시 굴린다

막힌 지점을 정리하면 하나다. fread 는 "지금 위치"를 읽는데, 그 위치가 코드에 없다. 있는 건 차분 4,949개뿐이다.

그런데 차분이 전부 있으면 위치도 전부 있는 것과 같다. 시작이 0으로 확정돼 있으니까. main 이 fseek(fp, 0, SEEK_SET) 을 해 두고 들어간다는 걸 이미 확인했다.

fverify 의 커서 이동 방식을 그린 도식. 656바이트 파일 위에서 +62·+419·-52·+197·-531·+37 여섯 번의 상대 이동을 거쳐 오프셋 132 에 도착한 뒤 한 바이트를 읽고 0x68 과 비교한다
fverify 의 커서 이동 방식을 그린 도식. 656바이트 파일 위에서 +62·+419·-52·+197·-531·+37 여섯 번의 상대 이동을 거쳐 오프셋 132 에 도착한 뒤 한 바이트를 읽고 0x68 과 비교한다

그래서 하는 일은 이렇다. objdump 출력을 위에서 아래로 훑으면서 fseek 를 만나면 커서 변수를 더하고, fread 를 만나면 그 순간의 커서 값을 목적지 슬롯에 기록하고 1 증가시킨다. cmp 를 만나면 비교 대상 슬롯을 찾아 "그 오프셋의 값은 이것"이라고 적는다.

패턴 매칭은 네 줄이면 된다. 인자가 전부 상수라 레지스터를 추적할 필요도 없다.

mov  esi, 0x3e            ; fseek 의 2번째 인자 — 양수는 esi
mov  rsi, 0xff...ffcc     ; 음수는 rsi 로 부호확장돼 들어온다
lea  rax, [rbp-0x299]     ; fread 의 목적지 슬롯
movzx eax, BYTE PTR [rbp-0x299] / cmp al, 0x68   ; 그 슬롯의 기대값

커서를 재생할 때 지켜야 할 stdio 규칙이 둘 있다.

음수로 가는 이동은 실패하고 커서가 안 움직인다. 무조건 더하면 그 뒤가 전부 어긋난다.

파일 끝을 넘어가는 이동은 성공한다. 대신 그 자리에서 읽으면 아무것도 안 들어온다. 막는 게 아니라 그대로 흉내내야 한다.

실제로 재생해 보니 음수 이동 실패는 0회, EOF 뒤 읽기도 0회였다. 커서가 656까지 올라간 적은 7번 있었지만 거기서 읽지는 않았다. 출제자가 유효 범위 안에서만 놀게 짜 놓은 것이다.

재생 중 실측한 것값
fseek 차분 범위-643 ~ +643 (음수 49.7%)
fread 한 번당 fseek 횟수5 ~ 10회, 평균 7.54
커서가 도달한 최댓값656 (EOF 경계, 7회)
음수 이동 실패0회
읽은 오프셋0~655 전부 정확히 한 번씩

마지막 줄이 결정적이다. 656개 오프셋이 빠짐없이 한 번씩 읽힌다. 겹치는 오프셋이 있었다면 상수끼리 충돌했을 텐데 충돌도 0이었다.


🎯 정적 재생이 맞나 — gdb 로 대조

여기까지는 종이 위 계산이다. 실행 중에도 같은 자리를 읽는지 확인해야 한다. fread 에 브레이크포인트를 걸고 진입 시점의 ftell 을 찍어 보면 된다. fread 의 4번째 인자가 FILE * 이라 rcx 에 들어 있다.

set pagination off
set confirm off
set $n = 0
break fread
commands
silent
set $n = $n + 1
printf "fread #%-2d  ftell=%4ld  -> buf[rbp-0x%lx]\n", $n, (long)ftell($rcx), $rbp - $rdi
if $n >= 10
  printf "... (총 656회 중 앞 10회만 표시)\n"
  kill
  quit
end
continue
end
run answer.bin
gdb -q -batch -x trace_reads.gdb ./extracted/main

gdb -batch 로 fread 에 브레이크포인트를 걸고 진입 때마다 ftell 을 호출해 찍은 런타임 로그. 앞 10회의 파일 오프셋이 132·320·617·420·22·445·614·321·234·20 으로 정적 재생 결과와 그대로 일치한다
gdb -batch 로 fread 에 브레이크포인트를 걸고 진입 때마다 ftell 을 호출해 찍은 런타임 로그. 앞 10회의 파일 오프셋이 132·320·617·420·22·445·614·321·234·20 으로 정적 재생 결과와 그대로 일치한다

정적으로 계산한 앞 10개가 132, 320, 617, 420, 22, 445, 614, 321, 234, 20 이었다. 런타임 ftell 이 같은 값을 뱉었다. 슬롯도 rbp-0x299 부터 1씩 내려가며 맞는다.

읽는 순서만 봐도 왜 삽질 1이 실패했는지가 보인다. 132에서 320으로, 다시 617로 튀어 다닌다. 프로그램 순서와 파일 순서는 아무 관계가 없다.



🚀 Full Exploit

전체 스크립트다. objdump 를 직접 부르고 커서를 재생해 answer.bin 을 떨군다. 외부 의존은 objdump(binutils) 하나뿐이고 인자는 없다.

#!/usr/bin/env python3
# fverify (DreamHack, Gold 4 / reversing) — 검증 파일 656바이트 복원
#
# main 은 파일 크기가 0x290(656)인지 보고 fverify() 로 넘긴다.
# fverify 는 분기 하나 없는 직선 코드로
#   fseek(f, imm, SEEK_CUR) 4949회 · fread(&slot, 1, 1, f) 656회 · cmp al, imm 656회
# 만 반복한다. 즉 파일 커서를 상대이동으로 흩뿌려 가며 656바이트를 순서만 뒤섞어 읽고,
# 각 바이트를 상수와 비교해 ok 를 누산한다.
#
# 따라서 objdump 출력을 그대로 재생(커서 시뮬레이션)하면
#   "몇 번째 fread 가 파일의 몇 번 오프셋을 읽는가" 가 복원되고,
# 그 오프셋에 대응하는 cmp 상수를 꽂으면 파일 전체가 나온다.
import re
import subprocess
import sys
from pathlib import Path
 
HERE = Path(__file__).resolve().parent
BIN = HERE / "extracted" / "main"
OUT = HERE / "answer.bin"
 
FVERIFY_START = 0x12E3   # main 이 call 하는 함수
FVERIFY_END = 0x2AADC    # ret
SIZE = 0x290             # main 의 cmp QWORD PTR [rbp-0x8],0x290
 
 
def disassemble(path):
    """objdump 로 .text 를 통째로 뜬다 (주소, 니모닉) 목록."""
    out = subprocess.run(
        ["objdump", "-d", "-M", "intel", str(path)],
        capture_output=True, text=True, check=True,
    ).stdout
    insns = []
    for line in out.split("\n"):
        m = re.match(r"\s*([0-9a-f]+):\t([0-9a-f ]+)\t(.*)", line)
        if not m:
            continue
        addr = int(m.group(1), 16)
        if FVERIFY_START <= addr <= FVERIFY_END:
            insns.append((addr, m.group(3).strip()))
    return insns
 
 
def sx(text):
    """objdump 가 찍은 64비트 즉치값을 부호 있는 정수로."""
    v = int(text, 16)
    return v - (1 << 64) if v >= (1 << 63) else v
 
 
def extract_ops(insns):
    """(seek, 오프셋) / (read, 스택슬롯) / (cmp, (스택슬롯, 기대값)) 시퀀스로 압축."""
    ops = []
    pend_off = pend_slot = pend_cmp_slot = None
    for _, text in insns:
        m = re.match(r"mov\s+r?e?si,(0x[0-9a-f]+)$", text)          # fseek 2번째 인자
        if m:
            pend_off = sx(m.group(1))
            continue
        m = re.match(r"lea\s+rax,\[rbp-(0x[0-9a-f]+)\]$", text)      # fread 목적지
        if m:
            pend_slot = int(m.group(1), 16)
            continue
        if text.startswith("call") and "fseek@plt" in text:
            ops.append(("seek", pend_off))
            continue
        if text.startswith("call") and "fread@plt" in text:
            ops.append(("read", pend_slot))
            continue
        m = re.match(r"movzx\s+eax,BYTE PTR \[rbp-(0x[0-9a-f]+)\]$", text)
        if m:
            pend_cmp_slot = int(m.group(1), 16)
            continue
        m = re.match(r"cmp\s+al,(0x[0-9a-f]+)$", text)               # ok &= (byte == imm)
        if m:
            ops.append(("cmp", (pend_cmp_slot, int(m.group(1), 16))))
            continue
    return ops
 
 
def replay(ops):
    """파일 커서를 그대로 흉내내 슬롯→파일오프셋 매핑을 얻고 기대 바이트를 채운다."""
    pos = 0
    slot_off = {}
    data = {}
    stats = {"seek": 0, "read": 0, "cmp": 0, "neg_fail": 0, "past_eof": 0, "eof_read": 0}
    for kind, val in ops:
        if kind == "seek":
            stats["seek"] += 1
            nxt = pos + val
            if nxt < 0:
                stats["neg_fail"] += 1   # fseek 실패 → 커서 유지
            else:
                pos = nxt
                if pos >= SIZE:
                    stats["past_eof"] += 1
        elif kind == "read":
            stats["read"] += 1
            if pos >= SIZE:             # EOF 뒤라 아무것도 안 읽힘
                stats["eof_read"] += 1
                slot_off[val] = None
            else:
                slot_off[val] = pos
                pos += 1
        else:
            stats["cmp"] += 1
            slot, want = val
            off = slot_off.get(slot)
            if off is not None:
                data[off] = want
    return data, stats
 
 
def main():
    if not BIN.exists():
        sys.exit(f"바이너리가 없습니다: {BIN}")
 
    insns = disassemble(BIN)
    ops = extract_ops(insns)
    data, st = replay(ops)
 
    print(f"[*] fverify 명령 수      : {len(insns)}")
    print(f"[*] fseek / fread / cmp  : {st['seek']} / {st['read']} / {st['cmp']}")
    print(f"[*] 음수 seek 실패       : {st['neg_fail']}   EOF 뒤 read: {st['eof_read']}")
    print(f"[*] 커서가 EOF 를 넘어간 횟수: {st['past_eof']}")
 
    missing = [i for i in range(SIZE) if i not in data]
    if missing:
        sys.exit(f"[!] 복원 실패 — 빈 오프셋 {len(missing)}개: {missing[:10]}")
 
    blob = bytes(data[i] for i in range(SIZE))
    OUT.write_bytes(blob)
    print(f"[+] {SIZE}바이트 전부 복원 → {OUT.name}")
    print()
    print(blob.decode("utf-8", "replace"))
 
    m = re.search(rb"DH\{[^}]+\}", blob)
    print(f"[+] FLAG: {m.group().decode() if m else '(못 찾음)'}")
 
 
if __name__ == "__main__":
    main()
python3 solve.py

solve.py 실행 결과 — objdump 파싱과 커서 재생으로 656바이트를 전부 복원해 answer.bin 으로 저장하고 그 내용인 fverify 제작 후기 영문과 DH 로 시작하는 64자리 hex 플래그를 그대로 출력한다
solve.py 실행 결과 — objdump 파싱과 커서 재생으로 656바이트를 전부 복원해 answer.bin 으로 저장하고 그 내용인 fverify 제작 후기 영문과 DH 로 시작하는 64자리 hex 플래그를 그대로 출력한다

복원된 656바이트는 출제자가 fverify 를 만든 이유를 늘어놓는 영문 독백이고, 그 한가운데 플래그가 박혀 있다.

There are countless functions within <stdio.h> - from fopen all the way through to fread, fwrite, fseek, and ftell.
But I've always hated how C lets you use just any file without question.
I wanted only the files I approved of to ever be touched.
 
So I devised my own creation: the fverify function. With it, I can filter out every unwanted file and keep only the chosen ones.
HA! HAHAHA!
 
And within this wicked scheme lies the flag:
DH{ce60d40e6fd2cc3303465d66559d021a5391a8245b65f517fda3bf61646bfe9a}.
 
In this terrifying world, isn't it only natural that someone like me would craft a function to verify files?
After all, one must always be prepared...

되먹여서 확인

플래그 문자열이 나왔다고 끝난 게 아니다. 656바이트가 전부 맞아야 Correct! 가 나온다. 바이너리에게 직접 물어본다.

#!/usr/bin/env python3
# 복원한 answer.bin 을 진짜 바이너리에 먹여 판정을 확인한다.
#   ① 원본 그대로            → Correct!
#   ② 한 바이트만 뒤집기     → Wrong!     (전 바이트가 검사된다는 증거)
#   ③ 길이를 줄이기          → size mismatch
import subprocess
from pathlib import Path
 
HERE = Path(__file__).resolve().parent
BIN = HERE / "extracted" / "main"
GOOD = HERE / "answer.bin"
 
 
def run(path):
    r = subprocess.run([str(BIN), str(path)], capture_output=True, text=True)
    return r.stdout.strip().replace("\n", " / ")
 
 
blob = GOOD.read_bytes()
print(f"answer.bin  {len(blob)}바이트  ->  {run(GOOD)}")
 
flipped = HERE / "bad.bin"
mut = bytearray(blob)
mut[300] ^= 0x01
flipped.write_bytes(bytes(mut))
print(f"bad.bin     300번 바이트 1비트 반전  ->  {run(flipped)}")
 
short = HERE / "short.bin"
short.write_bytes(blob[:100])
print(f"short.bin   100바이트로 자름         ->  {run(short)}")
python3 verify.py

verify.py 실행 결과 — 복원한 answer.bin 은 Correct!, 300번 바이트를 1비트만 뒤집은 bad.bin 은 Wrong!, 100바이트로 자른 short.bin 은 size mismatch 로 판정되어 656바이트 전부가 검사됨을 보여 준다
verify.py 실행 결과 — 복원한 answer.bin 은 Correct!, 300번 바이트를 1비트만 뒤집은 bad.bin 은 Wrong!, 100바이트로 자른 short.bin 은 size mismatch 로 판정되어 656바이트 전부가 검사됨을 보여 준다

세 갈래가 다 예상대로 나왔다. 1비트만 건드려도 Wrong! 이라는 게 656개 비교가 전부 살아 있다는 증거다.

재현 환경

정적 분석만으로 끝나는 문제라 libc 버전을 맞출 필요가 없다. 확인한 환경은 아래와 같다.

OSUbuntu 25.10
Python3.13.7
objdumpGNU Binutils 2.45
gdb16.3 (Ubuntu 16.3-1ubuntu2)
대상 바이너리extracted/main — BuildID 9de6a6c259c60547cbf3ea7d518af0de2d818e11, 빌드는 GCC 13.3.0 (Ubuntu 24.04)
복원 결과answer.bin 656 B, sha256 30e7d953e39fe81f1515f226c6bef93c3421fe8d53617d4aaa2dcc64ef0e0903

solve.py 는 main 이 extracted/ 아래 있다고 가정한다. 다른 데 두면 스크립트 상단의 BIN 만 고치면 된다. objdump 출력 형식에 의존하므로 binutils 가 아주 옛 버전이면 정규식이 안 맞을 수 있다.



📝 결론

주소가 안 적혀 있으면 계산해서 만들면 된다.

이 문제의 유일한 장벽은 SEEK_CUR 이다. 오프셋을 차분으로 쪼개 놓으면 디스어셈블리에는 "어디를 읽는지"가 한 군데도 안 남는다. 그런데 시작점이 확정돼 있고 차분이 전부 남아 있으면 그 정보는 사라진 게 아니라 흩어져 있을 뿐이다. 순서대로 더하면 그대로 돌아온다.

커진 코드는 어렵게 만들지 못한다.

35,257개 명령은 사람이 읽으라고 만든 게 아니다. 하지만 종류가 세 가지뿐이라 정규식 네 줄이면 전부 걷어진다. 반복이 많다는 건 파서를 짜기 쉽다는 뜻이기도 하다. .rodata 가 37바이트뿐인 걸 처음에 본 순간부터 방향은 정해져 있었다 — 데이터가 코드 안에 있다.

정적 계산은 런타임으로 한 번 대조한다.

커서 재생은 stdio 동작에 대한 가정 위에 서 있다. 음수 이동이 실패한다는 것, EOF 뒤로 넘어가는 이동은 성공한다는 것. 가정이 하나만 틀려도 그 뒤 전부가 조용히 어긋나는데 결과가 여전히 그럴듯한 ASCII 라 알아채기 어렵다. gdb -batch 로 ftell 열 개를 찍어 보는 데 몇 초면 된다.

방어 관점에서 — 이런 검증기는 아무것도 못 막는다.

fverify 가 하려던 일은 "승인한 파일만 열게 하기"다. 그런데 기준이 되는 파일 내용이 검사 코드 안에 통째로 들어 있으니, 배포한 순간 그 내용도 같이 배포한 셈이다. 분기를 없애고 상수를 흩뿌린 것은 사람 눈을 늦출 뿐 스크립트는 늦추지 못한다.

파일 무결성을 진짜로 검사하려면 원본이 바이너리 안에 있으면 안 된다. 해시 하나를 갖고 있다가 대조하는 방식이면 원본을 역산할 수 없다. fverify 가 지킨 파일에 플래그가 그대로 들어 있던 것은 그래서 우연이 아니다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥇 Gold 4] 지그재그로 접힌 플래그 — DreamHack quilt 풀이
blog

[🥇 Gold 4] 지그재그로 접힌 플래그 — DreamHack quilt 풀이

배포된 quilt는 플래그를 검사하는 프로그램이 아니라 플래그를 그림으로 굽는 인코더였다. /dev/urandom 192바이트 한복판에 인자를 그대로 얹고, 6비트씩 잘라 64색 팔레트로 32×32 블록을 칠한다. 되돌리는 길목에 놓인 함정은 홀수 격자행을 좌우로 뒤집는 한 줄이다.
#dreamhack#ctf#reversing+4
2026-08-20#dreamhack +5
[💠 Platinum 4] 0의 런렝스를 감마 코드로 되감기 — DreamHack Run 풀이
blog

[💠 Platinum 4] 0의 런렝스를 감마 코드로 되감기 — DreamHack Run 풀이

배포 파일은 10KB 짜리 stripped ELF 와 160KB 짜리 flag.enc 두 개뿐이다. 바이너리는 복호기가 아니라 인코더였고, 0 의 런렝스를 Elias 감마 유사 코드로 바꿔 쓰는 1810바이트짜리 루틴 하나가 전부였다. 분기 하나를 반대로 읽으면 복원본이 통째로 비트 반전된다. objdump 로 인코더를 읽고 gdb 로 못 박은 뒤 역함수를 써서 10000x10000 PNG 를 되살렸고, 복원본을 문제 바이너리에 재투입해 sha256 일치로 검증했다.
#dreamhack#ctf#reversing+6
2026-08-18#dreamhack +5
[🥇 Gold 4] 5바이트 스텁 97개로 부순 건 코드가 아니라 디스어셈블러였다 — DreamHack Vernichtet 풀이
blog

[🥇 Gold 4] 5바이트 스텁 97개로 부순 건 코드가 아니라 디스어셈블러였다 — DreamHack Vernichtet 풀이

.text 전체에 `eb ff c1 ff c9` 가 97개 박혀 있다. jmp 가 자기 오퍼랜드로 뛰어드는 겹침 명령이라 선형 스윕 디스어셈블러는 실행되지도 않는 유령 명령을 보여 준다. 길이를 유지한 채 NOP 으로 덮어 코드를 되살리고, 그 안에 숨어 있던 15×15 Hidato 퍼즐을 풀어 답안 파일의 sha256 을 플래그로 받아냈다.
#dreamhack#ctf#reversing+7
2026-08-17#dreamhack +5