CSS(Call Stack Spoofing)
对抗场景:
- 用在线程函数里,尤其是需要长期运行的线程函数。因为创建新线程基本都是会被监控的,中高端EDR会尝试遍历调用栈来查找敏感返回地址及扫描相关内存,所以CSS是有必要的 (此场景下使用比较重要)
- 用在敏感API调用链中,主要是在调用一些非syscall的API和在实现DirectSyscall之前的API调用链中
- 极个别高端EDR还会通过栈回溯的方式来确定系统调用的发起方是否是ntdll.dll和kernel32.dll等系统dll
- 正常程序:主程序模块->kernel32.dll->ntdll.dll->syscall,这样当0环执行结束返回3环的时候,这个返回地址应该是在ntdll所在的地址范围之内
- 直接进行系统调用:此时当ring0返回的时候,rip将会是你的主程序模块内,而并不是在ntdll所在的范围内
函数调用
操作系统在加载可执行文件到内存运行之前需要完成许多准备工作,其中一项重要的任务是将代码和数据存储在内存中的适当位置,并分配和初始化必要的堆栈
进程在内存中布局主要分为4个区域: 代码区, 数据区, 堆和栈.
函数调用栈
作用
- 保存函数的局部变量;
- 向被调用函数传递参数;
- 返回函数的返回值;
- 保存函数的返回地址. (返回地址是指从被调用函数返回后调用者应该继续执行的指令地址)
函数执行过程中会使用一块栈内存(栈帧)保存上述的值,在发生函数调用时,被调用函数的栈帧会被压入栈上,执行完后再弹出,因此栈的大小会根据函数调用的层级增加而增大,随函数的返回而缩小。
与栈相关的寄存器
- rsp寄存器, 始终指向函数调用栈栈顶
- 32位对应ESP寄存器
- rbp寄存器, 一般用来指向函数栈帧的开始位置
- 32位对应EBP寄存器
下面举例说明,函数调用栈以及rsp/rbp与栈之间的关系是什么
函数调用链: A()->B()->C(), 并且正在执行函数 C().
随着程序的运行, 如果 C, B 两个函数都执行完成并返回到A函数继续执行, 则栈的状态如下:
一些汇编指令
- call/ret
call 目标地址
在执行函数调用时,CPU会将rip寄存器的值压入栈中并将rip设置为目标地址,从而使CPU能够跳转到目标地址执行指令。
ret
用于从被调用函数返回到调用函数,实现原理是将call指令压入栈中的“返回地址”弹出并给rip寄存器。
- push/pop
push 源操作数
向堆栈中压入数据,压入数据后会提升栈顶指针,提升多少取决于压入数据的数据宽度
pop 目标操作数
释放压入堆栈中的数据,释放数据后会下降栈顶指针,下降多少取决于释放数据的数据宽度
- leave
leave 指令没有操作数, 它一般放在函数的尾部 ret 指令之前, 用于调整 rsp 和 rbp, 这条指令相当于:
mov %rbp, %rsp
pop %rbp
C语言函数栈帧过程
#include <stdio.h>
int sum(int a, int b)
{
int s = a + b;
return s;
}
int main(int argc, char* argv[])
{
__asm{
mov eax, eax
}
int n = sum(1, 2);
printf("n: %d\n", n);
return 0;
}
在sum函数还没有调用的时候堆栈的位置
002F186D push 2
002F186F push 1
002F1871 call _sum (02F1299h)
002F1876 add esp,8
002F1879 mov dword ptr [n],eax
push 2
把2压倒堆栈中,ESP地址变成0133FBB0,其值为2
push 1
把2压倒堆栈中,ESP地址变成0133FBAC,其值为1
call _sum (02F1299h)
把下一条指令的地址002F1876压入到堆栈中,并跳到函数执行地址
进入到sum函数的汇编
002F1960 push ebp
002F1961 mov ebp,esp
002F1963 sub esp,0CCh
002F1969 push ebx
002F196A push esi
002F196B push edi
002F196C lea edi,[ebp-0Ch]
002F196F mov ecx,3
002F1974 mov eax,0CCCCCCCCh
push ebp
将ebp寄存器的值推送到堆栈中,ESP为0133FBA4
mov ebp,esp
将esp寄存器的值移到ebp寄存器中,esp和ebp相等了
sub esp,0CCh
从esp寄存器的值中减去0CCh(十进制为204),给新的函数堆栈分配内存
push ebx
push esi
push edi
将ebx、esi和edi寄存器的值推到堆栈中。这些寄存器在x86汇编中经常被用作通用寄存器,并被保存,以便以后可以恢复它们的值
00EF185F mov ecx,3
00EF1864 mov eax,0CCCCCCCCh
00EF1869 rep stos dword ptr es:[edi]
在缓冲区添加0CCCCCCCCh
栈展开
stack unwinding
在抛出异常时,会暂停当前函数的执行并寻找匹配的 catch 子句来处理该异常。首先会检查 throw 语句是否在 try 块内,如果是的话,会检查与该 try 块相关的 catch 子句,看看是否能处理该异常。如果不能处理,则会退出当前函数,释放该函数的内存并销毁局部对象,并在调用该函数的上层函数中继续查找可以处理该异常的 catch 子句,直到找到为止。这一过程被称为栈展开。在处理完该异常的 catch 子句之后,程序会从 catch 子句之后的位置继续执行。
在其中需要注意的是
- 为局部对象调用析构函数
在展开栈的过程中,局部对象所占用的内存会被释放,并执行类型局部对象的析构函数。但是,要注意:如果在一个块中使用 new 动态分配内存,并且在释放该资源之前发生异常,导致块因异常退出,那么栈展开期间不会释放该资源,编译器也不会删除指针,导致内存泄露。
- 析构函数应该从不抛出异常
在栈展开时,如果析构函数抛出未经处理的异常,将导致调用标准库的 terminate 函数。这通常会导致程序调用 abort 函数并非正常退出。因此,析构函数永远不应该抛出异常。
- 异常与构造函数
如果在构造函数对象时发生异常,此时该对象可能只是被部分构造,要保证能够适当的撤销这些已构造的成员
- 未捕获的异常将会终止程序
不能不处理异常。如果找不到匹配的catch,程序就会调用库函数terminate。
#include <string>
#include <iostream>
using namespace std;
class MyException{};
class Dummy {
public:
// 构造函数
Dummy(string s) : MyName(s) { PrintMsg("Created Dummy:"); }
// 拷贝构造
Dummy(const Dummy& other) : MyName(other.MyName){ PrintMsg("Copy created Dummy:"); }
// 析构函数
~Dummy(){ PrintMsg("Destroyed Dummy:"); }
void PrintMsg(string s) { cout << s << MyName << endl; }
string MyName;
int level;
};
void C(Dummy d, int i) {
cout << "Entering Function C" << endl;
d.MyName = " C";
throw MyException();
cout << "Exiting Function C" << endl;
}
void B(Dummy d, int i) {
cout << "Entering Function B" << endl;
d.MyName = " B";
C(d, i + 1);
cout << "Exiting Function B" << endl;
}
void A(Dummy d, int i) {
cout << "Entering Function A" << endl;
d.MyName = " A" ;
// Dummy* pd = new Dummy("new Dummy"); //Not exception safe!!!
B(d, i + 1);
// delete pd;
cout << "Exiting FunctionA" << endl;
}
int main() {
cout << "Entering main" << endl;
try {
Dummy d(" M");
A(d,1);
}
catch (MyException& e) {
cout << "Caught an exception of type: " << typeid(e).name() << endl;
}
cout << "Exiting main." << endl;
return 0;
}
运行效果:
程序执行将从 C 中的 throw 语句跳转到 main 中的 catch 语句,并在此过程中展开每个函数。
- 根据创建 Dummy 对象的顺序,在它们超出范围时将其销毁
- 除了包含 catch 语句的 main 之外,其他函数均未完成
- 函数 A 绝不会从其对 B() 的调用返回,并且 B 绝不会从其对 C() 的调用返回
栈回溯
#include <stdio.h>
#include <excpt.h>
#include <conio.h>
#pragma warning(disable: 4311 4312 4313)
int fake_ebp_1, fake_ebp_2;
void __stdcall _StackTrace(int StackBase, int ebp, int esp)
{
int limit = 30, retaddr, calladdr;
printf("ebp ret call\n");
while ((ebp > esp) && (ebp < StackBase) && (limit--))
{
retaddr = *(int *)(ebp + 4);
calladdr = 0;
__try
{
if (*(unsigned char *)(retaddr - 5) == 0xe8)
{
calladdr = *(int *)(retaddr - 4) + retaddr;
}
} __except (EXCEPTION_EXECUTE_HANDLER) {}
printf("%08x %08x %08x\n", ebp, retaddr, calladdr);
ebp = *(int *)ebp;
}
printf("trace completed.\n");
}
__declspec(naked) void __stdcall StackTrace()
{
// iceboy's stack trace
__asm {
push esp
push ebp
push fs:[0x4] //; StackBase
call _StackTrace
retn
}
}
void b(int, int)
{
StackTrace();
}
void a(int, int, int)
{
b(0, 0);
}
int search_call(int fn1, int fn2)
{
while (true)
{
if (*(unsigned char *)(fn1++) == 0xe8)
{
if ((*(int *)fn1 + fn1 + 4) == fn2)
{
return fn1 + 4;
}
}
}
}
// fake call
__declspec(naked) void __stdcall d(int, int)
{
__asm {
push fake_ebp_1
push ebp
mov ebp, esp
push fake_ebp_2
push ebp
mov ebp, esp
call StackTrace
pop esp
pop ebp
pop eax
retn 8
}
}
// fake call & hide self
__declspec(naked) void __stdcall e(int, int)
{
__asm {
push ebp
mov ebp, [ebp]
push 0
push 0
call d
pop ebp
retn 8
}
}
void c(int, int, bool hideself)
{
if (!hideself)
{
d(0, 0);
} else
{
e(0, 0);
}
}
int main()
{
fake_ebp_1 = search_call((int)main, (int)a);
fake_ebp_2 = search_call((int)a, (int)b);
printf("address of function a: 0x%08x\n", a);
printf("address of function b: 0x%08x\n", b);
printf("address of function c: 0x%08x\n", c);
printf("address of function main: 0x%08x\n", main);
printf("\ntest 1: standard call\n");
a(0, 0, 0);
printf("\ntest 2: fake call\n");
c(0, 0, false);
printf("\ntest 3: fake call & hide self\n");
c(0, 0, true);
printf("\npress any key to continue...");
_getch();
printf("\n");
return 0;
}
代码解释
fake_ebp_1 = search_call((int)main, (int)a);
fake_ebp_2 = search_call((int)a, (int)b);
int search_call(int fn1, int fn2)
{
while (true)
{
if (*(unsigned char *)(fn1++) == 0xe8)
{
if ((*(int *)fn1 + fn1 + 4) == fn2)
{
return fn1 + 4;
}
}
}
}
对于E8 call可以用来计算被调函数的首地址
e8后面的四字节机器码是此时指令指针(EIP)的值与目的地址(被调函数首地址)的差值,也即两地址间的相对偏移
指令指针在这里可以叫做返回地址,意思就是从被调函数返回后应执行的第一条指令的地址
目的地址(被调函数首地址): 目的地址 = 返回地址 + 相对偏移
0092D07+FFFF3F6C = 00921073
故上述代码中
- fn1+4 返回地址
- fn2 目标地址
- *(int *)fn1 偏移量
- fake_ebp_1 main函数调用a函数的后的返回地址
- fake_ebp_2 a函数调用b函数后的返回地址
printf("address of function a: 0x%08x\n", a);
printf("address of function b: 0x%08x\n", b);
printf("address of function c: 0x%08x\n", c);
printf("address of function main: 0x%08x\n", main);
反汇编后发下结果输出的并不是这几个函数的真正首地址,而是它们在 静态函数跳转表 中的地址
ILT是一种用于静态函数跳转的表,它通过记录函数入口并跳转实现间接调用,这有助于提高程序的效率,特别是在调试阶段。ILT在编译release程序时不再使用。
其他知识:
__declspec(naked)
裸函数(naked function)是指在编译器生成代码时没有通常的形成栈的语句块(参数、局部变量的入栈操作),在函数结束时也没有恢复栈的动作(参数、局部变量的出栈操作,以及返回语句)的函数。裸函数可以自己完成参数的入栈出栈操作及返回操作,通常使用内嵌汇编码的方式完成。裸函数在写虚拟设备驱动时特别有用,但仅在x86系列CPU中有效。
方便进行栈欺骗
注意点:如果使用_declspec(naked)修饰的话,要注意自己恢复堆栈平衡
__declspec(naked) void __stdcall d(int, int)
{
__asm {
push fake_ebp_1
push ebp
mov ebp, esp
push fake_ebp_2
push ebp
mov ebp, esp
call StackTrace
pop esp
pop ebp
pop eax
retn 8
}
}
可以看出在d函数中在栈上压入了fake_ebp_1和fake_ebp_2,所以回溯的时候的路径与函数调用顺序是不同的,这就是栈欺骗。
在e函数中的
push ebp
mov ebp, [ebp]
这里并不是将ebp赋值为当前的esp,而是[ebp],也就是main函数的栈基址,所以在我们回溯的时候就会忽略函数c的函数栈帧,这也是栈欺骗的一个手段。
其他问题
问: 为什么这个返回地址00007FF69CC32387 + 偏移量 FFFFEED6 = 7FF7 9CC3 125D 不等于目标地址 07FF69CC3125Dh
答:
计算机以补码的形式存放数据,所以E8 指令后的4个字节偏移值也是以补码的形式存放的,在计算时如果要跳转的目标地址在E8指令所在的地址前面相对偏移是负值,如果在E8指令所在的地址后面,相对偏移是正值。在手动计算时要将其还原为原码再参与计算。
正数的补码、原码、反码都等于其自身,所以可以直接相加。
负数的补码转换为原码需要对其 标志位外的其他位取反 +1,还原为原码后再进行相加。
在X86下,因为地址宽度和偏移的数据宽度一致,所以相加后的进位会被舍弃掉。
在X64下,因为地址宽度大于偏移宽度,所以相加后的进位会被保留,所以就导致了计算的错误产生,在写程序时可以只使用低32位的地址与 偏移值进行运算,再进位丢弃后再与高32位的地址进行拼接。