前言
2023年10月6日,Bobby Cooke和Dylan Tran联合发表了一篇名为Reflective call stack detections and evasions1文章,深入探讨了调用堆栈检测和规避以及BokuLoader如何通过反射加载的方式将堆栈欺骗功能加载到Beacon中,并提到了BokuLoader中堆栈欺骗是利用了栈帧的合成,是对Dylan的LoudSunRun项目的整合。
提到LoudSunRun项目实际上是Dylan在理解堆栈欺骗这一技术过程中的产物,同时他还编写了一篇介绍性的文章,以便读者更好的理解这一技术,文章在此获得2。
正文
概览
LoudSunRun针对64位实现堆栈欺骗的过程主要分为两部分
-
1. 获取伪造栈帧的关键参数,主要通过Testing.c来实现
-
2. 伪造栈帧,主要通过test.asm实现
前置知识
在开始之前,我们可能需要了解一些关于x64栈帧展开的前置知识:
-
• x64默认调用约定Fastcall使用RCX RDX R8 R9对函数的前四个参数进行传参,多余参数入栈
-
• 在x64上RBP不再像x86上作为栈指针,不使用RBP来访问函数的局部变量和参数,而是作为一个通用寄存器,调试器不能够再使用RBP遍历栈帧
-
• x64可执行文件包含一个名为pdata区段,这个“异常目录”为可执行文件中的每个非叶子函数包含一个RUNTIME FUNCTION结构,调试器利用这些结构来进行栈帧的遍历。
-
• 无论如何你可以通过RUNTIME FUNCTION找到UNWIND INFO,UNWIND CODE,并获得函数的起始地址和结束地址,UNWIND CODE则用来描述如下某种序言操作:
- SAVE_NONVOL - Save a non-volatile register on the stack.
- PUSH_NONVOL - Push a non-volatile register on the stack.
- ALLOC_SMALL - Allocate space (up to 128 bytes) on the stack.
- ALLOC_LARGE - Allocate space (up to 4GB) on the stack.
关于序言:On X64, once a function has finished its prologue (and hence stack modifications), it does not modify the stack pointer until its epilogue reverses them, hence Rsp is static throughout the function body.
关于x64更多内容详见:https://codemachine.com/articles/x64_deep_dive.html
代码部分
如何编译
我们通过Visual Stdio打开项目,之前提到项目包含一个汇编文件,为了能够顺利编译,需要先进行如下操作
-
1. 右键test.asm文件,点击属性
-
2. 在项目类型中选择“自定义生成工具”
-
3. 点击应用,然后点击左边“自定义生成工具”中的"常规"
-
4. 在命令行中输入
ml64 /c %(fileName).asm
-
1. 在输出中输入
%(fileName).obj;%(Outputs)
如何使用
项目中对函数调用的栈欺骗都通过Spoof实现,Spoof是一个可变参数函数,由test.asm文件实现
PVOID NTAPI Spoof(PVOID a, ...);
无论如何Spoof的前七个参数是相对固定的,比如我们想对VirtualAllocEx进行栈帧调用的欺骗,那么通过Spoof可以这样实现:
Spoof((PVOID)(-1), 0, 1024, MEM_COMMIT | MEM_RESERVE, &p, VirtualAllocEx, (PVOID)1, (PVOID)PAGE_EXECUTE_READWRITE);
-
• 参数一到参数四分别对应64位Fastcall调用约定下的Rcx,Rdx,R8,R9四个寄存器
-
• 参数五代表一个特定结构体指针,其中包含栈帧伪造的关键参数,比如跳板指令地址,返回地址...等。
-
• 参数六表示调用函数的地址,比如VirtualAllocEx
-
• 参数七代表入栈参数数量,比如VirtualAllocEx入栈数量为1
代码理解
在解析Testing.c中代码含义之前,先通过下图对Spoof进行一个整体的理解,以便我们知道涉及具体代码时,为何这样做?为何需要这个?
上图就是最终进行函数调用时候的栈帧布局,JMP R11会跳转到函数起始位置,比如VirtualAllocEx...:),所以当我们使用ProcessHacker之类的工具查看函数调用栈时候就会依次遍历到跳板指令gadget位于的函数,BaseThreadInitThunk,RtlUserThreadStart,最后在此结束。
标注的返回地址则指向跳板指令,跳板指令必须位于某个合法库中,因为在栈帧展开时候此地址也会包含其中
下面我们再来查看主函数中详细代码实现:
首先在合法库中查找特定的跳板指令
p.trampoline = FindGadget((LPBYTE)GetModuleHandle(L"kernel32.dll"), 0x200000);
获取第一个伪造的返回地址,位于BaseThreadInitThunk中
ReturnAddress = (PBYTE)(GetProcAddress(LoadLibraryA("kernel32.dll"), "BaseThreadInitThunk")) + 0x14;
获取第二个伪造的返回地址,位于RtlUserThreadStart中
ReturnAddress = (PBYTE)(GetProcAddress(LoadLibraryA("ntdll.dll"), "RtlUserThreadStart")) + 0x21;
分别获取上面三个函数的栈帧大小(还记得前面的UNWIND_CODE:?)
p.BTIT_ss = (PVOID)CalculateFunctionStackSizeWrapper(ReturnAddress);
p.RUTS_ss = (PVOID)CalculateFunctionStackSizeWrapper(ReturnAddress);
p.Gadget_ss = (PVOID)CalculateFunctionStackSizeWrapper(p.trampoline);
获取到这些关键参数后调用Spoof
关于Spoof的实现,Dylan已经在代码中做了详细的注释,同时为了节省篇幅,不再在此堆砌大量的汇编代码,只展示重要部分:
-
• 调用函数参数处理部分
将参数赋值到正确的位置,以便最后的调用
looping:
xor r15, r15 ; r15 will hold the offset + rsp base
cmp r11, r13 ; comparing # of stack args added vs # of stack args we need to add
je finish
; ---------------------------------------------------------------------
; Getting location to move the stack arg to
; ---------------------------------------------------------------------
sub r14, 8 ; 1 arg means r11 is 0, r14 already 0x28 offset.
mov r15, rsp ; get current stack base
sub r15, r14 ; subtract offset
; ---------------------------------------------------------------------
; Procuring the stack arg
; ---------------------------------------------------------------------
add r10, 8
push [r10]
pop [r15] ; move the stack arg into the right location
; ---------------------------------------------------------------------
; Increment the counter and loop back in case we need more args
; ---------------------------------------------------------------------
add r11, 1
jmp looping
finish:
-
• 伪造栈帧部分
// Spoof栈帧
sub rsp, 200h
// 栈帧截断
push 0
// 伪造RtlUserThreadStart栈帧
sub rsp, [rdi + 56]
mov r11, [rdi + 64]
mov [rsp], r11
// 伪造BaseThreadInitThunk栈帧
sub rsp, [rdi + 32]
mov r11, [rdi + 40]
mov [rsp], r11
// 伪造跳板指令函数栈帧
sub rsp, [rdi + 48]
mov r11, [rdi + 80]
mov [rsp], r11
-
-
• 跳板指令寄存器赋值
为了执行完最终的调用函数并利用跳板指令 jmp [rbx] 顺利返回到Spoof中,需要先对rbx赋值,将jmp r11指令的下一条指令地址放到Rbx指向的内存中
mov [rdi], rbx // rbx此时保存的是返回地址
mov rbx, rdi // [rbx]此时保存的是返回地址
-
• 执行函数调用
jmp r11
总结
通过栈帧拼凑的方法进行主动调用栈欺骗,并利用跳板指令返回到主模块起始调用地址,同时又不会让主模块出现在调用栈中,除了作者使用的两个函数作为伪造栈帧,我们还可以通过修改代码使用其他函数进行栈帧的伪造。
引用
[1]:https://securityintelligence.com/x-force/reflective-call-stack-detections-evasions/
[2]:https://dtsec.us/2023-09-15-StackSpoofin/
原文始发于微信公众号(无名之):调用栈欺骗技术
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论