☰
不写驱动改硬件标识:用Detours HookAPI伪造硬盘串号与MAC地址
2026/10/11 14:59:09 网站建设 项目流程

简介:基于VC++的API Hook技术示例源码,演示借助detours库拦截并修改系统API调用,从而改变程序读取到的硬盘串号与MAC地址。项目面向想入门Hook编程、关注Windows底层安全与逆向开发的开发者,也适合用作系统安全测试、反调试对抗与软件保护思路的参考。资源共21个文件,核心为6个头文件与5个cpp源文件,辅以vcxproj/sln工程配置、filters过滤器、ReadMe说明文档等,7z压缩包仅12KB,体量虽小但工程结构完整,包含双项目与多套参考代码。项目拆分为HDHook与GetHDDSN两部分:HDHook基于detours生成Hook DLL,GetHDDSN载入该DLL并通过DeviceIoControl、GetAdaptersInfo读取硬件信息,两相对照可清晰理解API拦截前后的数据差异;源码另附WDK与WMI两套MAC获取参考方案,便于对比研究不同途径的实现细节。已有2188人学习下载,适合需要参考detours工程组织形式、学习API拦截改造方法或钻研硬件信息获取机制的Windows平台开发者,使用前需自行准备detours环境。

1. 不写驱动也能改硬件标识:HookAPI在用户态偷换硬盘串号和MAC地址

做设备模拟和测试环境的人应该都遇到过这个尴尬:某跨平台系统上线前要做硬件指纹校验,测试环境里几十台机器配置一样,串号却各不相同,日志一抓一大把,对着产品侧解释“为什么测试环境和线上对不上”特别费劲。HDHook这套源码解决的就是这个问题——它在用户态用HookAPI技术,把进程里对硬盘序列号和MAC地址的读取请求直接截住,返回伪造值,全程不需要写驱动、不需要动注册表、不需要重启。你只需要把DLL注入到目标进程,它读到什么,你就让它读不到真的。适合做设备指纹验证、序列号授权逻辑测试、多开出参调试的开发者,也适合想研究Detours inline hook用法的人。

2. 先弄懂Detours这套Hook方案:原理、选型和工程边界

2.1 用户态Hook的三条路线:Detours、手写inline hook和消息钩子

用户态拦截API调用,常见路线是三条。

第一条是用Detours这类现成的inline hook库。它的做法是改写目标API函数头部的几条指令,让它跳到我们自己的钩子函数执行。执行完钩子逻辑,再决定要不要调用原始函数。整个过程发生在进程内部,对其它进程、对内核完全无感。

第二条是自己手写inline hook。原理一样,但难点在细节——你要自己处理X86和X64指令长度、相对跳转的偏移计算、被改写指令的保存与执行。因为目标函数头几个字节被改成跳转指令后,原指令必须搬到别处执行,否则函数逻辑就坏了。这里涉及指令对齐、EIP重定位、线程同步,任何一个细节出错,进程直接崩。

第三条是用Windows的消息钩子,SetWindowsHookEx这类。它只能截获按消息机制走的API,比如键盘鼠标事件、窗口消息,拿不到GetVolumeInformationW这种直调式API的返回值。想改硬盘串号,这条路根本走不通。

Detours的优势在于,它把这些底层细节全部封装好,32位和64位都支持,还能在运行时把已经Hook的函数恢复原样。对HDHook这种要同时处理硬盘串号和MAC地址两个拦截点的场景,用Detours在工程上最省事。

2.2 Detours的拦截逻辑:首字节改写、trampoline和原函数还原

Detours的拦截逻辑,核心是三件事。

第一件,改写目标函数的入口指令。它会把目标函数前几条指令替换成一条无条件跳转,跳转到你的钩子函数。

第二件,构造一个trampoline函数。原本被替换掉的那几条指令,会被原封不动地搬到一个新分配的内存区域,后面再补一条跳转,跳回原函数剩余部分。这样,当你需要调用原始函数时,调用trampoline即可,原函数逻辑一点不受损。

第三件,维护一个hook链。如果多个模块都对同一个API做Hook,Detours会按顺序串联起来,每个钩子函数执行完后,可以决定是调用原始函数还是继续传递。

下面是一个标准的Detours挂钩代码结构:

#include <detours.h> // 函数指针,指向目标API的原始实现 static BOOL(WINAPI* Real_GetVolumeInformationW)( LPCWSTR lpRootPathName, LPWSTR lpVolumeNameBuffer, DWORD nVolumeNameSize, LPDWORD lpVolumeSerialNumber, LPDWORD lpMaximumComponentLength, LPDWORD lpFileSystemFlags, LPWSTR lpFileSystemNameBuffer, DWORD nFileSystemNameSize ) = GetVolumeInformationW; // 钩子函数,先执行业务逻辑,再调用原始函数 BOOL WINAPI Hook_GetVolumeInformationW( LPCWSTR lpRootPathName, LPWSTR lpVolumeNameBuffer, DWORD nVolumeNameSize, LPDWORD lpVolumeSerialNumber, LPDWORD lpMaximumComponentLength, LPDWORD lpFileSystemFlags, LPWSTR lpFileSystemNameBuffer, DWORD nFileSystemNameSize ) { // 这里做参数改写 BOOL ret = Real_GetVolumeInformationW( lpRootPathName, lpVolumeNameBuffer, nVolumeNameSize, lpVolumeSerialNumber, lpMaximumComponentLength, lpFileSystemFlags, lpFileSystemNameBuffer, nFileSystemNameSize ); // 再对返回值做处理 return ret; } // 安装钩子 void InstallHook() { DetourTransactionBegin(); DetourUpdateThread(GetCurrentThread()); DetourAttach(&(PVOID&)Real_GetVolumeInformationW, Hook_GetVolumeInformationW); DetourTransactionCommit(); }

Real_GetVolumeInformationW保存的是原始函数地址,Hook_GetVolumeInformationW是我们自己写的替换逻辑。DetourAttach传入第一个参数是原始函数指针的地址,需要注意它被强制转成了PVOID&,这是Detours的接口约定,写错类型会编译报错。

2.3 边界:这套方案改不了什么

说清楚边界,免得你下了源码后发现跟预期不符。

HDHook这套方案改的是“API返回值”,不是“硬件真实值”。也就是说,目标进程通过正常的Windows API去查询硬盘序列号和MAC地址时,拿到的是我们伪造的数据。但如果对方不是走API,而是直接往驱动的DeviceIoControl发IOCTL,那你拦到的可能性就大大降低。比如某些授权软件自己实现驱动级读写硬盘参数,那用户态Hook完全拦不住。

另外,注入的进程必须是你能拿到权限的进程。系统级进程、受保护进程,进程完整性级别比你高时,OpenProcess直接失败,DLL根本进不去。所以这套方案的使用场景是测试与调试,不是对抗强安全机制。

3. 把硬盘串号改掉:从拿真实序列号到返回伪造值

3.1 先拿到真实卷序列号:两条不同途径

要伪造硬盘序列号,你首先得知道真实值是什么,不然伪造出来的数据跟别处对不上。获取真实序列号有两条途径,对应不同的API。

第一条是文件系统层的卷序列号:GetVolumeInformationW返回的lpVolumeSerialNumber。这个值是文件系统格式化时生成的一个32位编号,不是硬盘的物理序列号。这个值的特点是:同一个分区格式化一次就变一次,跟物理硬盘、品牌、型号没有必然关系。但绝大多数软件检测“硬盘串号”,用的就是它,因为这是Ring3下最简单可靠的途径。

第二条是物理设备层序列号:通过DeviceIoControl向磁盘设备发送IOCTL_STORAGE_QUERY_PROPERTY,从返回的STORAGE_DEVICE_DESCRIPTOR里拿SerialNumberOffset。这个才是硬盘出厂时的物理序列号,比如WD开头的西数盘号。很多做授权绑定的软件会优先用这个,因为它更稳定、不随格式化变化。

3.2 截获GetVolumeInformationW的关键实现

包装好是改造的重点。

