CSS(Call Stack Spoofing)
对抗场景:

  1. 用在线程函数里,尤其是需要长期运行的线程函数。因为创建新线程基本都是会被监控的,中高端EDR会尝试遍历调用栈来查找敏感返回地址及扫描相关内存,所以CSS是有必要的 (此场景下使用比较重要)
  2. 用在敏感API调用链中,主要是在调用一些非syscall的API和在实现DirectSyscall之前的API调用链中
  • 极个别高端EDR还会通过栈回溯的方式来确定系统调用的发起方是否是ntdll.dll和kernel32.dll等系统dll
  • 正常程序:主程序模块->kernel32.dll->ntdll.dll->syscall,这样当0环执行结束返回3环的时候,这个返回地址应该是在ntdll所在的地址范围之内
  • 直接进行系统调用:此时当ring0返回的时候,rip将会是你的主程序模块内,而并不是在ntdll所在的范围内

函数调用

操作系统在加载可执行文件到内存运行之前需要完成许多准备工作,其中一项重要的任务是将代码和数据存储在内存中的适当位置,并分配和初始化必要的堆栈

进程在内存中布局主要分为4个区域: 代码区, 数据区, 堆和栈.

未命名文件.jpg

函数调用栈

作用

  1. 保存函数的局部变量;
  2. 向被调用函数传递参数;
  3. 返回函数的返回值;
  4. 保存函数的返回地址. (返回地址是指从被调用函数返回后调用者应该继续执行的指令地址)

函数执行过程中会使用一块栈内存(栈帧)保存上述的值,在发生函数调用时,被调用函数的栈帧会被压入栈上,执行完后再弹出,因此栈的大小会根据函数调用的层级增加而增大,随函数的返回而缩小。

与栈相关的寄存器

  • rsp寄存器, 始终指向函数调用栈栈顶
    • 32位对应ESP寄存器
  • rbp寄存器, 一般用来指向函数栈帧的开始位置
    • 32位对应EBP寄存器

下面举例说明,函数调用栈以及rsp/rbp与栈之间的关系是什么
函数调用链: A()->B()->C(), 并且正在执行函数 C().
未命名文件.jpg
随着程序的运行, 如果 C, B 两个函数都执行完成并返回到A函数继续执行, 则栈的状态如下:
未命名文件.jpg

一些汇编指令

  • 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函数还没有调用的时候堆栈的位置
image.png
image.png

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
image.png
image.png

push        1 

把2压倒堆栈中,ESP地址变成0133FBAC,其值为1
image.png
image.png

call        _sum (02F1299h) 

把下一条指令的地址002F1876压入到堆栈中,并跳到函数执行地址
image.png
image.png
进入到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相等了
image.png
image.png

sub         esp,0CCh

从esp寄存器的值中减去0CCh(十进制为204),给新的函数堆栈分配内存
image.png

push        ebx  
push        esi  
push        edi

将ebx、esi和edi寄存器的值推到堆栈中。这些寄存器在x86汇编中经常被用作通用寄存器,并被保存,以便以后可以恢复它们的值
image.png

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 子句之后的位置继续执行。
在其中需要注意的是

  1. 为局部对象调用析构函数

在展开栈的过程中,局部对象所占用的内存会被释放,并执行类型局部对象的析构函数。但是,要注意:如果在一个块中使用 new 动态分配内存,并且在释放该资源之前发生异常,导致块因异常退出,那么栈展开期间不会释放该资源,编译器也不会删除指针,导致内存泄露。

  1. 析构函数应该从不抛出异常

在栈展开时,如果析构函数抛出未经处理的异常,将导致调用标准库的 terminate 函数。这通常会导致程序调用 abort 函数并非正常退出。因此,析构函数永远不应该抛出异常。

  1. 异常与构造函数

如果在构造函数对象时发生异常,此时该对象可能只是被部分构造,要保证能够适当的撤销这些已构造的成员

  1. 未捕获的异常将会终止程序

不能不处理异常。如果找不到匹配的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;
}

运行效果:
image.png

未命名文件.jpg
程序执行将从 C 中的 throw 语句跳转到 main 中的 catch 语句,并在此过程中展开每个函数。

  1. 根据创建 Dummy 对象的顺序,在它们超出范围时将其销毁
  2. 除了包含 catch 语句的 main 之外,其他函数均未完成
  3. 函数 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)的值与目的地址(被调函数首地址)的差值,也即两地址间的相对偏移
指令指针在这里可以叫做返回地址,意思就是从被调函数返回后应执行的第一条指令的地址
目的地址(被调函数首地址): 目的地址 = 返回地址 + 相对偏移

image.png
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);

反汇编后发下结果输出的并不是这几个函数的真正首地址,而是它们在 静态函数跳转表 中的地址
image.png
image.png

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的函数栈帧,这也是栈欺骗的一个手段。

其他问题

img_v2_66b07202-c998-4db8-b6b4-bd9d10ca4deg.jpg
问: 为什么这个返回地址00007FF69CC32387 + 偏移量 FFFFEED6 = 7FF7 9CC3 125D 不等于目标地址 07FF69CC3125Dh

答:
计算机以补码的形式存放数据,所以E8 指令后的4个字节偏移值也是以补码的形式存放的,在计算时如果要跳转的目标地址在E8指令所在的地址前面相对偏移是负值,如果在E8指令所在的地址后面,相对偏移是正值。在手动计算时要将其还原为原码再参与计算。
正数的补码、原码、反码都等于其自身,所以可以直接相加。
负数的补码转换为原码需要对其 标志位外的其他位取反 +1,还原为原码后再进行相加。
在X86下,因为地址宽度和偏移的数据宽度一致,所以相加后的进位会被舍弃掉。
在X64下,因为地址宽度大于偏移宽度,所以相加后的进位会被保留,所以就导致了计算的错误产生,在写程序时可以只使用低32位的地址与 偏移值进行运算,再进位丢弃后再与高32位的地址进行拼接。