发布于

调用约定

作者

本文将对 32-bit 和 64-bit 程序的调用约定进行更为深入的探讨。

0x01 单个参数

calling-conventions-one-param.zip

Binary

1x01 源码

我们先快速浏览一下源码:

source.c
#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+0x80xdeadbeef 进行比较。

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 源码

source.c
#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)的数据结构。当程序调用一个函数时,函数的参数被压入栈中,而函数内部则可以按照相反的顺序逐个弹出这些参数进行处理。这种设计有几个原因:
便于管理栈指针:压栈和出栈操作可以通过简单的栈指针操作来实现,无需复杂的数据重排。这样可以减小指令的数量和复杂度,提高执行效率。
一致性:使用相同的栈结构来处理参数和局部变量可以简化函数调用和返回的实现,使得代码更加一致和可维护。
节省存储空间:压栈和出栈操作可以在相对较小的内存区域进行,不需要预留很大的内存来存储参数,这有助于节省内存空间。
举个例子:如果一个函数有三个参数:abc,调用函数时的顺序为 func(c, b, a),则在栈中的存储顺序为 push apush bpush c,而在函数内部获取参数的顺序为从栈顶依次弹出 pop cpop bpop 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 之外,我们还把参数压到了 rsirdx

0x03 更大的 64-bits 值

只是为了表明实际上最终使用的是 rdi 而不是 edi,我将更改原始的单参数代码以使用更大的数字:

source.c
#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