1.原理
函数调用 CALL 指令可拆分为两步操作:
1)、将调用者的下一条指令(EIP)的地址压栈
2)、跳转至将要调用的函数地址中(相对偏移或绝对地址)
那么在执行到子函数首地址位置时,返回地址(即调用函数中调用位置下一条指令的地址)就已经存在于堆栈中了,并且是 ESP 指向地址的值。下面通过栈帧的概念,了解编译器在接下来对堆栈进行的操作。
简言之,栈帧就是利用 EBP(栈帧指针,请注意不是 ESP)寄存器访问栈内部局部变量、参数、函数返回地址等的手段。程序运行中,ESP 寄存器的值随时变化,访问栈中函数的局部变量、参数时,若以 ESP 值为基准编写程序会十分困难,并且也很难使 CPU 引用到正确的地址。
所以,调用某函数时,先要把用作基准点(函数起始地址)的 ESP 值保存到 EBP,并维持在函数内部。这样,无论 ESP 的值如何变化,以 EBP 的值为基准能够安全访问到相关函数的局部变量、参数、返回地址,这就是 EBP 寄存器作为栈帧指针的作用。
在函数体代码的任何位置,EBP 寄存器指向的地址始终存储属于它的调用函数的 EBP 的值,根据这个原理可逐级向调用函数、调用函数的调用函数进行遍历,向上回溯。
这样有什么用呢?在将属于调用函数的 EBP 的值压栈之前,ESP 指向的地址存储的是由 CALL 指令压栈的调用函数中调用位置的下一条指令的地址(原 EIP)。那么根据这个逻辑,可以通过上面回溯的各级 EBP 的值,并根据 EBP+sizeof(ULONG_PTR) 获取到函数调用者函数体中的地址(当前函数的返回地址)。有了每级调用的函数体中的地址,那么获取该函数的详细信息及函数符号就变得容易了。
2.对抗思路
分配内存地址作为基地址的内存空间,并将以当前 ESP 为基地址的一段栈内存片段的数据拷贝到了新分配的内存空间的高内存区域中,修改 ESP 和 EBP 寄存器的值为新缓冲区中对应的两个寄存器指针应该指向的位置,相当于对堆栈片段进行了平移。
平移时首先根据 ESP 和 EBP 寄存器指向的内存地址定位需要拷贝的数据范围。在这里可能会向 EBP 指向的地址上面多拷贝一部分数据,以将参数和返回地址等数据一并拷贝到新分配的缓冲区中。拷贝完成之后,将 ESP 和 EBP 寄存器指向新缓冲区中对应的位置。
这时开始程序对堆栈的操作将会在新分配的内存缓冲区中进行。在 ShellCode 代码执行即将完成时,应会再将 ESP 和 EBP 的值还原回原来真正栈里的地址,避免弹栈时进入上面未知的内存区域导致程序异常。
3.验证
为了验证这个判断是否有效和真实,接下来需要实现上面猜想中描述的操作,看看调试器或检测系统是否能够成功地进行栈回溯。
下面的代码片段实现了分配新的缓冲区,并拷贝从 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!
请按任意键继续. . .
这就说明,这个判断至少到目前为止是正确的。
4.应对
栈回溯时以 TEB 的成员 StackBase 和 StackLimit 的值作为限制范围,而栈顶和栈底指针一开始就不在范围之中,那么栈回溯循环过程会在遍历第一个栈帧时就跳出遍历。
那么可不可以在栈回溯的时候,去掉通过这两个成员的值进行的限制呢?
这样考虑和推测,当然要想到任何一种可能出现的不正常的情况。ShellCode 中构造的新的栈片段中,最上级调用的栈区域可能并未赋给正确的值,包括原 EBP 或原 EIP 的值,比如这两个域在 ShellCode 代码中被临时地给简单地置为 0x00000000 了。那么放开 StackBase 和 StackLimit 的限制而直接地通过调用序列向上回溯,如果未处理好的话,很可能会在检测模块中发生非法访问等异常情况。
那么如果对原 EBP 或原 EIP 判断得好的话,比如对内存地址的有效性进行谨慎的判断,那么放开限制是否就可以了?
根据前面表达过的意思,你不能清楚地知道在 ShellCode 中对原 EBP 或原 EIP 的值改成什么样了,如果是非法的地址还算是比较好判断的。但是如果是正常的属于堆栈地址呢?这里的“正常”的意思是,原 EBP 或原 EIP 的值确实是“原 EBP 或原 EIP 的值”,但不是应该出现在这里的,而是诸如应该出现在下级调用中的“原 EBP 或原 EIP 的值”这样的。如此一来,将会导致无限循环遍历等问题。
要是样本的 ShellCode 更进一步,窃取其他线程的堆栈部分数据覆盖到自己构造的堆栈的高内存部分,那么在调试器或检测系统在栈回溯时,遍历到上层的调用项,被诱导进入另一个线程的调用栈序列中,那么获取到的数据就可能已经不是当前线程的数据了。
5.说明
本文中的代码片段在任意版本的 Visual Studio 或 Visual C++ 中均可编译通过,感兴趣的可自行测试。未贴出完整代码内容,需自行补充头文件包含等。另外上面部分代码在编译的时候会报出 warning C4731 的警告,提示栈帧指针寄存器 ebp 被内联汇编代码修改。直接无视即可。
来源:CSDN
作者:liuhaidon1992
链接:https://blog.csdn.net/liuhaidon1992/article/details/103926502