最近发现有很多漏洞利用或木马程序样本会通过一些技术手段,达到使自动化检测系统或分析人员调试工具的栈回溯机制失效的目的。在本文中我将会简单分析和推测一下这类恶意样本都是通过哪些套路来实现和栈回溯机制的对抗。需要注意的是,文中讨论的堆栈都是代指线程在用户层的堆栈,并未涉及内核层的堆栈。

0x0 准备

用这两天遇到的某个样本举例来说吧。那是个 RTF 文件格式的 CVE-2015-1641 漏洞利用样本。为调试其在 ShellCode 中创建子进程的行为,在 windbg 中给 ntdll!NtCreateUserProcess 函数下断点。命中断点之后,发现无法通过 kv 指令栈回溯来获取该线程当前时刻的调用栈序列,能获取到的栈帧只有当前所处的函数调用。而继续跟进该函数里所调用的任何一个函数中,发现调用栈仍旧只有一个栈帧。

Breakpoint 0 hit
eax=0900f130 ebx=00000000 ecx=00000044 edx=002e017c esi=05731400 edi=00000000
eip=76e65860 esp=0900edc8 ebp=0900f450 iopl=0         nv up ei pl nz na pe nc
cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000206
ntdll!NtCreateUserProcess:
76e65860 b85d000000      mov     eax,5Dh
0:009> kv
ChildEBP RetAddr  Args to Child              
0900f450 00000000 00000000 00000000 057302e0 ntdll!NtCreateUserProcess (FPO: [11,0,0])
0:009> p
0:009> p
0:009> t
eax=0000005d ebx=00000000 ecx=00000044 edx=7ffe0300 esi=05731400 edi=00000000
eip=76e671b0 esp=0900edc4 ebp=0900f450 iopl=0         nv up ei pl nz na pe nc
cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000206
ntdll!KiFastSystemCall:
76e671b0 8bd4            mov     edx,esp
0:009> kv
ChildEBP RetAddr  Args to Child              
0900f450 00000000 00000000 00000000 057302e0 ntdll!KiFastSystemCall (FPO: [0,0,0])

这个样本是怎么做到的呢?要理解这个问题,首先需要明确 windbg 或其他调试工具以及通常的检测系统都是怎么回溯栈的。要明白回溯栈的原理,那么就需要了解在 Windows 平台的 C/C++ 程序中调用函数时堆栈操作的逻辑。

0x1 原理

关于几种调用约定的区别和各自的特性,读者需自行了解。在这里不针对每种调用约定的压参方式做单独讨论,而是只关注调用时堆栈指针的改变。

根据规定,函数调用 CALL 指令可拆分为两步操作:

  1. 将调用者的下一条指令(EIP)的地址压栈
  2. 跳转至将要调用的函数地址中(相对偏移或绝对地址)

那么在执行到子函数首地址位置时,返回地址(即调用函数中调用位置下一条指令的地址)就已经存在于堆栈中了,并且是 ESP 指向地址的值。下面通过栈帧的概念,了解编译器在接下来对堆栈进行的操作。

简言之,栈帧就是利用 EBP(栈帧指针,请注意不是 ESP)寄存器访问栈内部局部变量、参数、函数返回地址等的手段。程序运行中,ESP 寄存器的值随时变化,访问栈中函数的局部变量、参数时,若以 ESP 值为基准编写程序会十分困难,并且也很难使 CPU 引用到正确的地址。

所以,调用某函数时,先要把用作基准点(函数起始地址)的 ESP 值保存到 EBP,并维持在函数内部。这样,无论 ESP 的值如何变化,以 EBP 的值为基准能够安全访问到相关函数的局部变量、参数、返回地址,这就是 EBP 寄存器作为栈帧指针的作用。

为了更直观地了解,下面根据一个 Demo 来进行说明。编译后在 helloworld() 函数的调用处下断点跟进执行,到 helloworld() 函数内的首地址位置。此时根据调试器的反汇编窗口观察 C 代码和汇编代码的对应。

void helloworld()
{
> 003A1000  push        ebp
  003A1001  mov         ebp,esp
  003A1003  sub         esp,40h
  003A1006  push        ebx
  003A1007  push        esi
  003A1008  push        edi
    printf("hello world!\n");
  003A1009  push        3E91C8h
  003A100E  call        printf (03A1180h)
  003A1013  add         esp,4
}
  003A1016  pop         edi
  003A1017  pop         esi
  003A1018  pop         ebx
  003A1019  mov         esp,ebp
  003A101B  pop         ebp
  003A101C  ret

