栈的调用约定

(1)函数参数的压栈顺序,(2)由调用者还是被调用者把参数弹出栈,(3)以及产生函数修饰名的方法。

普通函数

函数调用的三种约定,你都清楚吗 - 知乎 (zhihu.com)

__cdecl

C/CPP默认方式,参数从右向左入栈,主调函数负责栈平衡

__stdcall

WIN API默认方式,参数从右向左入栈,被调函数负责栈平衡

__fastacll

参数优先传给寄存器然后剩下的参数从右向左入栈传送

写测试代码观察反汇编结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include<stdio.h>

int __cdecl func1(int a, int b) {
return a + b;
}
int __stdcall func2(int a, int b) {
return a + b;
}
int __fastcall func3(int a, int b) {
return a + b;
}

int main() {
int a, b;
a = 2;
b = 3;

func1(a, b);
func2(a, b);
func3(a, b);
return 0;
}

VS:

发现64位没有任何区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
int main() {
00007FF615301880 push rbp
00007FF615301882 push rdi
00007FF615301883 sub rsp,128h
00007FF61530188A lea rbp,[rsp+20h]
00007FF61530188F lea rcx,[__CC37DA75_a@c (07FF615311008h)]
00007FF615301896 call __CheckForDebuggerJustMyCode (07FF615301361h)
00007FF61530189B nop
int a, b;
a = 2;
00007FF61530189C mov dword ptr [a],2
b = 3;
00007FF6153018A3 mov dword ptr [b],3

func1(a, b);
00007FF6153018AA mov edx,dword ptr [b]
00007FF6153018AD mov ecx,dword ptr [a]
00007FF6153018B0 call func1 (07FF61530114Fh)
00007FF6153018B5 nop
func2(a, b);
00007FF6153018B6 mov edx,dword ptr [b]
00007FF6153018B9 mov ecx,dword ptr [a]
00007FF6153018BC call func2 (07FF61530102Dh)
00007FF6153018C1 nop
func3(a, b);
00007FF6153018C2 mov edx,dword ptr [b]
00007FF6153018C5 mov ecx,dword ptr [a]
00007FF6153018C8 call func3 (07FF61530129Eh)
00007FF6153018CD nop
return 0;
00007FF6153018CE xor eax,eax
}
00007FF6153018D0 lea rsp,[rbp+108h]
00007FF6153018D7 pop rdi
00007FF6153018D8 pop rbp
00007FF6153018D9 ret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
int main() {
00CD1890 push ebp
00CD1891 mov ebp,esp
00CD1893 sub esp,0D8h
00CD1899 push ebx
00CD189A push esi
00CD189B push edi
00CD189C lea edi,[ebp-18h]
00CD189F mov ecx,6
00CD18A4 mov eax,0CCCCCCCCh
00CD18A9 rep stos dword ptr es:[edi]
00CD18AB mov ecx,offset _CC37DA75_a@c (0CDC008h)
00CD18B0 call @__CheckForDebuggerJustMyCode@4 (0CD1325h)
00CD18B5 nop
int a, b;
a = 2;
00CD18B6 mov dword ptr [a],2
b = 3;
00CD18BD mov dword ptr [b],3

func1(a, b);
00CD18C4 mov eax,dword ptr [b]
00CD18C7 push eax
00CD18C8 mov ecx,dword ptr [a]
00CD18CB push ecx
00CD18CC call _func1 (0CD1366h)
00CD18D1 add esp,8
func2(a, b);
00CD18D4 mov eax,dword ptr [b]
00CD18D7 push eax
00CD18D8 mov ecx,dword ptr [a]
00CD18DB push ecx
00CD18DC call _func2@8 (0CD105Fh)
00CD18E1 nop
func3(a, b);
00CD18E2 mov edx,dword ptr [b]
00CD18E5 mov ecx,dword ptr [a]
00CD18E8 call @func3@8 (0CD105Ah)
00CD18ED nop
return 0;
00CD18EE xor eax,eax
}
00CD18F0 pop edi
00CD18F1 pop esi
00CD18F2 pop ebx
00CD18F3 add esp,0D8h
00CD18F9 cmp ebp,esp
00CD18FB call __RTC_CheckEsp (0CD1249h)
00CD1900 mov esp,ebp
00CD1902 pop ebp
00CD1903 ret

