Linux 系统调用权威指南(二)

发布于:2022-12-16 ⋅ 阅读:(336) ⋅ 点赞:(0)

内核层面:sysenter入口

目前我们已经知道用户程序如何通过_ _kernel_vsyscall函数利用sysenter触发系统调用,下面来看看内核如何利用系统调用号来执行系统调用中的代码。

回想前面的章节,内核调用ia32_sysenter_target来注册系统调用处理函数。

此函数在arch/x86/ia32/ia32entry.S中以汇编代码实现。我们来看看eax寄存器中的值是在哪里被用来执行系统调用的:

sysenter_dispatch:
    call    *ia32_sys_call_table(,%rax,8)

这段代码和前文传统系统调用模式的代码很类似:名为ia32_sys_call_table的表存储着系统调用号。

在所有必要的记录工作完成后,传统系统调用模型以及sysenter系统调用模型采用相同的机制和系统调用表来分配系统调用。

参照 int $0x80(内核层面:int $0x80入口这一章节),可以了解到ia32_sys_call_table 是如何定义和构造的。

以上内容就是如何通过sysenter系统调用进入内核的全部过程。

【文章福利】小编在群文件上传了一些个人觉得比较好得学习书籍、视频资料,有需要的可以进群【977878001】领取!!!额外赠送一份价值699的内核资料包(含视频教程、电子书、实战项目及代码)

内核资料直通车:Linux内核源码技术学习路线+视频教程代码资料

学习直通车(腾讯课堂免费报名):Linux内核源码/内存调优/文件系统/进程管理/设备驱动/网络协议栈

sysexit: 从sysenter中返回

内核利用sysexit指令将执行环境恢复到用户程序。

sysexit指令的使用不像iret那么直接。调用者需要将返回地址写入rdx寄存器中,并将栈指针写入rcx寄存器。

这就意味着你的代码中需要计算执行环境要返回的地址,保存地址值,并在调用sysexit前能恢复。

可以在arch/x86/ia32/ia32entry.S 找到相关代码:

sysexit_from_sys_call:
        andl    $~TS_COMPAT,TI_status+THREAD_INFO(%rsp,RIP-ARGOFFSET)
        /* clear IF, that popfq doesn't enable interrupts early */
        andl  $~0x200,EFLAGS-R11(%rsp)
        movl    RIP-R11(%rsp),%edx              /* User %eip */
        CFI_REGISTER rip,rdx
        RESTORE_ARGS 0,24,0,0,0,0
        xorq    %r8,%r8
        xorq    %r9,%r9
        xorq    %r10,%r10
        xorq    %r11,%r11
        popfq_cfi
        /*CFI_RESTORE rflags*/
        popq_cfi %rcx                           /* User %esp */
        CFI_REGISTER rsp,rcx
        TRACE_IRQS_ON
        ENABLE_INTERRUPTS_SYSEXIT32

ENABLE_INTERRUPTS_SYSEXIT32是定义在arch/x86/include/asm/irqflags.h的宏,其中含有sysexit指令。

好了,你已经知道32位快速系统调用是如何工作的了。

64位快速系统调用

下一步之旅就是去探索64位快速系统调用了。其分别利用syscall 、sysret指令进入系统调用、从系统调用中返回。

syscall/sysret

Intel指令集参考指南解释了syscall指令是如何工作的:

SYSCALL invokes an OS system-call handler at privilege level 0. It does so by loading RIP from the IA32_LSTAR MSR (after saving the address of the instruction following SYSCALL into RCX). 

换句话说:为了让内核接收到系统调用,内核必须向IA32_LSTAR MSR注册当系统调用触发时要执行的代码地址。

在arch/x86/kernel/cpu/common.c 可以找到相关代码:

