- CMU 15-213「Machine-Level Programming V: Advanced Topics」的課程筆記
- 使用課程提供的 buffer overflow 程式碼觀察實際執行情形,並:
- 用 gdb 觀察 buffer overflow
- 透過 buffer overflow 更改 return address
- 觀察有/無使用 stack canary 的組合語言變化
由於我的 MacBook 是 M3 的,是 ARM64 架構,所以 VirtualBox 的 Ubuntu 虛擬機也是裝 ARM64 版本的。
但我想觀察 x86-64 組合語言,所以若要能在 ARM64 的 Ubuntu 虛擬機編譯和執行 x86-64 的程式,要先安裝 x86-64 的 cross-compiler、QEMU 模擬器、以及 gdb-multiarch
:
以下是 CMU 15-213 課程提供觀察 buffer overflow 的程式碼:
以上程式碼,會要求使用者輸入 string,並將使用者輸入的 string 放到 buffer buf
。
但有一個問題:這個程式用的是 gets
這個不安全的 C library function!
為什麼說 gets
是不安全的呢?
因為這個 function 不會做 bound checking,他不會檢查 buffer 是否已經被裝滿。他會從 terminal input 去 scan string,只要沒遇到 EOF
或 \n
,他就會一直把 char 丟到 buffer 內。若已經超過 buffer 的容量,那多餘的 char 就會覆蓋到其他記憶體的資料,所以駭客就可以用這種方式把想要的東西丟進去。
以此例而言,若 gets
丟了超過 4 個 char 到 buffer,那其他記憶體位址所存放的資料就會被覆蓋掉。
其他同樣類似較不安全的 standard C library functions 包括 strcpy
、strcat
等等,因為他們沒有做 bound checking。
為了安全起見,可以用比較安全的版本,像是 fgets
、strncpy
、strncat
等等。
-m64
:指定產生 64-bit x86 code-g
:可以用 gdb debug-fno-stack-protector
:關掉 stack canary 保護機制-z execstack
:允許執行位在 stack 的程式碼-no-pie
:不要使用 position-independent executable,這樣才能讓記憶體位址固定,方便分析將前述幾個保護措施關掉,才可以好好觀察 buffer overflow
我在我的虛擬機執行的結果,輸入 11 個 characters 沒事,但輸入 12 個 characters 就 segmentation fault,如下截圖:
使用 objdump
反組譯產生 x86-64 NASM 組合語言程式碼
-d
:disassemble 反組譯-M intel
:使用 Intel/NASM 語法(預設是用 AT&T 語法,但因為個人比較習慣 Intel 語法,所以改成 Intel 語法)以下是反組譯後得到的組合語言程式碼,後面會再做詳細的說明~
讓 qemu-x86_64
模式開啟 GDB 伺服器:
開啟另一個 terminal,執行以下指令:
設置自訂的 gdb tui layout,可同時顯示 source code、組合語言、以及 registers 的值:
將 gdb 的組合語言語法設置為 Intel 語法(預設會顯示成 AT&T 語法):
開啟前述自定義的 gdb tui layout:
執行以下指令設置 break point:
focus src
、focus asm
、focus regs
就會分別跳到程式碼、組合語言、registers 的畫面,這時可用上跟下來移動要看到的內容。focus cmd
則會回到下半部輸入 command 的部分。查看 rbp
和 rsp
registers 的值,以得知目前 main
的 stack frame:
rbp
:指向目前 stack frame 的 basersp
:指向目前 stack frame 的 toprip
:目前 CPU 準備執行的指令的位址可以看到目前 main
的 stack frame 就只有 0x2aaaab2aae30
,因為沒有 local variable,所以就沒有分配額外的 stack 空間給 main
。
接下來輸入 s
就會進入 call_echo()
:
此時的 rbp
和 rsp
都是 0x2aaaab2aae20
,也就是 call_echo
的 stack frame 是 0x2aaaab2aae20
。
接下來由於會涉及到組合語言層面的東西,所以用 si
指令,si
可以讓我們一個一個執行組合語言的指令。
輸入 si
之後,可以看到接下來即將要執行的指令是位在記憶體位址 0x40193e
的 call 0x401905 <echo>
指令。
x86-64 的 call
指令會做以下的事情:
rsp
-= 8call
指令的下一個指令的記憶體位址(依此例就是 0x401943
,也就是 return address)存放到前述 rsp
- 8 的記憶體位址內call
要跳過去的記憶體位址放到 rip
,依此例即 0x401905
輸入 si
,就會執行 0x40193e <call_echo+13> call 0x401905 <echo>
這一個指令。
rsp
的值從原本的 0x2aaaab2aae20
變成了 0x2aaaab2aae18
,被扣掉 8 了。用以下指令查看記憶體位址 0x2aaaab2aae18
存了什麼 data:
0x0000000000401943
,也就是 call_echo
的 call
指令的下一個指令的記憶體位址echo
return 到 call_echo
時要執行的指令,也就是 return addresscall_echo
的 stack frame 範圍就變成 0x2aaaab2aae20
(stack base)~ 0x2aaaab2aae18
(stack top)接下來即將被執行的組合語言指令是 0x401905 <echo> endbr64
,輸入 si
到下一個指令。
接下來要被執行的指令是 push rbp
,而 push rbp
和他的下一條指令 mov rbp,rsp
這兩條指令是所謂的 function prologue。
當進入一個新的 function,一定要執行 function prologue,也就是以下兩個指令:
push rbp
:
rsp
-= 8rbp
目前的值存放到前述 rsp
- 8 的記憶體位址內,也就是將前一個 stack frame 的 base,也就是 call_echo
的 stack frame 的 base 存放進去mov rbp,rsp
:
rsp
的值 copy 到 rbp
,也就是更新目前 stack frame 的 base 到 rbp
輸入 si
,就執行 0x401909 <echo+4> push rbp
。
info registers rbp rsp
查看 rsp
的值,可看到 rsp
的值從 0x2aaaab2aae18
變成 0x2aaaab2aae10
。x/1xg 0x2aaaab2aae10
查看 0x2aaaab2aae10
這個記憶體位址裡面存了什麼東西,可以看到存的是 0x2aaaab2aae20
,也就是前一個 stack frame 的 base。接下來即將被執行的指令是 0x40190a <echo+5> mov rbp,rsp
。輸入 si
執行這個指令。
可以看到 rbp
的值變成 0x2aaaab2aae10
,也就是目前 echo
這個 function 的 stack frame 的 base。
下一個要被執行的指令是 0x40190d <echo+8> sub rsp,0x10
,也就是將 rsp
- 16,也就是分配給這個 function 的 stack 的空間。輸入 si
執行此指令。
可看到 rsp
從 0x2aaaab2aae10
變成 0x2aaaab2aae00
。
接下來要執行的幾個指令是:
0x401911 <echo+12> lea rax,[rbp-0x4]
rbp
- 4 後的值存放到 rax
裡面,而這個 rbp
- 4 就是 char buf[4]
的起始位址print &buf
指令可看到 buf
的起始位址是 0x2aaaab2aae0c
:lea
只會計算記憶體位址,不會去存取那個記憶體位址裡面存放的值0x401915 <echo+16> mov rdi,rax
0x2aaaab2aae0c
copy 到 rdi
rdi
會用來存放 function call 的第一個參數gets
,而 gets
的唯一參數是 char *buf
,所以就是將 buf
的記憶體起始位址放到 rdi
裡面0x401918 <echo+19> mov eax,0x0
rax
register 清空成 00x40191d <echo+24> call 0x404db0 <gets>
gets
當正準備執行 0x40191d <echo+24> call 0x404db0 <gets>
時,也就是在上述截圖畫面時,先使用以下指令查看幾個記憶體位址內存的值:
記憶體位址 | 存放的資料 | |
---|---|---|
0x2aaaab2aae18 |
0x401943 |
return 到 call_echo 要執行的下一個指令的位址 |
0x2aaaab2aae10 |
0x2aaaab2aae20 |
call_echo 的 stack frame 的 base |
0x2aaaab2aae08 |
0x4aa108 |
garbage(舊 stack 遺留的資料) |
0x2aaaab2aae00 |
0x49b220 |
garbage(舊 stack 遺留的資料) |
接下來輸入 ni
,讓他執行完 gets
,gets
就會將前面輸入的 123456789012
從 buf
的起始位址開始將這 12 個 character 和一個 null terminator 放到 stack 裡面(所以總共放了 13 個 byte)。
用跟前面一樣的指令觀察被放了什麼東西到 stack 裡面,可看到:
記憶體位址 | 執行 gets 之前的資料 |
執行 gets 後 |
|
---|---|---|---|
0x2aaaab2aae18 |
0x401943 |
0x401900 |
原本應該是 return 到 call_echo 要執行的下一個指令的位址,結果 43 被 null terminator 覆蓋了 |
0x2aaaab2aae10 |
0x2aaaab2aae20 |
0x3231303938373635 |
原本應該要是 call_echo 的 stack frame 的 base,結果被更改了 |
0x2aaaab2aae08 |
0x4aa108 |
0x34333231004aa108 |
從 buf 的起始位址開始放入輸入的 characters |
0x2aaaab2aae00 |
0x49b220 |
0x49b220 |
不受影響 |
從 buf
的起始位址 0x2aaaab2aae0c
查看:
x/c
:查看指定的記憶體位址存放的 character 是什麼,可看到是 '1'
x/x
:查看指定的記憶體位址一個 byte 的資料,並以 16 進位顯示,可看到是 0x31
,也就是 character '1'
的 ASCII 編號x/s
:從指定的記憶體位址查看 string,直到 null terminator 為止,可看到是 123456789012
這個例子總共輸入了 12 個 characters 加一個 null terminator,所以總共存了 13 個 characters,就從 buf
的起始位址查看連續 13 個 bytes 的資料,即可看到以上截圖的結果。
下一個要執行的指令只是在設定 puts
的參數,也就是將 buf
的起始位址存放到 rdi
,然後呼叫 puts
:
0x401922 <echo+29> lea rax,[rbp-0x4]
0x401926 <echo+33> mov rdi,rax
0x401929 <echo+36> call 0x404fb0 <puts>
這裡連續輸入 4 個 ni
,直到準備執行 0x40192f <echo+42> leave
。
先記錄一下此時的 rbp
、rsp
、和 rip
:
leave
指令做的事情相當於以下兩個指令在做的事,也就是所謂的 function epilogue:
rbp
目前的值是 0x2aaaab2aae10
,也就是當前 stack frame 的 base,這個位址存放的資料是上一個 stack frame 的 base 位址mov rsp,rbp
,就會讓 rsp
指向 0x2aaaab2aae10
pop rbp
會將 rsp
指向的記憶體位址所存放的 data 放到 rbp
,然後 rsp
+= 80x2aaaab2aae10
存放的 data 在剛剛已經被覆蓋成 0x3231303938373635
,所以執行完這個指令後 rbp
會變成 0x3231303938373635
,而 rsp
會變成 0x2aaaab2aae18
輸入 si
,執行 0x40192f <echo+42> leave
。
可看到 rbp
跟 rsp
變得跟預期一樣。
接下來要執行的是 ret
指令,ret
會做的事:
rsp
指向的位址取出 8 bytes 的資料,放入 rip
rsp
是 0x2aaaab2aae18
,而這位址原本存放的是 0x401943
,也就是 return 到 call_echo
後要執行的下一個指令的位址,但因為被輸入的 string 的 null terminator 覆蓋,而變成 0x401900
rsp
+= 8,所以會變成 0x2aaaab2aae20
輸入 si
執行 ret
後,查看 registers 的值:
可以看到:
rsp
如預期的變成 0x2aaaab2aae20
rip
也如預期的變成 0x401900
,所以等下就會去執行位在 0x401900
這個記憶體位址的指令(接下來就會開始跳到不該過去的地方,最終造成 Segmentation fault)位在 0x401900
記憶體位址的指令,他會 jmp
到記憶體位址 0x401850
跳到 0x401850
後,他會一路執行指令直到位在 0x401880
的 ret
指令
那 ret
做的事情是會將 rsp
指向的記憶體位址儲存的 data 放到 rip
,也就是下一個要執行的指令的記憶體位址。
但此時 rsp
是 0x2aaaab2aae20
,那裡面存的是 0x2aaaab2aae30
,所以接下來就會去執行位在 0x2aaaab2aae30
的指令。
從以上截圖可看到位在 0x2aaaab2aae30
的指令是 0x2aaaab2aae30 shr BYTE PTR [rsi+0x2aaaab2a],1
,一旦執行這個指令後就 Segmentation fault,如下截圖。
為什麼 shr BYTE PTR [rsi+0x2aaaab2a],1
在這裡會造成 Segmentation fault:
rsi
的值加上 0x2aaaab2a
,然後去存取這個記憶體位址所存放的資料,再做 right shift 的動作rsi
的值是 0x0
,所以加上 0x2aaaab2a
還是 0x2aaaab2a
,所以他實際是去存取 0x2aaaab2a
這個記憶體位址的資料0x2aaaab2a
這個記憶體位址,所以就造成 Segmentation fault!0x2aaaab2a
這個記憶體位址的資料,可以看到他說 Cannot access memory at address 0x2aaaab2a
0x2aaaab2aae30
這個記憶體位址存的資料其實也只是記憶體位址 0x2aaaab2aaed0
(由下截圖可知),但因為這樣亂跳的結果導致 CPU 把它當成組合語言指令 shr BYTE PTR [rsi+0x2aaaab2a],1
以下是前述流程的圖文說明版本~
接下來做個小測試,想透過 buffer overflow 漏洞來更改 return address。
當要從 echo
return 時,想要讓他回到 main
的 0x40194e
這個位址,所以要將 0x40194e
透過 buffer overflow 機制寫到存放 return address 的 0x2aaaab2aae18
這個記憶體位址。
那從前面的例子可以知道輸入 12 個 characters 後,是第 13 個 character,也就是 null terminator,會覆蓋到 return address。
所以要先輸入 12 個隨機的 characters(ex. AAAAAAAAAAAA
),再接著將 0x40194e
這個記憶體位址送進去,那由於是 Little Endian,所以就是輸入 \x4e\x19\x40\x00\x00\x00\x00\x00
。
所以將一開始的指令改成輸入以下指令:
當執行到以上截圖的步驟時,也就是已經將輸入的 data 都放到 buf
裡面了,這時來查看幾個記憶體位址裡面被放了什麼東西:
0x2aaaab2aae18
,可看到他變成存放 0x40194e
buf
的起始位址 0x2aaaab2aae0c
可看到存入了 12 個 0x41
,也就是 'A'
的 ASCII 編號info symbol $pc
可知道目前在 echo + 43
si
之後再用 info symbol $pc
,就變成是在 main + 8 in section .text
rip
變成 0x40194e
,也就是透過 buffer overflow 插入的 return addresscall_echo
,然後又進入 echo
裡面Stack canary(金絲雀)是一個用來偵測是否有 buffer overflow 事件的一種機制。
會在 buffer 的上方放一個值,也就是 canary。當把東西放到 buffer 後,會檢查 canary 的值有沒有被更改過。若有被更改,就代表有 buffer overflow 發生。
現在的 GNU 編譯器預設都會加入 stack canary 這個保護機制。若要關閉這個機制,則編譯的指令要加入 -fno-stack-protector
這個 option。
前面的例子在編譯時,就有關閉 stack canary 這個保護機制。接下來要編譯出有使用 stack canary 的可執行檔:
反組譯產生組合語言:
觀察兩種版本組合語言到差異:
echo
的組合語言程式碼echo
的組合語言程式碼,主要的差異用藍字標示起來0x40191a
這個指令,也就是將 canary 放到 rbp-0x8
的位址後,用以上指令查看,可以看到這次的 canary 的值是 0xfa2ca19b4a74ec00
x/gx $fs_base + 0x28
指令查看從系統中讀取到的 canary 值是多少~上圖是此例有/無使用 stack canary 的記憶體空間示意圖。
若偵測到 canary 有被修改,程式就會結束,並且印出 stack smashing detected
的訊息。
Why Stack CANARY?
為什麼要叫做 Stack Canary(金絲雀)呢 🤔?為什麼不能是 Wombat(袋熊)?Quokka(短尾矮袋鼠)?或其他動物呢?難道其他動物沒有比金絲雀更可愛更值得這個殊榮/頭銜嗎 😡?!
這個名稱其實是有由來、有典故的~!
因為在 19~20 世紀的煤礦坑裡,最可怕的意外之一就是一氧化碳中毒。一氧化碳無色無味,人類察覺不到。而金絲雀對這些有毒氣體極度敏感,他們會比人類更早出現症狀,所以當時的礦工就會帶金絲雀一起到礦坑裡面,當金絲雀出現異常反應,礦工就能知道有有毒氣體洩漏、而能及早撤離。
這種使用金絲雀做 sentinel animal(哨兵動物)的用法,之後也衍生出更多不同的制度。像是使用哨兵雞偵測是否有禽流感,或是在實驗動物飼養環境內使用哨兵鼠偵測環境中是否存在傳染性病原。
所以衍伸到 buffer overflow 這個例子,也是利用寫入的 canary 值來偵測是否有 buffer overflow 事件,所以就叫做 stack canary~