根据上面的对应关系,提取出栈帧的结构。

push ebp      ; 先将属于调用函数的 EBP 值压栈,执行后 ESP 指向地址存储该 EBP 的值
mov  ebp, esp ; 将 ESP 的值赋给 EBP 寄存器,执行后 EBP 指向地址存储属于调用函数的 EBP 的值

...           ; 函数体。EBP 始终保持不变

mov  esp, ebp ; 将 EBP 的值赋给 ESP 寄存器,执行后 ESP 指向地址存储属于调用函数的 EBP 的值
pop  ebp      ; 弹出保存在栈中的调用函数的 EBP 的并赋给 EBP 寄存器
ret           ; 弹出保存在栈中的调用函数中调用位置的下一条指令的地址给 EIP 寄存器

根据以上的函数调用逻辑容易知道,在函数体代码的任何位置,EBP 寄存器指向的地址始终存储属于它的调用函数的 EBP 的值,根据这个原理可逐级向调用函数、调用函数的调用函数进行遍历,向上回溯。

这样有什么用呢?根据上面栈帧结构和 CALL 指令的操作可知,在将属于调用函数的 EBP 的值压栈之前,ESP 指向的地址存储的是由 CALL 指令压栈的调用函数中调用位置的下一条指令的地址(原 EIP)。那么根据这个逻辑,可以通过上面回溯的各级 EBP 的值,并根据 EBP+sizeof(ULONG_PTR) 获取到函数调用者函数体中的地址(当前函数的返回地址)。

有了每级调用的函数体中的地址,那么获取该函数的详细信息及函数符号就变得容易了。

0x2 推断

根据前面在 NtCreateUserProcess 断点处的寄存器状态中 EBP 和 ESP 的值,对比该线程 TEB 的 StackBase 和 StackLimit 的值,会发现 EBP 和 ESP 的值均不在该线程堆栈范围之中,也就是说:要么是 TEB 中的堆栈范围被修改了,要么是当前栈帧所处的堆栈被移动到自己分配的内存里了,也就是说,栈被“截断”并“移动”了。

esp=0900edc8 ebp=0900f450
...
0:009> !teb
TEB at 7ffd5000
    ExceptionList:        0900f440
    StackBase:            11520000
    StackLimit:           11512000
    SubSystemTib:         00000000
    FiberData:            00001e00
    ArbitraryUserPointer: 00000000
    Self:                 7ffd5000
    EnvironmentPointer:   00000000
    ClientId:             00000118 . 00000bf4
    RpcHandle:            00000000
    Tls Storage:          0572b098
    PEB Address:          7ffd7000
    LastErrorValue:       0
    LastStatusValue:      c0000024
    Count Owned Locks:    0
    HardErrorMode:        0

线程 TEB 中的成员 StackBase 域和 StackLimit 域,作为当前线程栈的范围存在。一般情况,在线程初始化时 StackBase 被赋初值之后将不再改变,而 StackLimit 作为动态的成员域,根据当前线程函数调用层级的递进,以固定的长度(如1KB)向下扩展。根据规定,所属每个函数调用的栈帧区域 EBP 和 ESP 寄存器所划定的空间,应该始终在当前线程的 StackLimit 到 StackBase 的范围之间存在。

先推测该样本篡改 TEB 里 StackBase 和 StackLimit 的值。那么直接编写动态库,在 WINWORD.exe 进程启动时进行注入,记录所有已存在和将创建的线程的 TEB 里 StackBase 和 StackLimit 的值,并以 TID 作为索引。这时候样本尚未加载并执行 ShellCode,所以这两个值在这时候是纯净的。后续在调用 NtCreateUserProcess 时取到之前记录值的列表,根据该线程的 TID 取到该线程对应的两个成员的初始值,再对比此时 TEB 中这两个成员的值,发现并未发生改变。

判断失误。