可以看出对于cdecl的func1,它push了两次4字节后,在主调函数add了回来(栈是反的)
而func2和func3都没有这个步骤,说明cdecl使得主调函数去保持栈平衡。
同时func3会先把参数传给寄存器。说明fastcall的目的是先不使用栈而是寄存器传参。

在func2内部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int __stdcall func2(int a, int b) {
00CD1840 push ebp
00CD1841 mov ebp,esp
00CD1843 sub esp,0C0h
00CD1849 push ebx
00CD184A push esi
00CD184B push edi
00CD184C mov edi,ebp
00CD184E xor ecx,ecx
00CD1850 mov eax,0CCCCCCCCh
00CD1855 rep stos dword ptr es:[edi]
00CD1857 mov ecx,offset _CC37DA75_a@c (0CDC008h)
00CD185C call @__CheckForDebuggerJustMyCode@4 (0CD1325h)
00CD1861 nop
return a + b;
00CD1862 mov eax,dword ptr [a]
00CD1865 add eax,dword ptr [b]
}
00CD1868 pop edi
00CD1869 pop esi
00CD186A pop ebx
00CD186B add esp,0C0h
00CD1871 cmp ebp,esp
00CD1873 call __RTC_CheckEsp (0CD1249h)
00CD1878 mov esp,ebp
00CD187A pop ebp
00CD187B ret 8

这个ret 8表示返回时弹出栈顶8字节: C3 xx xx

然后是研究fastcall前几个参数都是给哪些寄存器存放的
x86:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
	func3(a, b, a, b, b, a, b, a, a, b, a, a, b, a);
00B618D2 mov eax,dword ptr [a]
00B618D5 push eax
00B618D6 mov ecx,dword ptr [b]
00B618D9 push ecx
00B618DA mov edx,dword ptr [a]
00B618DD push edx
00B618DE mov eax,dword ptr [a]
00B618E1 push eax
00B618E2 mov ecx,dword ptr [b]
00B618E5 push ecx
00B618E6 mov edx,dword ptr [a]
00B618E9 push edx
00B618EA mov eax,dword ptr [a]
00B618ED push eax
00B618EE mov ecx,dword ptr [b]
00B618F1 push ecx
00B618F2 mov edx,dword ptr [a]
00B618F5 push edx
00B618F6 mov eax,dword ptr [b]
00B618F9 push eax
00B618FA mov ecx,dword ptr [b]
00B618FD push ecx
00B618FE mov edx,dword ptr [a]
00B61901 push edx
00B61902 mov edx,dword ptr [b]
00B61905 mov ecx,dword ptr [a]
00B61908 call @func3@56 (0B613C0h)
00B6190D nop

参数先从右至左依次压栈,然后最左侧两个参数分别存在ecx和edx
在x64下,依然是从左至右依次压栈,最左侧4个参数分别存在rcx,rdx,r8,r9寄存器中

linux下:

分别为rdi,rsi,rdx,rcx,r8,r9

补充

调用约定__cdecl、__stdcall和__fastcall的区别_win call stdcall-CSDN博客这篇上看到了这些说法:

__cdecl的特点

__cdecl 是 C Declaration 的缩写,表示 C 和 C++ 默认的函数调用约定。是C/C++和MFCX的默认调用约定。

  • 按从右至左的顺序压参数入栈、。
  • 由调用者把参数弹出栈。切记:对于传送参数的内存栈是由调用者来维护的,返回值在EAX中。因此对于像printf这样可变参数的函数必须用这种约定。
  • 编译器在编译的时候对这种调用规则的函数生成修饰名的时候,在输出函数名前加上一个下划线前缀,格式为_function。如函数int add(int a, int b)的修饰名是_add。

(1).为了验证参数是从右至左的顺序压栈的,我们可以看下面这段代码,Debug进行单步调试,可以看到我们的调用栈会先进入GetC(),再进入GetB(),最后进入GetA()。

(2).第二点“调用者把参数弹出栈”,这是编译器的工作,暂时没办法验证。要深入了解这部分,需要学习汇编语言相关的知识。

(3).函数的修饰名,这个可以通过对编译出的dll使用VS的”dumpbin /exports ProjectName.dll”命令进行查看(后面章节会进行详细介绍),或直接打开.obj文件查找对应的方法名(如搜索add)。

