在上一篇文章中,我們探討了 Stack 在函數呼叫與執行中的運作機制。
本篇將進一步說明 Stack 如何儲存區域變數,以及介紹其中一個常見的 Stack 相關漏洞。
文章架構如下:
本篇的範例程式碼如下:
#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;
}
在開始之前,請讀者使用以下命令編譯程式碼。gcc -fno-stack-protector -no-pie -o bof bof.c
此編譯方式是為了將因應各種攻擊而衍伸的記憶體保護機制關閉,以便初學時容易理解漏洞利用方式,日後的文章會詳細介紹這些保護機制。
註:有編譯警告先不用理他,後續會做說明
為了後續觀察,我們的程式有一個初始化的 int
變數,以及有初始化跟沒初始化的兩個字串。
接下來,我們同樣使用 Pwngdb 逐步執行並觀察,我們直接使用 b *main
斷點設在 main()
,接著使用 r
開始運行。
會先看到最前面的三行 Function Prologue,以及當前的 Stack 狀態。
註:Stack 當中會有一些原本的存在記憶體中的殘值(Garbage Values),示意圖中將此值標為淺灰色。
補充:為了方便記憶體管理和提高效能,作業系統通常會將記憶體對齊,這使得許多架構下的 Stack Frame 通常以 16 Bytes 為單位進行增長。
接著我們繼續往下
mov dword ptr [rbp - 4], 5
DWORD
代表 Double Word,一個 Word 是 2 Bytes,因此 DWORD
為 4 Bytes,因此這行指令的意思代表他把 5 這個值複製到從 rbp-4
這個位址往上加 4 Bytes 的空間中,這個位置是在 main()
的 Stack Frame 裡面,可以看到現在 Stack 變成這樣:int a = 5;
如同大家所猜測的,接下來的下三行代表 char buf[10] = "abcdefghji";
,我們一步一步看。
movabs rax, 0x6867666564636261
0x6867666564636261
整段值給 RAX
,0x61
是 以 16 進位顯示 a
的 ASCII,以此類推 0x68
為 h
。mov qword ptr [rbp - 0xe], rax
QWORD
代表 Quad Word 為 4 * 2 = 8 Bytes,這行指令代表把 RAX
的值複製到從 rbp-0xe
這個位址往上加 8 Bytes 的空間中,因此現在的 Stack 變成這樣:RAX
暫存器最多只能處理 8 Bytes(4 Words),這張表說明了在 x86-64 架構底下不同的暫存器最多可以處理多少 Bytes(8 Bits)。
補充:可以注意到 "abcdefgh" 的最低有效位元 "h" 填入記憶體中時是放在最高位元,如上圖所示。這種記憶體佈局方式稱為 Little-endian。
由於我們現在還缺 "ij",因此接下來就是把它補上,
mov word ptr [rbp - 6], 0x6a69
0x6a69
("ij")這個值複製到從 rbp-6
這個位址往上加 1 Word(2 Bytes)的空間中,現在 Stack 變成這樣:現在我們發現程式碼當中已初始化的區域變數,已經全部被填入值,並且儲存在 Stack 上了。
int a = 5;
char buf[10] = "abcdefghji";
char name[10];
我們注意到還有一個未初始化的 char name[10];
,這個變數也有被分配空間,但因為沒有賦值給它,因此沒有上述的步驟,等到我們等下需要用到它的時候就能更直觀的觀察 Stack 的變化。
我們繼續往下執行:
lea rax, [rip + 0xe86]
lea
為 Load Effective Address 的縮寫,第一行 lea rax, [rip + 0xe86]
的意思為將 rip + 0xe86
的位址載入到 RAX
裡,此時 RAX
的值為一個位址。從後面的資訊可以看出 rip + 0xe86
這個位址是 "Enter your name:" 這個字串的位址。mov rdi, rax
RAX
的值,也就是儲存 "Enter your name:" 的位址被複製給了 RDI
,為什麼要給 RDI
呢?這是為了下一行指令。call puts@plt
puts
這個外部函數,其實上面這三行是對應到我們程式中的這部分:
printf("Enter your name: \n");
由於編譯器發現我們只是簡單的輸出一串字串,不涉及任何格式化處理,因此編譯器幫我們優化程式,改為更高效的 puts
函數。而我們在上一篇有提到 x86-64 架構的呼叫慣例有一項為:函數的參數會依序存放在 rdi
、rsi
、rdx
、rcx
、r8
、r9
等暫存器中。因此上一行指令才會將需要輸出的第一個參數存入 RDI
這個暫存器。接下來,我們繼續往下:
我們注意到它將 rbp-0x18
這個位址載入到 RAX
,接著再把它複製給 RDI
,並且作為 gets
這個外部函數的第一個參數傳入,即為我們程式碼片段中的
gets(name);
現在我們可以看到 Stack 中
粉紅色的這段是分配給 name
這個變數的 10 Bytes 空間,從 rbp - 0x18
即 0x7fffffffd7a8
開始。
補充:從 Pwngdb 的資訊可以看到
rbp - 0x18
存著push rbp
這個指令的位址,這是記憶體殘值,不用理它。此外,這也是為什麼會說寫程式要保持初始化變數的好習慣,因為分配到的記憶體位址可能會有殘值,像現在這樣。
在這段組合語言中,可以注意到有一行指令為 mov eax, 0
,EAX
這個暫存器幾乎等價於 RAX
,只不過 RAX
為 8 Bytes,而 EAX
則是 RAX
中低位元的 4 Bytes,如上面提過的那張表。
依據上一篇提過的呼叫慣例,函數的回傳值會存入 RAX
,由於 gets
的回傳值型態為 char
,因此不需要用到整個 RAX
8 Bytes 那麼大的空間,因此清空 EAX
,作為後續儲存回傳值的準備。
我們繼續往下執行,Pwngdb 會停下讓我們輸入,現在我們輸入 "yourname",此時的 Stack 變成這樣:
最後的 0x00
代表 '\0',代表這個字串的結束。
再繼續往下
我們發現它又呼叫一次 puts
,並將 rbp - 0x18
(name
)的位址作為參數讓 puts
輸出。即為程式中的:
printf("%s\n", name);
此時,我們寫的程式已經全部運行完,因此接著就是 Function Epilogue,將程式控制權交還給呼叫者(__libc_start_call_main()
)。
現在,我們再回到一次 gets
的部分。
我們看一下 gets
的 Document,可以看到這樣描述 gets
:
The gets() function shall read bytes from the standard input stream, stdin, into the array pointed to by s, until a <newline> is read or an end-of-file condition is encountered. Any <newline> shall be discarded and a null byte shall be placed immediately after the last byte read into the array.
可以看到它說,他一直讀入我們輸入的字,直到遇到換行(也就是我們按下 Enter)或是讀到 EOF(End-of-file),並且把換行('\n')轉成 '\0' 後存到第一個參數的位址。
也就是說,無論我給 gets
的參數配置多大,gets
都不在意,它只在意我輸入時什麼時候按下 Enter。因此如果我現在輸入超過 9 個字(要預留 '\0'),那我輸入的東西就會覆蓋到其他位址,像是我輸入 11 個 'A' 就會變這樣:
可以看到我的輸入超出了我配置的 name
的粉紅色區塊,導致我的 buf 被覆蓋並且改值,如果將他以 %s 輸出會看到他變成 A。這個漏洞稱為緩衝區溢出(Buffer Overflow,BOF),由於它現在在 Stack 上發生,因此稱為 Stack Buffer Overflow。
由於 gets
這個函數會造成 Buffer Overflow,是個危險函數,因此在編譯時,編譯器可能會發出告警,提醒你應該使用 fgets
等能限制長度的安全函數。
除了 gets
外,下面幾個函數也不會檢查長度,因此有機會觸發 Buffer Overflow:
除此之外,像是格式轉換或是計算大小錯誤的人為疏失,也都有機會造成 Buffer Overflow。
我們回到我們有 Stack Buffer Overflow 的漏洞程式,前面我們看到若輸入超過 9 個就會造成前面的變數值被改變,那如果我們極端一點,輸入更長,像是下面這樣呢?
我們輸入了 39 個 A 接著往下執行,
此時, RBP
的值經過 leave
恢復成 Saved RBP,但此時 Saved RBP 被我們覆蓋成了 'AAAAAAAA',因此上方可以看的 RBP
現在的內容為 8 個 'A'(0x4141414141414141
),接著 Stack 頂端現在應該是 Return Address,也從原本的 __libc_start_call_main()
位址變成我們覆蓋掉的 0x41414141414141
。所以接下來,CPU 會去執行 0x41414141414141
這個位址,然而由於我們沒有這個位址的存取權限,因此會觸發記憶體區段錯誤(Segmentation Fault,Segfault)
透過這個例子能了解到,我們能藉由 Stack Buffer Overflow 覆蓋 Return Address,進而控制程式的執行流程。
下一篇文章將會說明如何利用精心調配的 Payload,藉由 Stack Buffer Overflow 精準的控制程式執行我們要它執行的內容。