iT邦幫忙

2024 iThome 鐵人賽

DAY 5
0

在上一篇文章中,我們探討了 Stack 在函數呼叫與執行中的運作機制。
本篇將進一步說明 Stack 如何儲存區域變數,以及介紹其中一個常見的 Stack 相關漏洞。
文章架構如下:

  • 程式碼分析
  • 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;
}

在開始之前,請讀者使用以下命令編譯程式碼。
gcc -fno-stack-protector -no-pie -o bof bof.c

此編譯方式是為了將因應各種攻擊而衍伸的記憶體保護機制關閉,以便初學時容易理解漏洞利用方式,日後的文章會詳細介紹這些保護機制。

註:有編譯警告先不用理他,後續會做說明


程式碼分析

為了後續觀察,我們的程式有一個初始化的 int 變數,以及有初始化跟沒初始化的兩個字串。

接下來,我們同樣使用 Pwngdb 逐步執行並觀察,我們直接使用 b *main 斷點設在 main(),接著使用 r 開始運行。

會先看到最前面的三行 Function Prologue,以及當前的 Stack 狀態。
image
image

註:Stack 當中會有一些原本的存在記憶體中的殘值(Garbage Values),示意圖中將此值標為淺灰色。

補充:為了方便記憶體管理和提高效能,作業系統通常會將記憶體對齊,這使得許多架構下的 Stack Frame 通常以 16 Bytes 為單位進行增長。

接著我們繼續往下
image

  • mov dword ptr [rbp - 4], 5
    DWORD 代表 Double Word,一個 Word 是 2 Bytes,因此 DWORD 為 4 Bytes,因此這行指令的意思代表他把 5 這個值複製到從 rbp-4 這個位址往上加 4 Bytes 的空間中,這個位置是在 main() 的 Stack Frame 裡面,可以看到現在 Stack 變成這樣:
    image
    而這部分其實就是對應的程式碼中的 int a = 5;

如同大家所猜測的,接下來的下三行代表 char buf[10] = "abcdefghji";,我們一步一步看。
image

  • movabs rax, 0x6867666564636261
    代表著它把 0x6867666564636261 整段值給 RAX0x61 是 以 16 進位顯示 a 的 ASCII,以此類推 0x68h
  • mov qword ptr [rbp - 0xe], rax
    QWORD 代表 Quad Word 為 4 * 2 = 8 Bytes,這行指令代表把 RAX 的值複製到從 rbp-0xe 這個位址往上加 8 Bytes 的空間中,因此現在的 Stack 變成這樣:
    image
    為什麼我們明明是將 buf 設為 "abcdefghji",但現在卻只填到 "h" 呢?這是因為 RAX 暫存器最多只能處理 8 Bytes(4 Words),這張表說明了在 x86-64 架構底下不同的暫存器最多可以處理多少 Bytes(8 Bits)。

    補充:可以注意到 "abcdefgh" 的最低有效位元 "h" 填入記憶體中時是放在最高位元,如上圖所示。這種記憶體佈局方式稱為 Little-endian。

由於我們現在還缺 "ij",因此接下來就是把它補上,
image

  • mov word ptr [rbp - 6], 0x6a69
    這行指令代表把 0x6a69 ("ij")這個值複製到從 rbp-6 這個位址往上加 1 Word(2 Bytes)的空間中,現在 Stack 變成這樣:
    image

現在我們發現程式碼當中已初始化的區域變數,已經全部被填入值,並且儲存在 Stack 上了。

int a = 5;
char buf[10] = "abcdefghji";
char name[10];

我們注意到還有一個未初始化的 char name[10];,這個變數也有被分配空間,但因為沒有賦值給它,因此沒有上述的步驟,等到我們等下需要用到它的時候就能更直觀的觀察 Stack 的變化。

我們繼續往下執行:
image

  • 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 架構的呼叫慣例有一項為:函數的參數會依序存放在 rdirsirdxrcxr8r9 等暫存器中。因此上一行指令才會將需要輸出的第一個參數存入 RDI 這個暫存器。

接下來,我們繼續往下:
image
我們注意到它將 rbp-0x18 這個位址載入到 RAX,接著再把它複製給 RDI,並且作為 gets 這個外部函數的第一個參數傳入,即為我們程式碼片段中的

gets(name);

