문제: 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) "
.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
쓰는 라이브러리 함수가 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
읽히는 대로 옮기면 이렇다.
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
+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
판정 코드가 이거다.
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], alok 는 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
| 항목 | 실측 |
|---|---|
| 본문 명령 수 | 35,257 |
fseek 호출 | 4,949 (전부 whence=1, 즉 SEEK_CUR) |
fread 호출 | 656 (전부 size=1, nmemb=1) |
cmp al, imm | 656 |
| 조건분기 · 산술 | 프롤로그 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
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
256번 전부 Wrong!. 정답 바이트 0x54('T')를 맞춘 회차도 출력이 똑같다.
당연한 결과였다. and eax,edx 로 전부 눌러 담고 마지막에 한 번만 뱉으니
부분 정답이 밖으로 새지 않는다. 조기 탈출이 없어 실행 시간 차이도 없다.
정적으로 푸는 것 말고 길이 없다.
💣 핵심 — 커서를 그대로 다시 굴린다
막힌 지점을 정리하면 하나다.
fread 는 "지금 위치"를 읽는데, 그 위치가 코드에 없다. 있는 건 차분 4,949개뿐이다.
그런데 차분이 전부 있으면 위치도 전부 있는 것과 같다. 시작이 0으로 확정돼 있으니까.
main 이 fseek(fp, 0, SEEK_SET) 을 해 두고 들어간다는 걸 이미 확인했다.

그래서 하는 일은 이렇다. 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.bingdb -q -batch -x trace_reads.gdb ./extracted/main
정적으로 계산한 앞 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
복원된 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
세 갈래가 다 예상대로 나왔다. 1비트만 건드려도 Wrong! 이라는 게 656개 비교가 전부 살아 있다는 증거다.
재현 환경
정적 분석만으로 끝나는 문제라 libc 버전을 맞출 필요가 없다. 확인한 환경은 아래와 같다.
| OS | Ubuntu 25.10 |
| Python | 3.13.7 |
| objdump | GNU Binutils 2.45 |
| gdb | 16.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
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…