void syscall_init(void)
{
        /* ... other code ... */
        wrmsrl(MSR_LSTAR, system_call);

MSR_LSTAR在arch/x86/include/uapi/asm/msr-index.h定义为 0xc0000082。

类似传统软件中断型的系统调用, 使用syscall触发系统调用时也定义了一些规范。

用户程序需要将系统调用号写入rax寄存器中。系统调用的参数要c传入通用寄存器中。

x86-64 ABI 章节A.2.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.

arch/x86/kernel/entry_64.S的注释中也有相关介绍。

现在我们已经知道了如何执行系统调用以及如何传递这些参数,开始着手写一些内联汇编代码。

自己写汇编使用syscall

基于前面介绍的例子,我们开始着手编写一小段含有内联汇编的C程序,代码中执行exit系统调用并传递退出状态:42.

首先,我们要找到exit的系统调用号。在这个例子中,我们需要从arch/x86/syscalls/syscall_64.tbl中读取这张表:

60 common exit sys_exit

exit的系统调用号是60. 根据前文介绍,我们只需将60写入eax寄存器,以及第一个参数(退出状态)写入rdi寄存器。

请看下面这段含有内联汇编的C代码。类似前面的例子,从清晰度来看,冗余的文字可能比代码本身更重要。

int
main(int argc, char *argv[])
{
  unsigned long syscall_nr = 60;
  long exit_status = 42;
  asm ("movq %0, %%rax\n"
       "movq %1, %%rdi\n"
       "syscall"
    : /* output parameters, we aren't outputting anything, no none */
      /* (none) */
    : /* input parameters mapped to %0 and %1, repsectively */
      "m" (syscall_nr), "m" (exit_status)
    : /* registers that we are "clobbering", unneeded since we are calling exit */
      "rax", "rdi");
}

接下来,编译,执行,然后检查退出状态:

$ gcc -o test test.c
$ ./test
$ echo $?
42

接下来,编译,执行,然后检查退出状态:

$ gcc -o test test.c
$ ./test
$ echo $?
42

现在我们已经知道了如何从用户程序中触发系统调用。接下来就要介绍内核如何利用系统调用号执行系统调用中的代码。

回想前文,我们知道如何将system_call函数的地址写入LSTAR MSR中。

下面就来看看这个函数中的代码是如何利用rax寄存器将执行环境切换到系统调用中的。可以参考arch/x86/kernel/entry_64.S:

call *sys_call_table(,%rax,8)  # XXX:    rip relative

类似传统系统调用,sys_call_table一张在C文件中定义的表,其利用#include将脚本产生的C代码包含进来。

在arch/x86/kernel/syscall_64.c,注意底部的#include:

asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
        /*
         * Smells like a compiler bug -- it doesn't work
         * when the & below is removed.
         */
        [0 ... __NR_syscall_max] = &sys_ni_syscall,
#include 
};

从前文我们知道,系统调用表定义在arch/x86/syscalls/syscall_64.tbl。与传统中断模式一样,脚本在内核编译的时候执行并通过syscall_64.tbl文件中的表生成syscalls_64.h文件。

上述代码包括能生成系统调用号索引的函数指针数组的C代码。

以上就是如何通过syscall系统调用进入内核的全过程。

sysret: 从syscall中返回

内核利用sysret指令将执行环境恢复到用户程序执行syscall的地方。

sysret比起sysexit来要简单一些,因为当执行syscall时,需要被恢复执行的地址保存在rcx寄存器中。

只要能将这个地址保存起来,并在执行sysret前将其恢复到rcx寄存器中,执行环境就能在触发syscall的地方恢复。

这种机制比较方便,因为sysenter却要求你自己在代码中计算这个地址,并将其重写到寄存器中。

上述代码在arch/x86/kernel/entry_64.S:

movq RIP-ARGOFFSET(%rsp),%rcx
CFI_REGISTER    rip,rcx
RESTORE_ARGS 1,-ARG_SKIP,0
/*CFI_REGISTER  rflags,r11*/
movq    PER_CPU_VAR(old_rsp), %rsp
USERGS_SYSRET64

其中USERGS_SYSRET64是定义在arch/x86/include/asm/irqflags.h中的宏,其中包含sysret指令。

好了,目前为止,你已经知道64位系统调用是如何工作的了。

syscall(2)半手动调用syscall

太棒了,针对不同系统调用模型,我们已经知道如何编写汇编代码去触发这些系统调用了。

通常来说,你没必要自己写汇编代码。glibc提供的封装器函数已经为你处理好了所有的汇编代码。

当然,也有一些系统调用,glibc并没有为其做好封装器。其中一个例子就是futex–快速用户层上锁系统调用。

等等,为什么futex没有系统调用封装器呢?

futex是为库函数调用准备的,而不是应用程序。因此,要想调用futex,你必须这样做:

  1. 为你想支持的平台生成汇编存根(stub)
  2. 使用glibc提供的syscall封装器

有些时候,如果你想执行那些没有封装器的系统调用,你别无选择,只能利用第二种方法:使用glibc提供的syscall函数。

我们试试利用glibc提供的syscall调用exit,其退出状态是42:

#include 
int
main(int argc, char *argv[])
{
  unsigned long syscall_nr = 60;
  long exit_status = 42;
  syscall(syscall_nr, exit_status);
}

接下来,编译,执行,然后检查退出状态:

$ gcc -o test test.c
$ ./test
$ echo $?
42

成功了!我们利用glibc提供的syscall封装器成功的调用了exit.

glibc syscall封装器内幕

来看看前面例子中syscall封装器在glibc中是如何工作的

在sysdeps/unix/sysv/linux/x86_64/syscall.S:

/* Usage: long syscall (syscall_number, arg1, arg2, arg3, arg4, arg5, arg6)
   We need to do some arg shifting, the syscall_number will be in
   rax.  */
        .text
