Skip to content

Latest commit

 

History

History
353 lines (297 loc) · 17.4 KB

Kernel VDSO Hack.md

File metadata and controls

353 lines (297 loc) · 17.4 KB

vsyscall的来历

在2002年一个patch被打入了内核,其作用就是和标题写的一样Add "sysenter" support on x86, and a "vsyscall" page.,从此vsyscall才正式的被引入到kernel当中。

vsyscall到底是因何而产生的没有精力去考证了,但是大体原因有两个:

  1. 为了和glibc解耦,但是这点从最终的实现上来说有些牵强,因此我更偏向于第二种原因
  2. 高精度函数的使用

在早期的系统中所有的系统调用都是通过int 0x80的方式来调用,这个过程中涉及到了用户态内核态的变化,因此需要耗费一定的时间,对于普通的系统调用来说当然是没有问题的,但是对于一些高精度函数比如gettimeofday这种获取到毫秒级别的函数来说太大的延迟是不能被接受的,那么对某些系统调用进行加速就被探讨了起来,其中诞生的一种想法就是vsyscall也就是虚拟系统调用。 既然传统的系统调用会有陷入内核态的操作,那如何才能在不陷入内核态的情况下就能获取到数据呢?内存共享是一个很好的选择,即一块内存区域内核态负责写入数据,而用户态可以直接读取,这就完全避免了模式切换的时间开销,那么当程序需要用到高精度函数的时候直接调用到vsyscall就可以快速获取到结果,当然vsyscall的代码自然不能放到glibc中而是由kernel实现,而用户态的逻辑以及映射数据的内存则都放在一个页当中,称为vsyscall page,定义好该页的映射起点,然后定义了4个高精度函数,每个函数间隔1kb,这都是约定好的地址,这样glibc只需要跳转到指定的地址就能调用到对应的函数,然而这终究是一个想法并没有得到实施。

后来Intel引入一个sysenter/sysexit指令用于实现系统调用的处理,这个指令本意上是用来取代int 0x80因为其有着更高的处理速度,但是第二个问题就来了那就是这个指令是Intel的而AMD也实现了自己的指令syscall/sysretAMD64位下不支持sysenter而同样的intel32位下也不支持syscall,这就导致必须要有一个方案能够兼容二者,至此vsyscall被重新抬上了桌面。

不过在早期只有int 0x80sysenter这两个选项,而按照现代的趋势,syscall因为其优越性而逐渐淘汰了sysenter成为系统调用的实现标准

kernel实现vsyscall page用来存放系统调用指令,而glibc的调用只需要指向这个地址就行,至于背后到底是int 0x80还是sysenter这个由kernel根据机器架构来决定。



static int __init sysenter_setup(void)
{
    static const char int80[] = {
    ........
    };
    static const char sysent[] = {
    ........
    };
    unsigned long page = get_zeroed_page(GFP_ATOMIC);


    __set_fixmap(FIX_VSYSCALL, __pa(page), PAGE_READONLY);
    memcpy((void *) page, int80, sizeof(int80));
    if (!boot_cpu_has(X86_FEATURE_SEP))
        return 0;


    memcpy((void *) page, sysent, sizeof(sysent));
    enable_sep_cpu(NULL);
    smp_call_function(enable_sep_cpu, NULL, 1, 1);
    return 0;
}


__initcall(sysenter_setup);

可以看到系统调用的实现被安排在page的首地址上,不过那时候还是32位时代所以不清楚约定的映射地址是什么。

VDSO的时代

使用vsyscall自然会带来方便,问题也随之而来一个是调试一个是安全,程序崩溃会用到核心转储core dump来记录进程崩溃时的状态比如堆栈信息,然而vsyscall的实现是依靠的将代码直接映射到内存页上调用不存在符号表这种东西,这会为调试带来麻烦,而且因为vsyscall映射的地址是固定的无法被ASLR影响到这就带来了极大的安全隐患,这显然是不能被内核的开发者们接受的因此而开发出vsyscall DSO将原本页中的代码重做成了ELF程序的动态链接库,并且为了能用到ASLR将其移动到user space当中装载,这点用到了辅助向量的技术,在进程构建时由内核向用户空间传递了vDSO的起始地址。

但是实际上来说,此时vsyscallVDSO是两个东西,而当到了3.0以后,VDSO完全实现了vsyscall上支持的系统调用从而导致开发者把移除vsyscall这档子事提上了日程,但是毕竟还有不少老程序还在用到vsyscall因此内核上在不是完全舍弃vsyscall的基础上重新实现了vsyscall的底层,即使是最大程度还原效果的native模式也只是正常的系统调用而非特制的vsyscall系的特定函数。

# cat /proc/26866/maps
    ........
7fff92acb000-7fff92ace000 r--p 00000000 00:00 0                          [vvar]
7fff92ace000-7fff92ad0000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

