1. 程式人生 > >從匯編角度來理解linux下多層函數調用堆棧運行狀態

從匯編角度來理解linux下多層函數調用堆棧運行狀態

see padding clas symbols edi inux -s alt sso

我們用下面的C代碼來研究函數調用的過程。

C++ Code
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

ASM Code
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函數的這裏開始看起:

ASM Code
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函數的匯編代碼:

ASM Code
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函數的指令:

ASM Code
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函數的返回指令:

ASM Code
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函數的返回指令:

ASM Code
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 編程一站式學習》

《網絡滲透技術》

從匯編角度來理解linux下多層函數調用堆棧運行狀態