從彙編角度來理解linux下多層函式呼叫堆疊執行狀態

s1mba發表於2014-05-06


我們用下面的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(23);
    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(23)
 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(23)
 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 程式設計一站式學習》

《網路滲透技術》


相關文章