我写了半个用户态杀软--R3 ShieldCore!!!

目录

我写了半个用户态杀软--R3 ShieldCore!!!

它叫 R3 ShieldCore,Windows 上的用户态行为拦截引擎,代码在 https://github.com/yunsjxh/R3ShieldCore 做的事一句话:把一份 DLL 注入进机器上几乎每一个进程,在进程里把用户态 API 换掉,在行为落地之前拦下来。

叫它"半个杀软"是因为它只占了用户态这一半——注入、hook、拦 API 调用,都在用户态;另一半在内核和硬件,它够不着。

约 110 处 inline hook,分给 17 个 guard,处理 18 类对象,纯 C++20。

注入:把同一份 DLL 放进每个进程

枚举进程用的是 NtGetNextProcess,一路 next 到 STATUS_NO_MORE_ENTRIES,带 SYNCHRONIZE | DllInject::ProcessAccess 权限。注不注进去,先过 ShouldSkipProcessInjection。

关键是判断这个进程有没有开始跑。没跑起来的进程,主线程的指令指针还停在 RtlUserThreadStart,这时候适合用 APC 投递;跑起来了的,只能起远程线程。判断方式是拿 NtGetNextThread 数线程:

cpp
NtGetNextThread(hProcess, nullptr, access, 0, 0, &thread1);
NTSTATUS status = NtGetNextThread(hProcess, thread1.get(), access, 0, 0, &thread2);
if (status == STATUS_NO_MORE_ENTRIES) {
    // 就一个线程。挂起它,看指令指针
    SuspendThread(thread1.get());
    GetThreadContext(thread1.get(), &c);
    if (c.Rip == pRtlUserThreadStart) {   // 还没跑起来
        // 走 APC 路
    }
}

为什么要看这个,而不是无脑起远程线程?因为会踩到一个真实故障:控制台程序如果被我们在它还没通知 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:

cpp
DWORD dwNewCreationFlags = dwCreationFlags | CREATE_SUSPENDED;
bRet = pThis->originalCreateProcessInternalW(..., dwNewCreationFlags, ...);

子进程建出来、主线程还挂着,这时候注入,成功才 ResumeThread;失败就 TerminateProcess 加关句柄、把 PROCESS_INFORMATION 清空、返回 FALSE。让一个一条钩子都没装的进程跑起来,是比杀掉它更坏的结局。

这里踩过一个坑。原函数在自己内部也要往刚建出来的子进程里写东西——RTL_USER_PROCESS_PARAMETERS,也就是环境块、命令行、当前目录,走的是 NtWriteVirtualMemory。而如果本进程被全量注入了,那次写会被我们自己的钩子判成"跨进程写内存 = 高危",BLOCK 模式下当场拒掉,于是原函数回滚、返回 FALSE、GetLastError() 是 5,根本走不到注入那一步。实测调用栈:

#0 KERNELBASE!NtWriteVirtualMemory   <- 被自己拦下的那一次
#5 KERNEL32!CreateProcessA
#4 KERNELBASE!CreateProcessInternalW <- 原函数

解法是开一个"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 都会让整个桌面挂掉。

进了进程,把 API 换掉

DLL 落地后的流程是 MH_Initialize → 设定线程冻结方式 → 各 guard 依次 MH_CreateHook + MH_QueueEnableHook 排队 → 最后一次 MH_ApplyQueued 全部生效。用"排队再一次性生效"而不是逐个立刻挂上,是为了避免钩子挂到一半的中间态。

线程冻结方式分两种:APC 路进来时本进程还没别的线程在跑,用 MH_FREEZE_METHOD_NONE_UNSAFE;其余情况用 MH_FREEZE_METHOD_FAST_UNDOCUMENTED,靠 MinHook 的未文档化手法把别的线程冻住再改代码。

挂点大致分布是:

  • ntdll:NtCreateKey / NtSetValueKey / NtCreateFile / NtWriteFile / NtOpenProcess / NtCreateThreadEx / NtWriteVirtualMemory / NtProtectVirtualMemory / NtAllocateVirtualMemory 等
  • user32:SetWindowsHookEx / SendInput / BlockInput / ClipCursor
  • ws2_32:connect / WSAConnect / sendto / bind
  • advapi32:服务相关那一批
  • combase / ole32:CoCreateInstance / CoGetClassObject

WMI、计划任务、摄像头设备这三类是纯 COM 接口,没有可挂的导出函数,只能去改 vtable 指针,和 MinHook 是两套手艺。

17 个 guard 的安装顺序不能乱:RegistryGuard 必须先装,因为其余 16 个都是先问它的 IsBypassed() 结论来决定自己挂不挂;而且共享通道也是它开的。通道开不起来的时候,这个进程直接跳过全部 hook,而不是装一半——装了一半的防护比没装更难查。

拿 NtCreateKey 举例(registry_guard.cpp):

