Zypherion-Technologies/HallWatch

GitHub: Zypherion-Technologies/HallWatch

HallWatch 是一款 Windows 用户态检测工具,通过在 syscall 指令字节上覆写 INT3 来捕获并识别各类间接系统调用技术。

Stars: 74 | Forks: 9

# HallWatch [![License: GPL v3](https://img.shields.io/badge/License-GPL_v3-A42E2B?logo=gnu&logoColor=white)](LICENSE) [![Website](https://img.shields.io/badge/zypherion.tech-1f6feb?logo=googlechrome&logoColor=white)](https://www.zypherion.tech) [![Discord](https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white)](https://discord.gg/JXx32jKJXY) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?logo=telegram&logoColor=white)](https://t.me/zypherion_technologies) [![X](https://img.shields.io/badge/Follow-000000?logo=x&logoColor=white)](https://x.com/Zypherion_Tech) 一旦你见过一次,间接系统调用(Indirect syscalls)就很简单了。加载器遍历 ntdll,从 `Nt*` 的 prologue 中读取 SSN,在随后找到 `0F 05` 这两个字节,自行设置 r10/rax/rdx/r8/r9,并直接 `jmp` 到这两个字节的位置。系统调用发生在 ntdll 内部。你在 kernel32 导出函数上的 hook 根本不会被触发。你在 ntdll 导出函数上的 hook 也不会被触发。在执行 SYSCALL 指令的那一刻,`[RSP]` 会指回加载器的 RWX 页面,但从外部来看,这个调用没有任何异常之处。 ``` HellHall proc mov r10, rcx mov eax, dwSSN jmp qword ptr [qAddr] ret HellHall endp ``` 这些变体都有自己的名字。Tartarus' Gate 和 RecycledGate 使用属于某个 stub 的 syscall 指令,但搭配的却是另一个 stub 的 SSN,因此即使你记录了你的 hook 所上报的 stub 名称,这也是个谎言。VEH syscall 蓄意触发访问冲突(access violation),并利用它自己的 VEH 重写 context,使得 RIP 落在 ntdll 的 syscall 指令上,且寄存器已准备就绪。Hell's Gate 完全不使用 ntdll。加载器将 `0F 05` 写入其自身的 RWX 页面并从那里执行。 内核模式(KM)的应对方案是开发一个驱动程序,我也许会在以后的空闲时间去做并发布一个项目,但短时间内不会发生 :D PAGE_GUARD 看起来应该可行。用 `PAGE_GUARD` 标记包含 syscall 字节的内存页,在 VEH 中捕获 `STATUS_GUARD_PAGE_VIOLATION`,进行检查,重定向到私有的 trampoline,设置 trap flag,单步跳出,然后重新武装该页面。无论加载器是如何到达那里的,每次 Nt 调用都会触发一次陷阱。除了 Hell's Gate 之外,这种方法对所有变体都有效。 问题在于操作系统并不配合。PAGE_GUARD 是一次性的,这意味着什么?每次它被触发时,该标志位就会被清除,你不得不重新设置它。你的处理程序会执行系统调用(使用 NtProtect 重新设置保护,使用 NtContinue 恢复执行),而这些系统调用有自己的 stub,且这些 stub 就位于你刚刚设置了保护的页面上。我通过在属于我们自己的单独页面上构建私有 syscall stub(分配 RWX 内存,写入 `mov r10,rcx; mov eax,SSN; syscall; ret`,锁定为 RX,从不碰 ntdll 的页面)绕过了大部分问题,但这很脆弱。Windows 的每一个小版本更新都会改变时序。样本创建的每一个线程都是一场与正在重建页面保护的完整性工作线程之间的竞态。在同一台机器上的十次运行测试中,演示成功率大约只有 30% 到 50%。 最终,我不再试图说服 Windows 让 PAGE_GUARD 按照我的意愿运行,而是尝试直接覆盖字节。 # 如果你难以理解,可以看看这个视频 :) https://github.com/user-attachments/assets/0cb670fd-e51b-413e-bf00-08f9297888ed syscall 指令处的字节是 `0F 05`。仅第一个字节(`0F`)是一系列两字节操作码(包括 SYSCALL、CPUID 和 RDTSC)的前缀。它本身是不可执行的;CPU 需要第二个字节才能完成解码。如果你用 `0xCC`(INT3)替换第一个字节,这对字节就会变成 `CC 05`,CPU 会将其解码为 INT3,随后跟着一个永远执行不到的游离字节。任何落入该地址的代码路径都会引发 `EXCEPTION_BREAKPOINT`。 这就是全部机制。我们的 VEH 捕获断点,查找该地址属于哪个 stub(我们在初始化时通过枚举 ntdll 的导出表构建了此映射),对触发的调用者执行三项检查,并将 `Context->Rip` 设置为一个执行真正系统调用并返回的私有 trampoline。该字节保持为 `CC`。下一个调用者会以同样的方式命中它,因此不需要来回切换页面保护属性。 需要明确的是:是的,这是一种通过覆盖字节实现的 hook。只不过它覆盖的不是人们通常所说的“ntdll hook”的那个字节。经典的 EDR hook 会用 `JMP ` 覆盖 stub 的*第一*个字节: ``` ntdll!NtAllocateVirtualMemory: E9 ?? ?? ?? ?? jmp my_hook ; overwrites "mov r10, rcx" ... 0F 05 syscall C3 ret ``` 这正是间接系统调用(Indirect syscalls)所绕过的机制。加载器自己读取 SSN 并直接跳转到 `0F 05` 处,prologue(以及你的 jmp)永远不会被执行,hook 永远不会触发。我们所做的是覆盖*执行 syscall 的字节本身*: ``` ntdll!NtAllocateVirtualMemory: 4C 8B D1 mov r10, rcx ; untouched B8 18 00 00 00 mov eax, 18h ; untouched CC 05 int3 / 05 ; was 0F 05, we wrote CC over the 0F C3 ret ``` 现在,无论你是如何到达该地址的都不重要了。无论是通过 prologue,还是通过跳过 prologue 的间接跳转,抑或是通过将 RIP 设置到该处的 VEH-syscall context 重写,随便哪种方式。只要 CPU 执行了那个字节,它就会陷入陷阱。它与经典的 hook 属于同一种技术流派,只是位置不同,覆盖范围也完全不同。 Trampoline 看起来是这样的: ``` F3 0F 1E FA endbr64 49 89 CA mov r10, rcx B8 mov eax, ssn 0F 05 syscall C3 ret ``` 这三项检查与 PAGE_GUARD 版本相比没有改变,因为它们就是最正确的三项检查;它们只是需要一个可靠存在的地方。 返回地址。`[RSP]` 是系统调用本应返回的位置。对于真正的调用,它位于 `ntdll`、`kernel32`、`kernelbase` 或相关的运行时 DLL 内部。对于 Hell's Hall,它位于加载器运行所在的任何 RWX 页面内。我们在初始化时通过对这些模块名称调用 `GetModuleHandle` 并从它们的 PE 头中读取 `.text` 范围,建立了一个可信返回目标的简短列表。 SSN。Stub 的 prologue 在到达 syscall 字节之前就已经执行了,因此 `eax` 保存了加载到其中的任何值。如果加载器执行了 Tartarus 替换,该值将与我们在枚举时从同一个 stub 中读取的 SSN 不匹配。我们记录下这种不匹配,并且 trampoline 在执行其自身的 syscall 之前会写入正确的 SSN,因此实际运行的内核函数是属于该字节原本对应的函数,而不是加载器想要的函数。该技术在同一步骤中即被记录又被中和。 栈回溯。从当前 context 进行 `RtlVirtualUnwind`,向上回溯五个栈帧。每个栈帧的 RIP 都应该位于一个已知模块内,并且应该有对应的 `RUNTIME_FUNCTION` 条目。Shellcode 和 ROP gadgets 即使在 `[RSP]` 本身看起来可信的情况下也无法通过此项检查,而加载器是可以伪造 `[RSP]` 的(它可以粗略预测其调用者在内存中的位置,并在那里伪造一个可信的返回地址)。 如果任何检查失败,我们就会将一个小型 struct 推入无锁环形队列(lock free ring)中,排出线程(drain thread)下次唤醒时就会将其打印出来。 ``` [!! hallwatch !!] indirect syscall (untrusted caller, wrong ssn for this stub) syscall : NtAllocateVirtualMemory syscall rip : 0x00007FF827660372 return addr : 0x00007FF7EED719D6 rax (ssn) : 0x0000000F (stub encodes 0x00000018) thread : 26388 ``` Hell's Gate 是 INT3 起不到作用的地方。我们从没修补过加载器的 RWX 页面,因为我们根本不知道它的存在。 我们转而采取的做法是使用能够起作用的最简单的扫描器。每隔 250 毫秒(基本上太频繁了,会浪费 CPU 周期,但这只是个 POC),完整性工作线程就会使用 `VirtualQuery` 遍历地址空间,查找任何具有可执行页面保护属性的 `MEM_COMMIT` 区域。如果该区域位于已加载的模块内部,就会被跳过。如果它位于我们自己的 trampoline 池中,也会被跳过。剩下的就是外部的可执行内存。我们会扫描其中最多 64 KB 的空间来寻找 `0F 05` 字节对,按地址去重,记录每个唯一的匹配项,我知道这很蠢,而且还会浪费 CPU 周历,但它只是个 POC,虽然这不意味着可以低估当前情况,但目前的实现就是这样。 这能捕捉到新分配的 RWX 内存中的 Hell's Gate,也能捕捉到影子 ntdll(加载器对 `ntdll.dll` 执行 `NtMapViewOfSection` 以在全新地址获取私有副本的行为)。影子的情况很有趣。该映射不在我们的模块快照中,因此其 syscall 指令会显示为外部可执行字节,尽管它们实际上来自磁盘上一个完全合法的已签名 DLL。 另一种变体:攻击者将 `0F 05` 直接硬编码到他们自己二进制文件的 `.text` 段中,这样 syscall 就位于已加载的模块而不是 RWX 页面内。外部的 RWX 扫描会跳过已加载的模块,因此这种手法过去常常成为漏网之鱼。所以我们还有另一个扫描器,它会遍历每个模块的可执行字节,跳过 ntdll、win32u 和 wow64cpu(这三个合理包含 syscall 指令的模块),并且只有在接下来的三个字节内出现 `C3` 时才会报告 `0F 05`。正是 `ret` 检查使这项功能变得可用。单独的 `0F 05` 在编译器输出中经常作为更大指令的一部分出现,但绝不会与 `C3` 相邻。真正的 syscall stub 则总是如此。日志: ``` [hallwatch] scan: module-text syscall stub at 0x00007FF7FFC9227C in HellsHall-Demo.exe -- Hell's Gate inside loaded module .text ``` 完整性工作线程还有另外两个小任务。它在每次时钟周期(tick)都会读取每个已修补 stub 处的字节,如果不是 `CC`,就会重新写回 `CC`。这是最简单的 unhook 应对方式:样本从某处(全新的 ntdll 映射、磁盘上的 PE 文件、或已知正常的字节表)读取原始的 `0F` 字节,并将其覆盖回我们修补过的地方。我们在下一个时钟周期会察觉到并再次覆盖修补。 每隔 50 个时钟周期(大约 12.5 秒),工作线程就会将我们的 VEH 重新安装到链表头部,并移除前一个句柄。样本可以通过遍历 `LdrpVectorHandlerList` 来取消我们的注册;工作线程限制了该空窗期持续的时间。如果你正在遭受攻击,12.5 秒是一个很长的时间窗口;但如果你是在沙箱中运行恶意软件,这只是一个很短的时间。等我测量出更频繁执行此操作的开销后,我可能会缩短这个时间。 工作线程在运行时会设置一个 VEH 会检查的 TLS 标志。工作线程自身的系统调用(用于修补的 NtProtect 和用于扫描的 NtQuery)也会像其他任何调用一样触发我们的断点,但该标志会指示 VEH 跳过检测逻辑,并通过 trampoline 静默重定向。 临界区(critical section)会在任何线程首次调用时通过三态比较交换(0 = uninit,2 = busy,1 = ready)进行自我初始化。这有点难看,但它避免了在 DLL 内部需要使用静态构造函数的情况,而在 Windows 上,静态构造函数会引发另一系列涉及 loader lock 等的问题。 有一件关于 ABI 的事情值得注意。INT3 是一个 trap,这意味着当我们的 VEH 被调用时,`Context->Rip` 指向的是*下一条*指令,而不是 trap 本身。如果我们修补了 `0x7FF827660372` 处的字节,`Context->Rip` 传到 VEH 时就会是 `0x7FF827660373`。`Record->ExceptionAddress` 指向 trap 指令处,但我们在将其重定向到 trampoline 之前必须重置 `Context->Rip = ExceptionAddress`,否则 trampoline 会延迟一个字节开始执行,并且 syscall 发挥不了任何作用。我告诉你这个是因为它曾是一个让我花了一堆时间才找出来的 Bug。 这里包含四个导出函数。`IscInitialize` 负责武装检测器;DllMain 会自动调用它,但如果你想要获得返回值,也可以从宿主进程中调用它。`IscGetDetectionCount` 返回一个单调递增的计数器。`IscShutdown` 等待处理中的处理程序完成并恢复 `0F` 字节。`IscFlush` 同步清空环形队列,如果你将检测器嵌入到需要在样本退出前输出事件的沙箱中,这会很有用。 最简单的集成方式只需使用 `LoadLibrary`。DllMain 会处理初始化,并从那里启动两个后台线程。 我们目前还无法捕捉到的情况。 执行完整性检查的样本。 使用我们未修补的 stub 的样本。目前的白名单包含大约 40 个名称,涵盖了攻击性的内存、进程、线程、section、token 和文件原语。只要 DllMain 能迅速完成,增加更多名称是渐进式的。在 loader lock 内部修补全部 488 个 stub 实在是太那个了(ehh...)... 拥有内核权限运行的样本。这不是用户模式下的问题。 我们最终选择了 INT3,但 trap 机制并不是最有趣的部分。它之所以比 PAGE_GUARD 效果更好,在于 trap 不需要与操作系统进行持续的协商。字节是 `CC`。它就会一直保持为 `CC`。操作系统对于 ntdll 中应该存在哪些字节并没有自己的意见。
标签:EDR, UML, 云资产清单, 底层安全, 检测引擎, 端点可见性, 脆弱性评估, 逆向工程