函数调用约定

现代的几乎所有的编程语言都离不开函数和参数的概念。而这个概念是编程语言级别的,而不是硬件级别的。也就是说硬件上本来没有函数的概念。只是函数的用的太普遍,硬件开始为函数准备专用的指令。

我们以x86的硬件举例。Cpu的功能是计算,读取数据,执行指令。这里面的问题就是指令如何执行。我们完全可以顺序的执行所有的指令,也可以达到计算机的计算目的。但是这样在使用者来看是不现实的,完全顺序的执行代码在编程的初期就被发现不适合开发。于是人们增加了循环,判断,跳转和函数。

函数开始作为几乎所有编程语言的基础。但是仍然有不使用函数的编程方式,通过大量的使用label和jump,不适用函数而在不同的过程代码中跳转在高性能编程里也是有市场的。因为这样省去了函数调用的开销(入栈出栈,保存上下文等)。

函数的典型特点是传递参数,返回结果。几乎所有的编程语言都需要设计如何传递参数,如何返回函数执行的结果。芯片只是规定了指令集,只要是指令集中的指令都是可以执行的正确指令,而函数是语义级别的功能块,如何让函数的大厦在指令集之上建立起来就是函数调用约定。函数调用约定主要解决这几个问题:

l 参数以什么顺序入栈或者以什么顺序进入寄存器完成传递

l 调用其它函数的时候要保存本函数的寄存器现场,谁来保存,保存哪些寄存器

l 函数退出时候要恢复调用者的寄存器现场。是调用者恢复还是被调用者恢复。恢复哪些寄存器

l 如何给函数命名。这里的命名是指如何编码参数和返回值类型到函数名中。一般编译之后的代码的函数名都不是代码中编程语言规定的函数名。而是根据这个生成的。

这对这几个问题的不同答案,有几种比较著名的约定:stdcall ,cdecl ,fastcall ,thiscall ,naked call

入栈顺序

函数命名方式

栈清理方

默认使用者

返回方式

Pascal

从左向右

函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸

被调用方

16位时代的计算机

EAX

Stdcall

从右向左

函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸

被调用方

Win32 API

Open Watcom C++

EAX

Fastcall(32位)

前两个参数顺序进入ECX/EDX,后面的参数从右向左入栈

被调用方

Microsoft或GCC __fastcall

__vectorcall

与fastcall的区别是其使用XMM/YMM开传递vector类型的参数

Vs2013 __vectorcall

Fastcall(64)

前4个参数按需进入RCX, RDX, R8, R9,其他的入栈

Borland register

前三个参数推入EAX,EDX,RCX,后面的参数从左到右入栈

被调用方

Delphi

Cdel

参数全部从右向左入栈,EAX, ECX,和EDX是调用者保存,其他的寄存器是被调用者保存

调用方

C x86

EAX

Syscall

从右向左入栈,调用者不保存寄存器,但是清理

调用方

Os/2

Thiscall(gcc)

与cdel一样,区别是this指针作为最后一个参数入栈

调用方

Thiscall(win)

This指针进入ECX

固定大小参数被调用方清理,可变长度参数调用方清理

System V AMD64 ABI

参数按照如下顺序直接进入寄存器:RDI, RSI, RDX, RCX , R8, and R9

函数调用的安全问题

c语言诞生就诞生了栈的概念,栈一般用来传递参数和记录返回地址。所以只要可以在程序中溢出这个栈,就能够修改这个返回地址。以前的栈攻击都是直接溢出,然后把可执行程序放到溢出的地方,被直接执行(stack smashing)。后来操作系统做了很多增强,例如不允许在栈上执行程序,甚至只要用户可写的内存都不准执行程序,甚至硬件都开始支持内存页的可执行性属性。

这些防御能力使得攻击者几乎不能把自己的可执行代码放到程序中执行,然而大部分的程序运行,几乎都需要外部的库。攻击者通过溢出漏洞控制程序的跳转执行想要执行的外部库函数(这些函数都是已经存在的加载库,所以已经被标记了可以执行),来达到自己的目的。这叫做Return-oriented programming(rop)。X64时代参数直接使用寄存器传递,所以这种攻击方式由于不能直接修改寄存器而受限,而攻击者仍然可以找可以修改寄存器的库代码片段,发生rop,从而修改寄存器。使用这种机制,只需要一个缓存溢出就可以做任何的调用,甚至让系统关机,全局删除数据都可以。

针对stack smashing防御方法比较知名的有DEP和intel的 Control-Flow Enforcement(CFE)。其实就是gcc中实作的金丝雀。就是在真实的栈后面加一片影子空间。如果影子空间被修改了就说明有溢出发生。但是如果溢出长度超过影子长度就没办法了。只是intel是指令级别的实现,gcc是函数级别的实现。同样的思路被用在pthread的线程guard之间。Pthread怕不同的线程之间的栈溢出,就设计了在不同线程的栈之间设置guard的机制,防止一个线程数据破坏另外一个线程。同样的超过guard长度的溢出可以突破这种防御。

函数调用的调试

分为内核函数调用和库的调用,还有二进制文件本身的函数调用。Ptrace可以在用户层拦截和修改用户进程的系统调用。在执行系统调用之前,内核会先检查当前进程是否处于被“跟踪”(traced)的状态。如果是的话,内核暂停当前进程并将控制权交给跟踪进程,使跟踪进程得以察看或者修改被跟踪进程的寄存器。这种机制是使用内核的系统调用配合做到的。

还有一种基于trap的,在打算中断的二进制文件位置插入陷阱指令(int 3),然后程序会调用自定义的陷阱代码。这是由内核和cpu的机制共同完成的。

还有一种方案是使用systemtap,systemtap本身是利用内核kprobe在内核事件中插入中间代码。是完全基于内核机制的。

还有一种方案是valgrind采用的中间代码。它先把二进制文件反汇编,然后再编译为中间代码,这个重新编译的过程就可以自由的插入自己的逻辑代码了。

还有一种使用跳转表格的做法。就是在二进制文件中添加额外的函数,将原来的函数直接二进制替换为到我们自己的函数表的调用。而我们会重新实现原来的函数(也可以直接拷贝),这种做法速度快。常用的软件是Dyninst。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券