Try   HackMD

用 GDB 從組合語言角度觀察 Buffer Overflow

  • 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

$ sudo apt -y install gcc-x86-64-linux-gnu qemu qemu-user gdb gdb-multiarch

課程提供的程式碼

以下是 CMU 15-213 課程提供觀察 buffer overflow 的程式碼:

/* bufdemo-nsp.c */
#include <stdio.h>

void echo() {
    char buf[4];
    gets(buf);
    puts(buf);
}

void call_echo() {
    echo();
}

int main() {

    call_echo();

    return 0;
}

以上程式碼,會要求使用者輸入 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 包括 strcpystrcat 等等,因為他們沒有做 bound checking。
為了安全起見,可以用比較安全的版本,像是 fgetsstrncpystrncat 等等。

編譯成 x86-64 執行檔

$ x86_64-linux-gnu-gcc -m64 -g -fno-stack-protector -z execstack -no-pie -static bufdemo-nsp.c -o bufdemo-x86
  • -m64:指定產生 64-bit x86 code
  • -g:可以用 gdb debug
  • -fno-stack-protector:關掉 stack canary 保護機制
  • -z execstack:允許執行位在 stack 的程式碼
  • -no-pie:不要使用 position-independent executable,這樣才能讓記憶體位址固定,方便分析

將前述幾個保護措施關掉,才可以好好觀察 buffer overflow

用 QEMU 在 ARM64 架構的 Ubuntu 虛擬機執行 x86-64 執行檔

$ qemu-x86_64 ./bufdemo-x86

執行結果

我在我的虛擬機執行的結果,輸入 11 個 characters 沒事,但輸入 12 個 characters 就 segmentation fault,如下截圖:

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

反組譯後的 x86-64 組合語言

$ x86_64-linux-gnu-objdump -d bufdemo-x86 -M intel > disasm_x86-64_NASM.txt

使用 objdump 反組譯產生 x86-64 NASM 組合語言程式碼

  • -d:disassemble 反組譯
  • -M intel:使用 Intel/NASM 語法(預設是用 AT&T 語法,但因為個人比較習慣 Intel 語法,所以改成 Intel 語法)

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

以下是反組譯後得到的組合語言程式碼,後面會再做詳細的說明~

0000000000401905 <echo>:
  401905:   f3 0f 1e fa             endbr64
  401909:   55                      push   rbp              ; 將 rsp - 8,再將前一個 stack frame 的 base 的記憶體位址放進去這個位址
  40190a:   48 89 e5                mov    rbp,rsp          ; 將目前 rsp 的值傳給 rbp
  40190d:   48 83 ec 10             sub    rsp,0x10         ; 分配 16 bytes 給 local variable
  401911:   48 8d 45 fc             lea    rax,[rbp-0x4]    ; 計算 rbp-0x4,也就是 buf 的起始位址,並將這個得到的記憶體位址存到 rax
  401915:   48 89 c7                mov    rdi,rax          ; 將 rax 的值傳到 rdi,依據 calling convention,rdi 存放 function 的第一個參數,所以等下會被當作 gets 的參數使用
  401918:   b8 00 00 00 00          mov    eax,0x0
  40191d:   e8 8e 34 00 00          call   404db0 <_IO_gets>
  401922:   48 8d 45 fc             lea    rax,[rbp-0x4]
  401926:   48 89 c7                mov    rdi,rax
  401929:   e8 82 36 00 00          call   404fb0 <_IO_puts>
  40192e:   90                      nop
  40192f:   c9                      leave
  401930:   c3                      ret

0000000000401931 <call_echo>: 
  401931:   f3 0f 1e fa             endbr64 
  401935:   55                      push   rbp 
  401936:   48 89 e5                mov    rbp,rsp 
  401939:   b8 00 00 00 00          mov    eax,0x0 
  40193e:   e8 c2 ff ff ff          call   401905 <echo> 
  401943:   90                      nop 
  401944:   5d                      pop    rbp 
  401945:   c3                      ret 

0000000000401946 <main>:
  401946:   f3 0f 1e fa             endbr64
  40194a:   55                      push   rbp
  40194b:   48 89 e5                mov    rbp,rsp
  40194e:   b8 00 00 00 00          mov    eax,0x0
  401953:   e8 d9 ff ff ff          call   401931 <call_echo>
  401958:   b8 00 00 00 00          mov    eax,0x0
  40195d:   5d                      pop    rbp
  40195e:   c3                      ret
  40195f:   90                      nop