从代码和程序调试的层面考虑,参数的压栈顺序和栈的清理我们都不用太观注,因为这是编译器的决定的,我们改变不了。但第三点却常常困扰我们,因为如果不弄清楚这点,在多个库之间(如dll、lib、exe)相互调用、依赖时常常出出现莫名其妙的错误。

__stdcall的特点

__stdcall是Standard Call的缩写,是C++的标准调用方式,当然这是微软定义的标准,__stdcall通常用于Win32 API中(可查看WINAPI的定义)。 microsoft的vc默认的是__cdecl方式,而windows API则是__stdcall,如果用vc开发dll给其他语言用,则应该指定__stdcall方式。堆栈由谁清除这个很重要,如果是要写汇编函数给C调用,一定要小心堆栈的清除工作,如果是__cdecl方式的函数,则函数本身(如果不用汇编写)则不需要关心保存参数的堆栈的清除,但是如果是__stdcall的规则,一定要在函数退出(ret)前恢复堆栈。

  • 按从右至左的顺序压参数入栈。
  • 由被调用者把参数弹出栈。切记:函数自己在退出时清空堆栈,返回值在EAX中。
  • __stdcall调用约定在输出函数名前加上一个下划线前缀,后面加上一个“@”符号和其参数的字节数,格式为_function@number。如函数int sub(int a, int b)的修饰名是_sub@8。

__fastcall的特点

__fastcall调用的主要特点就是快,因为它是通过寄存器来传送参数的。

  • 实际上__fastcall用ECX和EDX传送前两个DWORD或更小的参数,剩下的参数仍自右向左压栈传送,被调用的函数在返回前清理传送参数的内存栈。
  • __fastcall调用约定在输出函数名前加上一个“@”符号,后面也是一个“@”符号和其参数的字节数,格式为@function@number,如double multi(double a, double b)的修饰名是@multi@16。
  • __fastcall和__stdcall很象,唯一差别就是头两个参数通过寄存器传送。注意通过寄存器传送的两个参数是从左向右的,即第1个参数进ECX,第2个进EDX,其他参数是从右向左的入栈,返回仍然通过EAX。

__thiscall

__thiscall是C++类成员函数缺省的调用约定,但它没有显示的声明形式。因为在C++类中,成员函数调用还有一个this指针参数,因此必须特殊处理,thiscall调用约定的特点:

  • 参数入栈:参数从右向左入栈
  • this指针入栈:如果参数个数确定,this指针通过ecx传递给被调用者;如果参数个数不确定,this指针在所有参数压栈后被压入栈。
  • 栈恢复:对参数个数不定的,调用者清理栈,否则函数自己清理栈。

SYSCALL

系统调用是用户层调用内核层函数的接口
使用系统调用需要遵循一定格式。

内核中有一个系统调用表,是内核函数的指针数组。对应函数在数组中的下标为系统调用号。使用SYSCALL时

rax寄存器中存放调用号,参数放在:ebx,ecx,edx,esi,edi

windows上触发为int 2E;Linux上为int 80;

尝试在windows上调用,失败。

ROPgadget

通过ROP即面向返回的编程可以实现对NX保护的绕过。ROPgadget是一个用来寻找gadgets的工具

安装

1
2
3
4
5
sudo apt-get install python-capstone
git clone https://github.com/JonathanSalwan/ROPgadget.git
cd ROPgadget
sudo python setup.py install
ROPgadget --help
1
ROPgadget --binary 程序名称 | grep "汇编指令"

1
ROPgadget --binary 文件名 | grep rdi

获得所有和rdi有关的指令地址

1
ROPgadget --binary 文件名 --only "pop|ret"

获得所有和pop或者retrdi有关的地址

ROPgadget 介绍 | Leeyuxun の note
这篇很不错,怕丢了,复制一下:

