基础知识

在本仓库中,如无特殊说明,处理器指令集默认为x86_64指令集。

系统调用简介

在Linux中,内核提供一些操作的接口给用户态的程序使用,这就是系统调用。对于用户态的程序,其调用相应的接口的方式,是一条汇编指令syscall

比如说,创建子进程的操作,Linux内核提供了fork这个系统调用作为接口。那么,如果用户态程序想调用这个内核提供的接口,其对应的汇编语句为(部分)

movq $57, %rax
syscall

syscall这个指令会先查看此时RAX的值,然后找到系统调用号为那个值的系统调用,然后执行相应的系统调用。我们可以在系统调用列表中找到,fork这个系统调用的系统调用号是57。于是,我们把57放入rax寄存器中,然后使用了syscall指令。这就是让内核执行了fork

调用约定

提到这个,就不得不说Linux x86_64的调用约定。我们知道,系统调用往往会有许多参数,比如说open这个打开文件的系统操作,我们可以在include/linux/syscalls.h中找到其对应的C语言接口为

asmlinkage long sys_open(const char __user *filename, int flags, umode_t mode);

它接受三个参数。那么,参数传递是按照什么规定呢?事实上,当涉及到系统调用时,调用约定与用户态程序一般的调用约定并不相同。在System V Application Binary Interface AMD64 Architecture Processor Supplement的A.2.1节中我们可以看到:

  1. User-level applications use as integer registers for passing the sequence %rdi, %rsi, %rdx, %rcx, %r8 and %r9. The kernel interface uses %rdi, %rsi, %rdx, %r10, %r8 and %r9.
  2. A system-call is done via the syscall instruction. The kernel destroys registers %rcx and %r11.
  3. The number of the syscall has to be passed in register %rax.
  4. System-calls are limited to six arguments, no argument is passed directly on the stack.
  5. Returning from the syscall, register %rax contains the result of the system-call. A value in the range between -4095 and -1 indicates an error, it is -errno.
  6. Only values of class INTEGER or class MEMORY are passed to the kernel.

比较重要的是前五点。从这五点我们可以知道,如果要调用open系统调用,那么步骤是:

  1. pathname放入rdi寄存器
  2. flags放入rsi寄存器
  3. mode放入rdx寄存器
  4. open的系统调用号2放入rax寄存器
  5. 执行syscall指令
  6. 返回值位于rax寄存器

我们使用逆向工具查看汇编代码时,就是通过类似以上六步的方法,确定一个系统调用的相关信息的。

这个规范就称为内核接口的调用约定,可以从第一点就显著地看到,这个调用约定与用户态的程序是不同的。也就是说,如果我们用编译器直接编译

long sys_open(const char *pathname, int flags, mode_t mode);

那么,编译出来的可执行程序会认为,这个函数是用户态函数,其传参仍然是按 %rdi, %rsi, %rdx, %rcx, %r8, %r9的顺序,与内核接口不符。因此,gcc提供了一个标签asmlinkage来标记这个函数是内核接口的调用约定:

asmlinkage long sys_open(const char *pathname, int flags, mode_t mode);

当函数前面有这个标签时,编译器编译出的可执行程序就会认为是按内核接口的调用约定对这个函数进行调用的。详情可以看FAQ/asmlinkage

glibc封装

当然,我们平时写的代码中,99%不会直接用到上述的系统调用方法。当我们真的去写一个C程序时:

// syscall-wrapper-test.c
#include <unistd.h>

int main() {
    fork();
    return 0;
}

然后我们将其编译为汇编代码

gcc syscall-wrapper-test.c -S -o syscall-wrapper-test.S

只能看到这个指令:

callq fork

然后在整个汇编文件中都不会找到fork这个函数的实现。甚至我们如果将其编译为可执行程序

gcc syscall-wrapper-test.c -o syscall-wrapper-test

然后用逆向工具去反汇编,也会发现整个可执行程序中也不会有fork的实现,同时也不会找到任何对57这个系统调用号进行syscall的代码。

这是因为,我们在Linux上编写的程序,通常都会链接到glibc的动态链接库。我们用

ldd syscall-wrapper-list

查看其链接的动态链接库,就会看到

libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6

而glibc则提供了许多系统调用的封装。这使我们在编写程序的时候,并不需要直接和内核进行交互,而是借用glibc这层封装,更加安全、稳定地使用。关于glibc对系统调用的封装,详情请见官方文档SyscallWrappers

此外,glibc还提供一个特殊的封装——syscall:

#include <unistd.h>
long syscall(long number, ...);

这可以看作汇编指令syscall的封装。比如说,我们想自己实现一个open函数:

#include <unistd.h>
#include <sys/syscall.h>
#include <sys/types.h>

long my_open(const char *pathname, int flags, mode_t mode) {
    return syscall(SYS_open, pathname, flags, mode);
}

其中,SYS_open为一个宏,定义在sys/syscall.h头文件中,其值为2,也就是open系统调用的系统调用号。

当然,如果真的想在可执行程序中直接对内核进行系统调用,可以把glibc静态链接:

gcc syscall-wrapper-test.c -static -o syscall-wrapper-test

然后在syscall-wrapper-test这个可执行程序中就可以看到直接的syscall了。

对glibc的动态链接和静态链接各有利弊。对于恶意软件编写者来说,他们往往倾向于静态链接恶意软件。这是因为,分析者可以轻松地写一个动态链接库,将其使用的glibc中的API hook住,改变其行为,使它达不成目的。而如果静态链接,那么分析者只有通过修改内核等比较麻烦的方案才能改变其行为。而静态链接的坏处则在于,如果简单地使用-static选项进行静态链接,就是把整个库都链接进最终的可执行程序中。这会导致库中许多没有被用到的函数的代码也在可执行程序中,使可执行程序的体积增大。解决方案可以参考gcc的官方文档Compilation-options

内核接口

我们之前提到,在Linux内核中,可以在include/linux/syscalls.h文件中找到系统调用函数的声明(会加上sys_前缀)。而其实现则是使用SYSCALL_DEFINEn这个宏。比如说,我们在fs/open.c中可以看到

SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
	/* ... */
}

这代表:

  • 内核提供了一个接口,接受三个参数
  • 这个接口叫open
  • 第一个参数的类型是const char __user *,参数名为filename
  • 第二个参数的类型是int,参数名是flags
  • 第三个参数的类型是umode_t,参数名是mode