static BOOL(WINAPI* Real_GetVolumeInformationW)( LPCWSTR, LPWSTR, DWORD, LPDWORD, LPDWORD, LPDWORD, LPWSTR, DWORD ) = GetVolumeInformationW; BOOL WINAPI Hook_GetVolumeInformationW( LPCWSTR lpRootPathName, LPWSTR lpVolumeNameBuffer, DWORD nVolumeNameSize, LPDWORD lpVolumeSerialNumber, LPDWORD lpMaximumComponentLength, LPDWORD lpFileSystemFlags, LPWSTR lpFileSystemNameBuffer, DWORD nFileSystemNameSize ) { BOOL bRet = Real_GetVolumeInformationW( lpRootPathName, lpVolumeNameBuffer, nVolumeNameSize, lpVolumeSerialNumber, lpMaximumComponentLength, lpFileSystemFlags, lpFileSystemNameBuffer, nFileSystemNameSize); if (bRet && lpVolumeSerialNumber) { DWORD dwFakeSerial = 0x48C1B2A3; // 只对目标卷生效,其它卷放行 if (wcsncmp(lpRootPathName, L"C:\\", 3) == 0) { *lpVolumeSerialNumber = dwFakeSerial; } } return bRet; }

lpRootPathName是调用方传入的卷根路径,比如C:\,判断一下能避免把所有卷的序列号都改掉。lpVolumeSerialNumber是LPDWORD,指向调用方提供的缓冲区,直接把伪造值写进这个地址即可,不需要改API的返回值,因为返回值只表示成功失败。

一个容易被忽视的细节:GetVolumeInformationW的文档说明,如果调用时传了lpVolumeSerialNumber=NULL,函数可能提前走快捷路径返回,根本不给拦截的机会。所以你的钩子函数要先调用一次真实函数拿到返回值和有效数据,再决定改不改,这比先判断参数再放行更稳妥。

3.3 参数怎么按调用方期望填:卷序列号是DWORD不是字符串

这一点很多人翻车。卷序列号是DWORD,也就是一个32位无符号整数,不是字符串。格式化成NTFS时,系统生成的是类似A48C-1B2F的显示形式,但底层存的是两个16位半字的组合。你在钩子里要把0x48C1B2A3直接赋值给*lpVolumeSerialNumber,不要想着这里填个字符串再转。

有些程序拿到这个值后会格式化显示为十六进制,你填0x48C1B2A3,它显示出来就是48C1-B2A3,这是正常的。还有的程序会把这个值当成随机数种子做哈希,你填固定的值反而适合做确定性测试。

另一个参数是lpMaximumComponentLength,它返回的是文件名分量的最大长度,默认是255。这个值一般不要动,除非你明确要模拟特殊文件系统。lpFileSystemFlags也不要动,你还改成大写敏感标志,某些写文件逻辑就会出问题。

4. 改MAC地址:GetAdaptersInfo与DeviceIoControl,拦截点选哪里

4.1 两种读取路径,拦截点完全不同

MAC地址的读取路径,比硬盘序列号复杂一点。Windows下拿本地网卡MAC地址,最常见的API是GetAdaptersInfo,它返回IP_ADAPTER_INFO结构体数组,每块网卡对应一个结构体,其中的Address[6]字段就是6字节的MAC。

还有一条更底层的路径是DeviceIoControl配合IOCTL_NDIS_QUERY_GLOBAL_STATS,直接向网卡驱动查询。走这条路的一般是驱动级工具或者自己做协议栈的程序,在用户态拦截难度大得多。HDHook这套源码的拦截点放在GetAdaptersInfo,因为绝大多数应用层程序——浏览器、授权客户端、远程桌面工具——读MAC都是走这里。

4.2 钩住GetAdaptersInfo改写Address字段

static DWORD(WINAPI* Real_GetAdaptersInfo)( PIP_ADAPTER_INFO pAdapterInfo, PULONG pOutBufLen ) = GetAdaptersInfo; DWORD WINAPI Hook_GetAdaptersInfo( PIP_ADAPTER_INFO pAdapterInfo, PULONG pOutBufLen ) { DWORD dwRet = Real_GetAdaptersInfo(pAdapterInfo, pOutBufLen); if (dwRet != ERROR_SUCCESS) { return dwRet; } PIP_ADAPTER_INFO pCur = pAdapterInfo; while (pCur) { // 只改启用的网卡,回环地址不管 if (pCur->Type == MIB_IF_TYPE_ETHERNET) { BYTE bFakeMac[6] = { 0x00, 0x1E, 0x2A, 0x3B, 0x4C, 0x5D }; memcpy(pCur->Address, bFakeMac, 6); pCur->AddressLength = 6; } pCur = pCur->Next; } return dwRet; }

IP_ADAPTER_INFO是个单向链表,遍历它时通过pCur->Next逐项移动,直到NULL。Address字段是一个BYTE[8]的数组,但有效长度由AddressLength指定,多数网卡是6。你改写时只动前6字节即可,第7、8字节是保留位,一般不参与判断。

这里有一个很坑的细节:pAdapterInfo由调用方分配,如果缓冲区不够大,真实GetAdaptersInfo会返回ERROR_BUFFER_OVERFLOW,并把所需字节数写入pOutBufLen。你拦截时如果发现返回的不是ERROR_SUCCESS,不要强行继续遍历,因为此时pAdapterInfo里没数据,遍历会访问到野指针。

还有一点:pAdapterInfo传NULL时,调用方是在试探缓冲区大小。这种情况下真实函数也会返回ERROR_BUFFER_OVERFLOW,但pOutBufLen会被写入所需大小。你的钩子应当直接放行,让调用方先拿到正确的大小再去分配内存、二次调用。

4.3 结构体里的坑:长度字段和OutputBuffer

改写MAC时,有个结论很多人不知道:GetAdaptersInfo返回的IP_ADAPTER_INFO里,Address字段放在结构体偏前面的位置,后面紧跟的是IP地址列表、网关列表、DHCP服务器列表。如果你把AddressLength改成大于6的值,调用方就会多读几个字节,然后整个列表解析全部错位,表现为IP地址乱掉、路由信息拼接错误。

所以改写时一定遵循两个原则。第一,AddressLength保持原始值不改,或者强制置为6,千万别写成8。第二,Address字节不能填0x00开头,很多应用层逻辑会把00开头的地址当成无效网卡,直接过滤掉你的假MAC。测试时建议填0x02开头的本地管理地址范围,比如02:1E:2A:3B:4C:5D,这种地址不会跟真实网卡的OUI冲突,也方便你在日志里一眼认出是伪造值。

另外一个问题是多网卡场景。真实机器上可能有虚拟机和虚拟网卡,Type字段的值会五花八门。你用MIB_IF_TYPE_ETHERNET(6)过滤标准以太网卡没问题,但遇到虚拟网卡可能类型是IF_TYPE_SOFTWARE_LOOPBACK(24)或者其它值。如果目标程序对网卡数量敏感,你可以只改第一个以太网卡,其余放行,这样对调用方来说“有一块网卡的MAC是假值”,不会因为所有网卡都一样而暴露。

5. 把DLL挂进目标进程:注入方式对比与常见问题排查

5.1 静态注入DLL与动态CreateRemoteThread两种挂法

HDHook的DLL编译好之后,接下来要解决的是“怎么进目标进程”。两种挂法各有适用场景。

第一种是静态注入,在目标进程还没启动时,用Detours库的DetourCreateProcessWithDllEx创建带环境变量的进程,在入口点之前把DLL加载进去。适合目标进程由我们自己拉起、路径固定的场景。缺点是它要求调用方进程有足够的权限,而且目标进程的启动路径一旦变化,注入代码就要同步调整。

第二种是动态注入,把DLL挂到已经运行的进程上,核心步骤是OpenProcess拿到进程句柄,VirtualAllocEx在目标进程里分配一块内存,WriteProcessMemory写入DLL路径,最后CreateRemoteThread在目标进程里创建一个线程,线程入口指向LoadLibraryW,让目标进程自己去加载我们的DLL。

BOOL InjectDll(DWORD dwPid, const wchar_t* szDllPath) { HANDLE hProcess = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, FALSE, dwPid); if (!hProcess) { return FALSE; } size_t len = (wcslen(szDllPath) + 1) * sizeof(wchar_t); LPVOID pRemote = VirtualAllocEx(hProcess, NULL, len, MEM_COMMIT, PAGE_READWRITE); if (!pRemote) { CloseHandle(hProcess); return FALSE; } BOOL bWrite = WriteProcessMemory(hProcess, pRemote, szDllPath, len, NULL); if (!bWrite) { VirtualFreeEx(hProcess, pRemote, 0, MEM_RELEASE); CloseHandle(hProcess); return FALSE; } HMODULE hKernel32 = GetModuleHandleW(L"kernel32.dll"); FARPROC pLoadLibrary = GetProcAddress(hKernel32, "LoadLibraryW"); HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)pLoadLibrary, pRemote, 0, NULL); if (!hThread) { VirtualFreeEx(hProcess, pRemote, 0, MEM_RELEASE); CloseHandle(hProcess); return FALSE; } WaitForSingleObject(hThread, 10000); VirtualFreeEx(hProcess, pRemote, 0, MEM_RELEASE); CloseHandle(hThread); CloseHandle(hProcess); return TRUE; }

注意OpenProcess请求的权限位里,PROCESS_CREATE_THREAD是创建远程线程必须的,PROCESS_VM_OPERATION用于VirtualAllocEx和VirtualFreeEx,少一个权限位操作就会失败。CreateRemoteThread的参数里,线程入口直接复用当前进程的LoadLibraryW地址,这在同位数进程间有效——目标进程是64位,你的注入器也必须是64位编译,否则线程入口传进去的是32位地址,目标进程执行时直接访问违规。

等待线程结束的10秒超时,防止目标进程卡在DLL的DllMain里导致注入器挂死。

5.2 常见问题排查记录:现象、原因、解决

先说三个我踩过的坑。

现象一:DLL注入成功后,目标进程立刻崩溃,退出码0xC0000005。

原因:DLL的入口里直接用Detours的DetourAttach挂了钩子,但在DllMain里执行复杂的Hook逻辑本身就有风险——如果目标进程恰好有其它线程也在调用同一个API,Detours的指令改写动作就不是原子的,正好被其它线程撞上就会崩。解决:DllMain里只做初始化,把真正的Hook动作放在一个独立函数里,让注入后创建的那个远程线程去调用InstallHook(),远离DllMain的锁区。

现象二:64位进程怎么都注入不进去,OpenProcess返回失败。

原因:最常见的坑是权限不足,但如果你确认是以管理员身份运行,那就要检查目标进程的保护级别。受保护的系统进程不让外部进程碰。解决:换一个普通的测试进程做实验对象,或者把目标进程的完整性级别降下来再试。

现象三:改完硬盘序列号,程序提示“磁盘数据结构异常”。

原因:你的钩子把lpVolumeSerialNumber改了,但lpFileSystemFlags还是原来的,某些程序会拿序列号和文件系统标志做交叉校验,一旦发现序列号在NTFS上不符合生成规律,就认为数据坏了。解决:伪造的序列号尽量保持十六进制不全是0或全是F,同时lpFileSystemFlags保持原始值不动,别为了方便一个字段把所有返回值都改了。

还有两个关于MAC的排查方向。

现象四:伪造的MAC地址在某些程序里显示成00:00:00:00:00:00。

原因:不是没改成功,而是改的字节位置不对。有些程序读取MAC不是通过GetAdaptersInfo,而是通过GetAdaptersAddresses读IP_ADAPTER_ADDRESSES的PhysicalAddress。GetAdaptersAddresses是更现代的API,支持IPv6,结构体字段跟IP_ADAPTER_INFO完全不同。解决:在DLL里同时挂GetAdaptersAddresses,把它的PhysicalAddress和PhysicalAddressLength一起改掉。两个API都拦下来,才能在老程序和更新换代的框架面前保持一致。

现象五:改完MAC后网络显示“未识别的网络”,或者局域网访问时通时断。

原因:GetAdaptersInfo返回的是“当前生效”的配置,一些网络服务在启动时读取一次MAC并缓存下来。你动态注入时缓存已经建立,改掉返回值并不会让网络栈重新初始化。解决:在目标进程启动阶段注入,越早越好,让它在第一次读取MAC时就拿到伪造值。如果目标进程是随系统自启的服务,就把你的注入器做成开机自启或结合静态注入方案。

6. 验证改没改成功:写一个探针程序反查内存值与进程级确认

动态注入最大的问题是“黑盒感”——你根本不知道目标进程内部读到的到底是不是你填的那个假值。我一般会写一个探针程序,目标进程内部跑一个线程,定时把关键API的返回值打到调试日志里,注入前后对比。

void ProbeThread() { WCHAR wVolName[MAX_PATH] = { 0 }; DWORD dwVolSerial = 0; DWORD dwMaxCompLen = 0; DWORD dwFileFlags = 0; WCHAR wFsName[MAX_PATH] = { 0 }; GetVolumeInformationW(L"C:\\", wVolName, MAX_PATH, &dwVolSerial, &dwMaxCompLen, &dwFileFlags, wFsName, MAX_PATH); OutputDebugPrintf(L"[PROBE] Volume Serial: %08X\n", dwVolSerial); ULONG ulSize = 0; GetAdaptersInfo(NULL, &ulSize); PIP_ADAPTER_INFO pAdapter = (PIP_ADAPTER_INFO)malloc(ulSize); GetAdaptersInfo(pAdapter, &ulSize); if (pAdapter) { OutputDebugPrintf(L"[PROBE] MAC1: %02X-%02X-%02X-%02X-%02X-%02X\n", pAdapter->Address[0], pAdapter->Address[1], pAdapter->Address[2], pAdapter->Address[3], pAdapter->Address[4], pAdapter->Address[5]); } free(pAdapter); }

这个探针编译后被注入到目标进程,输出信息汇入调试器或日志文件。你对比注入前后两行日志,就能确认硬盘串号和MAC是否按预期替换。第二步是拿目标程序自身的逻辑做交叉验证——比如某跨平台系统的授权模块会显示设备指纹,注入后指纹信息里硬盘号、MAC都变成你填的那些值,这才是最终验收标准。

从那以后,我每次调HDHook都强制走一遍探针流程,哪怕只是改一个字节的假MAC,也要先注入探针确认,再跑业务逻辑验证。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询