- 发布于
调用约定
- 作者
- Name
- CuB3y0nd
- GitHub
- @CuB3y0nd
本文将对 32-bit 和 64-bit 程序的调用约定进行更为深入的探讨。
0x01 单个参数
calling-conventions-one-param.zip
Binary
1x01 源码
我们先快速浏览一下源码:
#include <stdio.h>
void vuln(int check) {
if (check == 0xdeadbeef) {
puts("Nice!");
} else {
puts("Not nice!");
}
}
int main() {
vuln(0xdeadbeef);
vuln(0xdeadc0de);
}
分别运行 32-bit 和 64-bit 的 vuln,我们会得到相同的输出:
Nice!
Not nice!
1x02 分析 vuln-32
用 pwndbg 对其进行反汇编。
$ disass main
Dump of assembler code for function main:
0x080491ac <+0>: lea ecx,[esp+0x4]
0x080491b0 <+4>: and esp,0xfffffff0
0x080491b3 <+7>: push DWORD PTR [ecx-0x4]
0x080491b6 <+10>: push ebp
0x080491b7 <+11>: mov ebp,esp
0x080491b9 <+13>: push ecx
0x080491ba <+14>: sub esp,0x4
0x080491bd <+17>: call 0x80491f4 <__x86.get_pc_thunk.ax>
0x080491c2 <+22>: add eax,0x2e3e
0x080491c7 <+27>: sub esp,0xc
0x080491ca <+30>: push 0xdeadbeef
0x080491cf <+35>: call 0x8049162 <vuln>
0x080491d4 <+40>: add esp,0x10
0x080491d7 <+43>: sub esp,0xc
0x080491da <+46>: push 0xdeadc0de
0x080491df <+51>: call 0x8049162 <vuln>
0x080491e4 <+56>: add esp,0x10
0x080491e7 <+59>: mov eax,0x0
0x080491ec <+64>: mov ecx,DWORD PTR [ebp-0x4]
0x080491ef <+67>: leave
0x080491f0 <+68>: lea esp,[ecx-0x4]
0x080491f3 <+71>: ret
End of assembler dump.
如果我们仔细观察对 vuln
函数的调用,我们会看到一个 Pattern。
push 0xdeadbeef
call 0x8049162 <vuln>
[...]
push 0xdeadc0de
call 0x8049162 <vuln>
在调用函数之前,我们实际上先将 参数
压入了栈。现在让我们来研究一下 vuln
函数。
$ b *0x08049162
$ r
Breakpoint 1, 0x08049166 in vuln ()
$ x/20wx $esp
0xffffd6cc: 0x080491d4 0xdeadbeef
第一个值是我之前的博客中提到的 返回地址
,而第二个值是 参数
。这是有道理的,因为返回地址在 调用
期间被压入栈,因此它应该位于栈顶。现在让我们反汇编 vuln
。
Dump of assembler code for function vuln:
0x08049162 <+0>: push ebp
0x08049163 <+1>: mov ebp,esp
0x08049165 <+3>: push ebx
0x08049166 <+4>: sub esp,0x4
0x08049169 <+7>: call 0x80491f4 <__x86.get_pc_thunk.ax>
0x0804916e <+12>: add eax,0x2e92
0x08049173 <+17>: cmp DWORD PTR [ebp+0x8],0xdeadbeef
0x0804917a <+24>: jne 0x8049192 <vuln+48>
0x0804917c <+26>: sub esp,0xc
0x0804917f <+29>: lea edx,[eax-0x1ff8]
0x08049185 <+35>: push edx
0x08049186 <+36>: mov ebx,eax
0x08049188 <+38>: call 0x8049030 <puts@plt>
0x0804918d <+43>: add esp,0x10
0x08049190 <+46>: jmp 0x80491a6 <vuln+68>
0x08049192 <+48>: sub esp,0xc
0x08049195 <+51>: lea edx,[eax-0x1ff2]
0x0804919b <+57>: push edx
0x0804919c <+58>: mov ebx,eax
0x0804919e <+60>: call 0x8049030 <puts@plt>
0x080491a3 <+65>: add esp,0x10
0x080491a6 <+68>: nop
0x080491a7 <+69>: mov ebx,DWORD PTR [ebp-0x4]
0x080491aa <+72>: leave
0x080491ab <+73>: ret
End of assembler dump.
在这里,我显示了该命令的完整输出。因为其中有很多内容都是相关的。我们发现,有一个地址为 ebp+0x8
的局部变量。后来,又将 ebp+0x8
与 0xdeadbeef
进行比较。
cmp DWORD PTR [ebp+0x8],0xdeadbeef
因此可以分析出 ebp+0x8
就是我们的参数。
现在我们知道,当有一个参数时,它会被压入栈,使栈看起来像:
return address param_1
1x03 分析 vuln-64
我们在这里再次反汇编 main
:
Dump of assembler code for function main:
0x0000000000401153 <+0>: push rbp
0x0000000000401154 <+1>: mov rbp,rsp
0x0000000000401157 <+4>: mov edi,0xdeadbeef
0x000000000040115c <+9>: call 0x401122 <vuln>
0x0000000000401161 <+14>: mov edi,0xdeadc0de
0x0000000000401166 <+19>: call 0x401122 <vuln>
0x000000000040116b <+24>: mov eax,0x0
0x0000000000401170 <+29>: pop rbp
0x0000000000401171 <+30>: ret
End of assembler dump.
我们发现 64-bit 和 32-bit 在传参上不一样了。正如我在这篇 博客 中所说的,参数被移至 rdi
(这里的反汇编中写的是 edi
,但 edi
只是 rdi
的低 32 bits 寄存器。原因是我们传入的参数只有 32 bits 大小,所以改为 EDI
可以节省内存)。如果我们再次中断 vuln
,我们可以使用以下命令检查 rdi
:
$ regs rdi
Note
如果只使用 regs
则会显示所有寄存器。
$ b *0x000000000040115c
$ r
Breakpoint 1, 0x000000000040115c in main ()
$ regs rdi
*RDI 0xdeadbeef
Note
64-bit 程序中,寄存器用于存放参数。但返回地址仍然压入栈,并且在 ROP 中放置在函数地址之后。
注:只有前六个参数才会分别保存在寄存器中,如果还有更多的参数的话则会保存在栈上。
0x02 多个参数
calling-convention-multi-param.zip
Binary
1x01 源码
#include <stdio.h>
void vuln(int check, int check2, int check3) {
if (check == 0xdeadbeef && check2 == 0xdeadc0de && check3 == 0xc0ded00d) {
puts("Nice!");
} else {
puts("Not nice!");
}
}
int main() {
vuln(0xdeadbeef, 0xdeadc0de, 0xc0ded00d);
vuln(0xdeadc0de, 0x12345678, 0xabcdef10);
}
1x02 分析 vuln-32
由于我们之前已经看到了几乎相同的二进制文件的完整反汇编,因此在这里我只会列出重要的部分:
0x080491dd <+30>: push 0xc0ded00d
0x080491e2 <+35>: push 0xdeadc0de
0x080491e7 <+40>: push 0xdeadbeef
0x080491ec <+45>: call 0x8049162 <vuln>
[...]
0x080491f7 <+56>: push 0xabcdef10
0x080491fc <+61>: push 0x12345678
0x08049201 <+66>: push 0xdeadc0de
0x08049206 <+71>: call 0x8049162 <vuln>
我们发现 压栈
和 传参
顺序是相反的。这是因为取参的时候是从低地址向高地址取参,而先入栈的在高地址,正好符合了取参从低向高的规则。
Note
大多数计算机系统结构中,栈是一种后进先出(Last In First Out,LIFO)的数据结构。当程序调用一个函数时,函数的参数被压入栈中,而函数内部则可以按照相反的顺序逐个弹出这些参数进行处理。这种设计有几个原因:
便于管理栈指针:压栈和出栈操作可以通过简单的栈指针操作来实现,无需复杂的数据重排。这样可以减小指令的数量和复杂度,提高执行效率。
一致性:使用相同的栈结构来处理参数和局部变量可以简化函数调用和返回的实现,使得代码更加一致和可维护。
节省存储空间:压栈和出栈操作可以在相对较小的内存区域进行,不需要预留很大的内存来存储参数,这有助于节省内存空间。
举个例子:如果一个函数有三个参数:a
、b
和 c
,调用函数时的顺序为 func(c, b, a)
,则在栈中的存储顺序为 push a
、push b
、push c
,而在函数内部获取参数的顺序为从栈顶依次弹出 pop c
、pop b
、pop a
。
虽然压栈和实际程序传参的顺序相反,但这种细节是由编译器和计算机体系结构来处理的,因此我们无需过多考虑。编译器会生成适当的指令来正确处理函数参数的压栈和出栈操作,以确保函数调用的正确执行。
$ b *0x080491ec
$ r
Breakpoint 1, 0x080491ec in main ()
$ s
$ x/20wx $esp
0xffffd6bc: 0x080491f1 0xdeadbeef 0xdeadc0de 0xc0ded00d
因此,如何将更多参数放置在栈上就变得非常清楚了。
return address param1 param2 param3 [...] paramN
1x03 分析 vuln-64
mov edx,0xc0ded00d
mov esi,0xdeadc0de
mov edi,0xdeadbeef
call 0x401122 <vuln>
[...]
mov edx,0xabcdef10
mov esi,0x12345678
mov edi,0xdeadc0de
call 0x401122 <vuln>
同理,根据上面的调试步骤查看寄存器内容,我们可以发现:除了 rdi
之外,我们还把参数压到了 rsi
和 rdx
。
0x03 更大的 64-bits 值
只是为了表明实际上最终使用的是 rdi
而不是 edi
,我将更改原始的单参数代码以使用更大的数字:
#include <stdio.h>
void vuln(long check) {
if (check == 0xdeadbeefc0dedd00d) {
puts("Nice!");
}
}
int main() {
vuln(0xdeadbeefc0dedd00d);
}
如果你反汇编 main
,你可以看到它被反汇编为:
movabs rax,0xeadbeefc0dedd00d
mov rdi,rax
call 0x401126 <vuln>
Note
movabs
用于将 mov
指令编码为 64-bit 指令,可将其视为 mov
。