在上一篇文章中,我們分析了以下這段程式碼並了解了什麼是 Stack Buffer Overflow。
#include "stdio.h"
#include "stdlib.h"
void win() {
system("/bin/sh");
}
int main() {
int a = 5;
char buf[10] = "abcdefghji";
char name[10];
printf("Enter your name: \n");
gets(name);
printf("%s\n", name);
return 0;
}
這篇文章將接續介紹如何利用 Stack Buffer Overflow 來獲取控制權,
文章架構如下:
win()
位址註:請使用以下命令編譯程式碼。
gcc -fno-stack-protector -no-pie -o bof bof.c
上一篇的結尾有提到我們可以透過 Stack Buffer Overflow 覆蓋 Return Address,藉此讓程式跳轉到我們覆蓋的位址。
那麼,接下來的問題是:我們希望跳轉到哪個位址?
由於我們的目標是取得該程式所運行的主機控制權,因此我們希望程式跳轉到能夠幫助我們實現控制權的程式碼區段。這樣的區段應該具備可執行性,並且包含可以協助我們執行系統命令的指令。
在這個範例中,win()
函數就是一個理想的目標,因為它內部執行了 system("/bin/sh")
,這條指令會啟動一個 shell,讓我們能夠直接與系統進行交互,進而取得主機的控制權。通過覆蓋 Return Address 並跳轉到 win()
函數,我們就能夠利用程式本身的功能打開一個 shell 取得控制權,這正是我們想要達到的效果。
因此接下來我們需要完成以下兩步:
win()
的位址win()
位址為了找到 win()
函數的位址,我們會使用 objdump,它是一個可以分析可執行檔的工具。其中,我們需要用到它反組譯(Disassemble)的功能。所謂的反組譯就是將可執行檔轉回去編譯器產生的組合語言程式。
組合語言有不同的格式,我們前幾篇文章透過 Pwngdb 分析過程中看到的組合語言格式為 Intel,我們可以使用下面的指令將當前的執行檔 bof
反組譯成 Intel 格式的組合語言。
objdump -M intel -d bof
其中 -d
是將可執行檔可執行的 Sections 反組譯(-D
是將整份檔案反組譯),-M
是指定組合語言格式。
可以看到輸出結果是以 Sections 劃分的,其中我們想關注的是我們的程式碼部分,即 "Disassembly of section .text"。
其中,我們可以看到裡面包含 win()
,如下:
紅框右半部是之前 Pwngdb 分析時,人看得懂的部分。左半部則是給電腦看的機器碼。藍框則是程式載入到記憶體時會被分配的的位址。
看一下可以發現整個程式的架構如下:
上下兩欄藍框的部分分別是 Function Prologue 和 Function Epilogue。可以注意到上方 Function Prologue 的部分 RSP
並沒有往下減,因此代表這個函數不需要分配額外的 Stack 空間,像下面這樣:
也因此在 Function Epilogue 不需要像之前一樣用到 leave
,前面提到 leave
等於 mov rsp, rbp; pop rbp;
,因為現在 RSP
跟 RBP
是一樣的,因此編譯器優化後只剩下 pop rbp
,如 objdump 出來的結果。
我們重點放在紅框中,win()
的內容,可以注意到程式將 rip+0xeb3
這個位址的內容作為 system()
函數的第一個參數傳入。rip+0xeb3
的位址 objdump 已經幫我們算好,即為下一條指令位址(0x401151
)加上 0xeb3
等於 0x402004
。
那麼 0x402004
這個存了什麼東西呢?我們發現 objdump 使用 -d
這個參數的輸出不包含不可執行的 Sections,因此我們發現 0x402004
沒有輸出出來,此時我們可以直接將 -d
改為 -D
,即 objdump -M intel -D bof
。
補充:我們會發現
objdump -M intel -D bof
的輸出結果很多不好觀察,此時可以將輸出結果導入進grep
,並且為了方便觀查可以將grep
帶入 -C <num> 一併輸出上下 <num> 行,例如:objdump -M intel -D bof | grep 402004 -C 5
可以看到 0x402004
的內容如下:
可以發現後面給人看的反組譯指令為 bad,這代表著 0x402004
不是一個指令,可以注意到 0x2f
、0x62
這些屬於 ASCII 中可視字元的區間,所以代表它是一串資料。透過直接觀查二進制檔案內容,我們可以得知 0x402004
存了什麼資料。使用 xxd 或是在 objdump 加上 -s
這個參數(objdump -M intel -s bof
)來觀察。
我們發現 0x402004
存了一串字串,字串內容為 /bin/sh,搭配剛剛分析的內容,這同時也是 system()
的一個參數。所以 win()
的內容為
system("/bin/sh");
如同我們的 Source Code,同時我們也了解到呼叫 system()
的這段程式位址從 0x40114a
開始。
除了直接用 objdump 觀察外,我們也可以使用 IDA 這個逆向分析工具,它除了可以反組譯外,也可以將反組譯出來的組合語言再反編譯成原本的 Source Code。
IDA 是 GUI 工具,將執行檔放入 IDA 中可以看到如下畫面。
藍框為當前程式的所有函數,紅框為點選函數的組合語言。最一開始會顯示程式進入點 _start
,按下空白鍵可以將地址包含進來,如下:
接下來透過左邊的函數列表,我們可以找到 win()
:
點選 win()
,右方的組合語言會跟著改變,可以看到內容基本跟剛剛透過 objdump 的內容一樣,如下:
差別在於,IDA 幫我們將要傳入 system()
的資料標示出來,並將此字串取名為 command,點選 command 可以直接跳到它的位址:
可以發現跟剛剛一樣,位於 0x402004
。
由於 IDA 幫我們標記了很多內容,因此現在我們也能輕易得知執行 system("/bin/sh");
的這段程式位於 0x40114a
我們已經找到想要跳轉的位址為執行 system("/bin/sh");
的 0x40114a
,但由於位址不會都由可視字元組成,以0x40
、0x11
、0x4a
來說 0x11
沒有辦法透過鍵盤打出來,因此不能在
"Enter your name:" 之後透過直接輸入的方式,將 Return Address 覆蓋成我們要得地址,因此我們需要使用工具。
它是一個 Python 的套件,在打 Pwn 的時候很有用。我們一邊寫這題的 Python 攻擊腳本一邊說明 Pwntools 的用法。
from pwn import *
首先,透過以上程式將 Pwntools 套件匯入。
接著,我們會使用 context
配置執行時的變數設定,可以透過官方文檔了解詳細資訊。現在對我們比較重要的設定是要告訴 Pwntools 程式運行時的架構(x86-64/x86)、作業系統等等。
我們可手動設置,例如
context.arch = 'amd64'
補充:amd64 即為 x86-64,都是 64 bits CPU 的架構,也是我們在系列文一開始說明的預設環境
然而,看官方文檔第一段,它推薦我們使用
context.binary = './bof'
此時,Pwntools 會依據這個執行檔的內容自動設好相關的架構與環境配置。因為 bof
是由我們自己編譯的本地端程式,擁有此份執行檔,因此我們使用這個方式。
現在我們需要將這隻程式運行起來,可以使用 process()
:
p = process('./bof')
我們將執行起來的 process
回傳給 p
,接著我們就能使用 p
來做後續動作。我們要等到程式輸出 "Enter your name: \n" 之後,對他輸入攻擊的 Payload。
因此我們要先接收程式的輸出,判斷它是否輸出了 "Enter your name:",才能做後續的處理。Pwntools 有許多接收資料的函數,現在的情況我們可以使用 recvuntil()
它會接收資料直到我們指定的字串為止,如下:
p.recvuntil('Enter your name: \n')
接著就能輸入 Payload,回憶一下前一篇的分析:
並看一下 objdump 出來的結果:
都能得出,現在要輸入的內容會從 rbp-0x18
開始往上填入記憶體,也就是說現在我們需要先隨便填 0x18(24)個值到 Saved RBP 之前:
接下來還要繼續把 Saved RBP 蓋掉才能抵達 Return Address。
由於我們架構是 x86-64
,因此 Saves RBP 為 8 Bytes,所以還要再輸入 8 個隨便值進去:
這些為了抵達 Return Address 的隨便填充值稱為 Padding。
我們先幫它設成變數:
padding = b'A' * 0x18 + b'B' * 8
接著我們也幫我們需要跳到的位址設變數,即我們剛剛分析出的 0x40114a
addr = 0x40114a
接下來,我們可以使用 flat()
來將上述兩變數結合成 Payload,flat 是用於將一串參數轉換成 Payload 的好用函數,除了會將參數接合起來外,也會依據我們設的 Context 將位址轉為 Bytes。例如我們現在的 addr=0x40114a
。
為了要填入進 Return Address 讓他變成下圖這樣,我們實際上 Padding 之後應該要接 b'\x4a\x11\x40\x00\x00\x00\x00\x00'
。
但只要將 0x40114a
傳入 flat()
就會自動幫我們轉成上述內容。
因此我們程式如下:
payload = flat(padding, addr)
接下來我們就能發送我們的 Payload 了,Pwntools 也有許多傳送資料的函數,由於 gets()
的特性是會一直接收資料直到收到換行,因此我們發送完 Payload 後也需要換行,這時可以使用 Pwntools 的函數 sendline()
,用法如下:
p.sendline(payload)
最後當我們程式執行完後,我們希望把控制權交還給我們,讓我們成功 Pwn 了之後能進到系統裡。我們使用 interactive()
:
p.interactive()
整隻程式內容如下:
from pwn import *
context.binary = './bof'
p = process('./bof')
p.recvuntil('Enter your name: \n')
padding = b'A' * 0x18 + b'B' * 8
addr = 0x40114a
payload = flat(padding, addr)
p.sendline(payload)
p.interactive()
執行之後可以看到我們成功 Pwn 了它:
有了攻擊當然也會有相應的保護機制,為了避免 Stack Buffer Overflow 能直接控制 Return Address,導致我們能輕易的像上述說明一樣 Pwn。有一個相應的保護機制稱為 Canary。
它保護的方式是在 Saved RBP 之前放入一個稱為 Canary 的值,如下:
當這個值被我們覆蓋掉時就會報錯,因此我們不能在像上面這樣直接把值蓋到我們想要的位置。
為了應對此保護機制,通常會搭配其他漏洞來想辦法爆破或是洩漏出 Canary 的值,在填入 Padding 時,一併把 Canary 的內容也填上,接著就能往後覆蓋 Return Address 了。
在 gcc 編譯時,使用 --fno-stack-protector
可以關閉此保護機制,上面的例子就是使用這方法,後續為了初學方便,也暫時會透過此方式關閉 Canary 保護機制。
從上述的攻擊說明,可以得知此次攻擊能成功是因為我們能直接透過組合語言找出對應的位址,因此就有一種基於作業保護機制是讓程式在載入 Stack、Heap、共享函式庫以及 ELF 檔中 Load Segment 的位址隨機,方式如下圖:
然而雖然在載入時讓從 Text 到 Bss 這段的起始位址隨機,但程式卻沒辦法支援執行一個隨機載入的位址。於是後來有人提出一種技術,讓程式可以被編譯成位置無關執行檔 (Position-Independent Executable,PIE),他可以支援執行隨機載入的位置,如同一種特殊的共享函式庫一樣,同時這也是 [Day3] 基礎知識 - 執行檔如何被執行 文章中提到 ELF Type 是 DYN 的原因。
補充:上面所說明的隨機載入不同區域起始位址的保護機制稱為 ASLR(Address Space Layout Randomization),是基於作業系統的保護機制,要調整此保護機制在 Linux 環境需要去調整 /proc/sys/kernel/randomize_va_space 這個檔案。ASLR 有三種不同的保護層級,如下:
繞過此保護機制的方式即為搭配其他漏洞去洩漏某個指令的位址,可以從上圖看到程式只是初始位置會被隨機載入,整個結構的相對位置都不會變化,可以看到下方比較圖:
因此只要能知道內部的相對偏移量,並藉由洩漏出的某的位置推算出整個結構偏移多少,就能知道其他每個部分的實際位址。
在 gcc 編譯時,使用 -no-pie
可以關閉此保護機制,同樣,後續為了初學方便,也暫時會透過此方式關閉 PIE 保護機制。