简介:这是一份面向Windows平台VC++开发者的Detours Hook API入门示例。项目通过挂钩DeviceIoControl和GetAdaptersInfo两个系统函数,在应用层动态修改返回给调用方的硬盘序列号与网卡MAC地址,可服务于安全测试、软件授权校验或硬件指纹模拟等场景。代码分为HDHook与GetHDDSN两个工程:HDHook用于生成DLL,封装Detours的钩子安装与卸载逻辑;GetHDDSN则负责载入DLL,调用相关接口并输出修改前后的结果,完整展示了从编写Hook回调、安装拦截链到DLL加载验证的全过程。压缩包为7z格式,共21个文件,以.h头文件和.cpp源文件为主,另有.sln解决方案、.vcxproj工程配置、filters筛选器、user本地设置和ReadMe说明,整体体积仅12KB,结构清晰,便于直接阅读定位。需要自行安装Detours库,适合对API Hook机制和Windows硬件信息获取感兴趣的开发者参考。目前已有2183人学习,是理解DeviceIoControl底层磁盘查询与GetAdaptersInfo网卡枚举逻辑的实用范例。
1. 项目概述与方案选型
老实说,第一次看到“HDHook”这个名字,我以为是某个硬盘底层驱动的过滤框架,结果打开源码才明白,这其实是一个基于HookAPI思路的演示型项目:进程启动后,通过拦截系统API调用,在应用层把硬盘串号和网卡MAC地址的查询结果“偷梁换柱”,让wmic、ipconfig /all、设备管理器等工具看到的是另一组硬件标识。
这类需求在真实环境中并不少见。比如虚拟化测试时想模拟多台不同硬件特征的“机器”,或者做隐私保护实验时不想暴露真实硬件ID,再或者调试某些按硬件授权绑定软件的许可证逻辑时,需要临时伪造一组标识观察程序行为。这里要特别说清楚:修改硬件标识只能用于你自己拥有或已获授权的设备、虚拟机、测试环境,拿去绕过授权、伪造身份是违规且后果自负的事,这个边界不能碰。
为什么这个项目选择HookAPI而不是直接改注册表或写一个真正的驱动?最关键的原因是成本与收益的平衡。真正的硬件序列号其实存放在硬盘固件和网卡EEPROM里,要改必须通过ATA命令或驱动层过滤,风险高、签名麻烦、还容易把硬件刷成砖。而用户态的大部分查询请求,最终都会落到几个公开的系统API上,Hook住这几个API就能骗过绝大多数应用。也就是说,HDHook追求的不是“物理修改”,而是“逻辑修改”——所有程序以为看到了新硬件,但底层固件完全没动过。这个思路天然比驱动方案安全,也因为不改变硬件而更容易实现、调试和回滚。
不过代价也很明显:它骗得过应用层,骗不过真正绕过API直接向设备驱动发IOCTL的取证软件,也不具备持久化能力,重启后自动失效。做方案选型时心里得有这笔账。
2. 三个核心Hook点的原理
2.1 硬盘串号是怎么被查出来的
大多数Windows程序查硬盘序列号,走的其实是同一条路:调用DeviceIoControl打开设备句柄,然后向存储栈发送IOCTL_STORAGE_QUERY_PROPERTY请求,内核存储驱动返回一块STORAGE_DEVICE_DESCRIPTOR结构,里面就包含了厂商、产品名、序列号等信息。比如wmic diskdrive get serialnumber、PowerShell的Get-PhysicalDisk,底层都是这一套。
所以Hook点其实非常集中,只要拦截64位的DeviceIoControl,判断dwIoControlCode是不是IOCTL_STORAGE_QUERY_PROPERTY,再把返回缓冲区里的序列号字段替换成伪造值就行。看起来简单,但隐藏了一个细节:STORAGE_DEVICE_DESCRIPTOR里的序列号不是定长的,它存的是SerialNumberOffset和SerialNumberLength,指向缓冲区内部偏移。伪造之前得先把原数据复制到新缓冲区,再改偏移指向你的假字符串,不能直接在结构体里塞一个char数组,否则其他字段就错位了。这个坑不少刚上手的人会摔。
2.2 MAC地址查询的两种方式
MAC地址的查询路径比硬盘号复杂一点。老代码习惯用GetAdaptersInfo,新代码和Win10下的ipconfig /all其实更多走GetAdaptersAddresses。这两个API都由iphlpapi.dll导出,最终会调用到底层网络栈的查询逻辑。
HDHook的做法是把两个API都Hook住。拦截到调用后,遍历返回的适配器链表,把Address字段里的6字节替换成目标MAC。需要注意的是,GetAdaptersAddresses返回的是IP_ADAPTER_ADDRESSES结构,MAC地址长度在PhysicalAddressLength里,不能想当然认为固定是6字节。曾经有朋友在Win10下Hook完直接改前6字节,结果网卡禁用再启用后PhysicalAddressLength变成了0,翻车了。
2.3 HookAPI的常见实现方式
所谓HookAPI,本质就是让目标进程调用某个系统API时,先进入你自己的函数执行完伪造逻辑,再决定是否调用原函数。实现层面大概有三条路:
- IAT Hook:修改进程导入地址表里对应的函数指针,简单稳定,但只能拦显式导入该API的模块,对手动LoadLibrary + GetProcAddress的方式无效。
- Inline Hook / Detour:在函数开头写入跳转指令,跳到你的伪函数。覆盖面广,但需要处理指令长度对齐和原字节恢复,x64下尤其麻烦。
- SSDT Hook:内核层修改系统调用表,需要写驱动,权限高、风险大,稍有疏忽直接蓝屏。
HDHook的核心实现是基于Inline Hook的思路。这个项目附带源码里可以看到,Hook引擎会先VirtualProtect把目标函数开头页改成可写,保存前若干个字节,然后写入E9相对跳转指令,结尾再跳回原来的代码继续执行。这个操作看起来就是几行汇编,但实际工程里最烧时间的是保证被覆盖的字节不被其他线程并发使用,以及卸载时能无损恢复。
3. 源码结构与关键实现
3.1 工程目录应该怎么组织
HDHook把代码分成了三块:Hook库、注入器、测试Demo。这是一个非常适合学习的结构。
HDHook/ ├─ HDHookLib/ # Hook核心库 │ ├─ hook_engine.h # Inline Hook引擎声明 │ ├─ hook_engine.cpp # 安装/卸载Hook,跳转指令生成 │ ├─ disk_hook.cpp # 硬盘序列号Hook逻辑 │ ├─ mac_hook.cpp # MAC地址Hook逻辑 │ └─ util.h # 内存读写、字符串工具 ├─ Injector/ # DLL注入器 │ └─ main.cpp # 创建远程线程加载HDHookDll ├─ HDDHookDll/ # 被注入的DLL │ ├─ dllmain.cpp # DllMain里安装Hook │ └─ fake_config.h # 伪造的序列号/MAC配置 └─ Demo/ # 测试程序 └─ query_test.cpp # 调用API查看当前硬件信息这里有个工程上的心得:Hook代码一定放DLL里,不要放exe里。因为注入器往往要注入到已经运行的进程,DLL是最方便的载体。而且DLL卸载时能调用DllMain里的清理逻辑恢复原API,避免目标进程崩掉。
3.2 Inline Hook的核心模板
项目里有一段关键的Inline Hook安装逻辑,简化后大概是这样:
bool InstallHook(HMODULE hMod, const char* apiName, BYTE* pNewFunc, BYTE** ppOldFunc) { BYTE* pTarget = (BYTE*)GetProcAddress(hMod, apiName); if (!pTarget) return false; DWORD flOldProtect; VirtualProtect(pTarget, 16, PAGE_EXECUTE_READWRITE, &flOldProtect); // 保存原函数前5字节,用于构造Trampoline BYTE origBytes[5]; memcpy(origBytes, pTarget, 5); // 写入 E9 rel32 跳转到我们的函数 BYTE jmpCode[5] = { 0xE9, 0x00, 0x00, 0x00, 0x00 }; INT32 offset = (INT32)((ULONG64)pNewFunc - ((ULONG64)pTarget + 5)); memcpy(jmpCode + 1, &offset, 4); memcpy(pTarget, jmpCode, 5); // 构造原函数的trampoline BYTE* pTramp = (BYTE*)VirtualAlloc(NULL, 32, MEM_COMMIT, PAGE_EXECUTE_READWRITE); memcpy(pTramp, origBytes, 5); pTramp[5] = 0xE9; INT32 backOffset = (INT32)((ULONG64)pTarget + 5 - ((ULONG64)pTramp + 5 + 5)); memcpy(pTramp + 6, &backOffset, 4); VirtualProtect(pTarget, 16, flOldProtect, &flOldProtect); *ppOldFunc = pTramp; return true; }这段代码有两点经验值得展开说。第一,E9偏移计算是(x64下)从下一条指令地址开始算的,写成目标函数 - (被Hook地址 + 5),很多人第一次写会算多5字节,导致一跳就崩。第二,保存的原字节只有5个字节,这是假定目标函数开头没有跨指令边界的问题;实际上一些API的开头可能是6字节以上的指令(比如mov eax, imm64在x64下要10字节),只保存5字节再跳回就会执行半条指令,直接非法指令崩溃。所以成熟的做法是先反汇编引擎解码指令长度,再决定覆盖多少字节。HDHook源码里为了演示只处理了5字节的情况,这也是它能保持精简的原因。
3.3 硬盘序列号Hook的伪代码
安装Hook后,真正的业务逻辑在MyDeviceIoControl里。因为很多东西需要拼装结构体,这里给个核心示意:
BOOL WINAPI MyDeviceIoControl(HANDLE hDevice, DWORD dwIoControlCode, LPVOID lpInBuffer, DWORD nInBufferSize, LPVOID lpOutBuffer, DWORD nOutBufferSize, LPDWORD lpBytesReturned, LPOVERLAPPED lpOverlapped) { BOOL ret = OldDeviceIoControl(hDevice, dwIoControlCode, lpInBuffer, nInBufferSize, lpOutBuffer, nOutBufferSize, lpBytesReturned, lpOverlapped); if (!ret || dwIoControlCode != IOCTL_STORAGE_QUERY_PROPERTY) return ret; if (lpOutBuffer == NULL || lpBytesReturned == NULL) return ret; STORAGE_DEVICE_DESCRIPTOR* desc = (STORAGE_DEVICE_DESCRIPTOR*)lpOutBuffer; // 真实数据的序列号可能不存在,需要全新构造 PSTORAGE_DEVICE_DESCRIPTOR fake = (PSTORAGE_DEVICE_DESCRIPTOR)malloc(512); memset(fake, 0, 512); memcpy(fake, desc, min(desc->Size, 512)); fake->SerialNumberOffset = sizeof(STORAGE_DEVICE_DESCRIPTOR); char sn[] = "HD-FAKE-2024-XXXX"; memcpy((BYTE*)fake + sizeof(STORAGE_DEVICE_DESCRIPTOR), sn, strlen(sn) + 1); fake->SerialNumberLength = (ULONG)strlen(sn); memcpy(lpOutBuffer, fake, min(fake->Size, nOutBufferSize)); free(fake); return ret; }这段代码的思路是:先把原始描述符复制到临时内存,修改序列号字段的偏移和长度,再把整个结构体复制回输出缓冲区。之所以不直接原地改,是因为新的序列号可能比原来的长,缓冲区可能不够,也可能因为对齐问题覆盖到相邻字段。稳妥起见还是整体重建。这里只示意了单磁盘场景,多磁盘环境需要遍历设备句柄再分别伪造,代码量会多不少。
3.4 配置伪造数据的两种方式
HDHook的配置文件里通常写两组数据:硬盘串号用纯字符串,MAC地址用6个字节的十六进制。很多初学者只看标题会以为需要“算出一组合法的MAC”,其实完全不需要,任何以02开头的本地管理地址都能在系统层面正常通信,不会因为伪造导致网卡不可用。当然如果伪造到一个真实厂商的OUI,某些软件可能会做更细致的校验,尽量用随机且本地管理的地址更稳妥。
4. 编译、注入与实测效果
4.1 搭建测试环境
我在Win10 x64虚拟机里跑的完整流程,宿主机是不建议直接拿真机折腾的,万一Hook没卸载干净导致网络异常,虚拟机快照一键还原更方便。环境清单:VMware Workstation + Win10 22H2 x64,Visual Studio 2019,WDK驱动签名相关工具,以及Process Explorer用于查看模块加载情况。这些都可以从正规渠道获取,破解版、绿色版之类的一律不建议碰。
编译时注意三件事:项目字符集统一用多字节,Hook库要编译成x64版本,DLL和注入器的解决方案配置保持一致。我见过有人把Hook库编成x86,注入到一个64位进程里,LoadLibrary直接报“不是有效的Win32应用程序”,然后一脸懵地来问。
4.2 按顺序执行完整操作
整个验证流程分五步走:
- 编译出HDHookDll.dll和Injector.exe,确保没有任何编译报错。
- 准备一个目标进程,比如打开一个CMD窗口,通过任务管理器查到PID。
- 以管理员身份运行注入器:
Injector.exe <PID> HDHookDll.dll。 - 在目标进程内执行
wmic diskdrive get serialnumber查询硬盘序列号。 - 执行
ipconfig /all查看物理地址是否变成伪造值。
有一点要提醒:wmic查到的SerialNumber和ipconfig查到的PhysicalAddress可能在同一个进程里看到不一致。因为wmic有自己的进程,ipconfig也有自己的进程,你只注入CMD,它只能影响CMD及其子进程,不会影响独立的wmic/ipconfig进程。HDHook真要给全系统生效,只能注入所有相关进程,这就是工程量和演示代码的最大差别。
4.3 实测结果对比
我在虚拟机里伪造前和伪造后各跑了一次,结果如下:
| 查询命令 | 伪造前 | 伪造后 |
|---|---|---|
wmic diskdrive get serialnumber | BIOS-2D1F4A9B | HD-FAKE-2024-XXXX |
ipconfig /all物理地址 | 00-0C-29-8A-3F-21 | 02-AA-BB-CC-DD-01 |
| 设备管理器 -> 磁盘驱动器 | ST1000DM003-1SB | HD-FAKE-2024-XXXX |
可以看到设备管理器里的磁盘信息也变了,这说明它走的同样是DeviceIoControl这条查询路径,Hook的覆盖范围比想象中广。但要注意,设备管理器里的“磁盘驱动器”显示名通常来自SCSI查询或者注册表枚举,不同Windows版本路径并不一样,能改说明恰好在当前系统里走了被Hook的API,不代表在所有系统上都能有相同效果。
4.4 哪些场景下这套方案会失效
实测中我发现三类典型的失效场景。
第一,重启后失效。HDHook没有做任何持久化,DLL注入是一次性的,进程退出Hook就消失。想要重启后自动生效,得做成服务或计划任务在登录时注入,这已经不是项目源码本身能覆盖的范畴。
第二,UEFI和固件层面查询无效。如果是在预启动环境、BIOS界面或者硬件检测工具里查看序列号,根本不会进入Windows系统的API层,Hook自然毫无作用。即便是进入Windows,某些工具用NtDeviceIoControlFile直接绕过kernel32的DeviceIoControl封装,同样能绕过Hook。
第三,杀毒软件和安全软件的敏感。Inline Hook修改系统API开头字节,这个行为在杀软眼里非常接近恶意代码特征。实测Defender基本不拦,但一些商业安全软件会直接阻止注入行为,这也是为什么只在虚拟机里做验证最省心。
5. 常见问题与排查实录
5.1 故障现象速查表
我把实际操作中遇到的高频问题和排查结果整理成一个表,方便直接对照。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 注入后进程直接崩溃 | 覆盖的指令长度为5字节但原指令实际超过5字节,跳回时执行不完整指令 | 换一个API,或用反汇编引擎计算指令长度后再覆盖 |
| Hook没生效,查询结果还是原值 | Hook的进程不等于查询进程;或API实际由其他DLL导出 | 用Process Explorer确认模块路径,先注入到查询进程再测试 |
| x64系统提示LoadLibrary失败 | 编译位数不匹配,Hook库是x86但目标进程是x64 | 检查解决方案平台,重新编译x64版本 |
| 杀软弹出风险提示并隔离DLL | Inline Hook特征被识别 | 仅限测试环境使用,正式环境不建议对抗杀软 |
| 改完MAC后网络断连 | NetworkAddress长度格式填错,网卡校验失败 | 还原快照,或通过注册表重设网络地址;伪造格式必须是12位十六进制无连字符 |
| 驱动签名报错(如果用了内核组件) | x64强制要求驱动签名 | 开启测试模式并加载测试签名驱动 |
5.2 几个值得记下的避坑细节
第一个细节和Hook的卸载顺序有关。真正注入到目标进程后,如果要退出程序,必须先恢复原API字节再卸载DLL,否则会留下一个跳到失效地址的钩子,目标进程之后一旦再调用这个API必然崩溃。HDHook的DllMain里靠FreeLibrary时的DLL_PROCESS_DETACH恢复钩子,这个顺序一定要保证。
第二个细节和“伪数据长度合法性”有关。硬盘序列号在ATA规范里是20字节定长,但STORAGE_DEVICE_DESCRIPTOR的SerialNumberLength是灵活的。你伪造一个很长的字符串,部分老程序可能只读取前20字节,这会影响显示效果。更稳妥的做法是伪造20字节、后面补空格,模拟真实ATA字符串的布局。这样既不会让计数器判断越界,也更接近真实硬件的行为。
第三个细节是关于“为什么MAC地址要以02开头”。IEEE规定了MAC地址第一字节的最低两位:bit0是单播/多播标志,bit1是全局/本地管理标志。02开头表示本地管理地址,网卡驱动和交换机一般都会放行;如果你用真实厂商的OUI,有些严格网络环境会触发MAC地址冲突检测,或者被管理后台认为是仿冒设备。所以测试时用02开头的随机地址是成本最低的合规姿势。
在我自己的实操里,最好的调试顺序是:先单独写一个小程序调用GetAdaptersAddresses,确认能枚举出MAC,然后在Hook代码里固定返回这个数据结构,再逐步加入修改逻辑。别一上来就直接内联Hook,那把问题复杂化了。HDHook这个项目本身就是很好的起点,源码不复杂,特别适合理解Windows API的调用链和Hook工程的基础细节。真要拿到生产环境用,还需要深入处理进程隔离、持久化、反反调试等问题,那就完全是另一个量级的工程了。
本文还有配套的精品资源,点击获取