ENTRY (syscall)
        movq %rdi, %rax         /* Syscall number -> rax.  */
        movq %rsi, %rdi         /* shift arg1 - arg5.  */
        movq %rdx, %rsi
        movq %rcx, %rdx
        movq %r8, %r10
        movq %r9, %r8
        movq 8(%rsp),%r9        /* arg6 is on the stack.  */
        syscall                 /* Do the system call.  */
        cmpq $-4095, %rax       /* Check %rax for error.  */
        jae SYSCALL_ERROR_LABEL /* Jump to error handler if error.  */
L(pseudo_end):
        ret                     /* Return to caller.  */

前面我们给出了x86_64 ABI文档的参考链接,其描述了用户层和内核层的调用规范。

这段汇编stud很酷,因为它同时遵守两种调用规范。传递到这个函数的参数遵守用户层调用规范,但是在转移到另一组不同的寄存器利用syscall进入内核之前,又遵守内核层规范。

以上就是在你要执行默认没有封装器的系统调用时,glibc提供的syscall封装器的工作方式。

虚拟系统调用

到目前为止,我们已经介绍了所有进入内核触发系统调用的方法,并演示了如何手动(或者半手动)触发系统调用将系统从用户层切换到内核层。

倘若程序能触发一些系统调用,而不需要进入到内核呢?

这就是Linux vDSO存在的原因。Linux vDSO是内核代码的一部分,但是却被映射到用户程序地址空间中在用户层执行。

这也就是一些系统调用不用进入到内核就能被执行的原因。举个这样的例子: gettimeofday系统调用。

程序调用gettimeofday并不会真正进入到内核。而是简单的调用了内核提供的一小段代码,然后在用户层执行。

没有软件中断,也不需要复杂的sysenter或者syscall的记录工作。gettimeofday只是一个普通的函数调用。

当你执行ldd命令时,可以看到vDSO出现在第一个条目中:

$ ldd `which bash`
 linux-vdso.so.1 =>  (0x00007fff667ff000)
 libtinfo.so.5 => /lib/x86_64-linux-gnu/libtinfo.so.5 (0x00007f623df7d000)
  libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f623dd79000)
  libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f623d9ba000)
  /lib64/ld-linux-x86-64.so.2 (0x00007f623e1ae000)

下面来看看vDSO在内核中是如何设置的。

内核中的vDSO

可以在 arch/x86/vdso/中找到vDSO的源码。 其中包括一小段汇编代码、一些C源文件和一个链接器脚本

此链接器脚本是个很酷的东西,可以具体去了解一下。

看看arch/x86/vdso/vdso.lds.S:

/*
 * This controls what userland symbols we export from the vDSO.
 */
VERSION {
        LINUX_2.6 {
        global:
                clock_gettime;
                __vdso_clock_gettime;
                gettimeofday;
                __vdso_gettimeofday;
                getcpu;
                __vdso_getcpu;
                time;
                __vdso_time;
        local: *;
        };
}

链接器脚本很有用处,但并不被大家所熟知。链接器脚本会处理vDSO要导出的符号表。

可以看到,vDSO导出了4个不同的函数,每个函数都有两个名字。 可以在此文件夹下的C文件中找到函数的定义。

例如,gettimeofday的源代码可以在arch/x86/vdso/vclock_gettime.c中找到:

int gettimeofday(struct timeval *, struct timezone *)
    __attribute__((weak, alias("__vdso_gettimeofday")));

这段代码将gettimeofday作为__vdso_gettimeofday 的弱别名(weak alias)。

同文件中的__vdso_gettimeofday函数中包含了当用户程序执行gettimeofday系统调用时真正在用户层执行的源代码。

在内存中定为vDSO

由于地址空间布局随机化(ASLR)的原因,当程序开始执行时,vDSO会被加载到随机的地址空间中。

如果vDSO加载到随机地址空间中,用户程序是如何找到它的呢?

回想前文提到的sysenter系统调用方法,用户程序要调用_ _kernel_vsyscall函数,而不是自己写sysenter汇编代码。

而_ _kernel_vsyscall函数也是vDSO的一部分。

提供的代码样例中通过搜索ELF辅助头文件找到和AT_SYSINFO匹配的头文件,头文件中含有_ _kernel_vsyscall函数的地址。

类似的,要定位到vDSO, 用户程序可以搜索ELF辅助头文件找到和AT_SYSINFO_EHDR匹配的头文件. 里面包含由链接器脚本生成的vDSO的ELF头的起始地址。

两个例子中,程序被加载时内核都会将其地址写入到ELF头中。这也就是为何正确的地址总是出现AT_SYSINFO_EHDR和AT_SYSINFO中。

一旦定为到ELF头部信息,用户程序就能解析ELF对象了(可以用libelf),并且可以根据需要调用ELF对象中的函数。