vvarvdso都是VDSO提供出来的功能,前者是只读变量区,而后者则是代码段。

具体实现

老时代的东西不再关注了,那就只关注一下新时代下vsyscallvDSO的实现,首先是vsyscall的实现与映射的流程:

void __init map_vsyscall(void)
{
    extern char __vsyscall_page;
    unsigned long physaddr_vsyscall = __pa_symbol(&__vsyscall_page);


    if (vsyscall_mode != NONE) {
        __set_fixmap(VSYSCALL_PAGE, physaddr_vsyscall,
                 PAGE_KERNEL_VVAR);
        set_vsyscall_pgtable_user_bits(swapper_pg_dir);
    }


    BUILD_BUG_ON((unsigned long)__fix_to_virt(VSYSCALL_PAGE) !=
             (unsigned long)VSYSCALL_ADDR);
}
#define FIXADDR_TOP    (round_up(VSYSCALL_ADDR + PAGE_SIZE, 1<<PMD_SHIFT) - \
             PAGE_SIZE)
#define VSYSCALL_ADDR (-10UL << 20)
#define PAGE_SHIFT        12
VSYSCALL_PAGE = (FIXADDR_TOP - VSYSCALL_ADDR) >> PAGE_SHIFT

这是4.17.18版本的kernel,可以看到在这个版本下vsyscall page被设置成了PAGE_KERNEL_VVAR也就是可以通过用户模式只读模式访问该页,这实则对应了emulate模式也就是说以后也只有这个模式了,这个模式的系统调用发生很有意思,page fault处理:

