模块与映像加载:内核模块加载、PsSetLoadImageNotifyRoutine、DLL注入检测
模块加载这件事,说白了就是Windows内核里最基础也最关键的操作之一。你写的驱动、别人写的DLL、系统自带的EXE,它们怎么进到内存里的?谁在管这件事?我们能不能在它们加载的瞬间插一脚?
嗯,今天我们就来聊聊这个话题。我会从内核模块加载讲起,然后深入到PsSetLoadImageNotifyRoutine这个回调机制,最后用实战案例演示如何用它做DLL注入检测。
内核模块加载:驱动是怎么进到内核的?
先说说驱动本身。你写了一个.sys文件,怎么让它跑起来?
用户态用CreateService + StartService,或者直接用ZwLoadDriver。内核态呢?其实最终都会走到IopLoadDriver这个函数。
我个人习惯把驱动加载流程拆成三步:
- 映像映射:把
.sys文件映射到内核地址空间 - 重定位:修正基址,处理导入表
- 入口调用:调用
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时,写了个简单的回调,打印所有加载的模块名。结果一开机,日志刷刷刷地滚——光是系统启动阶段就有上千个模块加载。嗯,这时候你就知道,回调里不能做重活。
IMAGE_INFO结构:你能拿到什么信息?
回调里有个IMAGE_INFO结构,它告诉你了这个模块的基本信息:
| 字段 | 含义 |
|---|---|
ImageBase |
模块加载的基址 |
ImageSize |
模块大小 |
Flags |
标志位,比如是不是驱动、是不是DLL |
这里有个细节——Flags里的IMAGE_IS_DRIVER位,可以用来区分是驱动加载还是用户态模块加载。我早期做检测时,就靠这个位过滤掉驱动加载,只关注DLL和EXE。
实战:DLL注入检测
好,理论讲完了。我们来做点实际的——检测DLL注入。
DLL注入的本质是什么?说白了,就是让目标进程加载一个它本来不该加载的DLL。常见手法有:
CreateRemoteThread+LoadLibrarySetWindowsHookEx- APC注入
- 反射式DLL注入
不管哪种手法,最终都会触发LoadImageNotifyRoutine。所以我们只要注册回调,然后判断:
- 这个DLL是不是目标进程该有的?
- 这个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:模块加载通知流程
下面这张图展示了从模块加载到回调触发的完整流程:
总结
模块加载通知是Windows内核提供的一个非常强大的机制。它让我们能在模块加载的瞬间感知到,并做出判断。对于驱动开发者来说,这是做安全检测、行为监控的利器。
但强大也意味着责任。回调里不能乱来,不能阻塞,不能做复杂操作。你想想看,系统每加载一个模块都要经过你的回调,你慢了,整个系统都跟着慢。
我个人习惯是:回调里只做判断和记录,把真正的处理逻辑放到一个单独的工作线程里。这样既不影响系统性能,又能完成检测任务。
嗯,关于模块加载和DLL注入检测,今天就聊到这里。如果你在实际项目中遇到相关问题,欢迎交流。
公众号:蓝海资料掘金营,微信deep3321