这样很酷,因为这就意味着vDSO能充分利用ELF有用的特性,比如 symbol versioning。

内核文档 Documentation/vDSO/中有解析vDSO和调用其中函数的例子。

glibc中的vDSO

大多数情况下,大家都会访问vDSO,但并不会意识到。那是因为glibc利用前面章节介绍的接口对其进行了封装抽象。

当一个程序被加载时,动态链接器和加载器便会加载程序依赖的DSOs,也包括vDSO.

当glibc解析被加载程序的ELF头部时,会存储有关于vDSO的一些位置信息。也包括简短的stub函数,用来在真正执行系统调用前搜索vDSO中的符号名。

举个例子,glibc中的gettimeofday函数,定义在sysdeps/unix/sysv/linux/x86_64/gettimeofday.c中:

void *gettimeofday_ifunc (void) __asm__ ("__gettimeofday");
void *
gettimeofday_ifunc (void)
{
  PREPARE_VERSION (linux26, "LINUX_2.6", 61765110);
  /* If the vDSO is not available we fall back on the old vsyscall.  */
  return (_dl_vdso_vsym ("gettimeofday", &linux26)
          ?: (void *) VSYSCALL_ADDR_vgettimeofday);
}
__asm (".type __gettimeofday, %gnu_indirect_function");

这段glibc中的代码会在vDSO中搜索gettimeofday函数并且返回其地址。这些工作都通过 间接函数封装好了。

这就是程序如何通过glibc调用gettimeofday并访问vDSO却完全不用切换到内核、引发特权级转变以及触发软件中断的全过程。

其中也总结了Linux 32/64位 Intel/AMD系统的每种系统调用方法的优势。

glibc 系统调用封装器

在讨论系统调用的时候,简单的提提glibc是如何处理系统调用是很有意义的。

对于许多系统调用来说,glibc只需要一个简单的封装函数将参数传入合适的寄存器中,然后执行syscall或者int $0x80指令,或者调用_ _kernel_vsyscall。

这些都是利用一系列在文本文件中定义的表完成的,这些表会经过脚本处理并输出C代码。

例如,`sysdeps/unix/syscalls.list `文件描述了一些常用的系统调用:

access - access i:si __access access acct - acct i:S acct chdir - chdir i:s __chdir chdir chmod - chmod i:si __chmod chmod

要了解每一栏目的含义,可以查看处理此文件的脚本注释: sysdeps/unix/make-syscalls.sh.

对于更复杂的系统调用,比如exit,其会触发那些有真正C代码或汇编代码实现的处理函数,并不会出现在类似这样的文本文件中。

以后的博文中会具体介绍glibc的实现以及linux内核中一些重要的系统调用。

重要的syscall相关bugs

很荣幸能利用这次机会提两个与linux系统调用相关的神奇bugs.

一起来瞧一瞧吧!

CVE-2010-3301

这个安全利用能让本地用户获取root权限。

引起这个漏洞的原因就在于汇编代码中的一个小bug,其允许用户程序触发x86_64系统中的传统系统调用。

此利用代码相当聪明:在一个特定的地址下,利用mmap生成一块内存区域,并利用一个整数让代码产生溢出:

(还记得上面章节中提到的传统中断方式中的这段代码吗?)

call *ia32_sys_call_table(,%rax,8)

此代码可以将执行环境切换到一块任意的地址中,在那执行内核代码,从而可以将运行的进程权限提升到root.

Android sysenter ABI 破坏

还记得前面说过不要在应用程序代码中硬编码sysenter ABI吗?

不幸的是,用android-x86的一些人就容易犯这些错误。只要内核的ABI一改变,andorid-x86就瞬间无法工作。

工作于内核的人最终用一些陈旧的sysenter硬编码序列来恢复sysenter ABI,以避免对Android设备的破坏。

这里是提交给Linux 内核的patch. 你也可以在提交信息中找到向android源码提交的攻击代码的链接

记住:千万不要自己编写汇编代码使用sysenter. 如果你因为某种原因要自己实现,可以使用前面例子中的一些代码,起码要仔细检查一下 __kernel_vsyscall函数。

结论

Linux内核中的系统调用机制是及其复杂的。触发系统调用有许多中方案,各都有其优缺点。

自己编写汇编代码来触发系统调用可不是个好主意,因为在你代码下层的ABI可能会崩溃。系统中的内核以及libc的实现会(可能会)选择最快的方式来触发系统调用。

如果你不能使用glibc提供的封装器(或者那个封装器不存在),你起码应该用syscall封装器函数,或者仔细检查vDSO提供的_ _kernel_vsyscall.

- - 内核技术中文网 - 构建全国最权威的内核技术交流分享论坛

原文地址:Linux 系统调用权威指南(二)(版权归原作者所有,侵删)

 

本文含有隐藏内容,请 开通VIP 后查看