static void
__bad_area_nosemaphore(struct pt_regs *regs, unsigned long error_code,
               unsigned long address, u32 *pkey, int si_code)
{
    ........
#ifdef CONFIG_X86_64
        /*
         * Instruction fetch faults in the vsyscall page might need
         * emulation.
         */
        if (unlikely((error_code & X86_PF_INSTR) &&
                 ((address & ~0xfff) == VSYSCALL_ADDR))) {
            if (emulate_vsyscall(regs, address))
                return;
        }

会检查错误地址是不是在vsyscall page里,是的话则调用到emulate_vsyscall来模拟执行。 接着是关于vDSO的实现,它实际上在核心功能上是完全照搬了vsyscall只不过是在表现形式上有所不同,如果说vsyscall的实现是将调用代码写入到一个页上并映射到user space中,那vDSO则要复杂一点,它在初始实现上就是通过把编译好的vdso-image映射到物理页面上,然后在elf装载时实现映射,这个功能主要依靠map_vdso函数实现,然后前者的映射对象是一个固定page,而后者则是根据一个vdso_image结构体的对象,这个结构体由初始化时创建。

void __init init_vdso_image(const struct vdso_image *image)
{
    BUG_ON(image->size % PAGE_SIZE != 0);


    apply_alternatives((struct alt_instr *)(image->data + image->alt),
               (struct alt_instr *)(image->data + image->alt +
                        image->alt_len));
}

因为是照搬的vsyscallvvar就是之前vsyscall设想的数据存储区域vDSO就是直接从这部分内存中读取到内核更新好的属于,达到高精度函数调用的目的。

![9e06419c-d4ab-4b39-8541-d0acc9de8e1a.png](Kernel VDSO Hack_files/9e06419c-d4ab-4b39-8541-d0acc9de8e1a.png)

这部分区域在内核中的实现被称为vsyscall_gtod_data,布局为一个struct vsyscall_gtod_data

struct vsyscall_gtod_data {
    unsigned seq;


    int vclock_mode;
    u64    cycle_last;
    u64    mask;
    u32    mult;
    u32    shift;


    /* open coded 'struct timespec' */
    u64        wall_time_snsec;
    gtod_long_t    wall_time_sec;
    gtod_long_t    monotonic_time_sec;
    u64        monotonic_time_snsec;
    gtod_long_t    wall_time_coarse_sec;
    gtod_long_t    wall_time_coarse_nsec;
    gtod_long_t    monotonic_time_coarse_sec;
    gtod_long_t    monotonic_time_coarse_nsec;


    int        tz_minuteswest;
    int        tz_dsttime;
};

这些都是代码上的解释,不如直接观察数据来的直观,因此可以从逆向的角度来分析一下vDSO的工作模式。首先是写一份demo代码来进行函数调用:

int main(int argc, char *argv[])
{
    struct timeval current_time;
    gettimeofday(&current_time, NULL);
    printf("seconds : %ld\nmicro seconds : %ld", current_time.tv_sec, current_time.tv_usec);
    return 0;
}

函数会调用到高精度函数gettimeofday,利用gdb运行起来并打印出maps

gef➤  i proc mappings 
process 203720
Mapped address spaces:


          Start Addr           End Addr       Size     Offset objfile
    ........    ........   ........    ........
      0x7ffff7fc6000     0x7ffff7fca000     0x4000        0x0 [vvar]
      0x7ffff7fca000     0x7ffff7fcc000     0x2000        0x0 [vdso]
      0x7ffff7fcc000     0x7ffff7fcd000     0x1000        0x0 /usr/lib/ld-2.33.so
      0x7ffff7fcd000     0x7ffff7ff1000    0x24000     0x1000 /usr/lib/ld-2.33.so
      0x7ffff7ff1000     0x7ffff7ffa000     0x9000    0x25000 /usr/lib/ld-2.33.so
      0x7ffff7ffb000     0x7ffff7ffd000     0x2000    0x2e000 /usr/lib/ld-2.33.so
      0x7ffff7ffd000     0x7ffff7fff000     0x2000    0x30000 /usr/lib/ld-2.33.so
      0x7ffffffde000     0x7ffffffff000    0x21000        0x0 [stack]
  0xffffffffff600000 0xffffffffff601000     0x1000        0x0 [vsyscall]

vDSO的代码段被映射到了0x7ffff7fca000 - 0x7ffff7fcc000将其提取出来,先放着等着后面分析。

gef➤  dumpmem vdso.so 0x7ffff7fca000 0x7ffff7fcc000

看一下glibc中的gettimeofday的实现方式

这个与glibc版本有很大关系,我本地测试的是2.17版本

   18 #include <sys/time.h>
   19 
   20 #ifdef SHARED
   21 
   22 # include <dl-vdso.h>
   23 
   24 # define VSYSCALL_ADDR_vgettimeofday    0xffffffffff600000ul
   25 
   26 void *gettimeofday_ifunc (void) __asm__ ("__gettimeofday");
   27 
   28 void *
   29 gettimeofday_ifunc (void)
   30 {
   31   PREPARE_VERSION (linux26, "LINUX_2.6", 61765110);
   32 
   33   /* If the vDSO is not available we fall back on the old vsyscall.  */
   34   return (_dl_vdso_vsym ("__vdso_gettimeofday", &linux26)
   35       ?: (void *) VSYSCALL_ADDR_vgettimeofday);
   36 }
   37 asm (".type __gettimeofday, %gnu_indirect_function");
   38 
   39 /* This is doing "libc_hidden_def (__gettimeofday)" but the compiler won't
   40    let us do it in C because it doesn't know we're defining __gettimeofday
   41    here in this file.  */
   42 asm (".globl __GI___gettimeofday\n"
   43      "__GI___gettimeofday = __gettimeofday");
   44 
   45 #else
   46 
   47 # include <sysdep.h>
   48 # include <errno.h>
   49 
   50 int
   51 __gettimeofday (struct timeval *tv, struct timezone *tz)
   52 {
   53   return INLINE_SYSCALL (gettimeofday, 2, tv, tz);
   54 }
   55 libc_hidden_def (__gettimeofday)
   56 
   57 #endif
   58 weak_alias (__gettimeofday, gettimeofday)
   59 libc_hidden_weak (gettimeofday)                                             

可以看到在SHARED的情况下,如果开启了vDSO则会调用到_dl_vdso_vsym ("__vdso_gettimeofday", &linux26)否则从vsyscall页面进行函数调用,这点实际上也符合gdb中追踪的预期

[-------------------------------------code-------------------------------------]
   0x7ffff7ac25fd <gettimeofday+29>:    mov    DWORD PTR [rsp+0x8],0x3ae75f6
   0x7ffff7ac2605 <gettimeofday+37>:    mov    QWORD PTR [rsp],rax
   0x7ffff7ac2609 <gettimeofday+41>:    mov    QWORD PTR [rsp+0x10],0x0
=> 0x7ffff7ac2612 <gettimeofday+50>:    call   0x7ffff7b4b010 <_dl_vdso_vsym>
   0x7ffff7ac2617 <gettimeofday+55>:    mov    rdx,0xffffffffff600000
   0x7ffff7ac261e <gettimeofday+62>:    test   rax,rax
   0x7ffff7ac2621 <gettimeofday+65>:    cmovne rdx,rax
   0x7ffff7ac2625 <gettimeofday+69>:    add    rsp,0x28
Guessed arguments:
arg[0]: 0x7ffff7b95d1f ("__vdso_gettimeofday")
arg[1]: 0x7fffffffd9a0 --> 0x7ffff7b94848 ("LINUX_2.6")

__vdso_gettimeofday这个函数的地址是0x7ffff7ffae54是落在vdso的范围之中的,那就说明确实是针对VDSO函数的调用,对之前获取到的vdso.so进行反汇编后发现这个函数反汇编的内容有点多,但是其实际逻辑不是很多,因此直接挂个源码吧

notrace int __vdso_gettimeofday(struct timeval *tv, struct timezone *tz)
{
    if (likely(tv != NULL)) {
        if (unlikely(do_realtime((struct timespec *)tv) == VCLOCK_NONE))
            return vdso_fallback_gtod(tv, tz);
        tv->tv_usec /= 1000;
    }
    if (unlikely(tz != NULL)) {
        tz->tz_minuteswest = gtod->tz_minuteswest;
        tz->tz_dsttime = gtod->tz_dsttime;
    }


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

就像之前所说的数据是获取自vsyscall_gtod_data,还是从逆向的角度说的话可以通过gdb找到这个vsyscall_gtod_data的地址然后来验证一下,这次挑一个逻辑少点的函数__vdso_time

0000000000000fc0 <__vdso_time@@LINUX_2.6>:
     fc0:    55                       push   %rbp
     fc1:    48 85 ff                 test   %rdi,%rdi
     fc4:    48 8b 05 dd c0 ff ff     mov    -0x3f23(%rip),%rax        # ffffffffffffd0a8 <__vdso_getcpu@@LINUX_2.6+0xffffffffffffc0b8>
     fcb:    48 89 e5                 mov    %rsp,%rbp
     fce:    74 03                    je     fd3 <__vdso_time@@LINUX_2.6+0x13>
     fd0:    48 89 07                 mov    %rax,(%rdi)
     fd3:    5d                       pop    %rbp
     fd4:    c3                       retq   
     fd5:    55                       push   %rbp
     fd6:    b8 60 00 00 00           mov    $0x60,%eax
     fdb:    0f 05                    syscall 
     fdd:    48 89 e5                 mov    %rsp,%rbp
     fe0:    5d                       pop    %rbp
     fe1:    c3                       retq   

直接从ffffffffffffd0a8的地址拿到了所需要的数据,通过crash查看一下内核中vsyscall_gtod_data的数据

# cat /proc/kallsyms|grep vsyscall_gtod_data
ffffffff826a7080 D vsyscall_gtod_data
crash> struct vsyscall_gtod_data 0xffffffff826a7080 -o
struct vsyscall_gtod_data {
  [ffffffff826a7080] unsigned int seq;
  [ffffffff826a7084] int vclock_mode;
  [ffffffff826a7088] u64 cycle_last;
  [ffffffff826a7090] u64 mask;
  [ffffffff826a7098] u32 mult;
  [ffffffff826a709c] u32 shift;
  [ffffffff826a70a0] u64 wall_time_snsec;
  [ffffffff826a70a8] gtod_long_t wall_time_sec;
  [ffffffff826a70b0] gtod_long_t monotonic_time_sec;
  [ffffffff826a70b8] u64 monotonic_time_snsec;
  [ffffffff826a70c0] gtod_long_t wall_time_coarse_sec;
  [ffffffff826a70c8] gtod_long_t wall_time_coarse_nsec;
  [ffffffff826a70d0] gtod_long_t monotonic_time_coarse_sec;
  [ffffffff826a70d8] gtod_long_t monotonic_time_coarse_nsec;
  [ffffffff826a70e0] int tz_minuteswest;
  [ffffffff826a70e4] int tz_dsttime;
}
SIZE: 104

结合内核源码的实现来看

notrace time_t __vdso_time(time_t *t)
{
    /* This is atomic on x86 so we don't need any locks. */
    time_t result = READ_ONCE(gtod->wall_time_sec);


    if (t)
        *t = result;
    return result;
}
time_t time(time_t *t)
    __attribute__((weak, alias("__vdso_time")));

用户态下的ffffffffffffd0a8地址应该就是映射的内核态中的ffffffff826a70a8

安全问题

vdso实质上还是一种内存映射且每个用户空间中都能看到,因为用户态针对该内存的权限只是r-x在平常的时候是没有问题的,但是倘若是系统有漏洞存在的话,vdso就很有可能成为利用点和突破点。

![e2bd25ea-1174-4e90-87cb-dbf6bcd225e1.png](Kernel VDSO Hack_files/e2bd25ea-1174-4e90-87cb-dbf6bcd225e1.png)

vdso是一块物理内存映射到多个虚拟内存以便各个进程调用,那么如果进程突破了r-x的限制能够写入这一块内存的话就代表其能够影响到全局的vdso调用,例如劫持了_vdso_time函数为自己的shellcode,这样当一个root进程调用vdso的函数的话就相当于在用root运行这段shellcode直接导致提权。

  1. 通过内核rop布置栈来调用到set_memory_rw修改用户态下虚拟内存页的读写权限
  2. 通过一个内核任意写漏洞直接修改到内核中的vdso,不过这个方法需要知道vdso在内核中的地址需要爆破
  3. 利用类似Dirty Cow这种竞争漏洞强行将shellcode写入到vdso的只读页面中,然后等待root触发

参考文档