用 GDB 觀察 Buffer Overflow

前置作業

qemu-x86_64 模式開啟 GDB 伺服器:

$ (echo "123456789012"; sleep 1) | qemu-x86_64 -g 1234 ./bufdemo-x86

開啟另一個 terminal,執行以下指令:

$ gdb-multiarch ./bufdemo-x86
(gdb) target remote localhost:1234

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

設置自訂的 gdb tui layout,可同時顯示 source code、組合語言、以及 registers 的值:

(gdb) tui new-layout mylayout {-horizontal src 2 asm 3 regs 3} 3 status 1 cmd 1

將 gdb 的組合語言語法設置為 Intel 語法(預設會顯示成 AT&T 語法):

(gdb) set disassembly-flavor intel

開啟前述自定義的 gdb tui layout:

(gdb) layout mylayout

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

  • 下半部是輸入 gdb command 的地方
  • 上半部左側到時候會顯示目前在 source code 的位址、中間則會顯示組合語言、右側則是顯示當前 registers 的值

開始用 gdb 觀察

執行以下指令設置 break point:

(gdb) break main    /// 在 main 設置 break point
(gdb) break echo    /// 在 echo 設置 break point
(gdb) continue      /// 若是在原生 x86-64 環境,用 run 指令即可,但由於我的情況是跑在 ARM64 虛擬機,所以用 continue 指令

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

  • 輸入 focus srcfocus asmfocus regs 就會分別跳到程式碼、組合語言、registers 的畫面,這時可用上跟下來移動要看到的內容。
  • 輸入 focus cmd 則會回到下半部輸入 command 的部分。

查看 rbprsp registers 的值,以得知目前 main 的 stack frame:

(gdb) info registers rbp rsp rip
rbp            0x2aaaab2aae30      0x2aaaab2aae30
rsp            0x2aaaab2aae30      0x2aaaab2aae30
rip            0x40194e            0x40194e <main+8>
  • rbp:指向目前 stack frame 的 base
  • rsp:指向目前 stack frame 的 top
  • rip:目前 CPU 準備執行的指令的位址

可以看到目前 main 的 stack frame 就只有 0x2aaaab2aae30,因為沒有 local variable,所以就沒有分配額外的 stack 空間給 main


接下來輸入 s 就會進入 call_echo()

(gdb) s

Screenshot 2025-07-13 at 1.13.46 PM (1)

此時的 rbprsp 都是 0x2aaaab2aae20,也就是 call_echo 的 stack frame 是 0x2aaaab2aae20


接下來由於會涉及到組合語言層面的東西,所以用 si 指令,si 可以讓我們一個一個執行組合語言的指令。

輸入 si 之後,可以看到接下來即將要執行的指令是位在記憶體位址 0x40193ecall 0x401905 <echo> 指令。

image

x86-64 的 call 指令會做以下的事情:

  1. rsp -= 8
  2. call 指令的下一個指令的記憶體位址(依此例就是 0x401943,也就是 return address)存放到前述 rsp - 8 的記憶體位址內
  3. call 要跳過去的記憶體位址放到 rip,依此例即 0x401905

輸入 si,就會執行 0x40193e <call_echo+13> call 0x401905 <echo> 這一個指令。

(gdb) info registers rbp rsp rip
rbp            0x2aaaab2aae20      0x2aaaab2aae20
rsp            0x2aaaab2aae18      0x2aaaab2aae18
rip            0x401905            0x401905 <echo>
  • 可看到這時 rsp 的值從原本的 0x2aaaab2aae20 變成了 0x2aaaab2aae18,被扣掉 8 了。

用以下指令查看記憶體位址 0x2aaaab2aae18 存了什麼 data:

(gdb) x/1xg 0x2aaaab2aae18
0x2aaaab2aae18: 0x0000000000401943
  • 可以看到存的是 0x0000000000401943,也就是 call_echocall 指令的下一個指令的記憶體位址
  • 也就是當要從 echo return 到 call_echo 時要執行的指令,也就是 return address
  • 這時 call_echo 的 stack frame 範圍就變成 0x2aaaab2aae20(stack base)~ 0x2aaaab2aae18(stack top)

