Hook 神威:绕过 EDR 内存保护
引言
在最近一次内部渗透测试中,我遇到了一款 EDR 产品(这里不方便透露具体名称)。
这款产品严重阻碍了我访问 lsass 内存的能力,导致我无法使用我们自定义版本的 Mimikatz 来转储明文凭据。
误入歧途
作为一名前恶意软件开发者,我清楚地知道,驱动层可以通过多种方式实现检测与拦截的目的。
我最先想到的是 ObRegisterCallback,这在许多杀毒产品中是非常常见的做法。
微软之所以实现这个回调机制,是因为早期大量杀软使用了极具争议性的 WinAPI Hook 技术,这些 Hook 的行为与恶意 Rootkit 如出一辙。
不过,在 MSDN 页面底部你会注意到一行文字:“仅在 Windows Vista SP1 与 Windows Server 2008 及以上版本可用。”
为了补充背景,我当时所用的环境是 Windows Server 2003,也就是说,系统根本不支持这种拦截机制。
在花了数小时做一些黑魔法(如对 csrss.exe 动手脚、尝试通过它继承 lsass.exe 的句柄)之后,我终于成功以 PROCESS_ALL_ACCESS 权限获取了对 lsass.exe 的句柄。这个技巧是通过滥用 csrss 来生成一个子进程,并让子进程继承现有的 lsass 句柄实现的。
⚠️ 注:这台机器并未安装任何 EDR,本次操作仅是一个概念验证(PoC)。
然而,在我以为 “大功告成”、准备庆祝成功绕过某款 EDR 的时候,现实却给了我迎头一击:EDR 成功拦截了向 csrss 注入 shellcode 的行为,以及通过 RtlCreateUserThread 创建线程的操作。
但奇怪的是,尽管无法成功利用子进程继承句柄的方式启动线程,代码依然神奇地获得了 PROCESS_ALL_ACCESS 权限的 lsass.exe 句柄
啥?!
等等,让我试试看,不整那些花里胡哨的操作,直接用这行代码打开 lsass.exe 的句柄:
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, lsasspid);
结果你猜怎么着?
我居然成功拿到了对 lsass.exe 的完全控制权限句柄。
EDR 完全没有反应,一点响动都没有。
这时我才意识到,我一开始的思路就错了:EDR 根本不在乎你是否拿到了句柄,它关注的是你 拿到句柄之后做了什么 。
回到正轨
既然我们已经知道,拿到 lsass.exe 的完全控制句柄根本不需要什么花里胡哨的技巧,那么我们现在可以继续前进,寻找下一个问题的根源。
当我立刻使用该句柄调用 MiniDumpWriteDump() 时,调用却惨烈失败。
让我们更深入地剖析这个警告信息:“Violation: LsassRead”。
我又没读取什么,搞什么呢?我只是想转储(dump)这个进程啊。
但我也知道,要对远程进程进行转储,MiniDumpWriteDump() 内部肯定会调用某些 WinAPI,比如 ReadProcessMemory(简称 RPM)。
我们来看下 ReactOS 上的 MiniDumpWriteDump 源码。
ps. 多次调用 ReadProcessMemory
正如你所见,函数(2)dump_exception_info() 以及许多其他函数,都依赖(3)ReadProcessMemory 来执行操作。
这些函数最终由(1)MiniDumpWriteDump 调用,而这很可能就是问题的根源。
这时候经验就派上用场了。你必须理解 Windows 系统内部机制,以及 WinAPI 的调用过程。以 ReadProcessMemory 为例 —— 它的工作流程大致如下:
ReadProcessMemory 只是一个 包装函数(wrapper),主要做一些合理性检查,比如空指针检查之类的,仅此而已。
但它接着会调用 NtReadVirtualMemory,这个函数会在执行 syscall 指令之前设置好寄存器。
syscall 是 CPU 进入内核态的指令,之后会调用另一个同名函数NtReadVirtualMemory,这个函数才是实际执行读取内存操作的逻辑。
有了这些知识,我们接下来需要识别 EDR 是如何检测并阻止对 RPM/NtReadVirtualMemory 的调用的。
答案其实很简单 - “Hooking”(钩子注入)。
在这里简要说一下:Hooking 允许你在某个函数的执行流程中插入自己的代码,从而获取它的参数和返回值。我可以百分之百确定该 EDR 是通过我曾提到过的一种或多种 Hook 技术来实现这个拦截的。
不过,读者也应当了解,大多数(如果不是全部)EDR 产品都会运行一个服务,** 该服务由内核模式下运行的驱动程序提供支持 **。
既然 EDR 有内核权限,那么它理应可以在 RPM 的调用栈中的任意层级进行 Hook。
但问题来了:
如果任何驱动程序都能轻松 Hook 任何层级的函数,那就会在 Windows 环境中打开一个巨大的安全漏洞。
因此,微软提出了解决方案,即内核补丁保护机制Kernel Patch Protection,简称 KPP,也叫 PatchGuard)。
KPP 会在几乎所有层级扫描内核内存,一旦检测到修改,就会触发蓝屏(BSOD)。这包括 ntoskrnl(Windows 内核模块)中实现 WinAPI 的那部分逻辑。因此,我们可以确信 EDR ** 不会 ** 也 ** 无法 ** 在调用栈的内核层级(如 ntoskrnl)设置 Hook。
这样,我们就可以合理推断,EDR 的 Hook 必然是在用户态的 ReadProcessMemory 和 NtReadVirtualMemory 函数上实现的。
Hook 的实现
要查看某个函数在我们应用程序内存中的地址,其实很简单,就像下面这样用 %p 格式的 printf 打印函数地址:
然而,与 ReadProcessMemory (RPM) 不同,NtReadVirtualMemory 并不是 ntdll.dll 中导出的函数,因此你无法像正常函数一样直接引用它。你必须指定该函数的签名,并在项目中链接 ntdll.lib才能调用。
一切准备就绪后,运行看看吧!
这将为我们提供 RPM 和 NtReadVirtualMemory 的地址。
接下来,我将使用我最喜欢的逆向分析工具 —— Cheat Engine,来读取内存并分析它们的结构。
ReadProcessMemory
NtReadVirtualMemory
ReadProcessMemory
对于 ReadProcessMemory 函数,看起来一切正常。
它执行了一些栈和寄存器的设置操作,然后调用了 KernelBase 中的 ReadProcessMemory(这个是另一个话题,暂不展开)。
最终,它会进入 ntdll.dll 中的 NtReadVirtualMemory。
但是,当你查看 NtReadVirtualMemory 时,如果你了解最基础的 detour hook 技术,你会一眼看出这段代码不正常。
函数的前 5 个字节已被修改,而后面的内容仍然保持原样。
你可以通过对比其它类似的 Nt* 函数来判断:所有这些函数的结构通常是这样的:
0x4C, 0x8B, 0xD1 ; mov r10, rcx ; Windows x64 syscall 调用标准0xB8, 0x3C, 0x00, 0x00, 0x00 ; mov eax, 0x3C ; 系统调用号 (syscall ID)0x0F, 0x05 ; syscall ; 执行系统调用0xC3 ; ret ; 返回
唯一的区别就是 syscall ID 不同(每个 Nt* API 对应一个特定的 syscall ID)。
但是,在 NtReadVirtualMemory 中,第一条指令竟然是一个 JMP 跳转到内存中其他地方的指令。
我们来跟踪一下这个跳转地址。
CyMemDef64.dll
哦,跟进跳转后,我们发现此时已经不再处于 ntdll.dll 的模块中,而是进入了 CyMemDef64.dll 模块。啊哈,现在一切都明白了。
这个 EDR 在 NtReadVirtualMemory 原始函数位置上放置了一个跳转指令(JMP), 将执行流程重定向到它自家的模块中(CyMemDef64.dll)。该模块对函数调用参数进行检查,用以检测是否存在恶意行为。
如果这些检查失败,Nt* 函数就会直接返回一个错误码,** 完全不会进入内核态,也不会执行真正的系统调用逻辑 **。
绕过方式(The Bypass)
现在我们已经非常清楚 EDR 是如何检测并拦截我们的 WINAPI 调用了。那么,我们该如何绕过它?
有两种解决方案:
修补被修补的内容(Re-Patch the Patch)
我们知道 NtReadVirtualMemory 函数原本应该是什么样子,因此我们可以很容易地将那条跳转(jmp)指令覆盖回正确的原始指令。
这样一来,我们的调用将不会再被 CyMemDef64.dll 拦截,而是会直接进入内核态(kernel)执行,在这个层面 EDR 无法进行控制。
ntdll 的 IAT Hook(Import Address Table Hook)
我们也可以构造一个新的函数,与前面 “修补方式”中提到的类似,但这一次我们 不去覆盖被 hook 的原函数,而是在其他位置重建该函数。
随后,我们遍历 ntdll.dll 的导入地址表(IAT),将指向 NtReadVirtualMemory 的函数指针替换为我们新建的 fixed_NtReadVirtualMemory。
这种方式的优点在于:
即便 EDR 检查它设置的 inline hook,它也会发现原函数表面上没有任何修改 ;只是该函数从未真正被调用,因为 ntdll 的 IAT 被重定向到我们自己的函数地址了。
最终结果(The Result)
我选择了第一种方式(修补被 hook 的函数)。这种方法简单直接,也能让我更快写完这篇文章🙂。
当然,实现第二种方法也很容易,我计划在接下来的几天内尝试那种方式。
AndrewSpecial.exe was never caught
顺便介绍一下这个 PoC 的代号:AndrewSpecial
结语(Conclusion)
当前的绕过方法对这个特定的 EDR 产品有效,但——要对类似的 EDR 实现通用绕过其实也很容易。因为它们在 hook 的能力上有天然限制(感谢 Windows 的 KPP(Kernel Patch Protection) 机制)。
我是不是忘了提?这个方法在 64 位系统下可完美运行(所有 Windows 版本都支持),而32 位系统目前尚未测试。
源码已开放,详见 HERE:
https://github.com/hoangprod/AndrewSpecial/tree/master
再次感谢你的阅读!如果我有任何错误或遗漏,欢迎指出!
sources:https://medium.com/@fsx30/bypass-edrs-memory-protection-introduction-to-hooking-2efb21acffd6
技术点评
分析上述代码原理,可知
通过恢复被 EDR 在 ntdll.dll 中 Hook 的 NtReadVirtualMemory 原始 syscall stub,直接重写其入口为合法的 syscall 指令,从而绕过用户态的检测与拦截。
通过动态判断系统版本获取正确的 syscall ID,并构造 shellcode 写回对应函数地址,使后续调用如 MiniDumpWriteDump 能够顺利读取敏感进程(如 lsass)内存而不触发拦截,属于典型的用户态绕过技术,兼具隐蔽性与兼容性,适用于当前多数基于用户态钩子的 EDR 绕过场景。
其中比较有趣的代码是作者考虑周到,程序覆盖解决了 x64 下不同版本 window 环境下 syscall 调用号的差异问题!
同时KPP的这一机制奠定EDR防护在Hook上存在用户态对抗的可能性。
原文始发于微信公众号(一个不正经的黑客):Hook 神威:绕过 EDR 内存保护
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论