現在我們可以看到 Stack 中
image
粉紅色的這段是分配給 name 這個變數的 10 Bytes 空間,從 rbp - 0x180x7fffffffd7a8 開始。

補充:從 Pwngdb 的資訊可以看到 rbp - 0x18 存著 push rbp 這個指令的位址,這是記憶體殘值,不用理它。此外,這也是為什麼會說寫程式要保持初始化變數的好習慣,因為分配到的記憶體位址可能會有殘值,像現在這樣。

在這段組合語言中,可以注意到有一行指令為 mov eax, 0EAX 這個暫存器幾乎等價於 RAX,只不過 RAX 為 8 Bytes,而 EAX 則是 RAX 中低位元的 4 Bytes,如上面提過的那張表
依據上一篇提過的呼叫慣例,函數的回傳值會存入 RAX,由於 gets 的回傳值型態為 char,因此不需要用到整個 RAX 8 Bytes 那麼大的空間,因此清空 EAX,作為後續儲存回傳值的準備。

我們繼續往下執行,Pwngdb 會停下讓我們輸入,現在我們輸入 "yourname",此時的 Stack 變成這樣:
image
image
最後的 0x00 代表 '\0',代表這個字串的結束。

再繼續往下
image
我們發現它又呼叫一次 puts,並將 rbp - 0x18name)的位址作為參數讓 puts 輸出。即為程式中的:

printf("%s\n", name);

此時,我們寫的程式已經全部運行完,因此接著就是 Function Epilogue,將程式控制權交還給呼叫者(__libc_start_call_main())。


Stack Buffer Overflow

現在,我們再回到一次 gets 的部分。
image

我們看一下 getsDocument,可以看到這樣描述 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' 就會變這樣:
image
可以看到我的輸入超出了我配置的 name 的粉紅色區塊,導致我的 buf 被覆蓋並且改值,如果將他以 %s 輸出會看到他變成 A。這個漏洞稱為緩衝區溢出(Buffer Overflow,BOF),由於它現在在 Stack 上發生,因此稱為 Stack Buffer Overflow。

由於 gets 這個函數會造成 Buffer Overflow,是個危險函數,因此在編譯時,編譯器可能會發出告警,提醒你應該使用 fgets 等能限制長度的安全函數。
除了 gets 外,下面幾個函數也不會檢查長度,因此有機會觸發 Buffer Overflow:

  • scanf():使用 %s 時,遇到換行才停止
  • strcpy():複製字串直到換行才停止
  • sprintf():將格式化輸入的結果輸入進我們要存的位址,不會檢查大小。
  • memcpy():將指定長度的 Bytes 複製到某個位址,若此位址的大小不足以容納複製的值,就會造成 Buffer Overflow
  • strcat():將指定的字串附加到目標字串尾端,若目標的大小不足以容納附加的值,就會造成 Buffer Overflow

除此之外,像是格式轉換或是計算大小錯誤的人為疏失,也都有機會造成 Buffer Overflow。

我們回到我們有 Stack Buffer Overflow 的漏洞程式,前面我們看到若輸入超過 9 個就會造成前面的變數值被改變,那如果我們極端一點,輸入更長,像是下面這樣呢?
image
我們輸入了 39 個 A 接著往下執行,
image
此時, RBP 的值經過 leave 恢復成 Saved RBP,但此時 Saved RBP 被我們覆蓋成了 'AAAAAAAA',因此上方可以看的 RBP 現在的內容為 8 個 'A'(0x4141414141414141),接著 Stack 頂端現在應該是 Return Address,也從原本的 __libc_start_call_main() 位址變成我們覆蓋掉的 0x41414141414141。所以接下來,CPU 會去執行 0x41414141414141 這個位址,然而由於我們沒有這個位址的存取權限,因此會觸發記憶體區段錯誤(Segmentation Fault,Segfault)
image

透過這個例子能了解到,我們能藉由 Stack Buffer Overflow 覆蓋 Return Address,進而控制程式的執行流程。

下一篇文章將會說明如何利用精心調配的 Payload,藉由 Stack Buffer Overflow 精準的控制程式執行我們要它執行的內容。


上一篇
[Day4] Stack 介紹
下一篇
[Day6] Stack 攻擊手法 - ret2text & 保護機制 - Canary/PIE
系列文
Pwn2Noooo! 執行即 Crash 的 PWNer 養成遊戲30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言