image

接下來即將被執行的組合語言指令是 0x401905 <echo> endbr64,輸入 si 到下一個指令。


image

接下來要被執行的指令是 push rbp,而 push rbp 和他的下一條指令 mov rbp,rsp 這兩條指令是所謂的 function prologue。

當進入一個新的 function,一定要執行 function prologue,也就是以下兩個指令:

push    rbp
mov     rbp,rsp
  • push rbp
    1. 先將 rsp -= 8
    2. 再將 rbp 目前的值存放到前述 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

image

  • 此時使用 info registers rbp rsp 查看 rsp 的值,可看到 rsp 的值從 0x2aaaab2aae18 變成 0x2aaaab2aae10
  • 使用 x/1xg 0x2aaaab2aae10 查看 0x2aaaab2aae10 這個記憶體位址裡面存了什麼東西,可以看到存的是 0x2aaaab2aae20,也就是前一個 stack frame 的 base。

image

接下來即將被執行的指令是 0x40190a <echo+5> mov rbp,rsp。輸入 si 執行這個指令。

image

可以看到 rbp 的值變成 0x2aaaab2aae10,也就是目前 echo 這個 function 的 stack frame 的 base。


image

下一個要被執行的指令是 0x40190d <echo+8> sub rsp,0x10,也就是將 rsp - 16,也就是分配給這個 function 的 stack 的空間。輸入 si 執行此指令。

image

可看到 rsp0x2aaaab2aae10 變成 0x2aaaab2aae00


image

接下來要執行的幾個指令是:

  1. 0x401911 <echo+12> lea rax,[rbp-0x4]
    • rbp - 4 後的值存放到 rax 裡面,而這個 rbp - 4 就是 char buf[4] 的起始位址
    • print &buf 指令可看到 buf 的起始位址是 0x2aaaab2aae0c
      image
    • PS. lea 只會計算記憶體位址,不會去存取那個記憶體位址裡面存放的值
  2. 0x401915 <echo+16> mov rdi,rax
  3. 0x401918 <echo+19> mov eax,0x0
    • rax register 清空成 0
  4. 0x40191d <echo+24> call 0x404db0 <gets>
    • 呼叫 gets

image

當正準備執行 0x40191d <echo+24> call 0x404db0 <gets> 時,也就是在上述截圖畫面時,先使用以下指令查看幾個記憶體位址內存的值:

image

記憶體位址 存放的資料
0x2aaaab2aae18 0x401943 return 到 call_echo 要執行的下一個指令的位址
0x2aaaab2aae10 0x2aaaab2aae20 call_echo 的 stack frame 的 base
0x2aaaab2aae08 0x4aa108 garbage(舊 stack 遺留的資料)
0x2aaaab2aae00 0x49b220 garbage(舊 stack 遺留的資料)

接下來輸入 ni,讓他執行完 getsgets 就會將前面輸入的 123456789012buf 的起始位址開始將這 12 個 character 和一個 null terminator 放到 stack 裡面(所以總共放了 13 個 byte)。

image

用跟前面一樣的指令觀察被放了什麼東西到 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 不受影響

image

buf 的起始位址 0x2aaaab2aae0c 查看:

  • x/c:查看指定的記憶體位址存放的 character 是什麼,可看到是 '1'
  • x/x:查看指定的記憶體位址一個 byte 的資料,並以 16 進位顯示,可看到是 0x31,也就是 character '1' 的 ASCII 編號
  • x/s:從指定的記憶體位址查看 string,直到 null terminator 為止,可看到是 123456789012

image

這個例子總共輸入了 12 個 characters 加一個 null terminator,所以總共存了 13 個 characters,就從 buf 的起始位址查看連續 13 個 bytes 的資料,即可看到以上截圖的結果。


image

下一個要執行的指令只是在設定 puts 的參數,也就是將 buf 的起始位址存放到 rdi,然後呼叫 puts

  1. 0x401922 <echo+29> lea rax,[rbp-0x4]
  2. 0x401926 <echo+33> mov rdi,rax
  3. 0x401929 <echo+36> call 0x404fb0 <puts>

這裡連續輸入 4 個 ni,直到準備執行 0x40192f <echo+42> leave


image