1
2
3
4
5
6
7
8
usage: ROPgadget [-h] [-v] [-c] [--binary <binary>] [--opcode <opcodes>]
[--string <string>] [--memstr <string>] [--depth <nbyte>]
[--only <key>] [--filter <key>] [--range <start-end>]
[--badbytes <byte>] [--rawArch <arch>] [--rawMode <mode>]
[--rawEndian <endian>] [--re <re>] [--offset <hexaddr>]
[--ropchain] [--thumb] [--console] [--norop] [--nojop]
[--callPreceded] [--nosys] [--multibr] [--all] [--noinstr]
[--dump] [--silent] [--align ALIGN] [--mipsrop <rtype>]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
-h, --help           显示帮助文档
-v, --version 版本号
-c, --checkUpdate 检测新版本是否可用
--binary <binary> 指定二进制文件进行分析
--opcode <opcodes> 在可执行段中查找opcode
--string <string> 在可读的段中查找字符串
--memstr <string> 查找单个byte在所有的可执行段中
--depth <nbyte> 搜索引擎的深度(默认10)
--only <key> 只显示特别的指令
--filter <key> 过滤特定指令
--range <start-end> 在地址之间寻找(0x...-0x...)
--badbytes <byte> 拒绝特定指令在gadget的地址下
--rawArch <arch> 指定文件架构: x86|arm|arm64|sparc|mips|ppc
--rawMode <mode> 指定源文件的mode: 32|64|arm|thumb
--rawEndian <endian> 指定源文件的字节顺序: little|big
--re <re> 正则表达式
--offset <hexaddr> 指定gadget的地址偏移
--ropchain ROP链的生成
--thumb 在ARM架构下使用搜索引擎thumb 模式
--console 使用交互终端对于搜索引擎
--norop 禁止ROP搜索引擎
--nojop 禁止JOP搜索引擎
--callPreceded 仅显示call-preceded的gadgets
--nosys 禁止SYS搜索引擎
--multibr 允许多分枝gadgets
--all 禁止删除重复的gadgets,即显示所有
--noinstr 禁止gadget指令终端打印
--dump 输出gadget bytes
--align ALIGN 对齐gadget地址(以字节为单位)
--mipsrop <rtype> MIPSj架构下有用的gadget查找器: stackfinder|system|tails|lia0|registers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --ropchain
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --depth 3
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --string "main"
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --string "m..n"
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --opcode c9c3
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --only "mov|ret"
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --only "mov|pop|xor|ret"
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --filter "xchg|add|sub|cmov.*"
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --norop --nosys
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --range 0x08041000-0x08042000
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --string main --range 0x080c9aaa-0x080c9aba
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --memstr "/bin/sh"
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --console
ROPgadget.py --binary ./test-suite-binaries/elf-Linux-x86 --badbytes "00|01-1f|7f|42"
ROPgadget.py --binary ./test-suite-binaries/Linux_lib64.so --offset 0xdeadbeef00000000
ROPgadget.py --binary ./test-suite-binaries/elf-ARMv7-ls --depth 5
ROPgadget.py --binary ./test-suite-binaries/elf-ARM64-bash --depth 5
ROPgadget.py --binary ./test-suite-binaries/raw-x86.raw --rawArch=x86 --rawMode=32

checksec

pwntool中用来检查程序开启了什么保护的指令

直接

1
2
e = ELF(“xxx”)
e.checksec()

输出:

1
2
3
4
5
Arch:     amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

栈的字节对齐

这里有一篇写的很好
x86_64 Linux 运行时栈的字节对齐 - 一川official - 博客园 (cnblogs.com)

栈的字节对齐,实际是指栈顶指针必须是16字节的整数倍。栈对齐使得在尽可能少的内存访问周期内读取数据,不对齐堆栈指针可能导致严重的性能下降。

上文我们说,即使数据没有对齐,我们的程序也是可以执行的,只是效率有点低而已,但是某些型号的Intel和AMD处理器,在执行某些实现多媒体操作的SSE指令时,如果数据没有对齐,将无法正确执行。这些指令对16字节内存进行操作,在SSE单元和内存之间传送数据的指令要求内存地址必须是16的倍数。

因此,任何针对x86_64处理器的编译器和运行时系统都必须保证, 它们分配内存将来可能会被SSE指令使用,所以必须是16字节对齐的,这也就形成了一种标准:

  • 任何内存分配函数(alloca, malloc, callocrealloc)生成的块的起始地址都必须是16的倍数。
  • 大多数函数的栈帧的边界都必须是16字节的倍数。

如上,在运行时栈中,不仅传递的参数和局部变量要满足字节对齐,我们的栈指针(rsp)也必须是16的倍数。