cpp
NTSTATUS NTAPI NtCreateKey_Hook(PHANDLE KeyHandle, ACCESS_MASK DesiredAccess,
    const RG_OBJECT_ATTRIBUTES* ObjectAttributes, ULONG TitleIndex,
    RG_UNICODE_STRING* Class, ULONG CreateOptions, PULONG Disposition)
{
    InterlockedIncrement(&g_activeHooks);

    R3ShieldCore::Event event;
    Action action = Action::Pass;
    NTSTATUS status = STATUS_UNSUCCESSFUL;

    __try {
        action = Evaluate(R3ShieldCore::Op::CreateKey, nullptr,
            ObjectAttributes ? ObjectAttributes->ObjectName : nullptr, nullptr,
            DesiredAccess, event, true);
    }
    __except (EXCEPTION_EXECUTE_HANDLER) {
        action = ExceptionAction(event);
    }

    if (action == Action::Block) {
        event.Status = static_cast<ULONG>(STATUS_ACCESS_DENIED);
        Publish(event);
        status = STATUS_ACCESS_DENIED;
    }
    else {
        status = pOriginalNtCreateKey(KeyHandle, DesiredAccess, ObjectAttributes,
            TitleIndex, Class, CreateOptions, Disposition);
        if (action == Action::Record) {
            event.Status = static_cast<ULONG>(status);
            if (status >= 0 && KeyHandle) {
                __try { RefineKeyPath(*KeyHandle, event); }
                __except (EXCEPTION_EXECUTE_HANDLER) {}
            }
            Publish(event);
        }
    }

    InterlockedDecrement(&g_activeHooks);
    return status;
}

骨架就四件事:Evaluate 判、Block 就直接返 STATUS_ACCESS_DENIED 不往下走、Record 就先调原函数再补路径再上报、首尾一对计数。

这段代码我试过抽象。想用模板把这一百来个函数收敛成一个,省掉那 130 处手工记账,结果 MSVC 在 __try 上直接甩 C2712——异常处理和带析构函数的 C++ 对象不能共处一个函数。所以每个 hook 都是手写、长得一模一样。注释里写了原因,免得下次再有"优化一下"的冲动。

顺带说一个判据上的规矩:多层过滤的顺序是先判断是不是文件对象 → 再判高危 → 最后才走豁免。高危路径往往正好落在豁免范围里,先走豁免就会被当噪音放过去。这个顺序在 registry_guard.cpp 和 file_guard.cpp 里反复标着。

事件从别的进程传回来

引擎和注入的 DLL 之间有两块共享内存,职责分得很开:

  • Policy:引擎写,DLL 用 FILE_MAP_READ 只读打开。里面是模式、开关位、各种名单、统计。
  • Events:环形缓冲,各进程写、引擎单消费者读。

Policy 只读不是随手写的——早先有个字段是"高危命中计数",让 hook 侧去递增,结果被注入的进程一写就 0xC0000005,异常被 __except 吞掉变成"静默放行",症状是"高危既不拦也不记"。所以凡是 hook 侧要递增的计数,一律放在 Events 那边的 ChannelHeader 里,那个映射才是可写的。

ChannelHeader 卡在 32 字节(static_assert 钉死),后面直接跟着 Event[Capacity]。发布一条事件的协议是:

cpp
LONG index = InterlockedIncrement(&channel->WriteIndex) - 1;
// 消费者没追上就丢弃 —— 绝不阻塞调用方
bool dropped = (index - channel->ReadIndex) >= channel->Capacity;
Event* slot = ChannelEventAt(channel, index);
if (dropped) { memset(slot, 0, sizeof(Event)); InterlockedIncrement(&channel->DroppedCount); }
else         { memcpy(slot, &event, sizeof(Event)); }
slot->Sequence = 0;
MemoryBarrier();
InterlockedExchange(&slot->Sequence, index + 1);   // 消费者只认 Sequence == index+1

生产者任何时候都不阻塞——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 线程,流程是这样:

  1. 逐个槽 CAS 抢,把 State 从 Free 抢成 Claimed
  2. InterlockedIncrement(Generation)——必须在发布之前
  3. 填请求字段
  4. MemoryBarrier(),再把 State 置成 Pending
  5. SetEvent 通知引擎
  6. 等本槽的应答事件,带超时。醒来先确认 State == Answered 且 AnsweredGeneration == 自己的世代号,不满足说明这是上一次请求的迟到答复,继续等
  7. 超时就自己把槽释放掉,返回兜底结论(默认 deny)

引擎那边是 UI 线程做的:扫 Pending 的槽、快照、弹窗、写结论和 AnsweredGeneration,然后 InterlockedCompareExchange(State, Answered, Pending)——只在槽还是 Pending 的时候才置成 Answered。如果 DLL 那边已经超时把槽释放了,CAS 就失败,这个答复直接被丢掉,不会串到别人的新请求上。

第 2 步和第 7 步合起来就是整个协议的命门。没有世代号的话,"上一个请求超时释放了槽、下一个请求占了同一个槽、上一个的答复这时候才到"——这条时序会把 A 的结论喂给 B,而 B 可能正好是个写注册表的操作。所以每个请求带着自己的世代号走,答复必须对号。

