x86 反汇编/调用约定示例
这里是一个简单的 C 函数
int MyFunction(int x, int y)
{
return (x * 2) + (y * 3);
}
使用 cl.exe,我们将为 MyFunction 生成 3 个单独的清单,分别使用 CDECL、FASTCALL 和 STDCALL 调用约定。在命令行中,您可以使用几个开关来强制编译器更改默认值
/Gd
: 默认调用约定为 CDECL/Gr
: 默认调用约定为 FASTCALL/Gz
: 默认调用约定为 STDCALL
使用这些命令行选项,以下是清单
int MyFunction(int x, int y)
{
return (x * 2) + (y * 3);
}
变为
PUBLIC _MyFunction
_TEXT SEGMENT
_x$ = 8 ; size = 4
_y$ = 12 ; size = 4
_MyFunction PROC NEAR
; Line 4
push ebp
mov ebp, esp
; Line 5
mov eax, _y$[ebp]
imul eax, 3
mov ecx, _x$[ebp]
lea eax, [eax+ecx*2]
; Line 6
pop ebp
ret 0
_MyFunction ENDP
_TEXT ENDS
END
在函数进入时,ESP 指向由调用者在堆栈上推送的返回地址call指令(即 EIP 的先前内容)。堆栈中任何高于入口 ESP 地址的参数都在执行 call 之前由调用者推送;在本例中,第一个参数位于 ESP+4(EIP 为 4 字节宽)处的偏移量,以及在堆栈上推送 EBP 后再加 4 个字节。因此,在第 5 行,ESP 指向保存的帧指针 EBP,并且参数位于 ESP+8(x)和 ESP+12(y)的地址。
对于 CDECL,调用者以从右到左的顺序将参数推入堆栈。由于使用了 ret 0,因此必须由调用者清理堆栈。
有趣的是,请注意在本函数中如何使用 lea 来同时执行乘法(ecx * 2)和将该数量加到 eax。类似于此的直观性不强的指令将在关于 直观性不强的指令 的章节中进一步探讨。
int MyFunction(int x, int y)
{
return (x * 2) + (y * 3);
}
变为
PUBLIC @MyFunction@8
_TEXT SEGMENT
_y$ = -8 ; size = 4
_x$ = -4 ; size = 4
@MyFunction@8 PROC NEAR
; _x$ = ecx
; _y$ = edx
; Line 4
push ebp
mov ebp, esp
sub esp, 8
mov _y$[ebp], edx
mov _x$[ebp], ecx
; Line 5
mov eax, _y$[ebp]
imul eax, 3
mov ecx, _x$[ebp]
lea eax, [eax+ecx*2]
; Line 6
mov esp, ebp
pop ebp
ret 0
@MyFunction@8 ENDP
_TEXT ENDS
END
该函数是在关闭优化的情况下编译的。在这里,我们可以看到参数首先保存在堆栈中,然后从堆栈中获取,而不是直接使用。这是因为编译器希望通过堆栈访问以一致的方式使用所有参数,而不仅仅是一个编译器这样操作。
没有使用正偏移量访问入口 SP 的参数,似乎调用者没有将它们推入,因此它可以使用 ret 0。让我们进一步调查
int FastTest(int x, int y, int z, int a, int b, int c)
{
return x * y * z * a * b * c;
}
以及相应的清单
PUBLIC @FastTest@24
_TEXT SEGMENT
_y$ = -8 ; size = 4
_x$ = -4 ; size = 4
_z$ = 8 ; size = 4
_a$ = 12 ; size = 4
_b$ = 16 ; size = 4
_c$ = 20 ; size = 4
@FastTest@24 PROC NEAR
; _x$ = ecx
; _y$ = edx
; Line 2
push ebp
mov ebp, esp
sub esp, 8
mov _y$[ebp], edx
mov _x$[ebp], ecx
; Line 3
mov eax, _x$[ebp]
imul eax, DWORD PTR _y$[ebp]
imul eax, DWORD PTR _z$[ebp]
imul eax, DWORD PTR _a$[ebp]
imul eax, DWORD PTR _b$[ebp]
imul eax, DWORD PTR _c$[ebp]
; Line 4
mov esp, ebp
pop ebp
ret 16 ; 00000010H
现在我们有 6 个参数,四个参数由调用者从右到左推送,最后两个参数再次传递到 cx/dx 中,并以与先前示例相同的方式处理。堆栈清理由 ret 16 完成,这对应于在执行 call 之前推送的 4 个参数。
对于 FASTCALL,编译器将尝试在寄存器中传递参数,如果寄存器不足,调用者仍然会以从右到左的顺序将它们推入堆栈。堆栈清理由被调用者完成。它被称为 FASTCALL,因为如果参数可以在寄存器中传递(对于 64 位 CPU,最大数量为 6),则不需要进行堆栈推送/清理。
函数的名称修饰方案:@MyFunction@n,其中 n 是所有参数所需的堆栈大小。
int MyFunction(int x, int y)
{
return (x * 2) + (y * 3);
}
变为
PUBLIC _MyFunction@8
_TEXT SEGMENT
_x$ = 8 ; size = 4
_y$ = 12 ; size = 4
_MyFunction@8 PROC NEAR
; Line 4
push ebp
mov ebp, esp
; Line 5
mov eax, _y$[ebp]
imul eax, 3
mov ecx, _x$[ebp]
lea eax, [eax+ecx*2]
; Line 6
pop ebp
ret 8
_MyFunction@8 ENDP
_TEXT ENDS
END
STDCALL 清单与 CDECL 清单只有一个区别,即它使用“ret 8”进行堆栈的自我清理。让我们举一个参数更多的例子
int STDCALLTest(int x, int y, int z, int a, int b, int c)
{
return x * y * z * a * b * c;
}
让我们看看 cl.exe 如何将该函数转换为汇编代码
PUBLIC _STDCALLTest@24
_TEXT SEGMENT
_x$ = 8 ; size = 4
_y$ = 12 ; size = 4
_z$ = 16 ; size = 4
_a$ = 20 ; size = 4
_b$ = 24 ; size = 4
_c$ = 28 ; size = 4
_STDCALLTest@24 PROC NEAR
; Line 2
push ebp
mov ebp, esp
; Line 3
mov eax, _x$[ebp]
imul eax, DWORD PTR _y$[ebp]
imul eax, DWORD PTR _z$[ebp]
imul eax, DWORD PTR _a$[ebp]
imul eax, DWORD PTR _b$[ebp]
imul eax, DWORD PTR _c$[ebp]
; Line 4
pop ebp
ret 24 ; 00000018H
_STDCALLTest@24 ENDP
_TEXT ENDS
END
是的,STDCALL 和 CDECL 之间的唯一区别是前者在被调用者中进行堆栈清理,后者在调用者中进行。由于 X86 的“ret n”,这在 X86 中节省了一些操作。
我们将使用两个示例 C 函数来演示 GCC 如何实现调用约定
int MyFunction1(int x, int y)
{
return (x * 2) + (y * 3);
}
以及
int MyFunction2(int x, int y, int z, int a, int b, int c)
{
return x * y * (z + 1) * (a + 2) * (b + 3) * (c + 4);
}
GCC 没有命令行参数来强制默认调用约定从 CDECL(对于 C)更改,因此它们将在文本中使用指令手动定义:__cdecl、__fastcall 和 __stdcall。
第一个函数(MyFunction1)提供以下汇编清单
_MyFunction1:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax
leal (%eax,%eax), %ecx
movl 12(%ebp), %edx
movl %edx, %eax
addl %eax, %eax
addl %edx, %eax
leal (%eax,%ecx), %eax
popl %ebp
ret
首先,我们可以看到名称修饰与 cl.exe 中的相同。我们还可以看到 ret 指令没有参数,因此调用函数正在清理堆栈。但是,由于 GCC 在清单中没有为我们提供变量名,因此我们必须推断出哪些参数是哪些。在设置堆栈帧后,函数的第一条指令是“movl 8(%ebp), %eax”。一旦我们记住(或第一次学习)GAS 指令具有以下通用形式
instruction src, dest
我们意识到,ebp(堆栈上推送的最后一个参数)偏移量 +8 处的值被移动到 eax。leal 指令更难解读,尤其是在我们没有任何 GAS 指令经验的情况下。形式“leal(reg1,reg2), dest”将括号中的值加在一起,并将值存储到 dest 中。转换为 Intel 语法,我们得到指令
lea ecx, [eax + eax]
这显然与乘以 2 相同。然后,访问的第一个值必须是传递的最后一个值,这似乎表明这里的值是从右到左传递的。为了证明这一点,我们将查看清单的下一部分
movl 12(%ebp), %edx
movl %edx, %eax
addl %eax, %eax
addl %edx, %eax
leal (%eax,%ecx), %eax
ebp 偏移量 +12 处的值被移动到 edx。然后 edx 被移动到 eax。然后将 eax 加到它自身(eax * 2),然后加回到 edx(edx + eax)。请记住,eax = 2 * edx,所以结果是 edx * 3。这显然是 y 参数,它在堆栈中距离最远,因此是最先被推送的。因此,GCC 上的 CDECL 通过以从右到左的顺序在堆栈上传递参数来实现,与 cl.exe 相同。
.globl @MyFunction1@8
.def @MyFunction1@8; .scl 2; .type 32; .endef
@MyFunction1@8:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
movl %ecx, -4(%ebp)
movl %edx, -8(%ebp)
movl -4(%ebp), %eax
leal (%eax,%eax), %ecx
movl -8(%ebp), %edx
movl %edx, %eax
addl %eax, %eax
addl %edx, %eax
leal (%eax,%ecx), %eax
leave
ret
首先注意,使用的名称修饰与 cl.exe 中的相同。敏锐的观察者已经意识到 GCC 使用了与 cl.exe 相同的技巧,即将 fastcall 参数从它们的寄存器(再次是 ecx 和 edx)移动到堆栈上的负偏移量。同样,优化被关闭。ecx 被移动到第一个位置(-4),edx 被移动到第二个位置(-8)。与上面的 CDECL 示例一样,-4(ecx)加倍,-8(edx)加倍。因此,-4(ecx)是 x,-8(edx)是 y。从这个清单看来,似乎值是按从左到右的顺序传递的,尽管我们需要看一下更大的 MyFunction2 示例
.globl @MyFunction2@24
.def @MyFunction2@24; .scl 2; .type 32; .endef
@MyFunction2@24:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
movl %ecx, -4(%ebp)
movl %edx, -8(%ebp)
movl -4(%ebp), %eax
imull -8(%ebp), %eax
movl 8(%ebp), %edx
incl %edx
imull %edx, %eax
movl 12(%ebp), %edx
addl $2, %edx
imull %edx, %eax
movl 16(%ebp), %edx
addl $3, %edx
imull %edx, %eax
movl 20(%ebp), %edx
addl $4, %edx
imull %edx, %eax
leave
ret $16
通过遵循 MyFunction2 中连续参数被添加到递增常数这一事实,我们可以推断出每个参数的位置。-4 仍然是 x,-8 仍然是 y。+8 按 1(z)递增,+12 按 2(a)递增。+16 按 3(b)递增,+20 按 4(c)递增。让我们列出这些值
z = [ebp + 8] a = [ebp + 12] b = [ebp + 16] c = [ebp + 20]
c 距离最远,因此是最先被推送的。z 距离顶部最近,因此是最晚被推送的。因此,参数以从右到左的顺序被推送,就像 cl.exe 一样。
然后让我们比较一下 GCC 中 MyFunction1 的实现
.globl _MyFunction1@8
.def _MyFunction1@8; .scl 2; .type 32; .endef
_MyFunction1@8:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax
leal (%eax,%eax), %ecx
movl 12(%ebp), %edx
movl %edx, %eax
addl %eax, %eax
addl %edx, %eax
leal (%eax,%ecx), %eax
popl %ebp
ret $8
名称修饰与 cl.exe 中的相同,因此 STDCALL 函数(以及 CDECL 和 FASTCALL)可以使用任一编译器进行汇编,并可以使用任一链接器进行链接,似乎是。设置堆栈帧,然后 [ebp + 8] 处的值加倍。之后,[ebp + 12] 处的值加倍。因此,+8 是 x,+12 是 y。同样,这些值按从右到左的顺序被推送。此函数还使用“ret 8”指令清理自己的堆栈。
查看一个更大的示例
.globl _MyFunction2@24
.def _MyFunction2@24; .scl 2; .type 32; .endef
_MyFunction2@24:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax
imull 12(%ebp), %eax
movl 16(%ebp), %edx
incl %edx
imull %edx, %eax
movl 20(%ebp), %edx
addl $2, %edx
imull %edx, %eax
movl 24(%ebp), %edx
addl $3, %edx
imull %edx, %eax
movl 28(%ebp), %edx
addl $4, %edx
imull %edx, %eax
popl %ebp
ret $24
我们在这里可以看到,ebp 偏移量 +8 和 +12 处的值仍然分别是 x 和 y。+16 处的值按 1 递增,+20 处的值按 2 递增,依此类推,一直到 +28 处的值。因此,我们可以创建以下表格
x = [ebp + 8] y = [ebp + 12] z = [ebp + 16] a = [ebp + 20] b = [ebp + 24] c = [ebp + 28]
其中 c 最先被推送,x 最晚被推送。因此,这些参数也按从右到左的顺序被推送。然后,此函数还使用“ret 24”指令清理堆栈上的 24 个字节。
识别以下 C 函数的调用约定
int MyFunction(int a, int b)
{
return a + b;
}
该函数是用 C 编写的,没有其他说明符,因此默认情况下为 CDECL。
识别函数 MyFunction 的调用约定
:_MyFunction@12
push ebp
mov ebp, esp
...
pop ebp
ret 12
该函数包含 STDCALL 函数的修饰名称,并清理自己的堆栈。因此,它是一个 STDCALL 函数。
此代码片段是未命名汇编函数的整个函数体。识别此函数的调用约定。
push ebp
mov ebp, esp
add eax, edx
pop ebp
ret
该函数设置了一个堆栈帧,因此我们知道编译器没有对其进行任何“奇怪”的处理。它访问尚未初始化的寄存器,即 edx 和 eax 寄存器。因此,它是一个 FASTCALL 函数。
push ebp
mov ebp, esp
mov eax, [ebp + 8]
pop ebp
ret 16
该函数有一个标准的堆栈帧,并且 ret 指令有一个参数来清理自己的堆栈。此外,它从堆栈中访问参数。因此,它是一个 STDCALL 函数。
关于以下函数调用,我们能得出什么结论?
mov ecx, x
push eax
mov eax, ss:[ebp - 4]
push eax
mov al, ss:[ebp - 3]
call @__Load?$Container__XXXY_?Fcii
有两件事应该立即引起我们的注意。首先是在函数调用之前,一个值被存储到 ecx 中。此外,函数名本身也被严重改编。此示例必须使用 C++ THISCALL 约定。在函数的改编名称中,我们可以选出两个英文单词,“Load”和“Container”。在不知道这种改编方案的具体情况的情况下,无法确定哪个词是函数名,哪个词是类名。
我们可以选出两个被传递给函数的 32 位变量和一个 8 位变量。第一个位于 eax 中,第二个最初位于 ebp 偏移 -4 的堆栈上,第三个位于 ebp 偏移 -3 的位置。在 C++ 中,这些可能对应于两个 int 变量和一个 char 变量。请注意,在改编函数名的末尾是三个小写字母“cii”。我们不能确定,但似乎这三个字母对应于三个参数(char、int、int)。我们无法从这里确定函数是否返回值,因此我们将假设函数返回 void。
假设“Load”是函数名,“Container”是类名(也可能反过来),以下是我们的函数定义
class Container
{
void Load(char, int, int);
}