它叫 R3 ShieldCore,Windows 上的用户态行为拦截引擎,代码在 afcK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6&6N6h3&6K6K9Y4S2Z5i4K6u0r3f1U0y4e0K9r3W2W2L8r3c8o6L8%4u0W2 做的事一句话:把一份 DLL 注入进机器上几乎每一个进程,在进程里把用户态 API 换掉,在行为落地之前拦下来。
叫它"半个杀软"是因为它只占了用户态这一半——注入、hook、拦 API 调用,都在用户态;另一半在内核和硬件,它够不着。
约 110 处 inline hook,分给 17 个 guard,处理 18 类对象,纯 C++20。
枚举进程用的是 NtGetNextProcess,一路 next 到 STATUS_NO_MORE_ENTRIES,带 SYNCHRONIZE | DllInject::ProcessAccess 权限。注不注进去,先过 ShouldSkipProcessInjection。
关键是判断这个进程有没有开始跑。没跑起来的进程,主线程的指令指针还停在 RtlUserThreadStart,这时候适合用 APC 投递;跑起来了的,只能起远程线程。判断方式是拿 NtGetNextThread 数线程:
为什么要看这个,而不是无脑起远程线程?因为会踩到一个真实故障:控制台程序如果被我们在它还没通知 csrss.exe 之前就触发了控制台初始化(KERNELBASE!ConsoleCommitState 早于 CsrClientCallServer),csrss 会返回 access denied,父进程的 CreateProcess 直接失败。所以没跑起来的必须走 APC,让它自己线程去执行,别抢跑。
APC 那条路会建一个互斥体(ProcessInitAPCMutex)去重,防止轮询路和同步路把同一个进程注两遍。32 位进程里还得跨架构去取 64 位 ntdll 的 RtlUserThreadStart,靠 wow64ext。
送进去的不是 DLL 路径加一句 LoadLibrary。是一段位置无关的反射 shellcode:靠 PEB 遍历自己把 LoadLibraryW、GetProcAddress 摸出来,再把 DLL 装起来,全程不依赖目标进程给任何东西。
轮询路有个天生的问题:进程已经跑起来了才轮到我们,中间那段就是盲区。所以还有一条同步路——在父进程里 hook CreateProcessInternalW(先找 kernelbase.dll,退而求其次 kernel32.dll),强制给创建标志或上 CREATE_SUSPENDED:
子进程建出来、主线程还挂着,这时候注入,成功才 ResumeThread;失败就 TerminateProcess 加关句柄、把 PROCESS_INFORMATION 清空、返回 FALSE。让一个一条钩子都没装的进程跑起来,是比杀掉它更坏的结局。
这里踩过一个坑。原函数在自己内部也要往刚建出来的子进程里写东西——RTL_USER_PROCESS_PARAMETERS,也就是环境块、命令行、当前目录,走的是 NtWriteVirtualMemory。而如果本进程被全量注入了,那次写会被我们自己的钩子判成"跨进程写内存 = 高危",BLOCK 模式下当场拒掉,于是原函数回滚、返回 FALSE、GetLastError() 是 5,根本走不到注入那一步。实测调用栈:
解法是开一个"CreateProcess 窗口":窗口内,由本线程建出来的子进程,对跨进程写内存、改页保护、建远程线程这几样一律透传;窗口一关立即失效。这个窗口对象是 POD,手工 Enter/Leave 配对——不能用 RAII,因为函数体里有 __try/__finally,带析构函数的对象一进来 MSVC 就报 C2712。
还有个"瘦会话"的设计。explorer.exe、svchost.exe、runtimebroker.exe 这三个进程一个 guard 都不装,只装 CreateProcessInternalW。目的是让它们拉起的子进程能走上面那条同步路,盲区趋近于零——"双击运行一个启动即写 MBR 的样本",只有这条路能赢。代价是这三个进程自己不受监控,这是故意的:explorer.exe 的文件 I/O 频率极高(开目录、缩略图、拖放),全量 hook 在 BLOCK 模式下必炸,而且 DLL 里任何一个 bug 都会让整个桌面挂掉。
DLL 落地后的流程是 MH_Initialize → 设定线程冻结方式 → 各 guard 依次 MH_CreateHook + MH_QueueEnableHook 排队 → 最后一次 MH_ApplyQueued 全部生效。用"排队再一次性生效"而不是逐个立刻挂上,是为了避免钩子挂到一半的中间态。
线程冻结方式分两种:APC 路进来时本进程还没别的线程在跑,用 MH_FREEZE_METHOD_NONE_UNSAFE;其余情况用 MH_FREEZE_METHOD_FAST_UNDOCUMENTED,靠 MinHook 的未文档化手法把别的线程冻住再改代码。
挂点大致分布是:
WMI、计划任务、摄像头设备这三类是纯 COM 接口,没有可挂的导出函数,只能去改 vtable 指针,和 MinHook 是两套手艺。
17 个 guard 的安装顺序不能乱:RegistryGuard 必须先装,因为其余 16 个都是先问它的 IsBypassed() 结论来决定自己挂不挂;而且共享通道也是它开的。通道开不起来的时候,这个进程直接跳过全部 hook,而不是装一半——装了一半的防护比没装更难查。
拿 NtCreateKey 举例(registry_guard.cpp):
骨架就四件事:Evaluate 判、Block 就直接返 STATUS_ACCESS_DENIED 不往下走、Record 就先调原函数再补路径再上报、首尾一对计数。
这段代码我试过抽象。想用模板把这一百来个函数收敛成一个,省掉那 130 处手工记账,结果 MSVC 在 __try 上直接甩 C2712——异常处理和带析构函数的 C++ 对象不能共处一个函数。所以每个 hook 都是手写、长得一模一样。注释里写了原因,免得下次再有"优化一下"的冲动。
顺带说一个判据上的规矩:多层过滤的顺序是先判断是不是文件对象 → 再判高危 → 最后才走豁免。高危路径往往正好落在豁免范围里,先走豁免就会被当噪音放过去。这个顺序在 registry_guard.cpp 和 file_guard.cpp 里反复标着。
引擎和注入的 DLL 之间有两块共享内存,职责分得很开:
Policy 只读不是随手写的——早先有个字段是"高危命中计数",让 hook 侧去递增,结果被注入的进程一写就 0xC0000005,异常被 __except 吞掉变成"静默放行",症状是"高危既不拦也不记"。所以凡是 hook 侧要递增的计数,一律放在 Events 那边的 ChannelHeader 里,那个映射才是可写的。
ChannelHeader 卡在 32 字节(static_assert 钉死),后面直接跟着 Event[Capacity]。发布一条事件的协议是:
生产者任何时候都不阻塞——hook 跑在别人的业务线程上,卡一下就是卡别人整个程序,所以缓冲满了就丢,记 DroppedCount。丢弃的那格也得占位,不占的话消费者会永远卡在那个序号上、此后一条都读不出来。
消费者这边的套圈处理更绕一点。如果 writeIndex - readIndex > Capacity,说明生产者已经把那一圈覆盖了、Sequence 再也不可能等于 readIndex + 1,这时必须主动把 readIndex 推到 writeIndex - Capacity,跳过这段已丢的事件。不推的话消费者就永久死锁在那儿。
Event 结构里字段挺多,挑几个有意思的:ObjectType 决定 Op 该按哪套枚举解释(注册表、文件、进程各有各的 Op 空间,共用同一个结构、同一条通道、同一套 UI);TargetProcessId / CreatorProcessId 专门用来区分进程创建里的"谁建的"和"建了谁";网络那些事件单独有 TargetPort / NetProtocol / LocalAddress,不把 IP 和端口拼成一个字符串,因为 UI 要分列,而且 IPv6 的 :: 跟端口分隔符会打架。
Event::Flags 是 32 位,到 0x80000000 就用满了。所以从某个版本起新语义一律走尾部追加的 Flags2,不再往 Flags 里挤。
跨进程 ABI 版本号现在到 20。尾部追加字段虽然不动前面的偏移,但会改 sizeof(Policy),而 sizeof 变了就必须升版本,原因是两个方向都会坏事:新 DLL 配旧引擎,旧引擎建的 section 更小,新 DLL 按新尺寸 MapViewOfFile 会直接失败;旧 DLL 配新引擎,旧 DLL 只映射自己那一段能成功,但它会按旧布局去读新加的 NeverInjectPaths 那块字节。两个方向最后都落成同一句话——版本对不上就当作"共享通道不可用",本进程跳过全部 hook,而不是含糊地按错误布局读内存。Policy 和 Channel 各带一个 magic('RGP1' / 'RGE1'),加上版本号和引擎 pid 一起校验。
ask 模式要弹窗,可麻烦在于:我的 DLL 跑在别人的进程里,窗得画在引擎进程里。这条通道没用命名管道,也没用窗口消息——那两样都带着能被目标进程观察、甚至干扰的语义。用的是命名共享内存加命名事件。
共享内存里排 8 个槽。每个槽有个 State(Free / Claimed / Pending / Answered)和一个 Generation(世代号)。生产者是任意进程里那个被卡住的 hook 线程,流程是这样:
引擎那边是 UI 线程做的:扫 Pending 的槽、快照、弹窗、写结论和 AnsweredGeneration,然后 InterlockedCompareExchange(State, Answered, Pending)——只在槽还是 Pending 的时候才置成 Answered。如果 DLL 那边已经超时把槽释放了,CAS 就失败,这个答复直接被丢掉,不会串到别人的新请求上。
第 2 步和第 7 步合起来就是整个协议的命门。没有世代号的话,"上一个请求超时释放了槽、下一个请求占了同一个槽、上一个的答复这时候才到"——这条时序会把 A 的结论喂给 B,而 B 可能正好是个写注册表的操作。所以每个请求带着自己的世代号走,答复必须对号。
几个边角:等的时候按 250 毫秒切片轮询,不是死等一整个超时,这样卸载通知一来就能立刻收手;同一个进程已经有请求在等用户时,新的直接返回兜底结论,既防刷屏也防"用户还没点完、同一个进程被问十遍";log 模式在 Ask 入口直接短路返回 Allow,压根不碰通道——观测模式不能因为通道不在就变成拒绝。