先記錄一下此時的 rbprsp、和 rip

image

leave 指令做的事情相當於以下兩個指令在做的事,也就是所謂的 function epilogue:

mov rsp,rbp ; 將 rbp 的值複製給 rsp pop rbp ; 從 rsp 指向的位置取 8 bytes 放到 base pointer,然後 rsp += 8
  1. rbp 目前的值是 0x2aaaab2aae10,也就是當前 stack frame 的 base,這個位址存放的資料是上一個 stack frame 的 base 位址
    所以當執行了 mov rsp,rbp,就會讓 rsp 指向 0x2aaaab2aae10
  2. 執行完 pop rbp 會將 rsp 指向的記憶體位址所存放的 data 放到 rbp,然後 rsp += 8
    由於記憶體位址 0x2aaaab2aae10 存放的 data 在剛剛已經被覆蓋成 0x3231303938373635,所以執行完這個指令後 rbp 會變成 0x3231303938373635,而 rsp 會變成 0x2aaaab2aae18

輸入 si,執行 0x40192f <echo+42> leave

image

可看到 rbprsp 變得跟預期一樣。


image

接下來要執行的是 ret 指令,ret 會做的事:

  1. rsp 指向的位址取出 8 bytes 的資料,放入 rip
    此時 rsp0x2aaaab2aae18,而這位址原本存放的是 0x401943,也就是 return 到 call_echo 後要執行的下一個指令的位址,但因為被輸入的 string 的 null terminator 覆蓋,而變成 0x401900
  2. rsp += 8,所以會變成 0x2aaaab2aae20

輸入 si 執行 ret 後,查看 registers 的值:

image

可以看到:

  • rsp 如預期的變成 0x2aaaab2aae20
  • rip 也如預期的變成 0x401900,所以等下就會去執行位在 0x401900 這個記憶體位址的指令(接下來就會開始跳到不該過去的地方,最終造成 Segmentation fault)

image

位在 0x401900 記憶體位址的指令,他會 jmp 到記憶體位址 0x401850

image

跳到 0x401850 後,他會一路執行指令直到位在 0x401880ret 指令

image

ret 做的事情是會將 rsp 指向的記憶體位址儲存的 data 放到 rip,也就是下一個要執行的指令的記憶體位址。

image

但此時 rsp0x2aaaab2aae20,那裡面存的是 0x2aaaab2aae30,所以接下來就會去執行位在 0x2aaaab2aae30 的指令。

image

從以上截圖可看到位在 0x2aaaab2aae30 的指令是 0x2aaaab2aae30 shr BYTE PTR [rsi+0x2aaaab2a],1,一旦執行這個指令後就 Segmentation fault,如下截圖。

image

為什麼 shr BYTE PTR [rsi+0x2aaaab2a],1 在這裡會造成 Segmentation fault:

  • 這是 right shift 指令
  • 他會將 rsi 的值加上 0x2aaaab2a,然後去存取這個記憶體位址所存放的資料,再做 right shift 的動作
  • 但此時 rsi 的值是 0x0,所以加上 0x2aaaab2a 還是 0x2aaaab2a,所以他實際是去存取 0x2aaaab2a 這個記憶體位址的資料
  • 但我們無法存取 0x2aaaab2a 這個記憶體位址,所以就造成 Segmentation fault!
  • 以下截圖就是用 gdb 檢視 0x2aaaab2a 這個記憶體位址的資料,可以看到他說 Cannot access memory at address 0x2aaaab2a
    image
  • 此外,0x2aaaab2aae30 這個記憶體位址存的資料其實也只是記憶體位址 0x2aaaab2aaed0(由下截圖可知),但因為這樣亂跳的結果導致 CPU 把它當成組合語言指令 shr BYTE PTR [rsi+0x2aaaab2a],1
    image

圖文版本說明

以下是前述流程的圖文說明版本~

001

002

003

004

005

006

007

008

009

010

011

012

013-1

014

015

透過 Buffer Overflow 更改 Return Address

接下來做個小測試,想透過 buffer overflow 漏洞來更改 return address。

image

當要從 echo return 時,想要讓他回到 main0x40194e 這個位址,所以要將 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

所以將一開始的指令改成輸入以下指令:

