做逆向的朋友碰面聊技术,最常见的话题就是某某App的签名算法怎么解、小程序某个接口的加密参数从哪来、安卓客户端里的so怎么还原。这些确实都是逆向,但基本都停留在用户态,也就是大家常说的三环。我这一年里花精力最多的反而是另一件事:把一个后缀为sys的内核驱动丢进反汇编器,从DriverEntry开始,一步步还原出它到底想干什么。内核驱动逆向和用户态逆向相比,完全是一个需要重新适应思路的领域。驱动运行的层级最高,能直接碰物理内存、注册回调、操纵对象,它的代码一旦失控,机器就是直接蓝屏。这篇文章梳理的是我自己在分析内核驱动时的一整套流程和沉淀下来的判断方法,适合已经玩过一般逆向、现在想往内核层深入的人做参考。
1. 内核驱动逆向到底在逆什么
1.1 分界线:三环逆向与内核态逆向的差异
用户态的EXE、DLL、App,被系统隔离在Ring3,它们想访问系统资源必须通过API、系统调用,逆向这类程序时,你重点看的是调用关系、算法实现、签名生成逻辑,整体上是在一个被操作系统框定的安全边界里分析。
到了内核驱动这一步,情况完全不同。驱动运行在Ring0,它有权限执行特权指令、读写任意物理内存、直接遍历活动进程链表、挂钩系统调用和内核回调。换句话说,用户态程序是通过“申请”来获取能力,驱动是“直接拿”。所以逆向内驱的核心不再只是看懂某个算法,而是搞清楚这个模块注册了哪些回调、挂在哪条链路、用什么方式与用户态通信,以及它试图对系统状态做什么修改。
还有一个实际差异是调试方式。用户态逆向可以用调试器附加进程、打断点、看堆栈,不用太担心把系统搞挂。内核驱动逆向一般在独立的虚拟机里做双机调试,一旦断点位置不对,或者单步走到了某些等待锁的路径,整个系统会直接卡死或者蓝屏。信息反馈也更原始,很多时候只能靠WinDbg的调试输出和崩溃转储来定位。
1.2 驱动文件与入口函数的基本盘
Windows内核驱动虽然也是PE文件格式,但它的入口和行为模式比普通用户态程序稳定得多。一个标准驱动不会像应用层程序那样以main或WinMain为逻辑起点,而是以DriverEntry作为初始化入口。系统在加载这个模块时,会通过I/O管理器调用DriverEntry,并传入两个参数:第一个是驱动对象指针PDRIVER_OBJECT,第二个是指向注册表服务键路径的PUNICODE_STRING。
用IDA打开一个sys文件,正常操作下你会在导出表里直接看到DriverEntry。如果样本做了优化或混淆,导出表里没有这个名字,那就需要手动定位。经验上,DriverEntry一般出现在代码段开头不远的位置,函数特征是对驱动对象结构体进行大量成员赋值,然后调用IoCreateDevice、IoCreateSymbolicLink这类内核导出函数。通过这些明显的初始化动作,基本可以确认函数身份。
需要特别说明的是,内核驱动和应用程序的错误处理机制也是两套。驱动函数返回的是NTSTATUS值,比如STATUS_SUCCESS(0)表示成功,0xC0000001等大量错误码表示失败。分析驱动时,看到一个函数末尾经常mov eax, 0、mov eax, 0xC0000001之类的操作,就要想到这是NTSTATUS返回约定,而不是简单的布尔值。
1.3 典型触发场景:恶意样本、反作弊、设备固件
我接触到的内核驱动逆向需求大致分三类。第一类是恶意驱动和Rootkit分析,这类样本会尝试隐藏进程、隐藏模块、对抗安全软件,甚至利用驱动漏洞做提权。第二类是反作弊或DRM类产品的内核模块审查,游戏安全领域很常见,很多关键校验不会放在用户态,而是下沉到驱动里,防止被直接定位绕过。第三类是硬件设备厂商的诊断工具或固件调试程序,比如某些外设的控制驱动、特定型号硬盘的调试工具,分析它们能反推硬件协议细节。
这三类场景对逆向产物的要求不一样:恶意样本分析以识别行为意图为主,反作弊内核模块审查更需要还原功能边界和绕过条件,设备驱动类分析则要落到寄存器读写顺序和I/O控制码含义上。但不管哪种情况,分析主线是一致的:先静态摸清模块结构,再动态确认行为路径,最终形成一份可解释的功能清单。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分析工具与调试环境的准备
2.1 静态工具选择与前处理
静态分析方面,我的首选是IDA Pro。新版IDA对内核驱动的支持已经相当成熟,识别出DriverEntry后,直接按F5看伪代码即可。没有IDA的情况下,Ghidra也是完全可用的替代方案,它对x64反编译的还原度近几年提升非常明显,而且免费开源,适合入门练手。
拿到样本后不要急着全部丢进反编译器,先做三件事:
第一,用PE查看工具(比如CFF Explorer、DIE)看文件头信息,确认编译时间戳、入口点RVA、区段数量和名字。驱动某些异常区段类型往往是混淆壳或自定义加壳的迹象,值得优先关注。
第二,看导入表。驱动必须通过导入表或MmGetSystemRoutineAddress来获取内核函数地址。导入表中的函数名会直接暴露模块定位。比如大量导入IoCreateDevice、IoCreateSymbolicLink、ZwOpenProcess、KeSetEvent、PsLookupProcessByProcessId这类函数,那么这是一个典型的设备交互类驱动;如果导入的是ObRegisterCallbacks、CmRegisterCallback,那就是偏系统监控类。
第三,搜索字符串表。别看不少样本会做字符串加密,但总有一部分字符串留在可读区域里。设备名、符号链接名、错误提示字符串,都可能是后续定位的锚点。
2.2 双机调试配置与驱动加载
内核驱动逆向不推荐直接在自己日常使用的机器上加载测试样本,特别是陌生样本。标准做法是准备一台Windows虚拟机作为调试机(目标机),宿主机或另一台机器作为调试器端,通过内核调试链路连接。
最方便的调试链路是VMware或VirtualBox提供的虚拟串口,在虚拟机配置里添加一个命名管道,宿主机端用WinDbg连接这个管道。命令大致是:
text复制windbg -k com:port=\\.\pipe\com_1,baud=115200,pipe
目标系统内需要开启调试模式并指定调试端口:
text复制bcdedit /debug on
bcdedit /dbgsettings serial debugport:1 baudrate:115200
Windows 10较新版本还支持通过网络内核调试(kdnet),配置思路类似,但网络调试需要确认网卡被调试器识别,稳定性和首包时间反而不如串口调试来得好掌握。我的经验是串口管道调试最稳,延迟低、不受IP变化影响,物理机上搞内核调试时再考虑kdnet。
驱动加载在虚拟机里操作。把编译好的sys文件放到目标机后,可以用系统自带的sc命令创建驱动服务并启动:
text复制sc create LabDriver type= kernel binPath= C:\Drivers\LabDriver.sys
sc start LabDriver
如果驱动没有经过微软签名,目标系统需要进入测试签名模式,或者在启动菜单里选择禁用驱动签名强制。测试签名模式可以通过下面命令开启:
text复制bcdedit /set testsigning on
重启生效。对于逆向分析来说,测试签名模式够用了,不建议为陌生样本反复折腾签名伪造。
2.3 符号、类型和结构体信息管理
内核逆向最痛苦的事情之一是没有符号。微软的ntoskrnl.exe等核心模块可以通过设置符号路径自动加载:
text复制set _NT_SYMBOL_PATH=srv*C:\Symbols*https://msdl.microsoft.com/download/symbols
样本驱动本身一般没有PDB,但我们在分析时要频繁用到内核结构体的成员布局,例如_DRIVER_OBJECT、_DEVICE_OBJECT、_IRP、_EPROCESS、_LIST_ENTRY。这些结构体可以从两部分获得权威信息:一部分是WDK安装目录下的头文件,另一部分就是WinDbg的dt命令。
用dt命令查看结构体布局非常方便,比如在WinDbg里执行:
text复制dt nt!_DRIVER_OBJECT
dt nt!_IRP
dt nt!_EPROCESS
IDA中则可以把这些结构体导入本地类型库,配合F5伪代码阅读,效率会高很多。没有符号的时候,我习惯对照公开内核源码或符号服务器导出的结构布局,自己整理一份与当前系统版本匹配的偏移表,避免反复翻找。
3. 从DriverEntry把静态主线拉出来
3.1 入口函数的辨识与快速定位
大多数内核驱动的入口函数会做几件事:初始化驱动对象,把Dispatch例程指针填进MajorFunction表,创建设备对象,创建符号链接,注册可能需要的一些清理回调。在IDA里反编译DriverEntry后,代码结构通常呈现为一个带switch或者大量函数指针赋值的初始化函数。
以Windows x64为例,_DRIVER_OBJECT结构体中与初始化强相关的主要成员布局大致如下:
| 偏移 | 成员 | 含义 |
|---|---|---|
| 0x00 | Type | 对象类型,驱动对象固定值 |
| 0x08 | Size | 对象大小 |
| 0x30 | DriverStart | 驱动映像起始地址 |
| 0x38 | DriverSize | 映像大小 |
| 0x58 | DriverInit | DriverEntry函数地址 |
| 0x60 | DriverStartIo | 启动IO例程 |
| 0x70 | MajorFunction | 分发例程表,IRP_MJ_*数组 |
不同Windows版本间偏移会有差异,分析时不能只靠记忆,应该实际用dt命令确认。
定位到DriverEntry之后,我会先把整个函数从头到尾读一遍,不急着深入某个分支。读的目的只有一个:找意图。它创建了设备对象但没有创建符号链接,说明接口不是开放的,可能是纯内核态功能;它把所有MajorFunction都指向同一个处理函数,那模块大概率是统一分发后再根据IRP内容二次路由,接下来每个IO控制码都会有自己的功能分支;它注册了IoRegisterShutdownNotification或ObRegisterCallbacks,那就意味着它关注系统全局状态,而不是单纯做设备读写。
3.2 创建设备对象、符号链接的意图判定
设备对象创建是驱动最明显的行为信号。函数IoCreateDevice传入的设备类型、设备名称,以及是否创建符号链接,直接决定用户态能否用CreateFile、DeviceIoControl访问到这个驱动。
看到这样的调用模式:
c复制RtlInitUnicodeString(&deviceName, L"\\Device\\MyDevice");
RtlInitUnicodeString(&symbolicLinkName, L"\\DosDevices\\MyDevice");
IoCreateDevice(DriverObject, sizeof(MY_DEVICE_EXTENSION), &deviceName,
FILE_DEVICE_UNKNOWN, 0, FALSE, &deviceObject);
IoCreateSymbolicLink(&symbolicLinkName, &deviceName);
这个驱动就非常直白:它给了应用层一个明确的设备路径。接下来用户态程序通过CreateFile打开\\.\MyDevice,再通过DeviceIoControl发出控制码,驱动侧统一在IRP_MJ_DEVICE_CONTROL里处理。大量功能型驱动都是这种结构。
资源管理器机制Windows 8硬件驱动则可能是另一种情况:创建设备但故意不创建符号链接,用户态用CreateFile无法直接打开,只能通过其他驱动、过滤驱动或者系统注册表里保留的设备句柄间接访问。这种隐藏设备的驱动在恶意样本里很常见,分析时尤其要留意设备名是否随机,以及是否又通过IoCreateSymbolicLink创建了一个看起来无害的链接名称。
3.3 MajorFunction分发表:驱动的能力目录
分发例程表可以被理解成驱动的“能力目录”。每个IRP主功能号都对应一类I/O请求。最多关注的几个主功能如下:
| 主功能号 | 名称 | 用途 |
|---|---|---|
| 0x00 | IRP_MJ_CREATE | 应用层打开设备时触发 |
| 0x02 | IRP_MJ_CLOSE | 关闭设备句柄时触发 |
| 0x0E | IRP_MJ_DEVICE_CONTROL | DeviceIoControl调用时触发,承载自定义功能 |
| 0x03 | IRP_MJ_READ | ReadFile调用时触发 |
| 0x04 | IRP_MJ_WRITE | WriteFile调用时触发 |
| 0x1B | IRP_MJ_PNP | 即插即用通知 |
| 0x1D | IRP_MJ_POWER | 电源管理通知 |
在IDA里找到DriverEntry对MajorFunction数组的赋值操作,例如:
c复制DriverObject->MajorFunction[IRP_MJ_CREATE] = DispatchCreate;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = DispatchClose;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DispatchIoctl;
看到这样的代码,优先跳到DispatchIoctl去分析。这是功能型驱动最核心的入口之一,用户态传进来的控制码会在这里被分发到不同处理函数。同时也不要忽略IRP_MJ_CREATE,很多驱动会在Create时做权限校验或者初始化每文件上下文,这也可能成为后续功能触达的前置条件。
4. 动态验证:用调试器逼出真实行为
4.1 在关键函数上下断看调用栈
静态分析只能告诉你代码里有什么可能的分支,动态调试才是确认代码真实执行路径的手段。加载驱动前,先在调试器里准备好断点。
常见的做法是把DriverEntry、DispatchCreate、DispatchIoctl、分发函数内部的几个关键分支都设置好断点。WinDbg命令:
text复制bu LabDriver!DriverEntry
bu LabDriver!DispatchIoctl
bu设置的是延迟断点,只要模块被加载,它会立刻下到对应函数位置,不需要提前知道模块基址。设置完断点后在目标机执行sc start LabDriver,调试器会命中DriverEntry,单步观察初始化过程中对驱动对象成员的写值。
在这个阶段,我喜欢结合调用栈指令观察上下文。驱动被加载时,执行DriverEntry的进程上下文通常是System或者服务控制管理器进程,这是正常的。真正有意思的是后续触发DispatchIoctl时的调用栈,它会告诉你控制码从哪里来:是某个服务程序,还是被其他内核模块转调。
4.2 IOCTL分发的现场勘验
进入IRP_MJ_DEVICE_CONTROL例程后,需要从_IRP结构体里提取请求参数。IRP里有几个重要字段:
| 字段 | 说明 |
|---|---|
| IoControlCode | 应用层传过来的IO控制码,决定具体操作 |
| InputBufferLength | 输入缓冲区长度 |
| OutputBufferLength | 输出缓冲区长度 |
| AssociatedIrp.SystemBuffer | 缓冲区方法为METHOD_BUFFERED时的共用缓冲区 |
| UserBuffer | 缓冲区方法为METHOD_NEITHER时的用户空间缓冲区 |
在内核调试器里可以这样查看:
text复制dt nt!_IRP ffff888012345678
然后查看IoControlCode具体值。控制码的组成有一定规律:设备类型、访问权限、功能号、缓冲方法。拿到控制码后,回到IDA里搜索这个数值,往往能在反汇编代码里直接定位到对应的case分支。
这一步是静态和动态结合的关键。静态分析时你看到的是函数指针赋值和switch跳转,动态调试时你能看到具体控制码落进哪个分支。两者一对照,功能映射关系就很清晰了。
4.3 修改返回值和入参做功能验证
动态调试还有一个好处:可以直接修改寄存器、内存和返回值来验证分析结论。
比如在DispatchIoctl末尾遇到一个mov eax, 0xC0000023之类的错误码,你想确认某个分支是否真的会执行到成功路径,可以在判断条件处直接改标志位,或者把入参长度改成函数期望的值,再继续观察后续执行。这样能快速判断代码里是否存在隐藏的权限校验。
在内核调试中修改内存需要注意,驱动里的很多状态是全局性的,改错位置不一定立刻崩溃,但可能会留下无法理解的副作用。我通常只在数据字段上改(比如输入缓冲区里的某个标志位),而不去改函数指针和控制流跳转目标。控制流修改一旦跳进错误地址,崩溃概率非常高。
5. 对抗痕迹、回调机制与隐蔽特征分析
5.1 字符串隐藏与动态地址解析
不少内驱样本会做字符串加密和敏感API动态获取。它们不在导入表里直接列出NtOpenProcess、ZwTerminateProcess这类高敏函数,而是通过MmGetSystemRoutineAddress或查找内核导出表的方式在运行时解析。
识别动态解析的关键在于找到MmGetSystemRoutineAddress的调用点。它会接收一个UNICODE_STRING参数,里面是要解析的函数名。如果函数名也做了加密,那就需要在调用前观察RtlInitUnicodeString的赋值来源,往往经过一个xor循环解密。此时可以用WinDbg在RtlInitUnicodeString上下断,打印每次初始化的字符串内容,动态环境就能直接绕过静态混淆。
字符串加密的处理也有套路。见到函数里出现大量连续异或、按字节加减、或者用固定魔数解密的循环,直接复制伪代码到IDAPython里跑一遍解密,比手动仿真快得多。我一般先搜0xDEADBEEF这类常用魔数,不行再考虑用模拟器给函数输入,观察内存输出。
5.2 DKOM类操作的特征识别
DKOM(直接内核对象操作)是驱动隐藏自身或隐藏进程的经典手法。原理非常简单:Windows通过双链表维护活动进程列表,驱动只要找到目标进程的EPROCESS结构体,再操作其中的ActiveProcessLinks双向链表指针,把目标节点摘掉,进程就不会出现在任务管理器里。
在反汇编代码里,这种操作的特征极其明显:出现大量对LIST_ENTRY结构体的手动赋值,反复做blink和flink的指针对调。伪代码里经常会看到:
c复制v4 = CurrentProcess->ActiveProcessLinks.Blink;
v4->Flink = CurrentProcess->ActiveProcessLinks.Flink;
CurrentProcess->ActiveProcessLinks.Flink->Blink = v4;
如果驱动分析到这里,基本可以断定它具备隐藏进程或遍历进程链的能力。进一步观察链表操作附近是否有EPROCESS偏移量的加减,再结合WinDbg查看实际偏移,就能还原它具体操作的是哪个字段。
这里要克制一点,逆向分析的目的在于确认行为和防护交给安全工程师,而不是把隐藏代码抄出来快速复用。分析结果落到报告中,作为检测规则、查杀特征和调查取证线索,才是有价值的。
5.3 注册回调与对象通知:隐藏的分析切入点
除了直接操作链表,很多模块会通过回调机制来达成监控或保护功能。ObRegisterCallbacks注册的进程句柄回调,可以在句柄创建和复制时拦截检查;CmRegisterCallback可以监控注册表操作;PsSetCreateProcessNotifyRoutine可以监控进程创建退出。
逆向分析时看到驱动导入ObRegisterCallbacks,要立刻意识到这是一个涉及权限控制的模块体。注册回调时传入的OB_CALLBACK_REGISTRATION结构体里有一个Altitude字段,它以字符串形式表示回调层级,不同安全产品通过海拔值排序,决定谁先收到通知。这个字符串特征可以用来识别模块的身份或厂商。
看反汇编代码时,重点提取回调函数的地址和Altitude值。随后直接跳到回调函数地址,观察它做了哪些判断。比如进程句柄回调,经常能看到访问进程权限掩码、比较进程ID、调用相关函数设置返回状态。这种逻辑还原后,该驱动是否在做反调试、是否在保护某个进程,就会非常清晰。
6. 一个读写控制功能的完整还原路径
6.1 从控制码和缓冲区类型切入
前面几节说得偏理论,这里用一个我实际还原过的典型功能来串一遍完整流程。假设目标驱动创建了设备\Device\TestDefender,并创建了符号链接\DosDevices\TestDefender,在DispatchIoctl里对IRP_MJ_DEVICE_CONTROL做了switch分支。
定位到分发函数后,往下翻看到类似这样的伪代码:
c复制switch (irpSp->Parameters.DeviceIoControl.IoControlCode)
{
case 0x9C402000:
return ProcessReadMemory(irp, irpSp);
case 0x9C402004:
return ProcessWriteMemory(irp, irpSp);
case 0x9C402008:
return QueryModuleInfo(irp, irpSp);
}
0x9C402000这个数值一眼就能拆出来:0x9C4是设备类型,0x000是功能号,方法位为0,对应METHOD_BUFFERED。看到这个布局,往下看ProcessReadMemory时,我就知道输入输出缓冲都通过AssociatedIrp.SystemBuffer传递,不需要再做用户态地址转换。
6.2 伪代码还原与中间层验证
进入ProcessReadMemory函数后,我看到典型的几段逻辑:先从SystemBuffer里拷贝输入参数结构体,取出PID、地址、长度;再调用PsLookupProcessByProcessId获取EPROCESS指针;随后是内存分配和一个复制循环。伪代码类似:
c复制status = PsLookupProcessByProcessId((HANDLE)param->ProcessId, &process);
if (!NT_SUCCESS(status)) return status;
if (param->Length > 0x1000) return STATUS_INVALID_PARAMETER;
buffer = ExAllocatePoolWithTag(NonPagedPool, param->Length, 'mRDt');
ProbeForRead(param->Address, param->Length, 1);
// 循环拷贝
RtlCopyMemory(buffer, param->Address, param->Length);
单看伪代码还不能确定拷贝方向,我需要用调试器验证。在ExAllocatePoolWithTag和RtlCopyMemory上下断,然后在应用层写一个测试程序,向设备发送0x9C402000控制码,把目标PID和地址填进去。首轮命中后查看输入结构体和CopyMemory的源地址目的地址,发现它是把一个用户态虚拟地址的内容拷贝到内核分配的缓冲区,再通过SystemBuffer返回给应用层。到这里功能就明确了:内核态指定进程内存读取。
6.3 输出结论与闭环验证
功能还原之后的闭环验证很关键。我会在调试器里对ProcessReadMemory的返回状态做修改,比如把函数开头强制改成STATUS_ACCESS_DENIED,然后重复触发应用层请求,观察调用方是否收到错误码。通过这种方式确认驱动和应用层之间的交互约定确实如我推测。
更完整的还原还包括对输入结构体的字段定义。比如拿到一个请求包,通过逐个置位测试,确认结构体前4字节是PID,接下来8字节是地址,再接下来4字节是长度。这种验证虽然耗时,但能避免只看反汇编就得出偏差结论。分析报告写到最后,我会把这些字段映射关系整理成一张参数表,包括控制码、功能名、入参结构、出参结构、权限校验逻辑,这份表格就是整个逆向项目最有价值的沉淀物。
7. 长期做内核逆向才摸清的几条门道
7.1 版本漂移与结构体布局问题
Windows内核结构体版本漂移非常严重。同一个_EPROCESS,不同系统版本里成员偏移不同,甚至成员名都变了。你从网上找到的一份结构体偏移表,很可能只适用于某个特定构建号,换一台机器就不对。
应对方法就是一切以实际调试目标机的结构为准。在WinDbg里多用dt命令,不要省这几秒钟。自己整理一份针对当前系统的结构偏移速查表,用熟了之后,分析效率会明显提升。不要轻信旧笔记里的经验值,内核逆向的很多错误都来自“上次某版本了有效,这次应该也能用”这种惯性思维。
7.2 池内存和调度相关坑
内核驱动的判断条件有时非常隐蔽,尤其是涉及内存池和非分页内存的时候。在调试分析时经常碰到的情况是,驱动从非分页池分配内存,但代码里对缓冲区尺寸的校验不够严格,导致溢出。这种溢出在逆向分析时很难通过静态观察100%确认,往往需要结合触发条件做动态验证。
另一个常见的坑是自旋锁、同步事件和中断请求级别问题。分析某个函数时看到KeWaitForSingleObject,或者看到改中断请求级别的代码,就要意识到这里有并发和调度逻辑。单步调试这个区域时很容易死锁或蓝屏,不要在这个位置冒进,改用日志断点和条件断点来观察状态变化。我在分析带锁代码时通常会把断点下在锁请求前后,而不是锁中间区域。
7.3 落地原则与边界感
最后说点实在的。内核驱动逆向是个非常烧经验的技术方向,我踩过的坑远比顺利的时刻多。如果你想入坑,基础建议是:先熟练掌握用户态逆向和C语言指针、链表、结构体内存布局,再拿一些自己写的或开源的简单驱动练手,最后再碰真实样本。不要一上来就分析复杂Rootkit,那样大概率会被消灭在字符串加密和虚拟机检测的迷宫里。
工具链上要真正吃透WinDbg,而不是只会在IDA里看伪代码。动态和静态结合,才能在内核驱动逆向里走得更远。分析结果的应用边界也要清楚:它能帮你做恶意样本行为判定、做安全产品检测规则、做漏洞研究,但不应该被用来开发对抗系统的工具。保持这个边界,内核逆向这条路可以走很久。
