從彙編角度來理解linux下多層函式呼叫堆疊執行狀態
我們用下面的C程式碼來研究函式呼叫的過程。
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
int bar(int c, int d)
{ int e = c + d; return e; } int foo(int a, int b) { return bar(a, b); } int main(void) { foo(2, 3); return 0; } |
如果在編譯時加上-g選項,那麼用objdump反彙編時可以把C程式碼和彙編程式碼穿插起來顯示,這樣C程式碼和彙編程式碼的對應關係看得更清楚。反彙編的結果很長,以下只列出我們關心的部分。
simba@ubuntu:~/Documents/code/asm$ objdump -dS a.out
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
int bar(int c, int d)
{ 80483dc: 55 push %ebp 80483dd: 89 e5 mov %esp,%ebp 80483df: 83 ec 10 sub $0x10,%esp int e = c + d; 80483e2: 8b 45 0c mov 0xc(%ebp),%eax 80483e5: 8b 55 08 mov 0x8(%ebp),%edx 80483e8: 01 d0 add %edx,%eax 80483ea: 89 45 fc mov %eax,-0x4(%ebp) return e; 80483ed: 8b 45 fc mov -0x4(%ebp),%eax } 80483f0: c9 leave 80483f1: c3 ret 080483f2 <foo>: int foo(int a, int b) { 80483f2: 55 push %ebp 80483f3: 89 e5 mov %esp,%ebp 80483f5: 83 ec 08 sub $0x8,%esp return bar(a, b); 80483f8: 8b 45 0c mov 0xc(%ebp),%eax 80483fb: 89 44 24 04 mov %eax,0x4(%esp) 80483ff: 8b 45 08 mov 0x8(%ebp),%eax 8048402: 89 04 24 mov %eax,(%esp) 8048405: e8 d2 ff ff ff call 80483dc <bar> } 804840a: c9 leave 804840b: c3 ret 0804840c <main>: int main(void) { 804840c: 55 push %ebp 804840d: 89 e5 mov %esp,%ebp 804840f: 83 ec 08 sub $0x8,%esp foo(2, 3); 8048412: c7 44 24 04 03 00 00 movl $0x3,0x4(%esp) 8048419: 00 804841a: c7 04 24 02 00 00 00 movl $0x2,(%esp) 8048421: e8 cc ff ff ff call 80483f2 <foo> return 0; 8048426: b8 00 00 00 00 mov $0x0,%eax } 804842b: c9 leave 804842c: c3 ret |
要檢視編譯後的彙編程式碼,其實還有一種辦法是gcc -S main.c,這樣只生成彙編程式碼main.s,而不生成二進位制的目標檔案。
整個程式的執行過程是main呼叫foo,foo呼叫bar,我們用gdb跟蹤程式的執行,直到bar函式中的int e = c + d;語句執行完畢準備返回時,這時在gdb中列印函式棧幀,因為此時棧已經生長到最大。
simba@ubuntu:~/Documents/code/asm$ gdb a.out
GNU gdb (GDB) 7.5-ubuntu
Copyright (C) 2012 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "i686-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /home/simba/Documents/code/asm/a.out...done.
(gdb) start
Temporary breakpoint 1 at 0x8048412: file foo_bar.c, line 22.
Starting program: /home/simba/Documents/code/asm/a.out
Temporary breakpoint 1, main () at foo_bar.c:22
22 foo(2, 3);
(gdb) s
foo (a=2, b=3) at foo_bar.c:17
17 return bar(a, b);
(gdb) s
bar (c=2, d=3) at foo_bar.c:11
11 int e = c + d;
(gdb) disas
Dump of assembler code for function bar:
0x080483dc <+0>: push %ebp
0x080483dd <+1>: mov %esp,%ebp
0x080483df <+3>: sub $0x10,%esp
=> 0x080483e2 <+6>: mov 0xc(%ebp),%eax
0x080483e5 <+9>: mov 0x8(%ebp),%edx
0x080483e8 <+12>: add %edx,%eax
0x080483ea <+14>: mov %eax,-0x4(%ebp)
0x080483ed <+17>: mov -0x4(%ebp),%eax
0x080483f0 <+20>: leave
0x080483f1 <+21>: ret
End of assembler dump.
(gdb) si
0x080483e5 11 int e = c + d;
(gdb)
0x080483e8 11 int e = c + d;
(gdb)
0x080483ea 11 int e = c + d;
(gdb)
12 return e;
(gdb)
13 }
(gdb) bt
#0 bar (c=2, d=3) at foo_bar.c:13
#1 0x0804840a in foo (a=2, b=3) at foo_bar.c:17
#2 0x08048426 in main () at foo_bar.c:22
(gdb) info registers
eax 0x5 5
ecx 0xbffff744 -1073744060
edx 0x2 2
ebx 0xb7fc6000 -1208197120
esp 0xbffff678 0xbffff678
ebp 0xbffff688 0xbffff688
esi 0x0 0
edi 0x0 0
eip 0x80483f0 0x80483f0 <bar+20>
eflags 0x206 [ PF IF ]
cs 0x73 115
ss 0x7b 123
ds 0x7b 123
es 0x7b 123
fs 0x0 0
gs 0x33 51
(gdb) x/20x $esp
0xbffff678: 0x0804a000 0x08048482 0x00000001 0x00000005
0xbffff688: 0xbffff698 0x0804840a 0x00000002 0x00000003
0xbffff698: 0xbffff6a8 0x08048426 0x00000002 0x00000003
0xbffff6a8: 0x00000000 0xb7e394d3 0x00000001 0xbffff744
0xbffff6b8: 0xbffff74c 0xb7fdc858 0x00000000 0xbffff71c
在執行程式時,作業系統為程式分配一塊棧空間來儲存函式棧幀,esp暫存器總是指向棧頂,在x86平臺上這個棧是從高地址向低地址增長的,我們知道每次呼叫一個函式都要分配一個棧幀來儲存引數和區域性變數,現在我們詳細分析這些資料在棧空間的佈局,根據gdb的輸出結果圖示如下:
圖中每個小方格表示4個位元組的記憶體單元,例如b: 3這個小方格佔的記憶體地址是0xbffff6a4~0xbffff6a8,我把地址寫在每個小方格的下邊界線上,是為了強調該地址是記憶體單元的起始地址。我們從main函式的這裡開始看起:
1
2 3 4 5 |
foo(2, 3);
8048412: c7 44 24 04 03 00 00 movl $0x3,0x4(%esp) 8048419: 00 804841a: c7 04 24 02 00 00 00 movl $0x2,(%esp) 8048421: e8 cc ff ff ff call 80483f2 <foo> |
要呼叫函式foo先要把引數準備好,第二個引數儲存在esp+4指向的記憶體位置,第一個引數儲存在esp指向的記憶體位置,可見引數是從右向左依次壓棧的。然後執行call指令,這個指令有兩個作用:
1. foo函式呼叫完之後要返回到call的下一條指令繼續執行,所以把call的下一條指令的地址0x8048426壓棧,同時把esp的值減4,esp的值現在是0xbffff69c(可以在main函式開始執行時info r 一下,此時esp為0xbffff6a0)。
2. 修改程式計數器eip,跳轉到foo函式的開頭執行。
現在看foo函式的彙編程式碼:
1
2 3 4 5 6 7 8 9 10 11 12 |
int foo(int a, int b)
{ 80483f2: 55 push %ebp 80483f3: 89 e5 mov %esp,%ebp 80483f5: 83 ec 08 sub $0x8,%esp return bar(a, b); 80483f8: 8b 45 0c mov 0xc(%ebp),%eax 80483fb: 89 44 24 04 mov %eax,0x4(%esp) 80483ff: 8b 45 08 mov 0x8(%ebp),%eax 8048402: 89 04 24 mov %eax,(%esp) 8048405: e8 d2 ff ff ff call 80483dc <bar> } |
push %ebp指令把ebp暫存器的值壓棧,同時把esp的值減4。esp的值現在是0xbffff698,下一條指令把這個值傳送給ebp暫存器。這兩條指令合起來是把原來ebp的值儲存在棧上,然後又給ebp賦了新值。在每個函式的棧幀中,ebp指向棧底,而esp指向棧頂,在函式執行過程中esp隨著壓棧和出棧操作隨時變化,而ebp是不動的,函式的引數和區域性變數都是通過ebp的值加上一個偏移量來訪問,例如foo函式的引數a和b分別通過ebp+8和ebp+12來訪問。所以下面的指令把引數a和b再次壓棧,為呼叫bar函式做準備,然後把返回地址壓棧,呼叫bar函式:
現在看bar函式的指令:
1
2 3 4 5 6 7 8 9 10 11 12 |
int bar(int c, int d) { 80483dc: 55 push %ebp 80483dd: 89 e5 mov %esp,%ebp 80483df: 83 ec 10 sub $0x10,%esp int e = c + d; 80483e2: 8b 45 0c mov 0xc(%ebp),%eax 80483e5: 8b 55 08 mov 0x8(%ebp),%edx 80483e8: 01 d0 add %edx,%eax 80483ea: 89 45 fc mov %eax,-0x4(%ebp) |
這次又把foo函式的ebp壓棧儲存,然後給ebp賦了新值,指向bar函式棧幀的棧底,通過ebp+8和ebp+12分別可以訪問引數c和d。bar函式還有一個區域性變數e,可以通過ebp-4來訪問。所以後面幾條指令的意思是把引數c和d取出來存在暫存器中做加法,計算結果儲存在eax暫存器中,再把eax暫存器存回區域性變數e的記憶體單元。
在gdb中可以用bt命令和frame命令檢視每層棧幀上的引數和區域性變數,現在可以解釋它的工作原理了:如果我當前在bar函式中,我可以通過ebp找到bar函式的引數和區域性變數,也可以找到foo函式的ebp儲存在棧上的值,有了foo函式的ebp,又可以找到它的引數和區域性變數,也可以找到main函式的ebp儲存在棧上的值,因此各層函式棧幀通過儲存在棧上的ebp的值串起來了。
現在看bar函式的返回指令:
1
2 3 4 5 6 |
return e;
80483ed: 8b 45 fc mov -0x4(%ebp),%eax } 80483f0: c9 leave 80483f1: c3 ret |
bar函式有一個int型的返回值,這個返回值是通過eax暫存器傳遞的,所以首先把e的值讀到eax暫存器中。
然後執行leave指令,這個指令是函式開頭的push %ebp和mov %esp,%ebp的逆操作:
1. 把ebp的值賦給esp,現在esp的值是0xbffff688。
2. 現在esp所指向的棧頂儲存著foo函式棧幀的ebp,把這個值恢復給ebp,同時esp增加4,esp的值變成0xbffff68c。
最後是ret指令,它是call指令的逆操作:
1. 現在esp所指向的棧頂儲存著返回地址,把這個值恢復給eip(pop),同時esp增加4,esp的值變成0xbffff690。
2. 修改了程式計數器eip,因此跳轉到返回地址0x804840a繼續執行。
地址0x804840a處是foo函式的返回指令:
1
2 3 |
804840a: c9 leave
804840b: c3 ret |
重複同樣的過程,又返回到了main函式。
根據上面的分析,ebp最終會重新獲取值0x00000000, 而從main函式返回到0xb7e39473地址去執行,最終esp值為0xbffff6b0。
當main函式最後一條指令執行完是info r 一下可以發現:
esp 0xbffff6b0 0xbffff6b0
ebp 0x0 0x0
實際上回過頭發現main函式最開始也有初始化的3條彙編指令,先把ebp壓棧,此時esp減4為0x6ffffba8,再將esp賦值給ebp,最後將esp減去8,所以在我們除錯第一條執行的指令(movl $0x3,0x4(%esp) )時,esp已經是0x6ffff6a0,與前面對照發現是吻合的。那麼main函式回到哪裡去執行呢?實際上main函式也是被其他系統函式所呼叫的,比如進一步si 下去會發現 是 被 libc-start.c 所呼叫,最終還會呼叫exit.c。為了從main函式入口就開始除錯,可以設定一個斷點如下:
(gdb) disas main
Dump of assembler code for function main:
0x0804840c <+0>: push %ebp
0x0804840d <+1>: mov %esp,%ebp
0x0804840f <+3>: sub $0x8,%esp
0x08048412 <+6>: movl $0x3,0x4(%esp)
0x0804841a <+14>: movl $0x2,(%esp)
0x08048421 <+21>: call 0x80483f2 <foo>
0x08048426 <+26>: mov $0x0,%eax
0x0804842b <+31>: leave
0x0804842c <+32>: ret
End of assembler dump.
(gdb) b *0x0804840c
Breakpoint 1 at 0x804840c: file foo_bar.c, line 21.
(gdb) r
Starting program: /home/simba/Documents/code/asm/a.out
Breakpoint 1, main () at foo_bar.c:21
21 {
(gdb) i reg
eax 0x1 1
ecx 0xbffff744 -1073744060
edx 0xbffff6d4 -1073744172
ebx 0xb7fc6000 -1208197120
esp 0xbffff6ac 0xbffff6ac
ebp 0x0 0x0
esi 0x0 0
edi 0x0 0
eip 0x804840c 0x804840c <main>
eflags 0x246 [ PF ZF IF ]
cs 0x73 115
ss 0x7b 123
ds 0x7b 123
es 0x7b 123
fs 0x0 0
gs 0x33 51
(gdb) x/x $esp
0xbffff6ac: 0xb7e394d3
(gdb) x/10i 0xb7e394d3-10
0xb7e394c9 <__libc_start_main+233>: inc %esp
0xb7e394ca <__libc_start_main+234>: and $0x74,%al
0xb7e394cc <__libc_start_main+236>: mov %eax,(%esp)
0xb7e394cf <__libc_start_main+239>: call *0x70(%esp)
0xb7e394d3 <__libc_start_main+243>: mov %eax,(%esp)
0xb7e394d6 <__libc_start_main+246>: call 0xb7e52fb0 <__GI_exit>
0xb7e394db <__libc_start_main+251>: xor %ecx,%ecx
0xb7e394dd <__libc_start_main+253>: jmp 0xb7e39414 <__libc_start_main+52>
0xb7e394e2 <__libc_start_main+258>: mov 0x3928(%ebx),%eax
0xb7e394e8 <__libc_start_main+264>: ror $0x9,%eax
(gdb) x/x $esp+4+0x70
0xbffff720: 0x0804840c
可以看到main函式最開始時,esp為0xbffff6ac,ebp為0,eip為0x804840c,esp所指的0xb7e394d3就是main函式執行完的返回地址,如何證明呢?
可以看到0xb7e394cf 處的指令 call *0x70(%esp) ,即將下一條地址壓棧,列印一下 esp+4+0x70 指向的地址為0x804840c,也就是main函式的入口地
址。此外可以看到呼叫call 時esp 應該為0xbffff6b0,與main 函式執行完畢時的esp 值一致。
知道了main函式的返回地址,我們也就明白了所謂的shellcode的大概實現原理,利用棧空間變數的緩衝區溢位將返回地址覆蓋掉,將esp所指返回地址pop到eip時,就會改變程式的流程,不再是正確地退出,而是被我們所控制了,一般是跳轉到一段shellcode(機器指令)的起始地址,這樣就啟動了一個shell。
注意函式呼叫和返回過程中的這些規則:
1. 引數壓棧傳遞,並且是從右向左依次壓棧。
2. ebp總是指向當前棧幀的棧底。
3. 返回值通過eax暫存器傳遞。
這些規則並不是體系結構所強加的,ebp暫存器並不是必須這麼用,函式的引數和返回值也不是必須這麼傳,只是作業系統和編譯器選擇了以這樣的方式實現C程式碼中的函式呼叫,這稱為Calling Convention,Calling Convention是作業系統二進位制介面規範(ABI,Application Binary Interface)的一部分。
參考:
《linux c 程式設計一站式學習》
《網路滲透技術》
相關文章
- 從彙編視角解析函式呼叫中的堆疊運作函式
- 函式呼叫中堆疊的個人理解函式
- 使用DbgHelp獲取函式呼叫堆疊之inline assembly(內聯彙編)法函式inline
- 深入理解JavaScript執行上下文、函式堆疊、提升的概念JavaScript函式
- 從人類行為的角度理解狀態管理
- PHP列印呼叫函式入口地址(堆疊),方便調式PHP函式
- Java多執行緒-程式執行堆疊分析Java執行緒
- 從彙編角度分析C語言的過程呼叫C語言
- 從彙編層面深度剖析 C++ 虛擬函式C++函式
- 解讀 JavaScript 之引擎、執行時和堆疊呼叫JavaScript
- JavaScript的工作原理:引擎,執行時和呼叫堆疊JavaScript
- PHP-stacktrace: PHP 程式外檢視函式呼叫堆疊PHP函式
- JavaScript 工作原理之一-引擎,執行時,呼叫堆疊(譯)JavaScript
- [譯] JavaScript 如何工作:對引擎、執行時、呼叫堆疊的概述JavaScript
- Java多執行緒-執行緒狀態Java執行緒
- Linux下編譯生成SO並進行呼叫執行Linux編譯
- JavaScript是如何工作的:引擎,執行時和呼叫堆疊的概述!JavaScript
- 【譯】JavaScript的工作原理:引擎,執行時和呼叫堆疊的概述JavaScript
- [golang]如何看懂呼叫堆疊Golang
- 從函式式的角度反思函式
- 自執行函式的理解函式
- 從底層理解Python的執行Python
- C++ 反彙編:關於函式呼叫約定C++函式
- 彙編中引數的傳遞和堆疊修正(轉)
- iOS逆向之旅(基礎篇) — 彙編(四) — 彙編下的函式iOS函式
- 【譯】理解Javascript函式執行—呼叫棧、事件迴圈、任務等JavaScript函式事件
- 深入理解 函式、匿名函式、自執行匿名函式函式
- 多執行緒常用函式執行緒函式
- Memcached 多執行緒和狀態機執行緒
- C++函式呼叫棧從何而來C++函式
- 深入理解Java多執行緒與併發框(第①篇)——執行緒的狀態Java執行緒
- 從執行上下文(ES3,ES5)的角度來理解"閉包"S3
- 如何利用執行緒堆疊定位問題執行緒
- Java進階專題(十五) 從電商系統角度研究多執行緒(下)Java執行緒
- 多執行緒脫離狀態 + 排程執行緒
- 多執行緒的執行緒狀態及相關操作執行緒
- JavaScript自執行函式(function(){})()的理解JavaScript函式Function
- Swift下如何疊加UIButton狀態SwiftUI