$ ( printf 'AAAAAAAAAAAA\x4e\x19\x40\x00\x00\x00\x00\x00'; sleep 1 ) | qemu-x86_64 -g 1234 ./bufdemo-x86

image

當執行到以上截圖的步驟時,也就是已經將輸入的 data 都放到 buf 裡面了,這時來查看幾個記憶體位址裡面被放了什麼東西:

image

  • 原本存放 return address 的位址 0x2aaaab2aae18,可看到他變成存放 0x40194e
  • buf 的起始位址 0x2aaaab2aae0c 可看到存入了 12 個 0x41,也就是 'A' 的 ASCII 編號
  • 下圖就是目前 stack 內的狀況:

return_addr

image

  • 這時用 info symbol $pc 可知道目前在 echo + 43
  • 輸入 si 之後再用 info symbol $pc,就變成是在 main + 8 in section .text
  • image

    也可看到 rip 變成 0x40194e,也就是透過 buffer overflow 插入的 return address
  • 繼續執行下去的話,他又會再進入 call_echo,然後又進入 echo 裡面

從組合語言角度觀察有/無使用 Stack Canary 的差異

Stack Canary

Stack canary(金絲雀)是一個用來偵測是否有 buffer overflow 事件的一種機制。

會在 buffer 的上方放一個值,也就是 canary。當把東西放到 buffer 後,會檢查 canary 的值有沒有被更改過。若有被更改,就代表有 buffer overflow 發生。

編譯出有 Stack Canary 保護機制的執行檔

現在的 GNU 編譯器預設都會加入 stack canary 這個保護機制。若要關閉這個機制,則編譯的指令要加入 -fno-stack-protector 這個 option。

前面的例子在編譯時,就有關閉 stack canary 這個保護機制。接下來要編譯出有使用 stack canary 的可執行檔:

$ 86_64-linux-gnu-gcc -m64 -g -z execstack -no-pie -static bufdemo-nsp.c -o bufdemo-x86_w_stack_canary

反組譯產生組合語言:

$ x86_64-linux-gnu-objdump -d bufdemo-x86_w_stack_canary -M intel > disasm_x86-64_NASM_w_stack_canary.txt

有/無 Stack Canary 的組合語言差異

觀察兩種版本組合語言到差異:

asm_stack_canary

  • 上圖左側是沒有使用 stack canary 機制的 echo 的組合語言程式碼
  • 右側則是有使用 stack canary 的 echo 的組合語言程式碼,主要的差異用藍字標示起來

canary_asm

image

  • 當執行完了 0x40191a 這個指令,也就是將 canary 放到 rbp-0x8 的位址後,用以上指令查看,可以看到這次的 canary 的值是 0xfa2ca19b4a74ec00
  • 也可以用 x/gx $fs_base + 0x28 指令查看從系統中讀取到的 canary 值是多少~
  • PS. 每次執行程式的 canary 值都是不一樣的喔!

canary_mem

上圖是此例有/無使用 stack canary 的記憶體空間示意圖。

image

若偵測到 canary 有被修改,程式就會結束,並且印出 stack smashing detected 的訊息。

Why Stack CANARY?

為什麼要叫做 Stack Canary(金絲雀)呢 🤔?為什麼不能是 Wombat(袋熊)?Quokka(短尾矮袋鼠)?或其他動物呢?難道其他動物沒有比金絲雀更可愛更值得這個殊榮/頭銜嗎 😡?!

這個名稱其實是有由來、有典故的~!

因為在 19~20 世紀的煤礦坑裡,最可怕的意外之一就是一氧化碳中毒。一氧化碳無色無味,人類察覺不到。而金絲雀對這些有毒氣體極度敏感,他們會比人類更早出現症狀,所以當時的礦工就會帶金絲雀一起到礦坑裡面,當金絲雀出現異常反應,礦工就能知道有有毒氣體洩漏、而能及早撤離。

這種使用金絲雀做 sentinel animal(哨兵動物)的用法,之後也衍生出更多不同的制度。像是使用哨兵雞偵測是否有禽流感,或是在實驗動物飼養環境內使用哨兵鼠偵測環境中是否存在傳染性病原。

所以衍伸到 buffer overflow 這個例子,也是利用寫入的 canary 值來偵測是否有 buffer overflow 事件,所以就叫做 stack canary~

https://en.wikipedia.org/wiki/Sentinel_species