突然注意到,多次执行这个恶意样本并同样在 NtCreateUserProcess 命中断点时,这时候的 ESP 和 EBP 的值始终是 0x0900XXXX 左右的地址,而 StackBase 和 StackLimit 的值是无规律的地址数值。从而可以断定在该样本的 ShellCode 中,并非是修改 StackBase 和 StackLimit 的值使堆栈范围发生改变,而是其分配了以 0x0900XXXX 左右的内存地址作为基地址的内存空间,并将以当前 ESP 为基地址的一段栈内存片段的数据拷贝到了新分配的内存空间的高内存区域中,修改 ESP 和 EBP 寄存器的值为新缓冲区中对应的两个寄存器指针应该指向的位置,相当于对堆栈片段进行了平移。

可能说到这里,有的读者还没明白我表述的意思,那么下面以示意图作为解释。

时间紧急,图画得比较糙,将就着看吧。图的左侧是线程真正的栈的内存空间,右侧是 ShellCode 分配的新的缓冲区。平移时首先根据 ESP 和 EBP 寄存器指向的内存地址定位需要拷贝的数据范围。在这里可能会向 EBP 指向的地址上面多拷贝一部分数据,以将参数和返回地址等数据一并拷贝到新分配的缓冲区中。拷贝完成之后,将 ESP 和 EBP 寄存器指向新缓冲区中对应的位置。

这时开始程序对堆栈的操作将会在新分配的内存缓冲区中进行。在 ShellCode 代码执行即将完成时,应会再将 ESP 和 EBP 的值还原回原来真正栈里的地址,避免弹栈时进入上面未知的内存区域导致程序异常。

0x3 验证

为了验证这个判断是否有效和真实,接下来需要实现上面猜想中描述的操作,看看调试器或检测系统是否能够成功地进行栈回溯。

下面的代码片段实现了分配新的缓冲区,并拷贝从 ESP 指针指向位置到 调用函数的 EBP 在栈中存储位置加上调用函数的返回地址的存储位置这个范围的栈片段,到新分配的缓冲区中最高位置区域,为低内存预留了 0x100000 字节的空间。

void simplesubfunc() {
    printf("a simple sub function!\n");
}

void buildmystack() {
    ULONG_PTR stackbase, stacklimit;
    ULONG_PTR p_ebp, pp_ebp = 0, p_esp, delta;
    ULONG_PTR p_new_esp = 0, pp_delta;

    PVOID p_new_stack = NULL;

    __asm pushad;
    __asm pushfd;
    __asm push 0;
    __asm push 0;
    __asm push 0;
    __asm push 0;

    // 获取栈的基本信息
    __asm mov   eax,        fs:[0x04] ; 取 StackBase 域的值
    __asm mov   stackbase,  eax       ;
    __asm mov   ebx,        fs:[0x08] ; 取 StackLimit 域的值
    __asm mov   stacklimit, ebx       ;
    __asm mov   p_ebp,      ebp       ;
    __asm mov   p_esp,      esp       ;

    stackbase -= 2 * sizeof(ULONG_PTR);
    delta = p_ebp - p_esp;

    // 获取调用者的 EBP 在栈中的位置
    if (p_esp > stacklimit &&
        p_esp < stackbase  &&
        p_ebp > stacklimit &&
        p_ebp < stackbase) {
        pp_ebp = *(ULONG_PTR *)p_ebp;
    }

    // 搭建新的栈空间并移动栈指针
    if (pp_ebp > stacklimit &&
        pp_ebp < stackbase) {
        pp_delta = pp_ebp - p_esp;
        p_new_stack = malloc(pp_delta + 0x100000 + 2 * sizeof(ULONG_PTR));
        p_new_esp = (ULONG_PTR)p_new_stack + 0x100000;
        memcpy((PVOID)p_new_esp, (PVOID)p_esp, pp_delta + 2 * sizeof(ULONG_PTR));
        __asm mov   eax,   p_new_esp  ;
        __asm mov   esp,   eax        ;
        __asm mov   ebx,   eax        ;
        __asm add   eax,   delta      ; 计算当前 ebp 应指向的位置
        __asm mov   ebp,   eax        ;
        __asm add   ebx,   pp_delta   ;
        __asm mov   [eax], ebx        ; 修正调用者 ebp 在栈中位置
    }

    // 执行正式函数体代码
    simplesubfunc();

    // 恢复栈指针到原栈中的位置并释放内存
    if (p_new_stack) {
        __asm mov   esp,   p_esp      ;
        __asm mov   ebp,   p_ebp      ;
        __asm mov   eax,   ebp        ;
        __asm mov   ebx,   pp_ebp     ;
        __asm mov   [eax], ebx        ;
        free(p_new_stack);
    }

    __asm pop  eax;
    __asm pop  eax;
    __asm pop  eax;
    __asm pop  eax;
    __asm popfd;
    __asm popad;
}

