轉載:http://shitouer.cn/2010/06/method-called/
代碼如下:
#include “stdlib.h”
int sum(int a,int b,int m,int n)
{
return a+b;
}
void main()
{
int result = sum(1,2,3,4);
system(“pause”);
}
有四個參數的sum函數,接着在main方法中調用sum函數。在debug環境下,單步調試如下:
11: void main()
12: {
00401060 push ebp
;保存ebp,執行這句之前,ESP = 0012FF4C EBP = 0012FF88
;執行后,ESP = 0012FF48 EBP = 0012FF88,ESP減小,EBP不變
00401061 mov ebp,esp
;將esp放入ebp中,此時ebp和esp相同,即執行后ESP = 0012FF48 EBP = 0012FF48
;原EBP值已經被壓棧(位於棧頂),而新的EBP又恰恰指向棧頂。
;此時EBP寄存器就已經處於一個非常重要的地位,該寄存器中存儲着棧中的一個地址(原EBP入棧后的棧頂),
;從該地址為基准,向上(棧底方向)能獲取返回地址、參數值(假如main中有參數,“獲取參數值”會比較容易理解,
;不過在看下邊的sum函數調用時會有體會的),向下(棧頂方向)能獲取函數局部變量值,
;而該地址處又存儲着上一層函數調用時的EBP值!
00401063 sub esp,44h
;把esp往上移動一個范圍
;等於在棧中空出一片空間來存局部變量
;執行這句后ESP = 0012FF04 EBP = 0012FF48
00401066 push ebx
00401067 push esi
00401068 push edi
;保存三個寄存器的值
00401069 lea edi,[ebp-44h]
;把ebp-44h加載到edi中,目的是保存局部變量的區域
0040106C mov ecx,11h
00401071 mov eax,0CCCCCCCCh
00401076 rep stos dword ptr [edi]
;從ebp-44h開始的區域初始化成全部0CCCCCCCCh,就是int3斷點,初始化局部變量空間
;REP ;CX不等於0 ,則重復執行字符串指令
;格式: STOS OPRD
;功能: 把AL(字節)或AX(字)中的數據存儲到DI為目的串地址指針所尋址的存儲器單元中去.指針DI將根據DF的值進行自動
;調整. 其中OPRD為目的串符號地址.
;以上的語句就是在棧中開辟一塊空間放局部變量
;然后把這塊空間都初始化為0CCCCCCCCh,就是int3斷點,一個中斷指令。
;因為局部變量不可能被執行,執行了就會出錯,這時候發生中斷提示開發者。
13: int result = sum(1,2,3,4);
00401078 push 4
0040107A push 3
0040107C push 2
0040107E push 1
;各個參數入棧,注意查看寄存器ESP值的變化
;亦可以看到參數入棧的順序,從右到左
;變化為:ESP = 0012FEF8–>ESP = 0012FEF4–>ESP = 0012FEF0–>ESP = 0012FEEC–>ESP = 0012FEE8
00401080 call @ILT+15(boxer) (00401014)
;調用sum函數,可以按F11跟進
;注:f10(step over),單步調試,遇到函數調用,直接執行,不會進入函數內部
;f11(step into),單步調試,遇到函數調用,會進入函數內部
;shift+f11(step out),進入函數內部后,想從函數內部跳出,用此快捷方式
;ctrl+f10(run to cursor),呵呵,看英語注釋就應該知道是什么意思了,不再解釋
00401085 add esp,10h
;調用完函數后恢復/釋放棧,執行后ESP = 0012FEF8,與sum函數的參數入棧前的數值一致
00401088 mov dword ptr [ebp-4],eax
;將結果存放在result中,原因詳看最后有關ss的注釋
14: system(“pause”);
0040108B push offset string “pause” (00422f6c)
00401090 call system (0040eed0)
00401095 add esp ,4
;有關system(“pause”)的處理,此處不討論
15: }
00401098 pop edi
00401099 pop esi
0040109A pop ebx
;恢復原來寄存器的值,怎么“吃”進去,怎么“吐”出來
0040109B add esp,44h
;恢復ESP,對應上邊的sub esp,44h
0040109E cmp ebp,esp
;檢查esp是否正常,不正常就進入下邊的call里面debug
004010A0 call __chkesp (004010b0)
;處理可能出現的堆棧異常,如果有的話,就會陷入debug
004010A5 mov esp,ebp
004010A7 pop ebp
;恢復原來的esp和ebp,讓上一個調用函數正常使用
004010A8 ret
;將返回地址存入eip,轉移流程
;如果函數有返回值,返回值將放在eax返回(這就是很多軟件給秒殺爆破的原因了,因為eax的返回值是可以改的)
—————————————————————————————————————————–
;以上即是主函數調用的反匯編過程,下邊來看調用sum函數的過程:
;上邊有說在00401080 call @ILT+15(boxer) (00401014)這一句處,用f11單步調試,f11后如下句:
00401014 jmp sum (00401020)
;即跳轉到sum函數的代碼段中,再f11如下:
6: int sum(int a,int b,int m,int n)
7: {
00401020 push ebp
00401021 mov ebp,esp
00401023 sub esp,40h
00401026 push ebx
00401027 push esi
00401028 push edi
00401029 lea edi,[ebp-40h]
0040102C mov ecx,10h
00401031 mov eax,0CCCCCCCCh
00401036 rep stos dword ptr [edi]
;可見,上邊幾乎與主函數調用相同,每一步不再贅述,可對照上邊主函數調用的注釋
8: return a+b;
00401038 mov eax,dword ptr [ebp+8]
;取第一個參數放在eax
0040103B add eax,dword ptr [ebp+0Ch]
;取第二個參數,與eax中的數值相加並存在eax中
9: }
0040103E pop edi
0040103F pop esi
00401040 pop ebx
00401041 mov esp,ebp
00401043 pop ebp
00401044 ret
;收尾操作,比前邊只是少了檢查esp操作罷了
有關ss部分的注釋:
;一般而言,ss:[ebp+4]處為返回地址
;ss:[ebp+8]處為第一個參數值(這里是a),ss:[ebp+0Ch]處為第二個參數(這里是b,這里8+4=12=0Ch)
;ss:[ebp-4]處為第一個局部變量(如main中的result),ss:[ebp]處為上一層EBP值
;ebp和函數返回值是32位,所以占4個字節
LINUX平台可以用GDB進行反匯編和調試
2. 最簡C代碼分析
為簡化問題,來分析一下最簡的c代碼生成的匯編代碼:
# vi test1.c
int main()
{
return 0;
}
編譯該程序,產生二進制文件:
# gcc test1.c -o test1
# file test1
test1: ELF 32-bit LSB executable 80386 Version 1, dynamically linked, not stripped
test1是一個ELF格式32位小端(Little Endian)的可執行文件,動態鏈接並且符號表沒有去除。
這正是Unix/Linux平台典型的可執行文件格式。
用mdb反匯編可以觀察生成的匯編代碼:
# mdb test1
Loading modules: [ libc.so.1 ]
> main::dis ; 反匯編main函數,mdb的命令一般格式為 <地址>::dis
main: pushl %ebp ; ebp寄存器內容壓棧,即保存main函數的上級調用函數的棧基地址
main+1: movl %esp,%ebp ; esp值賦給ebp,設置main函數的棧基址
main+3: subl $8,%esp
main+6: andl $0xf0,%esp
main+9: movl $0,%eax
main+0xe: subl %eax,%esp
main+0x10: movl $0,%eax ; 設置函數返回值0
main+0x15: leave ; 將ebp值賦給esp,pop先前棧內的上級函數棧的基地址給ebp,恢復原棧基址
main+0x16: ret ; main函數返回,回到上級調用
>
注:這里得到的匯編語言語法格式與Intel的手冊有很大不同,Unix/Linux采用AT&T匯編格式作為匯編語言的語法格式
如果想了解AT&T匯編可以參考文章:Linux AT&T 匯編語言開發指南
問題:誰調用了 main函數?
在C語言的層面來看,main函數是一個程序的起始入口點,而實際上,ELF可執行文件的入口點並不是main而是_start。
mdb也可以反匯編_start:
> _start::dis ;從_start 的地址開始反匯編
_start: pushl $0
_start+2: pushl $0
_start+4: movl %esp,%ebp
_start+6: pushl %edx
_start+7: movl $0x80504b0,%eax
_start+0xc: testl %eax,%eax
_start+0xe: je +0xf <_start+0x1d>
_start+0x10: pushl $0x80504b0
_start+0x15: call -0x75 <atexit>
_start+0x1a: addl $4,%esp
_start+0x1d: movl $0x8060710,%eax
_start+0x22: testl %eax,%eax
_start+0x24: je +7 <_start+0x2b>
_start+0x26: call -0x86 <atexit>
_start+0x2b: pushl $0x80506cd
_start+0x30: call -0x90 <atexit>
_start+0x35: movl +8(%ebp),%eax
_start+0x38: leal +0x10(%ebp,%eax,4),%edx
_start+0x3c: movl %edx,0x8060804
_start+0x42: andl $0xf0,%esp
_start+0x45: subl $4,%esp
_start+0x48: pushl %edx
_start+0x49: leal +0xc(%ebp),%edx
_start+0x4c: pushl %edx
_start+0x4d: pushl %eax
_start+0x4e: call +0x152 <_init>
_start+0x53: call -0xa3 <__fpstart>
_start+0x58: call +0xfb <main> ;在這里調用了main函數
_start+0x5d: addl $0xc,%esp
_start+0x60: pushl %eax
_start+0x61: call -0xa1 <exit>
_start+0x66: pushl $0
_start+0x68: movl $1,%eax
_start+0x6d: lcall $7,$0
_start+0x74: hlt
>
問題:為什么用EAX寄存器保存函數返回值?
實際上IA32並沒有規定用哪個寄存器來保存返回值。但如果反匯編Solaris/Linux的二進制文件,就會發現,都用EAX保存函數返回值。
這不是偶然現象,是操作系統的ABI(Application Binary Interface)來決定的。
Solaris/Linux操作系統的ABI就是Sytem V ABI。
概念:SFP (Stack Frame Pointer) 棧框架指針
正確理解SFP必須了解:
IA32 的棧的概念
CPU 中32位寄存器ESP/EBP的作用
PUSH/POP 指令是如何影響棧的
CALL/RET/LEAVE 等指令是如何影響棧的
如我們所知:
1)IA32的棧是用來存放臨時數據,而且是LIFO,即后進先出的。棧的增長方向是從高地址向低地址增長,按字節為單位編址。
2) EBP是棧基址的指針,永遠指向棧底(高地址),ESP是棧指針,永遠指向棧頂(低地址)。
3) PUSH一個long型數據時,以字節為單位將數據壓入棧,從高到低按字節依次將數據存入ESP-1、ESP-2、ESP-3、ESP-4的地址單元。
4) POP一個long型數據,過程與PUSH相反,依次將ESP-4、ESP-3、ESP-2、ESP-1從棧內彈出,放入一個32位寄存器。
5) CALL指令用來調用一個函數或過程,此時,下一條指令地址會被壓入堆棧,以備返回時能恢復執行下條指令。
6) RET指令用來從一個函數或過程返回,之前CALL保存的下條指令地址會從棧內彈出到EIP寄存器中,程序轉到CALL之前下條指令處執行
7) ENTER是建立當前函數的棧框架,即相當於以下兩條指令:
pushl %ebp
movl %esp,%ebp
8) LEAVE是釋放當前函數或者過程的棧框架,即相當於以下兩條指令:
movl ebp esp
popl ebp
如果反匯編一個函數,很多時候會在函數進入和返回處,發現有類似如下形式的匯編語句:
pushl %ebp ; ebp寄存器內容壓棧,即保存main函數的上級調用函數的棧基地址
movl %esp,%ebp ; esp值賦給ebp,設置 main函數的棧基址
........... ; 以上兩條指令相當於 enter 0,0
...........
leave ; 將ebp值賦給esp,pop先前棧內的上級函數棧的基地址給ebp,恢復原棧基址
ret ; main函數返回,回到上級調用
這些語句就是用來創建和釋放一個函數或者過程的棧框架的。
原來編譯器會自動在函數入口和出口處插入創建和釋放棧框架的語句。
函數被調用時:
1) EIP/EBP成為新函數棧的邊界
函數被調用時,返回時的EIP首先被壓入堆棧;創建棧框架時,上級函數棧的EBP被壓入堆棧,與EIP一道行成新函數棧框架的邊界
2) EBP成為棧框架指針SFP,用來指示新函數棧的邊界
棧框架建立后,EBP指向的棧的內容就是上一級函數棧的EBP,可以想象,通過EBP就可以把層層調用函數的棧都回朔遍歷一遍,調試器就是利用這個特性實現 backtrace功能的
3) ESP總是作為棧指針指向棧頂,用來分配棧空間
棧分配空間給函數局部變量時的語句通常就是給ESP減去一個常數值,例如,分配一個整型數據就是 ESP-4
4) 函數的參數傳遞和局部變量訪問可以通過SFP即EBP來實現
由於棧框架指針永遠指向當前函數的棧基地址,參數和局部變量訪問通常為如下形式:
+8+xx(%ebp) ; 函數入口參數的的訪問
-xx(%ebp) ; 函數局部變量訪問
假如函數A調用函數B,函數B調用函數C ,則函數棧框架及調用關系如下圖所示:
+-------------------------+----> 高
frame of C 圖 1-1
再分析test1反匯編結果中剩余部分語句的含義:
# mdb test1
Loading modules: [ libc.so.1 ]
> main::dis ; 反匯編main函數
main: pushl %ebp
main+1: movl %esp,%ebp ; 創建Stack Frame(棧框架)
main+3: subl $8,%esp ; 通過ESP-8來分配8字節堆棧空間
main+6: andl $0xf0,%esp ; 使棧地址16字節對齊
main+9: movl $0,%eax ; 無意義
main+0xe: subl %eax,%esp ; 無意義
main+0x10: movl $0,%eax ; 設置main函數返回值
main+0x15: leave ; 撤銷Stack Frame(棧框架)
main+0x16: ret ; main 函數返回
>
以下兩句似乎是沒有意義的,果真是這樣嗎?
movl $0,%eax
subl %eax,%esp
用gcc的O2級優化來重新編譯test1.c:
# gcc -O2 test1.c -o test1
# mdb test1
> main::dis
main: pushl %ebp
main+1: movl %esp,%ebp
main+3: subl $8,%esp
main+6: andl $0xf0,%esp
main+9: xorl %eax,%eax ; 設置main返回值,使用xorl異或指令來使eax為0
main+0xb: leave
main+0xc: ret
>
新的反匯編結果比最初的結果要簡潔一些,果然之前被認為無用的語句被優化掉了,進一步驗證了之前的猜測。
提示:編譯器產生的某些語句可能在程序實際語義上沒有用處,可以用優化選項去掉這些語句。
問題:為什么用xorl來設置eax的值?
注意到優化后的代碼中,eax返回值的設置由 movl $0,%eax 變為 xorl %eax,%eax ,這是因為IA32指令中,xorl比movl有更高的運行速度。
概念:Stack aligned 棧對齊
那么,以下語句到底是和作用呢?
subl $8,%esp
andl $0xf0,%esp ; 通過andl使低4位為0,保證棧地址16字節對齊
表面來看,這條語句最直接的后果是使ESP的地址后4位為0,即16字節對齊,那么為什么這么做呢?
原來,IA32 系列CPU的一些指令分別在4、8、16字節對齊時會有更快的運行速度,因此gcc編譯器為提高生成代碼在IA32上的運行速度,默認對產生的代碼進行16字節對齊
andl $0xf0,%esp 的意義很明顯,那么 subl $8,%esp 呢,是必須的嗎?
這里假設在進入main函數之前,棧是16字節對齊的話,那么,進入main函數后,EIP和EBP被壓入堆棧后,棧地址最末4位二進制位必定是1000,esp -8則恰好使后4位地址二進制位為0000。看來,這也是為保證棧16字節對齊的。
如果查一下gcc的手冊,就會發現關於棧對齊的參數設置:
-mpreferred-stack-boundary=n ; 希望棧按照2的n次的字節邊界對齊, n的取值范圍是2-12
默認情況下,n是等於4的,也就是說,默認情況下,gcc是16字節對齊,以適應IA32大多數指令的要求。
讓我們利用-mpreferred-stack-boundary=2來去除棧對齊指令:
# gcc -mpreferred-stack-boundary=2 test1.c -o test1
> main::dis
main: pushl %ebp
main+1: movl %esp,%ebp
main+3: movl $0,%eax
main+8: leave
main+9: ret
>
可以看到,棧對齊指令沒有了,因為,IA32的棧本身就是4字節對齊的,不需要用額外指令進行對齊。
那么,棧框架指針SFP是不是必須的呢?
# gcc -mpreferred-stack-boundary=2 -fomit-frame-pointer test1.c -o test
> main::dis
main: movl $0,%eax
main+5: ret
>
由此可知,-fomit-frame-pointer 可以去除SFP。
問題:去除SFP后有什么缺點呢?
1)增加調式難度
由於SFP在調試器backtrace的指令中被使用到,因此沒有SFP該調試指令就無法使用。
2)降低匯編代碼可讀性
函數參數和局部變量的訪問,在沒有ebp的情況下,都只能通過+xx(esp)的方式訪問,而很難區分兩種方式,降低了程序的可讀性。
問題:去除SFP有什么優點呢?
1)節省棧空間
2)減少建立和撤銷棧框架的指令后,簡化了代碼
3)使ebp空閑出來,使之作為通用寄存器使用,增加通用寄存器的數量
4)以上3點使得程序運行速度更快
概念:Calling Convention 調用約定和 ABI (Application Binary Interface) 應用程序二進制接口
函數如何找到它的參數?
函數如何返回結果?
函數在哪里存放局部變量?
那一個硬件寄存器是起始空間?
那一個硬件寄存器必須預先保留?
Calling Convention 調用約定對以上問題作出了規定。Calling Convention也是ABI的一部分。
因此,遵守相同ABI規范的操作系統,使其相互間實現二進制代碼的互操作成為了可能。
例如:由於Solaris、Linux都遵守System V的ABI,Solaris 10就提供了直接運行Linux二進制程序的功能。
詳見文章:關注: Solaris 10的10大新變化
3. 小結
本文通過最簡的C程序,引入以下概念:
SFP 棧框架指針
Stack aligned 棧對齊
Calling Convention 調用約定 和 ABI (Application Binary Interface) 應用程序二進制接口
今后,將通過進一步的實驗,來深入了解這些概念。通過掌握這些概念,使在匯編級調試程序產生的core dump、掌握C語言高級調試技巧成為了可能。
反匯編深入分析函數調用
函數:
intfun(inta,intb) {
charvar[128] = "A";
a = 0x4455;
b = 0x6677;
returna + b;
}
intmain() {
fun(0x8899,0x1100);
return0;
}
F11跟蹤到fun,alt+8看反匯編代碼:
//參數壓棧,遵循__cdecl調用規范,參數由右向左
00401078 push 1100h//第一個參數壓棧
0040107D push 8899h//第二個參數壓棧
00401082 call @ILT+0(_fun) (00401005)//調用函數
00401087 add esp,8//被調用函數的堆棧由主調函數來清空堆棧,使堆棧平衡。
由上圖的EIP可以看到0040B500就是下條要執行的指令,在Memory窗口中可以看到內存數據99880000和11000000,實質上是0x8899,0x1100,(intel處理器一般都是小端存儲),還可以看到有內存數據87104000,實質上是00401087,在主調函數中,可以很清楚的看到00401087被調函數返回以后執行的第一條指令,也就是堆棧清空指令(遵循__cdecl調用規范)。Call指令隱含做了一個操作:就是把函數返回后執行的第一條指令壓入堆棧(push)。
1: int fun(int a, int b) {
0040B500 push ebp
0040B501 mov ebp,esp //調用函數通常的做法,通過ebp基址寄存器來操作堆//棧數據
0040B503 sub esp,0C0h//為什么是C0h(不是因為堆棧保護,T網KX提;2NvU_d'O
防止緩沖區overflow,而是DEBUG選項造成的)
0040B509 push ebx
0040B50A push esi
0040B50B push edi
0040B50C lea edi,[ebp-0C0h]
0040B512 mov ecx,30h //C0h除以4,就是30h,因為rep stos用的是dword
0040B517 mov eax,0CCCCCCCCh
0040B51C rep stos dword ptr [edi] //用0CCCCCCCCh初始化堆棧
2: char var[128] = "A";
0040B51E mov ax,[string "A" (0041f10c)] //此時EBP = 0012FF24
0040B524 mov word ptr [ebp-80h],ax //80h也就是128,寫了一個字
0040B528 mov ecx,1Fh //1Fh是31
0040B52D xor eax,eax //清零
0040B52F lea edi,[ebp-7Eh]
0040B532 rep stos dword ptr [edi] //一共是32個雙字,開始寫了一個字,rep stos
0040B534 stos word ptr [edi] //寫入了31個雙字,還剩下一個字由stos完成
//var的地址是:0x0012fea4
3: a = 0x4455;
0040B536 mov dword ptr [ebp+8],4455h
4: b = 0x6677;
0040B53D mov dword ptr [ebp+0Ch],6677h
5: return a + b;
0040B544 mov eax,dword ptr [ebp+8]
0040B547 add eax,dword ptr [ebp+0Ch] //返回值通過eax保存
6: }
0040B54A pop edi
0040B54B pop esi
0040B54C pop ebx //彈棧(windows的API都會有這三個寄存器的保存,回復工作)
0040B54D mov esp,ebp
0040B54F pop ebp //恢復ebp寄存器
0040B550 ret //默認操作,D網管2j中TKg4h_O理C=育管`網O理k發達 地專%X無2|p.x1QcZ的_U恢復EIP:將堆棧中的00401087pop給EIP
執行完:0040B50B push edi如下圖:
ESP
:0012FE58與剛進入函數的時候的ESP:0012FF28之間的堆棧圖如下:
執行完:0040B51C rep stos dword ptr [edi]后EDI為:0012FF24,如下圖:
理解調用棧最重要的兩點是:棧的結構,EBP寄存器的作用。
首先要認識到這樣兩個事實:
1、一個函數調用動作可分解為:零到多個PUSH指令(用於參數入棧),一個CALL指令。CALL指令內部其實還暗含了一個將返回地址(即CALL指令下一條指令的地址)壓棧的動作。
2、幾乎所有本地編譯器都會在每個函數體之前插入類似如下指令:PUSH EBP; MOV EBP ESP;
即,在程序執行到一個函數的真正函數體時,已經有以下數據順序入棧:參數,返回地址,EBP。
由此得到類似如下的棧結構(參數入棧順序跟調用方式有關,這里以C語言默認的CDECL為例):
+| (棧底方向,高位地址) |
| .................... |
| .................... |
| 參數3 |
| 參數2 |
| 參數1 |
| 返回地址 |
-| 上一層[EBP] | <-------- [EBP]
“PUSH EBP”“MOV EBP ESP”這兩條指令實在大有深意:首先將EBP入棧,然后將棧頂指針ESP賦值給EBP。“MOV EBP ESP”這條指令表面上看是用ESP把EBP原來的值覆蓋了,其實不然——因為給EBP賦值之前,原EBP值已經被壓棧(位於棧頂),而新的EBP又恰恰指向棧頂。
此時EBP寄存器就已經處於一個非常重要的地位,該寄存器中存儲着棧中的一個地址(原EBP入棧后的棧頂),從該地址為基准,向上(棧底方向)能獲取返回地址、參數值,向下(棧頂方向)能獲取函數局部變量值,而該地址處又存儲着上一層函數調用時的EBP值!
一般而言,ss:[ebp+4]處為返回地址,ss:[ebp+8]處為第一個參數值(最后一個入棧的參數值,此處假設其占用4字節內存),ss:[ebp-4]處為第一個局部變量,ss:[ebp]處為上一層EBP值。
由於EBP中的地址處總是“上一層函數調用時的EBP值”,而在每一層函數調用中,都能通過當時的EBP值“向上(棧底方向)能獲取返回地址、參數值,向下(棧頂方向)能獲取函數局部變量值”。
如此形成遞歸,直至到達棧底。這就是函數調用棧。
編譯器對EBP的使用實在太精妙了。
從當前EBP出發,逐層向上找到所有的EBP是非常容易的:
unsigned int _ebp;
__asm _ebp, ebp;
while (not stack bottom)
{
//...
_ebp = *(unsigned int*)_ebp;
}
