虚拟机环境:Windows 11 24H2
一、前言
在之前的分析中,我们通过 PowerShell 使用 CIM 指令查询了硬盘信息:
Get-CimInstance -ClassName MSFT_PhysicalDisk -Namespace root/Microsoft/Windows/Storage | Select-Object FriendlyName, SerialNumber, UniqueId
当使用 CE (Cheat Engine) 搜索 char* 类型的序列号时,发现有一个地址位于 ClipSp.sys 驱动模块中:

二、逆向思路
我们将一步步探究该地址数据的来源。首先,记录下目标地址距离模块基址的偏移:0x9F024。
重启虚拟机,在第一次断点断下后重新加载符号(否则仅有 nt 模块有符号)。此时,硬盘序列号尚未写入目标地址。我们对地址头部下一个 1 字节的硬件写入断点:
kd> ba w1 ffff802'3af5f024 "u @Rip"

之后让虚拟机继续运行。当我们进入登录页面并输入密码时,断点被触发:

当前断下的位置,可以通过 IDA 静态分析或查看调用堆栈进行分析。由于是可以用 IDA 正常分析的普通情况,此处我们略过静态分析,继续执行。
记录第二次断下的位置后,我们在 IDA 中查看对应偏移:


可以看到,IDA 中的字节数据与 Windbg 中断点位置的字节并不相符,这种情况在 nt 模块的初始化函数中也存在。对于此类动态解密的代码,我们直接使用 Windbg 进行动态分析即可。
首先,查看所有寄存器的值,并重点观察 rcx 寄存器:

