linux vdso int 0x80,[译] Linux 系统调用权威指南

本文详细介绍了Linux系统调用的实现,包括32位和64位环境下的sysenter、syscall机制。通过汇编代码示例,展示了如何手动触发系统调用,以及内核如何处理这些调用。还讨论了glibc的系统调用封装器和vDSO(虚拟动态共享对象)的工作原理,最后提到了一些与系统调用相关的安全漏洞和注意事项。
摘要由CSDN通过智能技术生成

/

wrmsr(MSR_IA32_SYSENTER_EIP, (unsigned long) ia32_sysenter_target, 0);

MSR_IA32_SYSENTER_EIP在arch/x86/include/uapi/asm/msr-index.h中定义为 0x00000176。

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

* 32bit SYSENTER instruction entry.

*

* Arguments:

* %eax System call number.

* %ebx Arg1

* %ecx Arg2

* %edx Arg3

* %esi Arg4

* %edi Arg5

* %ebp user stack

* 0(%ebp) Arg6

回想一下,传统的系统调用方法包括返回到用户程序的机制:iret指令。

要理解让sysenter正确执行的逻辑不太简单,因为不像软件中断,sysenter并不存储返回地址。

实际上,在执行sysenter指令之前,内核要做的一些记录工作都是会随着时间而变化的(如果已经改变了,你就会看到下文中将要描述的bugs了)

为了兼容未来特性的变化,用户程序使用内核中的函数 _ _kernel_vsyscall。当调用该函数的进程执行时,会被映射到每个用户进程空间中去。

这听起来有点诡异。它是内核中的代码,却在用户层执行。

实际上,_ _kernel_vsyscall是vDSO(virtual Dynamic Shared Object)的一部分,而vDSO允许程序在用户层中执行内核代码。

后文中会具体介绍什么是vDSO,vDSO做了什么以及工作原理是什么。

我们先来了解_ _kernel_vsyscall的一些细节。

_ _kernel_vsyscall内幕

_ _kernel_vsyscall函数封装了sysenter调用的规范,定义在arch/x86/vdso/vdso32/sysenter.S:

__kernel_vsyscall:

.LSTART_vsyscall:

push %ecx

.Lpush_ecx:

push %edx

.Lpush_edx:

push %ebp

.Lenter_kernel:

movl %esp,%ebp

sysenter

_ _kernel_vsyscall既然是vDSO(也被称为共享库)的一部分,那用户程序是如何在运行时定位该函数地址的呢?

_ _kernel_vsyscall函数的地址被写入到 ELF 辅助向量, 用户程序以及库函数(主要指glibc)可以在这里找到其地址并使用。

以下方法可用来查找ELF辅助向量:

使用getauxval函数,带有AT_SYSINFO参数.

迭代搜索环境变量,然后从内存中解析这些变量.

第一种方法较为简单,但glibc 2.16前的版本不支持。 下文例子中介绍的代码中实现的是第二种方法。

从有关__kernel_vsyscall的代码中可以看出,在执行sysenter指令之前__kernel_vsyscall会做一些记录工作。

因此,我们需要做的就是手动利用sysenter进入到内核中:

. 在ELF辅助向量中找到AT_SYSINFO,也就是_ _kernel_vsyscall被写入的地址 . 像传统系统调用一样,将系统调用号和参数写入寄存器中 . 调用_ _kernel_vsyscall函数

你可能从来没写过属于你自己的sysenter封装函数,那是因为内核利用sysenter进入和退出系统调用的规范是会变化的,到那时候你的代码就会崩溃了。

在开始用sysenter触发系统调用前,你都要调用_ _kernel_vsyscall函数。

那么,让我们开始吧。

自己写汇编使用sysenter

继续利用前面传统系统调用的例子,我们调用exit,退出状态是42.

exit的系统调用号是1. 根据前文描述,我们只需要将系统调用号写进eax寄存器中,并把第一个参数(退出状态)写到ebx寄存器中。

(实际上这个例子可以简化一些,但我认为多用一些文字描述会更有趣,让那些之前没见过GCC内联汇编代码的人也可以用下面的例子作为参考)

#include

#include

int

main(int argc, char* argv[], char* envp[])

{

unsigned int syscall_nr = 1;

int exit_status = 42;

Elf32_auxv_t *auxv;

/* auxilliary vectors are located after the end of the environment

* variables

*

* check this helpful diagram: https://static.lwn.net/images/2012/auxvec.png

*/

while(*envp++ != NULL);

/* envp is now pointed at the auxilliary vectors, since we've iterated

* through the environment variables.

*/

for (auxv = (Elf32_auxv_t *)envp; auxv->a_type != AT_NULL; auxv++)

{

if( auxv->a_type == AT_SYSINFO) {

break;

}

}

/* NOTE: in glibc 2.16 and higher you can replace the above code with

* a call to getauxval(3): getauxval(AT_SYSINFO)

*/

asm(

"movl %0, %%eax \n"

"movl %1, %%ebx \n"

"call *%2 \n"

: /* output parameters, we aren't outputting anything, no none */

/* (none) */

: /* input parameters mapped to %0 and %1, repsectively */

"m" (syscall_nr), "m" (exit_status), "m" (auxv->a_un.a_val)

: /* registers that we are "clobbering", unneeded since we are calling exit */

"eax", "ebx");

}

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

$ gcc -m32 -o test test.c

$ ./test

$ echo $?

42

成功了! 我们利用 sysenter触发了exit系统调用,而不需要引发软件中断。

内核层面: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系统调用进入内核的全部过程。

sysexit: 从sysenter中返回

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

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

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

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注册当系统调用触发时要执行的代码地址。

void syscall_init(void)

{

/* ... other code ... */

wrmsrl(MSR_LSTAR, system_call);

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

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

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

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

成功了! 我们利用syscall系统调用方式来触发exit系统调用。避免了软件中断,如果我们要用micro-benchmark 来计算时间的话,这种方法执行起来要快许多。

内核层面:syscall入口

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

回想前文,我们知道如何将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代码包含进来。

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却要求你自己在代码中计算这个地址,并将其重写到寄存器中。

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,你必须这样做:

为你想支持的平台生成汇编存根(stub)

使用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中是如何工作的

/* 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源文件和一个链接器脚本

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

/*

* 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文件中找到函数的定义。

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中的符号名。

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代码。

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.

继续关注将来研究单个系统调用及其实现的相关文章。

相关博文

如果你喜欢这篇博客,你可能对以下底层技术的相关博客也感兴趣。

Acknowledgement

此文是一篇完整的译文,尽量在原文的基础上做到通俗易懂

仅为学习使用,未经博主同意,请勿转载。

作者[新浪微博:@diting0x]

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值