前幾天和柯柯交流一個小問題,說是如何在一個函數(shù)內得到調用該函數(shù)的函數(shù)地址。有點拗口,就是說如果有一個函數(shù)A(當然我們在這個問題中并不知道它是哪個函數(shù))調用了B函數(shù),現(xiàn)在希望用個什么辦法得到A函數(shù)的地址。
我首先聯(lián)想到的是,一般調試器都能給出嵌套的函數(shù)調用關系。那么肯定是有什么辦法解決這個問題。上網(wǎng)查了一通之后只找到一些debug用的API和一些開發(fā)環(huán)境提供的調整宏等等,感覺不是很適用。后來想想,函數(shù)調用都涉及到“函數(shù)調用棧”(call stack),也許這里可以得到些什么信息。隱約回想起以前匯編課里老師講過的一些函數(shù)調用時要“壓棧”、“要保存現(xiàn)場”等,但已經(jīng)記得不太清楚了,于是就又上網(wǎng)找了些函數(shù)調用棧的知識,發(fā)現(xiàn)了一些有意思的信息(上網(wǎng)時看到ChinaUnix上的一篇,也是轉的,原地址和作者不詳,如果你知道請告訴我):
-
一個函數(shù)調用動作可分解為:零到多個PUSH指令(用于參數(shù)入棧),一個CALL指令。CALL指令內部其實還暗含了一個將返回地址(即CALL指令下一條指令的地址)壓棧的動作。
-
幾乎任何本地編譯器都會在每個函數(shù)體之前插入類似如下指令:PUSH EBP; MOV EBP ESP;即,在程式執(zhí)行到一個函數(shù)的真正函數(shù)體時,已有以下數(shù)據(jù)順序入棧:參數(shù),返回地址,EBP。
這里我最關心的是:函數(shù)調用時,會在棧里壓入返回地址,和EBP。
因為函數(shù)調用的返回地址,正是調用指令Call的下一個指令的地址,那么,有了返回地址,就可以得到Call指令的位置了。有Call指令的位置又能干什么呢?幸好匯編課里的知識還記得一點點:Call指令就是一個跳轉指令,它可以讓IP(instruction point[Thanks to RednaxelaFX])指向要跳轉的指令的地址,從那里開始執(zhí)行。對于函數(shù)調用來說,就是讓IP指向被調用的函數(shù)的地址。Call指令的操作數(shù)其實和被調用函數(shù)的地址有非常重要的關系。有了Call指令的操作數(shù),就可以計算出被調用函數(shù)的地址。
但僅僅有這個還不夠,比如,A調用了B,那么在A函數(shù)中肯定有一個Call指令,但這個Call指令中的操作數(shù)是和B函數(shù)地址相關的,與A的函數(shù)地址直接關系不大(至少在沒有其它信息的情況下,不能計算出A的地址)。而我們要得到的卻是A函數(shù)的地址。所以,得向上再找一層,找到調用A函數(shù)的地方,那個地方的Call指令里的操作數(shù)才和A函數(shù)地址有關。也就是說,Z函數(shù)調用了A函數(shù),A函數(shù)調用了B函數(shù)。現(xiàn)在要得到A函數(shù)的地址,我們得在Z函數(shù)里找Call指令的操作數(shù)。這時候EBP就派上用場了。本地編譯器在每個函數(shù)體之前插入的指令(PUSH EBP; MOV EBP ESP)構造了一個巧妙的結構,使得我們可以順著函數(shù)調用棧一層一層向上,找到所有調用關系。
如何向上查找呢?我們看看函數(shù)調用時棧、EBP的值的情況就知道了。
假設現(xiàn)在函數(shù)在正Z函數(shù)內執(zhí)行,那么此時棧和EBP的值可能是像下圖這樣的: 我們先不管現(xiàn)在EBP指向的內存(0x000f)中的內容XXX是什么(要不然會是雞生蛋生雞的問題),總之目前在棧中的著色塊中的內容是屬于函數(shù)Z的參數(shù),Z執(zhí)行結束后應該返回的地址以及Z函數(shù)的局部變量值。 現(xiàn)在Z函數(shù)調用A函數(shù),會先將傳給A的參數(shù)壓棧,然后將現(xiàn)在這個指令(就是"Call A"啦)的下一個指令的地址壓入棧中,以便A函數(shù)完后返回到Z中繼續(xù)執(zhí)行。然后進入A函數(shù)的內存空間,首先就是調用PUSH EBP,也就是將Z的EPB的內容(地址0x000f)壓入棧中,然后再MOV EBP ESP,讓EBP有一個新的棧頂(此時棧頂中的內容不就是Z函數(shù)時EBP的內容么?),然后再將A函數(shù)的局部變量壓入棧中,開始執(zhí)行A函數(shù)的代碼。這時,棧和EBP的情況就像如圖所示了: 哈,這樣就很清楚了,原來現(xiàn)在的EBP中的內容,正是上一級函數(shù)的EBP中的內容。而每一個函數(shù)的EBP指向的位置,向棧頂可以得到該函數(shù)的局部變量,向棧底可以得到函數(shù)的返回地址和參數(shù)。于是我們就可以根據(jù)這個結構層層向上,找到任何一層我們想找的函數(shù)EBP,從而也就能得到相應的返回地址了。 好,從B函數(shù)中得到Z函數(shù)對A函數(shù)調用點的返回地址的問題也就解決了。現(xiàn)在就是處理Call指令的問題了。 我在Visual Studio 2003的Debug版中進行反匯編調試,發(fā)現(xiàn)Call指令對應的機器指令都是5個byte,第一個byte(E8)是指令的器碼,猜想后面4個byte應該就是它的轉移的目標地址了。結果按這個地址去找,發(fā)現(xiàn)根本不對,想想?yún)R編也忘得差不多了,于是又去找了教程看看,才記起原來Call的操作數(shù)并不是絕對地址,而是偏移地址(跳轉目標地址-Call指令地址-sizeof(Call指令)),這樣就好辦了,我有返回地址,于是就有了向上5個byte就是Call的地址,再從這個地址中取出Call指令機器碼的后四個字節(jié),加上返回地址,就得到了目標地址。 原以為已經(jīng)搞定了。不過還有一個小插曲,就是在VS的Debug版中,Call并不直接跳到一函數(shù)中去(不知道為什么),而是跳到一塊代碼區(qū),這塊區(qū)域內排布了很多的Jmp指令用于各種跳轉(不知道為什么這么搞,也許是為調試的功能而設計的吧,誰知道?還請不吝賜教),不過沒關系,也就是多走一點路而已,Jmp指令的操作數(shù)和Call指令的意義是一樣的,最終Jmp是跳到函數(shù)代碼塊中去的。于是也就得到了想要的結果。 下面是代碼:
#include "stdafx.h"
2
3#include <string>
4
5unsigned int GetCallerAddress(void)
6{
7 unsigned int _ebp;
8 __asm mov _ebp, ebp
9
10 for (int i=2; i != 0; --i)
{
11 _ebp = *(unsigned int *)(_ebp);
12 }
13 unsigned int* ipAddress = (unsigned int*)(*(unsigned int *)(_ebp + 4));
14
15 ipAddress = (unsigned int*)((unsigned char *)ipAddress - 5);
16 unsigned int callInstructAddress = (unsigned int)ipAddress;
17 ipAddress = (unsigned int*)((unsigned char *)ipAddress + 1);
18 int funcAddrOffset = *ipAddress;
19 unsigned int *jumAddr = (unsigned int*)(callInstructAddress + funcAddrOffset + 5);
20 callInstructAddress = (unsigned int)jumAddr;
21 jumAddr = (unsigned int*)((unsigned char *)jumAddr + 1);
22 funcAddrOffset = *jumAddr;
23
24 return funcAddrOffset + callInstructAddress + 5;
25}
26
27void fun1();
28
29void fun2()
30{
31 fun1();
32}
33
34void fun3()
35{
36 fun1();
37}
38
39
40void fun1()
41{
42 unsigned int _ebp;
43 __asm mov _ebp, ebp // 取當前EBP
44 unsigned int _preEbp = *(unsigned int *)(_ebp); //得到上層函數(shù)的EBP
45 unsigned int* ipAddress = (unsigned int*)(*(unsigned int *)(_preEbp + 4)); // 取得返回地址
46 ipAddress = (unsigned int*)((unsigned char *)ipAddress - 5); // 得到Call指令地址
47 unsigned int callInstructAddress = (unsigned int)ipAddress; // 保存Call指令地址
48 ipAddress = (unsigned int*)((unsigned char *)ipAddress + 1);
49 int funcAddrOffset = *ipAddress; // 得到Call指令操作數(shù)
50 unsigned int *jumAddr = (unsigned int*)(callInstructAddress + funcAddrOffset + 5); // 找到Jmp指令
51 callInstructAddress = (unsigned int)jumAddr; // 保存jmp指令地址
52 jumAddr = (unsigned int*)((unsigned char *)jumAddr + 1);
53 funcAddrOffset = *jumAddr; // 得到jmp指令操作數(shù)
54 unsigned int addr = funcAddrOffset + callInstructAddress + 5; //得到函數(shù)地址
55
56 // 或者:unsigned int addr = GetCallerAddress();
57 printf("fun1 said : Caller Addres is 0x%08x\n", addr);
58}
59
60int _tmain(int argc, _TCHAR* argv[])
61{
62 fun1();
63 fun2();
64 fun3();
65
66 return 0;
67}
68
69
PS:后經(jīng)柯柯驗證,只有VC6、2003、2008的Debug版里才有效。Release版中不行,具體原因未細查(沒時間,畢竟不是"正務",呵呵)。以后再遇到時再細究吧。至少,現(xiàn)在對函數(shù)調用棧有了一些新的認識。很開心,呵呵呵。
這兩天翻看《Windows95編程大奧秘》(候捷譯)中,作者在分析PE格式的時候提到了,Call指令并不直接將程序控制轉到目標函數(shù),而是轉入一個Jmp的代碼塊中,由Jmp來最終將控制權交給函數(shù)。為什么這么做呢?作者給出的結論是這樣做可以使得載入器的行為變得簡單。因為Jmp的操作數(shù)是存放在idata區(qū)的一個“變量”,載入器只需要將被調用的DLL的地址一次寫入這個“變量”中就可以了。如果不這么做,那么需要在每個Call指令中的位置對函數(shù)地址進行Fixup,這樣會有更多的工作量。
OK,你不要笑話我說還看Win95的書哦。是的,我承認我不知道上面這段話中內容在現(xiàn)在的XP或是Vista或是2000中是否依然有效(因為我沒有去驗證過),但我看到了解決的方向。另外,這本書真的像候捷先生所說,“仍然極具技術價值”。我很認同!
鑒于RednaxelaFX的提示和本書給的信息,我下一步將償試從PE文件來找這個問題的解決之道,并順帶學習一下PE格式。讀完《Win95》后,也可能會寫篇讀后感,敬請留意,嘿嘿。