kd> db fffffd09915554cb
fffffd09`915554cb 36 35 31 34 5f 44 33 31-37 5f 42 39 46 38 5f 38 6514_D317_B9F8_8B01_000C_2966_91IE_0597
很明显,rcx 寄存器指向了我们的硬盘序列号字符串。那么下一步,就是追溯 rcx 的来源。我们向上查看约 -0x100 字节的汇编代码:

接下来,使用 Windbg 查看图中调用的两个函数分别是什么:

因此,这里的执行流程是:首先调用 ExAllocatePool2 申请内存,然后调用 ZwDeviceIoControlFile,并将申请到的内存作为 OutputBuffer 来接收数据。我们计算这两个函数调用点距离模块基址的偏移,分别是:0xF11E6 和 0xF123E。
重启虚拟机,在申请内存之前下断。重启后,我们先直接查看这两个偏移地址的汇编代码:

可以看到,代码依然不是真实的指令数据,与之前情况一致。所以,我们再次对 clipsp+0x9F024 下硬件写入断点。
进入登录界面输入密码触发断点后,再查看 0xF11E6 和 0xF123E 偏移的位置:

可以发现,只有当 Rip 指令指针位于 clipsp 模块内部时,其代码段才是解密后的真实数据。
现在,我们对这两个地址下软件断点:
1: kd> bp fffff804`1af811e6
1: kd> bp fffff804`1af8123e

当第一个断点命中时,我们单步执行 (p),并观察 rax 寄存器的值,它保存了 ExAllocatePool2 函数返回的申请内存的地址。我们继续运行 (g),直到命中第二个断点。
这样,我们就拿到了作为 ZwDeviceIoControlFile 函数输出缓冲区的内存地址。调用 ZwDeviceIoControlFile 之后,硬盘信息就会被写入这片内存。

从上图可以看到,调用 ZwDeviceIoControlFile 之后,地址 ffffe78c6666d740 分别在偏移 +0x39 处写入了硬盘名称,在偏移 +0x8B 处写入了硬盘序列号。
因此,我们再次重启,对申请到的内存的 +0x39 和 +0x8B 偏移处下硬件写入断点,以捕获数据写入的精确时刻:
kd> ba w1 fffff08e+f0264500+39 "u @Rip"
kd> ba w1 fffff08e+f0264500+8b "u @Rip"

断点命中后,我们查看调用堆栈 (k):

从堆栈可以看到,这是我们应用层使用 DeviceIoControl 并指定控制码 IOCTL_STORAGE_QUERY_PROPERTY 进行查询的完整内核流程,最终调用了函数 RaUnitStorageQueryPropertyIoctl。如果是使用 IOCTL_SCSI_PASS_THROUGH 进行查询,则会走 RaUnitScsiPassThroughIoctl 函数。
现在,假设我们不知道这个查询如何最终获取到序列号,让我们继续向上层进行逆向分析。根据堆栈返回,我们一层层追溯:
-
分析 IopProcessBufferedIoCompletion:

注释指出,v6 的数据被拷贝给 v5,所以 v5 是最早申请的那片内存,而 v6 是我们要寻找的序列号来源,需要往上层寻找 a1 参数。
-
继续向上层追溯 a1:

-
定位到 IopCompleteRequest:

注释说明 Tail 在 Irp 中的偏移是 0x78,这里 +0x78 和上一张图的 -0x78 刚好抵消。
-
进一步向上寻找:



我们发现一直追下去似乎进入了复杂的通用分发流程。这时,一个更有效的方法是:对堆栈中距离我们断点最近的非 nt 模块调用(如 storport!RaUnitStorageQueryPropertyIoctl+0x16d)的下层调用点下断。
我们再次重启,重复之前的操作,在调用 ZwDeviceIoControlFile 之后、数据写入之前,对 RaUnitStorageQueryPropertyIoctl 函数内部(或更上层的 RaUnitDeviceControlIrp)调用点下软件断点,以避免其他系统调用的干扰。

断下后,我们检查相关参数(Irp+0x70 和 Irp+0x18)指向的内存,发现此时数据仍为空,说明还未执行到写入的位置。

我们直接对缓冲区头部下硬件写入断点,继续运行。

断点命中!查看此时的调用堆栈,我们已经成功定位到了核心函数——RaGetUnitStorageDeviceProperty。顾名思义,这是一个“获取”设备属性的函数,正是我们寻找的数据源头。
如果你从未分析过 Windows 驱动的 I/O 请求传递,建议先了解 IofCallDriver 这个关键函数:

从堆栈可知,从 IofCallDriver 进入了 RaDriverDeviceControlIrp,说明后者的第一个参数是 PDEVICE_OBJECT 类型。我们在 IDA 中修改参数类型以便于分析:

回到核心函数 RaGetUnitStorageDeviceProperty。分析可知,它的第一个参数是 DeviceExtension。在 storport 驱动模型中,它对应 _RAID_UNIT_EXTENSION 结构体。在 IDA 中修改参数类型后,逻辑变得清晰:

函数从 a1->Identity.SerialNumber 中获取数据,并准备拷贝到输出缓冲区中。
现在,我们重启系统,在调用 ZwDeviceIoControlFile 之前,对 RaGetUnitStorageDeviceProperty 函数下软件断点。
断点命中后,我们使用 Windbg 的 dx 命令查看其第一个参数(即 _RAID_UNIT_EXTENSION 结构体)中的内容:

kd> dx -id 0,0xffffe50612bdd080 -r1 ((storport!_STOR_IDENTITY *)0xffffe5060e328218)
[+0x000] Length : 0x28
[+0x008] Buffer : 0xffffe5060f6b90 : "6514"

至此,真相大白:ClipSp.sys 中保存的硬盘序列号,正是通过复制 _RAID_UNIT_EXTENSION. Identity.SerialNumber 中的数据而获得的。
三、扩展验证与总结
接下来,我们可以使用 DeviceTree 工具进行验证。搜索 Device\RaidPort,查看其设备类型:

验证其 Type 为 FILE_DEVICE_DISK。然后查看该设备对象的 DeviceExtension 字段:

这里获取到的 DeviceExtension 地址,与我们之前在调试器中获取的 _RAID_UNIT_EXTENSION 地址是一致的。
结论:本篇通过动态调试与静态分析相结合的方法,完整追溯了 ClipSp.sys 驱动模块中硬盘序列号的写入源头——storport.sys 驱动维护的 _RAID_UNIT_EXTENSION 结构体。这也是许多开源 HWID (Hardware ID) 欺骗工具修改硬盘序列号时所针对的底层位置。当然,它们通常不会去修改 ClipSp.sys 内部缓存的那份副本。
这个逆向过程涉及了对 Windows 存储栈、I/O 请求包(IRP)传递、以及内核内存管理的理解,是一次典型的 Windows 内核驱动逆向分析实践。希望这个分析思路能对你在进行类似的逆向工程或安全研究时有所启发。
本文技术分析基于 Windows 11 24H2 内核,详细步骤涉及 Windbg 调试及 IDA 静态分析,适合有一定内核及C/C++基础的开发者深入阅读。更多底层技术讨论,欢迎访问云栈社区与广大开发者交流。