几个边角:等的时候按 250 毫秒切片轮询,不是死等一整个超时,这样卸载通知一来就能立刻收手;同一个进程已经有请求在等用户时,新的直接返回兜底结论,既防刷屏也防"用户还没点完、同一个进程被问十遍";log 模式在 Ask 入口直接短路返回 Allow,压根不碰通道——观测模式不能因为通道不在就变成拒绝。

卸载顺序,一步都不能换

卸载这五步是这个项目里我最不敢动的地方:

cpp
R3ShieldCorePrompt::NotifyShutdown();      // 1. 让还在等答复的 Ask 立刻收手
MH_DisableHook(MH_ALL_HOOKS);              // 2. 新调用走回原函数
Sleep(100);                                // 3. 给"跨过前几字节、还没进计数"的线程一点时间
RegistryGuard::WaitForHooksToDrain(10000); //    ...17 个 guard 各等一次
m_newProcessInjector.reset();              // 4. 等 CreateProcessInternalW 的在飞调用归零
MH_Uninitialize();                         // 5. 确认没人在 DLL 里了,才释放 trampoline

第 5 步之前的每一件事,都是为了确保没有线程还待在这个 DLL 的代码里。早期版本直接 MH_Uninitialize,结果把宿主打崩:那一步会释放 trampoline,而此刻可能有线程正卡在 hook 里(ask 模式下甚至停在等用户点按钮,最长几十秒),它返回的时候就是野指针。WER 报的是 r3shieldcore-lib.dll_unloaded,异常码 c0000005。实测打崩过 MyApp.exe 和 OfficeClickToRun.exe。

第 3 步能奏效,靠的是每个 hook 首尾那对 InterlockedIncrement/Decrement(&g_activeHooks)。这也是我说"130 处手工记账"是笔债的原因——这 130 处里任何一处漏了递减,WaitForHooksToDrain 就永远等不到归零,卸载卡死在那儿。

几个真踩过的坑

掩码用错位。 注册表那边判断"是不是写意图",我一开始拿 KEY_WRITE 当掩码。问题是它的 READ_CONTROL(0x00020000) 位和 KEY_READ 完全重叠,结果把一大票纯读打开全判成了写意图。实测十二秒里刷出 240 条噪音。这种错它不报错,只是安静地做错事。

一个连字符。 轮询路和同步路本来该共用同一个拼名函数去造那个去重互斥体的名字。同步路那份写成了 L"\\ProcessInitAPCMutex-pid=%u"——拼出来是个对象路径,而私有命名空间根本没被真的创建过,于是 CreateMutex 每次都返回 ERROR_PATH_NOT_FOUND,每次都抛异常。同步注入路从来没成功过一次,子进程被原样放行,全靠 10 毫秒轮询兜底,而日志里看起来一切正常。

注入两次就死。 会话信号量是按 pid 命名的。同一个进程被注入两次时,第二次的 create 拿到的是同一个信号量对象(计数已被第一次拿走),acquire() 就永久阻塞。如果第二次走的是 APC 路,这段代码跑在主线程上,于是主线程卡死、main 永远进不去:现象是窗口一片空白、进程常驻。改成先 OpenSemaphoreW 探测,存在就直接放弃这次注入——fail-fast,后来的让位给先到的。

只堵最后一步等于没堵。 代码注入链是四步:NtAllocateVirtualMemory 申请可执行内存 → NtWriteVirtualMemory 写 shellcode → NtProtectVirtualMemory 改成可执行 → NtCreateThreadEx 起线程。早先我只挂了最后一步,前面三步完全裸奔。补上之后,判据的唯一关键是目标进程 ≠ 当前进程:这几个 API 在每个进程里都被合法高频调用(.NET/Java 的 JIT、浏览器的 JIT、任何带 GC 的运行时都在自己进程里 allocate + write + protect),对本进程必须一律放行,否则整个系统当场瘫掉。

它拦不到什么

上面这一整套都建立在"它走用户态 API"的前提上。反过来,程序自己带 syscall 号直接进内核、手工映射一份 DLL、或者复制一个令牌——这三条我一条都拦不住。不是没修,是用户态这个位置本身够不着。

仓库里带了个 dskill.exe,就是用直 syscall 绕过我全部 hook 的。我把它打进发布包,一来让"能被绕过"这句话有实证,二来它也是自我保护生效后留给自己的逃生门。

顺着这个边界往下推:写 MBR 的恶搞程序拦不到(那不走任何用户态 API,得靠 minifilter 在内核拦 IRP_MJ_WRITE);提前进内核覆写硬盘、改 BIOS 的那种更够不着。但注册表自启、计划任务、加载 DLL、拉起子进程、出站连接这些要经过我挂着的 API 的动作,能在半路截住——银狐、熊猫烧香这类样本落在用户态的那部分手脚,就属于这一类。

说到底

它是那半个杀软:用户态这一半,注入、hook、拦 API,做得还算认真;内核和硬件那半边够不着。110 处钩子装在每一个进程里,从里面往外看。你要是想搞清楚自己机器上谁在动手,它够用——墙它变不出来。

评论 0

发表评论
评论需要审核后才会显示

还没有评论,来说点什么吧。