函式呼叫和計算機結構的高度關聯
Copyright (慣C) 2015-2017, 2022 宅色夫
在 C 語言中,“function” 其實是特化的形式,並非數學意義上的函數,而隱含一個狀態到另一個狀態的關聯,因此,我們將一般的 C function 翻譯為「函式」,以區別數學意義上的函數 (如 abs, cos, exp)。貌似直觀的函式呼叫背後隱含著各式深奧的議題,諸如 calling convention, application binary interface (ABI), stack 和 heap 等等。倘若你對資訊安全有一定的認識,會知道 stack(-based) buffer overflow,但真正讓攻擊者得逞的機制尚有前述的函式呼叫,至於 Return-oriented programming (ROP) 型態的攻擊則修改函式的回傳地址,這也是 calling convention 的範疇。
本講座將帶著學員重新探索函式呼叫背後的原理,從程式語言和計算機結構的發展簡史談起,讓學員自電腦軟硬體演化過程去掌握 calling convention 的考量,伴隨著 stack 和 heap 的操作,再探討 C 程式如何處理函式呼叫、跨越函式間的跳躍 (如 setjmp 和 longjmp),再來思索資訊安全和執行效率的議題。
其實由 Dennis M. Ritchie (以下簡稱 dmr) 開發的早期 C 語言編譯器並未明確要求 function prototype 的順序。dmr 在 1972 年發展的早期 C 編譯器,原始程式碼後來被整理在名為 "last1120c" 磁帶中,若我們仔細看 c00.c 這檔案,可發現位於第 269 行的 mapch© 函式定義,在沒有 forward declaration 的狀況下,就分別於第 246 行和第 261 行呼叫,奇怪吧?
而且只要再瀏覽 last1120c 裡頭其他 C 語言程式後,就會發現根本沒有 #include
或 #define
這一類 C preprocessor 所支援的語法,那到底怎麼編譯呢?在回答這問題前,摘錄 Wikipedia 頁面的訊息:
As the C preprocessor can be invoked separately from the compiler with which it is supplied, it can be used separately, on different languages.
Notable examples include its use in the now-deprecated imake system and for preprocessing Fortran.
原來 C preprocessor 以獨立程式的形式存在,所以當我們用 gcc 或 cl (Microsoft 開發工具裡頭的 C 編譯器) 編譯給定的 C 程式時,會呼叫 cpp (伴隨在 gcc 專案的 C preprocessor) 一類的程式,先行展開巨集 (macro) 或施加條件編譯等操作,再來才會出動真正的 C 語言編譯器 (在 gcc 中叫做 cc1
)。值得注意的是,1972-1973 年間被稱為 "Very early C compilers" 的實作中,不存在 C preprocessor (!),當時 dmr 等人簡稱 C compiler 為 cc
,此慣例被沿用至今,而無論原始程式碼有幾個檔案,在編譯前,先用 cat
(該程式的作用是 "concatenate and print file") 一類的工具,將檔案串接為單一檔案,再來執行 "cc" 以便輸出對應的組合語言,之後就可透過 assembler (組譯器,在 UNIX 稱為 as
) 轉換為目標碼,搭配 linker (在 UNIX 稱為 ld
) 則輸出執行擋。
因此,在早期的 C 語言編譯器,強制規範 function prototype 及函式宣告的順序是完全沒有必要的,要到 1974 年 C preprocessor 才正式出現在世人面前,儘管當時的實作仍相當陽春,可參見 dmr 撰寫的〈The Development of the C Language〉,C 語言的標準化則是另一段漫長的旅程,來自 Bell Labs 的火種,透過 UNIX 來到研究機構和公司行號,持續影響著你我所處的資訊社會。
在早期的 C 語言中,若一個函式之前沒有聲明 (declare),一旦函式名稱出現在表達式中,後面跟著 (
左括號,那它會被解讀為回傳型態為 int
的函式,並且對它的參數沒有任何假設。但這樣行為可能會導致問題,考慮以下程式碼:
factorial
函式在被呼叫時,會從堆疊 (stack) 或暫存器中取出整數型態的參數,若忽略第 3 行 function prototype,編譯器將無從正確判斷,在第 6 行傳遞給 factorial
函式的參數是否符合預期的型態和數量。過往不用在 C 程式特別做 function prototype 的特性已自 C99 標準中刪除,因此省略 function prototype 將導致編譯錯誤。
GCC 14 對 function prototype 貫徹 C 語言規範,過去版本的 GCC 對於程式碼較為寬容,但 GCC 14 會將不符合語言規格的行為視作錯誤:
The initial ISO C standard and its 1999 revision removed support for many C language features that were widely known as sources of application bugs due to accidental misuse. For backwards compatibility, GCC 13 and earlier diagnosed use of these features as warnings only. Although these warnings have been enabled by default for many releases, experience shows that these warnings are easily ignored, resulting in difficult to diagnose bugs. In GCC 14, these issues are now reported as errors, and no output file is created, providing clearer feedback to programmers that something is wrong.
你或許會好奇,function prototype 的規範還有什麼好處呢?這就要從《Rationale for International Standard – Programming Languages – C》(以下簡稱 C9X RATIONALE) 閱讀起,依據第 70 頁 (PDF 檔案對應於第 78 頁),提到以下的解說範例:
編譯器裡頭的最佳化階段 (optimizer) 可從 function prototype 得知,傳遞給函式 compare
的兩個指標型態參數,由於明確標注 const
修飾子,所以僅有記憶體地址的使用並讀取相對應的內容,但不會因而變更指標所指向的記憶體內容,從而沒有產生副作用 (side effect)。這樣編譯器可有更大的最佳化空間,可對照你所不知道的 C 語言:編譯器和最佳化原理篇,得知相關最佳化手法。
一如 C9X RATIONALE 提到,現代 C 語言和其他受 Algol-68 影響的程式語言,具備 function prototype 機制,這使得編譯時期,就能進行有效的錯誤分析和偵測。無論是 C 語言、B 語言,還是 Pascal 語言,都可追溯到 ALGOL 60。
ALGOL 是 Algorithmic Language (演算法使用的語言) 的縮寫,提出巢狀 (nested) 結構和一系列程式流程控制,今日我們熟知的 if-else 語法,就在 ALGOL 60 出現。ALGOL 60 和 COBOL 程式語言並列史上最早工業標準化的程式語言。
黑格爾在其 1820 年的著作《法哲學原理》(Grundlinien der Philosophie des Rechts) 提到: (德語原文)
Was vernünftig ist, das ist wirklich; und was wirklich ist, das ist vernünftig
英語可解讀為 "What is rational is actual and what is actual is rational." (凡是合乎理性的都是現實的,現實的都是合乎理性),其中 "vernünftig" 和 "Vernuft" (理性) 有關,英譯成 "reasonable" 或 "rational" 都非漢語「合理」的意思。黑格爾認為,宇宙的本原是絕對精神 (der absolute Geist),它自在地具備著一切,外化出自然界、人類社會、精神科學,最後在更高的層次上回歸自身。像是 C 語言這樣的工業標準,至今仍活躍地演化,當我們回顧發展軌跡時,凡是合乎理性 (vernuftig),也就必然會出現、或成為現實 (wirklich),反過來說也成立。甚至我們可推敲 C9X RATIONALE 字裡行間,每個看似死板規則的背後,其實都可追溯出像是上方的討論。
數學定義的 Function (函數)
f 可視為從 A 到 f(A) 的函數。
Parameter vs. Argument
argc
(實際參數的數量) 和 argv
(實際參數的向量)Wikipedia 的 entry point 條目 備註提到:
the vector term in this variable's name is used in traditional sense to refer to strings.
但並沒有提供參考資料,這慣例來自 BCPL,字串字面值(string literal)是有兩個元素的向量,分別為其長度和真正的資料 [2],在《BCPL 參考手冊》中可見看到使用 vector 一詞表示字串,而 BCPL 源於 CPL,後者使用 vector 指代一維陣列、matrix 指代二維陣列。C 語言的前身 B 語言設計之初,也參照 BCPL,連同 argc/argv 也帶到 C 語言中。
在 C 語言中,"function" 其實是特化的形式,並非數學意義上的函數,而隱含一個狀態到另一個狀態的關聯。 (函式)
摘自 What is the difference between functions in math and functions in programming? 的討論:
In functional programming you have Referential Transparency, which means that you can replace a function with its value without altering the program. This is true in Math too, but this is not always true in Imperative languages.
..
The main difference, is, then, that ALWAYS if you callf(x)
in math, you will get the same answer, but if you callf'(x)
in C, the answer may not be the same (to same arguments don't get the same output).
其中 Imperative languages 可翻譯為「命令式程式語言」,幾乎所有電腦硬體都採命令式工作,較高階的命令式程式語言使用變數和更複雜的語句,但仍依從相同的典範。
在數學函數中 ,一個輸入值有固定的輸出值,無論計算多少次, 的結果總是 ,但在 C 函式中,函式的執行不僅依賴於輸入值,而且會受到全域變數、記憶體內容、已開啟的檔案、其他變數,甚至是作業系統/執行環境等諸多因素的影響。在 Linux 一類 UNIX 風格的作業系統中,呼叫 getpid 函式永遠會成功得到某個整數,而且同一個 process (行程) 中,無論 getpid() 呼叫多少次,必得到同一個整數,但在其他 process 中,getpid() 會得到另一個數值。再者,考慮以下 C 程式:
此函式沒有輸入值,但每次呼叫後都返回不同的結果。反之,函數的返回值只依賴於其輸入值,這種特性就稱為 Referential Transparency。
背景知識:
以網路卡的流程為例:
Virtual Memory 與 C 語言角度的 memory: source (注意 address 是降冪還是升冪)
int content = 10
。gonzo
的內容 God's own prototype
會是放在 text segment 裡,只有 pointer 所存的位址會在 data segment 。size
命令來觀察video: Call Stack: 生動地解釋函式之間的關聯
ELF segment & section
一個 segment 包含若干個 section
program loader
XIP: execution in place
gcc -S bigreturn.c -o bigreturn.s
)#include <stdio.h> typedef struct { int a[1]; } Foo; Foo get_foo() { Foo ret = {}; return ret; } int main(){ Foo b = get_foo(); return 0; }
對應的組合語言如下 [link]
可以發現回傳時直接存到 %eax 中,main 函式直接去 %eax 取
p.s. 因為一個整數只有 32bits 所以使用 32 位元的暫存器
get_foo: pushq %rbp movq %rsp, %rbp movl $0, -4(%rbp) movl -4(%rbp), %eax popq %rbp ret main: pushq %rbp movq %rsp, %rbp subq $16, %rsp movl $0, %eax call get_foo movl %eax, -4(%rbp) movl $0, %eax leave ret
typedef struct { int a[2]; } Foo;
[link]
換成使用 64bits 的暫存器 %rax 放 2 個整數
movq -8(%rbp), %rax movl $0, %eax call get_foo movq %rax, -8(%rbp)
typedef struct { int a[4]; } Foo;
[link]
換成使用 2 個 64bits 的暫存器 %rax 放 4 個整數
movq -16(%rbp), %rax movq -8(%rbp), %rdx movl $0, %eax call get_foo movq %rax, -16(%rbp) movq %rdx, -8(%rbp)
typedef struct { int a[8]; } Foo;
[link]
出現 leaq 指令出現
LEA (Load Effective Address) 用法查到很多種,又都不像是這裡的用法
movq -40(%rbp), %rcx movq -32(%rbp), %rax movq -24(%rbp), %rdx movq %rax, (%rcx) movq %rdx, 8(%rcx) movq -16(%rbp), %rax movq -8(%rbp), %rdx movq %rax, 16(%rcx) movq %rdx, 24(%rcx) leaq -32(%rbp), %rax movq %rax, %rdi movl $0, %eax call get_foo
stack frame 最好的朋友是 2 個暫存器:
x86_64 暫存器:
用一個小程式來觀察 stack 的操作: (檔名 stack.c
)
編譯時加上 -g
以利後續 GDB 追蹤:
-no-pie
編譯選項是抑制 Position Independent Executables (PIE),便於後續分析。若你的 gcc 版本較舊,可能沒有該編譯選項,可自行移去。
PIE 是啟用 address space layout randomization (ASLR) 的預備動作,用以強化核心載入程式時,確保虛擬記憶體的排列不會總是一樣。
透過 gdb 追蹤程式:
在 GDB 中使用 disas
命令將其反組譯,預設是 AT&T 語法,我們可改為 Intel 語法,得到更簡潔的輸出:
以 (gdb)
開頭的文字表示在 GDB 輸入的命令
關於二者語法的差異,可見 Intel and AT&T Syntax.
我們準備要觀察進入 function 時 stack 的操作,因此將中斷點設定於進入 funcA()
之前,也就是第 10 行的位置:
看到 Breakpoint 1
的訊息,就表示成功觸發中斷點:
此時 stack 示意如下:
在執行 call funcA
之後,call
指令會做 push next instruction address,也就是回到 main
的返回地址:
call funcA
接著進入 funcA(),其 instruction 操作如下:
push rbp
mov rbp, rsp
sub rsp,0x10
至此,funcA
的 stack frame 就已完成。在函式呼叫尾聲,即將返回時,funcA
會執行 leave
,其效果如下:
mov rsp, rbp
pop rbp
此時 rsp 已經指向 main
函式的返回地址,接著呼叫 ret 時,rip就會指向返回定址,並將 stack frame 的狀態回復到 main 的 stack frame
另外,我們也可比較 funcA
與 funcB
之差異: funcA
有 sub rsp, 0x8
這道指令,但 funcB
卻沒有,是因編譯器已知 funcB
之後,就不會再呼叫別的函式,也沒有 push
, pop
等操作,因此 rsp
也不需要特別保留一段空間給 funcB
。
stack frame 之範圍於 System V Application Binary Interface AMD64 Architecture Processor Supplement 中定義為:
關於遞迴呼叫,詳見 你所不知道的C語言:遞迴呼叫篇
infinite.c
用 GDB 執行和測試,記得加上 -g
:
如果將 infinite.c 改為以下,重複上述動作:
將得到:
繼續修改 infinite.c
為以下,重複上述動作:
將得到以下:
stack 裡面有 x (parameter), y (local variable), return address
stack frame
觀察 UNIX Process 中的 stack 空間:
60000Hex - 3f000Hex = 21000Hex = 135168Dec
135168 * 4 = 540672
這跟前面的數字很接近!
以下實驗需要用到 PEDA 這項 GDB 擴充,安裝方式:
準備測試程式: (檔名 bof.c
)
這段程式碼展示最基本的 buffer overflow 如何達成攻擊。由第 8 行可見,被攻擊者使用缺乏長度檢查的函式 gets(), 此外上面有一個函式會去執行 /bin/sh
,雖然使用者在一般情境無法合法的呼叫他,但是卻可以透過 buffer overflow 達到改變程式流程,並觸發這個危險的函式。
首先,我們先將程式做編譯。這邊需要特別注意的是,我們需要加上 -fno-stack-protector
以關閉 CANNARY
這個記憶體保護機制,相關的記憶體保護機制會在後面稍做介紹。
接著可以嘗試觀察這之程式的行為,可以發現程式的行為非常單純,他會將你的輸入照實的印出來,這麼單純的程式裡頭到底暗藏的什麼玄機就讓我們繼續看下去!
接著可以嘗試對這支程式做一些粗暴的事情:用超過長度的字串塞爆他。
這支程式沒意外的崩潰了,並顯示 Segmentation fault
,
可以看到在記憶體中塞了滿滿的 0x61
,也就是我們剛剛輸入的 a
。在上面的 stack 介紹中曾經提到區域變數會被存放於 stack 中,因此 input
這個區域變數是位於 main
函式的 stack 中。而位於 stack
最頂端的是函式的 return address
因此我們可推測是輸入的 a
蓋到 return address
導致 rip
指到無法存取的地方。
將中斷點下在 main+53
的位置,並觀察接下來 rsp
,也就是位於 return address
的值
可見 return address
指向 0x6161616161616161
使用 vmmap
可知 0x6161616161616161
並不屬於該程式可以存取之範圍,所以才會拋出 Segmentation fault
這樣的訊息。
可推斷在 gets(input)
之後之記憶體狀況如下圖:
evil()
既然我們可以把 rip
導到 0x6161616161616161
讓他崩潰,為何不將其導到 evil() 呢?
x86-64是以little endian
將值存放於記憶體中,因此我們必須先將 0x4005b6
轉換為 little endian 的表示法:\xb6\x05@\x00\x00\x00\x00\x00
接著還缺到 return address 的 offset,因此可以回到 GDB 中計算。為了方便計算這邊的輸入值為依序輸入 abc…xyz
看到 $rsp
的第一個字為 y
,而 'y' 之前有 24 個字母,也就是我們需要填入 24 個值才碰的到 return address,利用得到的資訊撰寫以下 exploit,並成功執行 /bin/sh
其stack 結構如下:
ROP attack 基礎概念: 使用一連串的指令組合成一個完整的功能
實驗以讓程式執行 bin/sh 為例
rop.c
編譯時關閉 gcc 的保護 stack overflow 保護機制及關閉 ASLR
用 gdb 確認 overflow 並找出 overflow 的 offset
想讓程式執行 exec("/bin/sh")
,但程式中沒有這段程式碼,所以必須透過 ROP 組成。
目標: 藉由 ROP 將各分散在各處的指令組成得以觸發系統呼叫,從而執行 shell
系統呼叫的執行方式
system call table
Syscall # | Param 1 | Param 2 | Param 3 | Param 4 | Param 5 | Param 6 |
---|---|---|---|---|---|---|
rax | rdi | rsi | rdx | r10 | r8 | r9 |
59 | const char *filename | const char *const argv[] | const char *const envp[] | - | - | - |
因此可整理出觸發系統並執行 shell 的條件為:
找出可利用的指令將 rax, rdi, rsi, rdx 的內容設定成目標數值
以尋找帶有 rdi 的指令為例:
找到可利用的指令並記錄記憶體位置,切記指令結尾必須要是 ret
gdb 找可用的 buffer (作為 filename)
python pwntool module 製作 payload (檔名 payload.py
)
用 gdb 開啟程式並載入 payload 測試
在正常操作的情況下執行 ret 當下的 stack
RSP 的位置固定為 0000|
ret 後的程式碼狀況
當輸入我們所製作的攻擊字串
第一次 ret 後,執行了一次 pop 讓 RSP 指到下一道指令
指令執行位置,可以發現下一道指令又是 ret,程式會繼續執行我們所加入的第 2 道指令
到了要執行系統呼叫時的暫存器狀態
回顧系統呼叫執行的規則:
發生成因
造成問題
程式如下 n 會大到 107,當 arr 用 line 4 宣告會 segmentation fault
但改成動態宣告就可以正常執行了,為什麼呢?
想過會不會是 long (8 byte) * 10^7 把記憶體用完了?
將 Line 5 到 Line 7 改為 calloc,確認是否會遇到 SegFault
若要清楚地回答這問題,需要一些背景知識:
https://vorpus.org/blog/why-does-calloc-exist/
從實作的觀點,calloc 和 malloc + memset 是不同的!
之所以 stack 不可以 heap 可以
是因為 long arr[n + 1]; 時,作業系統會去衡量要求的記憶體足不足夠,此時發現要得太大,因此發出 segfault.
而 malloc() 是屬於妳要多少跟我說,我都給妳 (overcommit)。
所以拿到的空間可能會比要求的小,作業系統發現開始不夠用時就開始殺東殺西把空間滕出來,直到砍到沒東西能砍了 OOM。
因此發現,malloc() 存取大容量其實是有很大風險的,
先前使用 stack 會 segfault 其實是一個 "正確" 的錯誤提醒,而不是錯誤,我誤會他了。
calloc() 跟 malloc + memset 有很大的不同:
free() 釋放的是 pointer 指向位於 heap 的連續記憶體,而非 pointer 本身佔有的記憶體 (*ptr)。
舉例來說:
編譯不會有錯誤,但運作時會失敗:
倘若改為以下:
則會編譯和執行都成功。
因此,為了防止對同一個 pointer 作 free() 兩次的操作,而導致程式失敗,free() 後應該設定為 NULL。
在 GNU/Linux 裡頭觀察 malloc
事先安裝 gdb 和包含除錯訊息的套件
GDB 操作:
glibc 提供了 malloc_stats()
和 malloc_info()
這兩個函式,可顯示 process 的 heap 資訊
延伸閱讀
https://sourceware.org/gdb/onlinedocs/gdb/Target-Description-Format.html
malloc: first-fit => https://github.com/jserv/mini-arm-os/blob/master/07-Threads/malloc.c
free()
後設成 NULL
考慮以下程式:
使用 GDB,觀察 int *a = (int *) malloc(100);
的結果:
接著觀察 int *b = (int *) malloc(100);
的結果:
發現,系統為了加速效能,因此在 free 時,系統並不會馬上把之前 malloc 出來的 chunk 歸還給系統,反而是進行集中管理,並在下一次程式 malloc 一樣大小的 chunk 時,直接將預先分配好的空間分配給 malloc 請求者。
接著嘗試觀察被 free 釋放的 chunk 都跑去哪裡了呢?
將中斷點設在 free 之前:
將中斷點設定於 return 之前:
[ ]
標註的部分為 linked list 串連的地址free
出來的 chunk, glibc 會去將一些特定大小的 chunk 做集中管理,其集中管理的機制又分為:fast bin
small bin
large bin
等等因此推斷 double free 的檢查機制是透過檢查所要 free
的 chunk 是否屬於集中管理的 chunk。
autofree
一旦超出物件的生命週期時,上述 free_stack()
會自動呼叫