void helloworld() {
    buildmystack();
    printf("hello world!\n");
}

int main(int argc, char* argv[]) {
    helloworld();
    return 0;
}

在函数 simplesubfunc() 处下断点,用 windbg 启动执行,命中断点后通过 kv 指令观察调用栈,发现调用序列中已经不能回溯到上级各层的调用了。

(5644.3e20): Break instruction exception - code 80000003 (first chance)
eax=016e40d0 ebx=012fe000 ecx=00000000 edx=000000e4 esi=013b1d40 edi=013b1d40
eip=013b1129 esp=016e3fec ebp=016e4038 iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
*** WARNING: Unable to verify checksum for HookDemo.exe
HookDemo!simplesubfunc+0x9:
013b1129 cc              int     3
0:000> kv
ChildEBP RetAddr  Args to Child              
016e4038 00000000 00000206 013b1d40 013b1d40 HookDemo!simplesubfunc+0x9 (FPO: [Non-Fpo])
0:000> !teb
TEB at 01027000
    ExceptionList:        012ffdc8
    StackBase:            01300000
    StackLimit:           012fe000
    SubSystemTib:         00000000
    FiberData:            00001e00
    ArbitraryUserPointer: 00000000
    Self:                 01027000
    EnvironmentPointer:   00000000
    ClientId:             00005644 . 00003e20
    RpcHandle:            00000000
    Tls Storage:          0102702c
    PEB Address:          01024000
    LastErrorValue:       0
    LastStatusValue:      c0000139
    Count Owned Locks:    0
    HardErrorMode:        0

对比 TEB 中 StackBase 和 StackLimit 域的值和命中断点时 CPU 寄存器状态中 ESP 和 EBP 指向的值,发现 ESP 和 EBP 已经不在线程栈的范围中了。但是程序的向下执行并没有受到任何影响:

a simple sub function!
hello world!
请按任意键继续. . .

这就说明,这个判断至少到目前为止是正确的。

0x4 应对

栈回溯时以 TEB 的成员 StackBase 和 StackLimit 的值作为限制范围,而栈顶和栈底指针一开始就不在范围之中,那么栈回溯循环过程会在遍历第一个栈帧时就跳出遍历。

那么可不可以在栈回溯的时候,去掉通过这两个成员的值进行的限制呢?

这样考虑和推测,当然要想到任何一种可能出现的不正常的情况。ShellCode 中构造的新的栈片段中,最上级调用的栈区域可能并未赋给正确的值,包括原 EBP 或原 EIP 的值,比如这两个域在 ShellCode 代码中被临时地给简单地置为 0x00000000 了。那么放开 StackBase 和 StackLimit 的限制而直接地通过调用序列向上回溯,如果未处理好的话,很可能会在检测模块中发生非法访问等异常情况。

那么如果对原 EBP 或原 EIP 判断得好的话,比如对内存地址的有效性进行谨慎的判断,那么放开限制是否就可以了?

根据前面表达过的意思,你不能清楚地知道在 ShellCode 中对原 EBP 或原 EIP 的值改成什么样了,如果是非法的地址还算是比较好判断的。但是如果是正常的属于堆栈地址呢?这里的“正常”的意思是,原 EBP 或原 EIP 的值确实是“原 EBP 或原 EIP 的值”,但不是应该出现在这里的,而是诸如应该出现在下级调用中的“原 EBP 或原 EIP 的值”这样的。如此一来,将会导致无限循环遍历等问题。

要是样本的 ShellCode 更进一步,窃取其他线程的堆栈部分数据覆盖到自己构造的堆栈的高内存部分,那么在调试器或检测系统在栈回溯时,遍历到上层的调用项,被诱导进入另一个线程的调用栈序列中,那么获取到的数据就可能已经不是当前线程的数据了。

0x5 说明

本文中的代码片段在任意版本的 Visual Studio 或 Visual C++ 中均可编译通过,感兴趣的可自行测试。未贴出完整代码内容,需自行补充头文件包含等。另外上面部分代码在编译的时候会报出 warning C4731 的警告,提示栈帧指针寄存器 ebp 被内联汇编代码修改。直接无视即可。

- THE END -

文章链接: https://xiaodaozhi.com/analysis/28.html