汇编中的函数调用我们需要着重讲述一下。一般我们用 call 发起调用,最后需要用 ret/retn/retf 来返回。我们逐个来看汇编中的函数调用。
1、相关指令
- call ADDRESS:
发起调用,根据 ADDRESS 的地址决定是发起段内调用还是段间调用。当是段内调用时,将 eip 入栈,然后 jmp 到 ADDRESS 处;当是段间调用时,将 cs、eip 依次入栈,然后 jmp 到 ADDRESS 处。
- enter:
进行堆栈准备工作,将 ebp 原来的内容入栈,将当前 esp 存入 ebp。相当于:
push ebp
mov ebp, esp
- leave:
恢复堆栈,是 enter 指令的逆向操作,如你所想:
mov esp, ebp
pop ebp
- retn [NUMBER]:
段内返回,不带 [] 内的 NUMBER 时,表示是无参的函数调用或者 __cdecl 调用约定,当有 NUMBER 时,返回后将 esp 的值加 NUMBER,表示将入栈的参数全部出栈,一般是 __stdcall 调用约定。作用如下:
pop eip
add esp, NUMBER
- retf [NUMBER]:
段间返回,后面的 NUMBER 含义和 retn 的一样,只是出栈时还要将 cs 出栈,如下:
pop eip
pop cs
add esp, NUMBER
- ret [NUMBER]:
是 leave 和 retn 的结合体,恢复堆栈后返回。NUMBER 的含义和 retn 的含义一样:
leave
retn NUMBER
2、函数堆栈
当我们发起函数调用时,当函数准备好堆栈时堆栈的内容是什么样的呢?用个例子一探究竟。
HWND GetHWndByClassName(LPCWSTR lpszClsName)
{
HWND hWnd = NULL;
hWnd = ::FindWindowW(lpszClsName, NULL);
return hWnd;
}
HWND hTrayWnd = GetHWndByClassName(L"Shell_TrayWnd);
比如我们有这样一段简单的代码,那么它未优化的汇编代码如下:
.data
__str_Shell_Tray_Wnd db 'Shell_TrayWnd', 0
.code
; ....
; HWND GetHWndByClassName(LPCWSTR)
GetHWndByClassName proc near
loc_hWnd = dword ptr -4h
lpszClsName = dword ptr 8
; 准备堆栈
push ebp
mov ebp, esp
; 为局部变量分配内存
sub esp, 4
; 保存寄存器
push ebx
push esi
push edi
xor esi, esi
; 调用 FindWindowW
mov eax, [ebp + lpszClsName]
push eax
call ds:FindWindowW
; 将 FindWindowW 返回值保存到局部变量
mov [ebp + loc_hWnd], eax
; 清场
leave
retn 4
GetHWndByClassName endp
; 调用 GetHWndByClassName 的地方
push offset __str_Shell_Tray_Wnd
call GetHwndByClassName
mov [ebp + loc_hTrayWnd], eax
在函数 GetHWndByClassName中,堆栈明细如下:
如你所见,一个__stdcall 调用约定下的汇编函数一般的结构都是这样:
__My_Proc proc near
loc_var_n dword ptr -LOC_BYTES
..
loc_var_2 dword ptr -8h
loc_var_1 dword ptr -4h
param_1 dword ptr 8h
param_2 dword ptr 0Ch
...
param_n dword ptr PARAM_BYTES
; 函数体开始
; 准备堆栈
push ebp
mov ebp, esp
; 为局部变量分配内存
sub esp, LOC_BYTES
; 保存寄存器
push ebx
push esi
push edi
xor esi, esi
; 函数正文
; ...
; ...
; 函数清场
leave
retn PARAM_BYTES
__My_Proc endp
函数的第一个参数的偏移是
8h,第二个参数的偏移是 (8h + 第一个参数字节大小)……依次类推。函数的第一个局部变量的偏移是 -4h,第二个局部变量的偏移是 (-4h - 第一个局部变量的字节大小)……依次类推。
3、__cdecl 调用约定
当函数是__cdecl 调用约定时,函数会怎么样呢?比如我们有一个函数:
void __cdecl foo(int a, int b)
{
// ...
}
foo(89, 24);
那么,它的汇编代码将如下:
_foo proc near
param_b = dword ptr -8
param_a = dword ptr -4
enter
; ...
leave
retn
_foo endp
; 调用 foo
push 24
push 89
call _foo
; 调用者平衡堆栈
add esp, 8
可以看到,对于 __cdecl 调用约定的函数 foo,最后的返回 retn 后不加任何数值,在调用房执行完 call 后,再将 esp 加上所有传递参数的总字节数大小。所以,在逆向过程中,如果你看到一个 call 后紧接着有 add esp, NUMBER 的操作,基本可以断定被调的这个函数是 __cdecl 调用约定。
4、函数返回值
那么,当函数具有返回值时,堆栈又是如何呢?对于具有返回值的函数,一般有两种情况。
- 返回值小——可以用 eax 存储
对于这种情况,将直接将返回值存储到 eax 中。调用完函数后,调用者直接从 eax 中取值。如:
int getInteger()
{
int retVal = 0;
scanf("%d", &retVal);
return retVal;
}
int myAge = getInteger();
printf("%d\n", myAge);
它的未经优化的汇编代码将如下所示:
.data
__str_Scanf_d db '%d', 0
__str_Printf_d db '%d\n', 0
.code
getInteger proc near
loc_retVal = dword ptr -4
; 准备堆栈
push ebp
mov ebp, esp
sub esp, 4
; retVal 置0
xor eax, eax
mov [ebp + loc_retVal], eax
; 调用 scanf
lea eax, [ebp + loc_retVal]
push eax
push offset __strScanf_d
call _scanf
; 平衡 __cdecl 调用约定的 scanf 函数堆栈
add esp, 8
; 将返回值保存在 eax 中
mov eax, [ebp + loc_retVal]
; 返回
retn
getInteger endp
; 调用 getInteger
loc_myAge = dword ptr -4
call getInteger
mov [ebp + loc_myAge], eax
; 执行 printf
mov eax, [ebp + loc_myAge]
push eax
push offset __str_Printf_d
call _printf
; 平衡 __cdecl 调用堆栈
add esp, 8
- 返回值是结构体
对于返回值是结构体或类的函数,处理过程可能不一样。一般来说,汇编代码会将取返回值的指针加到参数中最后入栈,最后将这个返回值指针保存到 eax 中返回。如下:
struct CFoo
{
int m_val;
HICON m_hIcon;
char* m_szName;
};
CFoo getFoo(char* lpszName)
{
CFoo tmp;
scanf("%d", &tmp.m_val);
tmp.m_hIcon = 0;
tmp.m_szName = lpszName;
return tmp;
}
int main()
{
CFoo foo = getFoo("foo_Name");
printf("%d, %s", foo.m_val, foo.m_szName);
return 0;
}
未优化汇编代码如下:
.data
__str_Foo_Name db 'foo_name', 0
__str_Scanf_d db '%d', 0
__str_Printf db '%d, %s', 0
.code
getFoo proc near
tmp_offset = dword ptr -0Ch
tmp_m_val = dword ptr -0Ch
tmp_m_hIcon = dword ptr -8
tmp_m_szName = dword ptr -4
ptr_retValue = dword ptr 8
param_lpszName = dword ptr 0Ch
push ebp
mov ebp, esp
sub esp, 0ch
push esi
push ecx
lea eax, [ebp + tmp_m_val]
push eax
push offset __str_Scanf_d
call _scanf
add esp, 8
mov [ebp + tmp_m_hIcon], 0
mov eax, [ebp + tmp_m_va;]
mov ecx, [ebp + param_lpszName]
mov esi, [ebp + tmp_m_hIcon]
; 将结果存入返回值
mov edx, [ebp + ptr_retValue]
mov [edx], eax
mov [edx + 4], esi
mov [edx + 8], ecx
; 将返回值地址存入eax
mov eax, [ebp + ptr_retValue]
pop ecx
pop esi
mov esp, ebp
pop ebp
retn
getFoo endp
main proc near
loc_foo = dword ptr -0Ch
; ...
; 参数入栈
push offset __str_Foo_Name
; 返回值结构体地址入栈
lea eax, [ebp + loc_foo]
push eax
; 函数调用
call getFoo
add esp, 8
; 其他操作
; ...
main endp
通过示例可以看到,对于返回值是结构体的函数,先将参数入栈,最后将存放返回值的结构体地址入栈,最终在该函数中赋值完毕后,将该返回值存入 eax 并返回。
当然,这只是未经优化的汇编代码,比较容易理解。在大部分的逆向过程中,你得面对经过优化的代码,相信我,那里面的逻辑将非常复杂,所以,你必须具有非常扎实的汇编基础。下一篇将通过一个实际示例从头到尾利用 IDA Pro 来展示如何逆向分析。