模块与映像加载:内核模块加载、PsSetLoadImageNotifyRoutine、DLL注入检测

模块加载这件事,说白了就是Windows内核里最基础也最关键的操作之一。你写的驱动、别人写的DLL、系统自带的EXE,它们怎么进到内存里的?谁在管这件事?我们能不能在它们加载的瞬间插一脚?

嗯,今天我们就来聊聊这个话题。我会从内核模块加载讲起,然后深入到PsSetLoadImageNotifyRoutine这个回调机制,最后用实战案例演示如何用它做DLL注入检测。

内核模块加载:驱动是怎么进到内核的?

先说说驱动本身。你写了一个.sys文件,怎么让它跑起来?

用户态用CreateService + StartService,或者直接用ZwLoadDriver。内核态呢?其实最终都会走到IopLoadDriver这个函数。

我个人习惯把驱动加载流程拆成三步:

  1. 映像映射:把.sys文件映射到内核地址空间
  2. 重定位:修正基址,处理导入表
  3. 入口调用:调用DriverEntry

这里有个坑——我早期做驱动开发时,以为DriverEntry返回STATUS_SUCCESS就万事大吉了。其实不是。如果DriverEntry里某些初始化失败了,但你没处理好,驱动虽然加载了,但后续操作全是雷。

注意:驱动加载成功后,DriverEntry返回前,系统已经把你的驱动对象挂到了对象树上。如果这时候蓝屏,卸载都不好卸。

PsSetLoadImageNotifyRoutine:在加载瞬间插一脚

好,现在我们知道驱动怎么加载了。那问题来了——我们能不能在任何模块(EXE、DLL、SYS)加载进内存的那一刻,收到通知?

能。PsSetLoadImageNotifyRoutine就是干这个的。

这个API的签名很简单:

NTSTATUS PsSetLoadImageNotifyRoutine(
    _In_ PLOAD_IMAGE_NOTIFY_ROUTINE NotifyRoutine
);

回调函数的原型:

VOID LoadImageNotifyRoutine(
    _In_opt_ PUNICODE_STRING FullImageName,
    _In_ HANDLE ProcessId,
    _In_ PIMAGE_INFO ImageInfo
);

每次有模块加载,系统就会调用你注册的这个回调。注意,是每次。DLL加载、EXE启动、驱动加载,全都会触发。

我记得第一次用这个API时,写了个简单的回调,打印所有加载的模块名。结果一开机,日志刷刷刷地滚——光是系统启动阶段就有上千个模块加载。嗯,这时候你就知道,回调里不能做重活。

经验之谈:回调里不要申请内存、不要等待锁、不要调用可能阻塞的API。你想想看,系统每加载一个模块都要等你,你慢了,整个系统都慢。

IMAGE_INFO结构:你能拿到什么信息?

回调里有个IMAGE_INFO结构,它告诉你了这个模块的基本信息:

字段 含义
ImageBase 模块加载的基址
ImageSize 模块大小
Flags 标志位,比如是不是驱动、是不是DLL

这里有个细节——Flags里的IMAGE_IS_DRIVER位,可以用来区分是驱动加载还是用户态模块加载。我早期做检测时,就靠这个位过滤掉驱动加载,只关注DLL和EXE。

实战:DLL注入检测

好,理论讲完了。我们来做点实际的——检测DLL注入。

DLL注入的本质是什么?说白了,就是让目标进程加载一个它本来不该加载的DLL。常见手法有:

  • CreateRemoteThread + LoadLibrary
  • SetWindowsHookEx
  • APC注入
  • 反射式DLL注入

不管哪种手法,最终都会触发LoadImageNotifyRoutine。所以我们只要注册回调,然后判断:

  1. 这个DLL是不是目标进程该有的?
  2. 这个DLL的路径是不是可疑的?

我曾经在项目中遇到过一个问题:某款安全软件用PsSetLoadImageNotifyRoutine检测注入,结果误报了一堆系统DLL。为什么?因为它只判断了DLL名,没判断路径。攻击者把恶意DLL改成kernel32.dll放在当前目录下,系统加载的是系统目录的,但攻击者加载的是当前目录的——路径不同。

所以,检测时一定要对比完整路径

下面是一个简化版的检测回调:

VOID MyLoadImageNotify(
    _In_opt_ PUNICODE_STRING FullImageName,
    _In_ HANDLE ProcessId,
    _In_ PIMAGE_INFO ImageInfo
)
{
    UNICODE_STRING suspiciousDll = RTL_CONSTANT_STRING(L"\\Device\\HarddiskVolume1\\evil.dll");
    
    // 只关注用户态模块
    if (!(ImageInfo->Flags & IMAGE_IS_DRIVER))
    {
        // 检查是不是我们关注的进程
        if (ProcessId == TargetProcessId)
        {
            // 对比完整路径
            if (FullImageName != NULL && 
                RtlCompareUnicodeString(FullImageName, &suspiciousDll, TRUE) == 0)
            {
                // 发现可疑加载,记录日志或阻止
                DbgPrint("检测到可疑DLL加载: %wZ, 进程: %p\n", 
                         FullImageName, ProcessId);
            }
        }
    }
}
核心要点:
  • 回调里只做判断和记录,不要做阻止操作
  • 要阻止加载,需要用PsSetCreateProcessNotifyRoutine配合其他机制
  • 路径对比要用完整路径,不要只用文件名

卸载回调:别忘了清理

驱动卸载时,一定要调用PsRemoveLoadImageNotifyRoutine把回调移除。不然驱动卸载了,回调地址还在系统里,下次有模块加载,系统跳到一个非法地址——蓝屏。

我早期犯过这个错。调试的时候,驱动卸载了,忘了移除回调。结果下一次加载驱动时,系统直接蓝屏,报错指向一个空指针。查了半天才发现是回调没清理。

血的教训:注册的回调一定要在DriverUnload里移除。顺序是:先移除回调,再释放资源,最后返回。

SVG:模块加载通知流程

下面这张图展示了从模块加载到回调触发的完整流程:

模块加载通知流程 用户态:LoadLibrary / CreateProcess 内核态:IopLoadDriver / MiMapViewOfImage PsSetLoadImageNotifyRoutine 回调 回调内部处理 1. 检查 Flags 判断模块类型 2. 对比完整路径 3. 记录日志 / 触发告警 注意事项 回调中不要做阻塞操作 | 不要申请内存 | 不要调用可能等待的API 驱动卸载前必须调用 PsRemoveLoadImageNotifyRoutine 移除回调

总结

模块加载通知是Windows内核提供的一个非常强大的机制。它让我们能在模块加载的瞬间感知到,并做出判断。对于驱动开发者来说,这是做安全检测、行为监控的利器。

但强大也意味着责任。回调里不能乱来,不能阻塞,不能做复杂操作。你想想看,系统每加载一个模块都要经过你的回调,你慢了,整个系统都跟着慢。

我个人习惯是:回调里只做判断和记录,把真正的处理逻辑放到一个单独的工作线程里。这样既不影响系统性能,又能完成检测任务。

嗯,关于模块加载和DLL注入检测,今天就聊到这里。如果你在实际项目中遇到相关问题,欢迎交流。

一句话记住:PsSetLoadImageNotifyRoutine 让你在模块加载瞬间插一脚,但这一脚要轻、要快、要准。

公众号:蓝海资料掘金营,微信deep3321