ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Windows计算器变中文:DLL注入与IAT Hook实战教程

Windows计算器变中文:DLL注入与IAT Hook实战教程

你大概率看过各种计算器皮肤、换字体、换BGM的骚操作,但有没有想过——把Windows系统自带计算器里显示的“123456”,直接在进程内部变成“一二三四五六”?不换皮肤,不改注册表,不去动计算器的任何窗口控件属性,靠的是“Dll注入 + Hook IAT”这一套组合拳。这个项目我前后折腾了两天,代码量不大,但效果极其直观,属于那种“写完一次就彻底搞懂PE导入表”的教科书级Demo。它不需要复杂的驱动、不需要内核态权限,一个普通用户权限的DLL注入器就能跑通。

这个项目适合谁?如果你对Windows PE结构只知道大概、写过几个简单的窗口程序,但从来没实际改过导入表;或者你想给某个进程“偷换API”却不知道从哪下手;又或者你只是想看看那些CTF和漏洞报告里的IAT Hook到底是怎么个原理——这篇就是为你准备的。看完你不仅能复现出计算器全中文显示的效果,还能把整套Hook思路扩展成自己的命令行翻译工具、文本监控工具或者反作弊入门研究。

1. 到底是给谁玩的:这个Demo要解决的问题

1.1 我为什么偏偏选中了计算器

说起来也简单:计算器是普通用户能接触到的最“标准”的Win32程序之一。经典版calc.exe是一个独立进程,有自己的主窗口类名,有明确的文本输出区域,而且测试起来完全无害——就算Hook失败、进程崩了,关掉重开也就三秒的事。对比一下,要是拿某个商业软件来当靶子,一方面权限和合法性问题说不清,另一方面人家还可能做完整性校验,刚注入就被查杀了。

更重要的是,计算器的数字显示路径非常干净。经典计算器界面上那块显示区域,本质上是一个标准的文本控件。当用户按下数字键或者按下“=”,计算器内部会把格式化好的数字字符串写到这个控件上。这个“写”的动作,正好会经过user32.dll导出的某个文本设置API。我们只要在这个必经之路上做手脚,就能把阿拉伯数字偷换成中文数字。换句话说,计算器是一个“Hook点极其明确、效果即时可见”的测试靶。

1.2 项目预期的效果长什么样

先给你看一组目标效果,方便后面对照验证。

操作原始显示Hook后显示
按11一
按123 + 456 =579五七九
输入1.51.5一点五
输入-8-8负八
输入1e1010000000000一零零零零零零零零零零

注意最后一行,我故意没有把10000000000读成“一百亿”,因为那是标准中文读数的算法问题,属于进阶功能。第一版Demo我强烈建议只做字符级映射,也就是把“123”显示成“一二三”,而不是“一百二十三”,原因后文会专门讲。

环境限制也要说清楚:本Demo跑在经典版calc.exe上,也就是Windows 7、Windows 8、Windows 10早期镜像自带的那个Win32计算器。Windows 11自带的UWP版计算器是另一种进程模型(AppContainer权限+现代UI框架),普通DLL注入进不去,改了IAT也不影响它的渲染管线。所以如果你的机器上是UWP版,建议先在虚拟机或者备用环境里装一个经典版系统来复现。

2. 先把IAT讲透:改一张地址表=给函数换了个中介

2.1 可执行文件里那张“导入地址表”到底是什么

一个普通的calc.exe本身并没有“包含”SetWindowTextW这个函数的代码。Windows下的PE文件分得很清楚:代码和数据的实现分布在各式DLL里,exe只负责“声明我要用你”。加载器在启动进程时,会找到这些DLL,把每个导出函数的真实地址填到exe进程内存里的一张表里。这张表就叫IAT(Import Address Table,导入地址表)。

用中介租房来类比最合适:exe是租客,DLL是房东,IAT就是中介手里那本电话簿。租客打电话给中介说“我要找房东A办件事”,中介翻开电话簿找到房东A的号码(真实地址),把号码报给租客。如果没有这本电话簿,租客根本不知道房东住哪。关键点在于:电话簿存在exe自己的手里,中介和房东都在别处。所以理论上,只要你能改动电话簿上的号码,就能让租客明明说“找房东A”,最后却拨给了“房客B(我们的Hook函数)”。

2.2 IAT Hook到底改了什么东西

IAT Hook不修改user32.dll里SetWindowTextW那一段原始函数机器码,也不修改计算器进程里任何指令。它改的只有一件事:calc.exe模块IAT表中“user32.dll!SetWindowTextW”这一项里存放的数值,从原本的真实函数地址,替换成我们自己DLL中Hook函数的地址。

调用链会变成这样:

  1. calc.exe内部调用SetWindowTextW。
  2. 程序跳转到IAT表项,准备拿真实地址。
  3. 结果表项里的地址指向了我们的Hook_SetWindowTextW。
  4. 我们的函数处理字符串,把阿拉伯数字换成中文。
  5. 处理完后,通过保存的“原函数指针”直接调用真正的SetWindowTextW。
  6. 真正的函数被调用,文本控件显示的是“五七九”,而calc.exe内部毫不知情。

这一套操作下来,calc.exe自始至终都以为自己调用的还是那个系统函数。这就是IAT Hook最重要的特性:进程内、API级、不需要修改目标机器码。很多安全软件、兼容层、翻译插件、注入型外挂都用这套原理工作。

2.3 先验证一件事:把Calc的IAT读出来看看

在你动手改IAT之前,我建议先写一个只读遍历函数,把calc.exe导入表中user32.dll那一串函数名打出来。这一步能帮你确认两件事:一是目标进程到底有没有从user32.dll导入SetWindowTextW;二是如果没有,说明这个版本的计算器根本没直接调用它,那下面替换就无从谈起。

我实际测试下来,经典版calc.exe都会同时导入SetWindowTextW和SetWindowTextA这对函数。有的精简版系统可能只导入了W版本,这也正常。如果连W和A都没有,那就要换一个Hook点,比如改Hook窗口过程的消息钩子,但这就超出IAT Hook的范围了。

3. 可复现的注入路线:为什么SetWindowsHookEx最适合GUI程序

3.1 两种主流注入方式,我为什么选消息钩子

要把承载Hook函数的DLL塞进calc.exe进程,常见做法有两个:

注入方式原理优点缺点
SetWindowsHookEx消息钩子给目标线程挂一个消息钩子,触发钩子回调时系统强制把DLL加载进目标进程适合GUI程序、稳定、有官方卸载接口DLL路径必须是绝对路径、只能注入带消息循环的进程
CreateRemoteThread + LoadLibrary在目标进程中远程创建线程,用线程入口调用LoadLibrary加载DLL适用范围广、实现简单粗暴要OpenProcess权限、容易被杀软拦、线程痕迹明显

计算器是典型的GUI程序,主线程有消息循环,窗口类名固定,简直是为SetWindowsHookEx量身定做的场景。用远程线程的方案也能跑,但没必要——消息钩子是系统API,默认就允许你把DLL注入到已加载到桌面的进程,整个过程比远程线程温柔得多,代码量也少一截。

3.2 注入器怎么找到计算器并加载DLL

注入器本身是一个小exe,核心步骤只有三步半:

第一步,FindWindow找到计算器主窗口。经典calc.exe的主窗口类名是“CalcFrame”:

HWND hCalc = FindWindowW(L"CalcFrame", NULL); if (!hCalc) { wprintf(L"没找到计算器窗口,请先启动calc.exe\n"); return -1; }

第二步,从窗口句柄拿进程ID和线程ID:

DWORD pid = 0; DWORD tid = GetWindowThreadProcessId(hCalc, &pid); if (tid == 0) return -1;

第三步,加载我们的Hook DLL,导出钩子回调,然后SetWindowsHookEx把它挂到目标线程:

HMODULE hDll = LoadLibraryW(L"C:\\Tools\\CalcHook\\Release\\CalcHook.dll"); if (!hDll) return -1; HOOKPROC hookProc = (HOOKPROC)GetProcAddress(hDll, "HookProc"); if (!hookProc) return -1; HHOOK hHook = SetWindowsHookExW(WH_GETMESSAGE, hookProc, hDll, tid); if (!hHook) { wprintf(L"SetWindowsHookEx失败,错误码:%d\n", GetLastError()); FreeLibrary(hDll); return -1; } wprintf(L"注入成功,按回车卸载钩子...\n"); getchar(); UnhookWindowsHookEx(hHook);

注意SetWindowsHookEx的第四个参数传的是目标线程ID,不是进程ID。传0表示全局钩子,但那就得把DLL注入到每个GUI进程,完全没必要,我们只要计算器一个线程就够了。有个细节很容易踩坑:LoadLibrary的路径必须是绝对路径,系统不会帮你相对路径转绝对路径。

3.3 DLL被加载进calc.exe之后干了什么

当SetWindowsHookEx成功后,系统会让calc.exe加载我们的CalcHook.dll,并调用DllMain的DLL_PROCESS_ATTACH分支。我就在这个分支里调用IAT Hook的安装函数。

这里必须强调一个Windows开发的基本纪律:DllMain里不要调用LoadLibrary、不要创建窗口、不要等待锁、不要弹MessageBox。DllMain执行期间,系统持有进程的加载器锁,一旦在里面做复杂操作,很容易死锁或者导致进程崩溃。所以DllMain里只做一件事:调用我们的PatchIAT来替换函数指针。PatchIAT只读内存、写内存、调VirtualProtect,不依赖其他DLL,是安全的。

DLL同时需要导出一个空的HookProc回调,因为SetWindowsHookEx要求回调必须在一个已加载的DLL里,而且这个DLL要被注入目标进程。HookProc自己本身不需要做任何逻辑,当系统调用它时,DLL就已经在calc.exe进程里了,我们的DllMain也已经跑完,IAT Hook已经生效。

卸载时,注入器调用UnhookWindowsHookEx,钩子解除,如果calc.exe里没有其他引用,DLL引用计数会归零并自动卸载。如果此时我们修改过的IAT没有还原,理论上calc.exe后续再调用SetWindowTextW就会跳到已卸载的地址,必然崩溃。所以在DLL卸载分支要调用RestoreIAT把原函数地址写回去。这也是我前面说的:干净卸载是工程素养,不是可选优化。

4. 写IAT Hook的完整代码:从遍历导入表到替换函数指针

4.1 定位目标函数:遍历导入描述符的完整套路

IAT Hook的核心难点不在替换那一下,而在“如何在一堆导入项里找到你想要的函数”。流程是固定的,先拿DOS头,再拿NT头,再取数据目录第二张表(IAT),然后遍历IMAGE_IMPORT_DESCRIPTOR数组。

下面这段是我实际工程里用的核心函数,我做了精简注释,按32位版本演示:

#include <windows.h> #include <stdio.h> #include <string.h> typedef BOOL (WINAPI *PFN_SetWindowTextW)(HWND, LPCWSTR); static PFN_SetWindowTextW g_OrigSetWindowTextW = NULL; BOOL PatchIAT(const char* targetDll, const char* targetFunc, void* hookFn, void** origFn) { HMODULE hMod = GetModuleHandleW(NULL); if (!hMod) return FALSE; BYTE* pBase = (BYTE*)hMod; PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)pBase; if (pDos->e_magic != IMAGE_DOS_SIGNATURE) return FALSE; PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)(pBase + pDos->e_lfanew); if (pNt->Signature != IMAGE_NT_SIGNATURE) return FALSE; IMAGE_DATA_DIRECTORY iatDir = pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IAT]; if (iatDir.VirtualAddress == 0) return FALSE; PIMAGE_IMPORT_DESCRIPTOR pDesc = (PIMAGE_IMPORT_DESCRIPTOR)(pBase + iatDir.VirtualAddress); while (pDesc->Name != 0) { const char* dllName = (const char*)(pBase + pDesc->Name); if (_stricmp(dllName, targetDll) == 0) { // 找到目标DLL的导入描述符,开始遍历函数项 PIMAGE_THUNK_DATA pThunk = (PIMAGE_THUNK_DATA)(pBase + pDesc->FirstThunk); PIMAGE_THUNK_DATA pOrigThunk = pDesc->OriginalFirstThunk ? (PIMAGE_THUNK_DATA)(pBase + pDesc->OriginalFirstThunk) : pThunk; for (; pThunk->u1.AddressOfData != 0; pThunk++, pOrigThunk++) { if (IMAGE_SNAP_BY_ORDINAL(pThunk->u1.Ordinal)) continue; PIMAGE_IMPORT_BY_NAME pByName = (PIMAGE_IMPORT_BY_NAME)(pBase + pOrigThunk->u1.AddressOfData); const char* funcName = (const char*)pByName->Name; if (strcmp(funcName, targetFunc) == 0) { *origFn = (void*)pThunk->u1.Function; DWORD oldProtect; VirtualProtect(&pThunk->u1.Function, sizeof(SIZE_T), PAGE_READWRITE, &oldProtect); pThunk->u1.Function = (SIZE_T)hookFn; VirtualProtect(&pThunk->u1.Function, sizeof(SIZE_T), oldProtect, &oldProtect); return TRUE; } } } pDesc++; } return FALSE; }

这代码里有两个地方是要点:

第一,遍历每一项时,用来读函数名的指针必须来自OriginalFirstThunk(如果存在),不能直接拿FirstThunk去读函数名。因为FirstThunk在我们修改前存的可能是原函数地址,它本来就不是一个“指向IMAGE_IMPORT_BY_NAME结构”的RVA。OriginalFirstThunk指向的才是导入名称表(INT),那里存着函数名或者序号。比较严谨的写法是像上面这样,单独用一个pOrigThunk跟着同步遍历。

第二,VirtualProtect是把IAT所在内存页从只读改成可写。正常情况下PE的IAT节区是只读的,直接往里写地址会触发访问违规。写完以后再把页属性恢复原样,不要让整页权限一直开着可读写,不然杀软更容易报警。

4.2 替换之前,先保存原函数指针

第4.1节的代码里有一行*origFn = (void*)pThunk->u1.Function;,这一步极其重要。我们要保存原始地址,后面Hook函数才能回调真正的API。如果不保存,替换后整个世界就失去了通往原函数的通道。

安装的时候,全局变量这样挂载:

extern "C" __declspec(dllexport) BOOL InstallCalcHook(void) { return PatchIAT( "USER32.dll", "SetWindowTextW", (void*)Hook_SetWindowTextW, (void**)&g_OrigSetWindowTextW ); }

卸载的时候把原地址写回去:

void RestoreCalcHook() { HMODULE hMod = GetModuleHandleW(NULL); // 重新遍历一次,这次把 pThunk->u1.Function 改回 g_OrigSetWindowTextW }

这里有个工程经验:卸载别想当然地按“先改回去再FreeLibrary”的顺序,一定要保证DllMain在DLL_PROCESS_DETACH时先执行Restore,而注入器那边先不要立刻退出。用消息钩子方式卸载,官方推荐先UnhookWindowsHookEx,然后让进程继续跑一会儿,等系统释放引用。我通常的做法是在Unhook之后Sleep几百毫秒再退出注入器,避免DLL已经在计算器进程里被FreeLibrary,但真正的卸载通知还没执行完。

4.3 Hook函数本体:拿到字符串先处理后转发

替换完IAT之后,calc.exe每次调用SetWindowTextW,实际都会进到我们的Hook函数:

BOOL WINAPI Hook_SetWindowTextW(HWND hWnd, LPCWSTR lpString) { if (lpString == NULL) { return g_OrigSetWindowTextW(hWnd, lpString); } wchar_t buffer[256] = {0}; wcsncpy_s(buffer, 255, lpString, _TRUNCATE); // 把阿拉伯数字字符串替换成中文数字字符串 TranslateToChinese(buffer, 256); return g_OrigSetWindowTextW(hWnd, buffer); }

为什么把字符串先拷贝到一个局部buffer里?因为lpString是const的,指向的内存可能是calc.exe内部某个静态缓冲区或者只读段,直接改它大概率崩,而且即使没崩,也会污染程序自己的数据。正确做法永远是“拷贝-替换-传给原函数”。

还有一个很多人会忽略的细节:Hook函数体内的函数名不能和原函数同名,否则在IAT替换后的调用链起点和Hook函数体内调用原函数时,编译器可能会优化出乌龙。我在工程里统一命名成了Hook_SetWindowTextW,原函数指针用全局变量g_OrigXXX。

4.4 为什么这个Hook不会递归死循环

不少第一次写Hook的人会问:我调用的不是g_OrigSetWindowTextW,而是直接又调了一次SetWindowTextW,会不会再次跳进Hook_SetWindowTextW然后无限递归?

答案是:会。所以你绝不能在Hook函数里直接调用SetWindowTextW这个名字。所有对原函数的调用,都必须通过保存好的函数指针g_OrigSetWindowTextW来实施。因为函数指针里保存的是替换前的真实地址,调用它不会经过IAT,不会再次触发我们的Hook。这种“指针直达原函数”的方式是IAT Hook的命根子,你在封装通用Hook库的时候同样要保持这个约定。

5. 中文数字转换:先做“逐字替换”,再看“标准读数”

5.1 为什么第一版只做字符映射

计算器输出的字符串可能是负数(-8)、小数(1.5)、科学计数法(1.2345e+10)、千位分隔符(1,234,567)。如果你第一版就上标准中文读数算法,那要把“整数部分按亿/万/个分段、每段内部按千百十处理、零的规则、小数部分逐字、科学计数法特殊处理”全部想清楚,工作量翻三倍不止,而且很容易在某一个case上显示错误,比如“一万零零一”这种诡异的输出。

所以我当时的取舍是:第一版只做字符级映射,把所有数字字符替换成对应的中文汉字,其他字符按规则保留。

void TranslateToChinese(wchar_t* text, int size) { if (text == NULL) return; wchar_t tmp[256] = {0}; int j = 0; for (int i = 0; text[i] != 0 && j < size - 1; i++) { wchar_t c = text[i]; switch (c) { case L'0': tmp[j++] = L'零'; break; case L'1': tmp[j++] = L'一'; break; case L'2': tmp[j++] = L'二'; break; case L'3': tmp[j++] = L'三'; break; case L'4': tmp[j++] = L'四'; break; case L'5': tmp[j++] = L'五'; break; case L'6': tmp[j++] = L'六'; break; case L'7': tmp[j++] = L'七'; break; case L'8': tmp[j++] = L'八'; break; case L'9': tmp[j++] = L'九'; break; case L'-': tmp[j++] = L'负'; break; case L'.': tmp[j++] = L'点'; break; case L',': tmp[j++] = L','; break; case L'+': tmp[j++] = L'+'; break; case L'e': case L'E': tmp[j++] = L'e'; break; default: tmp[j++] = c; break; } } tmp[j] = 0; wcsncpy_s(text, size, tmp, _TRUNCATE); }

这个函数有几个需要解释的决策:

数字0映射成“零”,1到9映射成“一”到“九”。我没用“壹贰叁”大写,是因为大写汉字在计算器这种场景下更像是金融票据格式,普通数字显示用小写汉字更自然。如果你想要大写,把switch里的字对应换掉就行,一秒完成,决策权重不大。

负号映射成“负”,小数点点映射成“点”。这是为了视觉可读性:“负八”比“-八”更符合题目“中文数字”的气质。但是逗号映射成中文逗号“,”的问题是宽度问题,后面专门说。

科学计数法里的e和+号保留原样。因为“1.5e+10”如果翻译成“一点五e+一零”会很难受,而且e本来就是拉丁字母,保留能让用户知道这是个指数表达式。

5.2 进阶:标准中文读数到底难在哪

如果你想让计算器把“10000000000”显示成“一百亿”而不是“一零零零零零零零零零零”,那就要上标准读数算法。我给出核心思路,完整实现工程里就有。

整数部分的核心规则:按四位一组从低位往高位分,组内是“千百十”,组间从低到高是“万、亿、万亿”。难点是“零”的插入规则,这个几乎人人都会写错:

  • 10001应该读“一万零一”,不是“一万零零一”。
  • 100000001应该读“一亿零一”,不是“一亿零零一”。
  • 1001001应该读“一百万一千零一”,不是“一百万一千一”(这里要补零)。

判断标准一句话:当某一段不是最高段且段内值不为零时,如果它前面已经出现了未填补的零段,就填一个“零”;同时段内的非零位之间,如果有零位需要补“零”。具体实现时通常是每一段取出四位,逐位判断是否为零,用一个waitingForZero标志位来延迟填充。

小数部分就简单:直接逐字读出“点一二三”,不必处理“零点零零一”的优化规则。

如果你对读数算法实在没把握,我可以给个判断经验:写出来后拿测试用例过一遍,重点是10001、100000001、1001001、10000001这四组数,全对才算通过。这四组基本覆盖了零规则的所有变体。

5.3 中文数字比阿拉伯数字宽:一个不能忽视的显示问题

这是实际运行后一定会遇到的第一个视觉问题。阿拉伯数字字符宽度基本固定,而中文“零”和“一”的宽度大约是两个数字字符宽。计算器显示区域如果原本能容纳15个数字,中文字符塞进去可能只能显示7个左右,后面全被截断。

我在Demo里给的方案很朴素:如果转换后的字符串长度太长,就直接放弃转换,把原字符串传给SetWindowTextW。判断经验是:原始数字字符串长度超过6个字符,转成中文后极大概率超出显示框,此时不要硬转。这个判断我写在TranslateToChinese前面:

// 原数字字符串长度超过6,汉字更宽,会超出显示区,放弃转换 if (wcslen(lpString) > 6) { return g_OrigSetWindowTextW(hWnd, lpString); }

当然,这只适合Demo演示,追求完美显示的话,后面可以考虑自定义控件缩放或者自绘,那就是另一个大型项目了。

6. 一个容易翻车的细节:为什么显示和读取要“双向翻译”

6.1 计算器可能反读显示框的文本

IAT Hook刚跑通时,我遇到过一个诡异现象:在计算器里连续做几次运算后,程序突然表现错乱,甚至闪退。排查了半天,发现根因是计算器在某些操作(比如按下“MR”回调存储值、复制结果、或者内部状态同步)时会调用GetWindowTextW,把我们转换后的“五七九”当成原始数值读回去解析。系统API不认识中文数字,解析失败,于是内部状态变成NaN或者错误值。

这就有一个很重要的Hook设计原则:你不仅要拦截“写入”方向的API,还要考虑“读取”方向是否受影响。只改写入不改读取,等于只管拉水管不管排水,迟早水漫金山。

6.2 同时Hook GetWindowTextW,把中文还原回去

于是我又加了一个Hook点,目标从SetWindowTextW扩展到了GetWindowTextW。当某一方尝试读取显示框文本时,我们先把中文数字还原成阿拉伯数字,再返回给调用者。

typedef BOOL (WINAPI *PFN_GetWindowTextW)(HWND, LPWSTR, int); static PFN_GetWindowTextW g_OrigGetWindowTextW = NULL; BOOL WINAPI Hook_GetWindowTextW(HWND hWnd, LPWSTR lpString, int nMaxCount) { BOOL ret = g_OrigGetWindowTextW(hWnd, lpString, nMaxCount); if (ret > 0) { TranslateBackToNumber(lpString, nMaxCount); } return ret; }

还原函数是转换函数的逆过程,逻辑对称,把“零一二三四五六七八九”映射回0-9,“负”映射回负号,“点”映射回小数点。这块代码完全可以在TranslateToChinese的基础上逆着写一遍。

装Hook时同时装两个:

PatchIAT("USER32.dll", "SetWindowTextW", (void*)Hook_SetWindowTextW, (void**)&g_OrigSetWindowTextW); PatchIAT("USER32.dll", "GetWindowTextW", (void*)Hook_GetWindowTextW, (void**)&g_OrigGetWindowTextW);

6.3 递归保护与重入保护

你可能会问:Hook_GetWindowTextW里关调用g_OrigGetWindowTextW,那如果有人又调用了Hook的GetWindowTextW岂不是又进来一轮?不会,因为g_OrigGetWindowTextW保存的地址本身就不经过IAT。但有一种更隐蔽的重入风险:原函数GetWindowTextW内部可能会再触发某些其他API,而那个API正好又调用了SetWindowTextW,紧接着我们的Hook_SetWindowTextW又执行,最后又回过来调用GetWindowTextW,形成复杂的互相重入。

为了把这种风险压到最低,我的Hook函数里加了一个简单的调用深度计数:

static LONG g_inHook = 0; if (InterlockedIncrement(&g_inHook) == 1) { // 执行翻译逻辑 } InterlockedDecrement(&g_inHook);

第一次进入时,只有一个线程在Hook逻辑里,后面的层层调用看到计数不为1就直接走原函数逻辑,不再次翻译。这个技巧在写通用Hook框架时非常有用:它保证Hook逻辑在同一线程里只执行最外层一次,内部子调用全部直通原函数。

6.4 一个实测结论:经典计算器其实很少反向读显示框

说了这么多吓人的情况,实际操作中发现经典calc.exe在正常的“123+456=”流程中,并不会去反向读取显示框。它内部维护的是自己的数值状态,显示框只是一个“显示器”。但当用户做复制、粘贴、历史记录回退这类辅助操作时,反向读取确实存在。

所以这个“双向翻译”不是每个场景都需要,但如果你要把这套Demo改造成通用Hook库,我还是强烈建议写进去。它带来的只是两个Hook点的开销,收获的却是稳定性。你不需要在出问题时才想起来补这个洞。

7. 实测验证与那些一踩一个准的坑

7.1 一步步把效果跑出来

在经典计算器里复现这个效果的完整流程我帮你捋一遍:

  1. 打开经典calc.exe,确认窗口出现。
  2. 确认当前进程位数。如果calc是32位进程,注入器也必须编译成32位,DLL也是32位。如果calc是64位,角度一样。位数不匹配最常见的现象就是注入器返回“找不到进程”或者SetWindowsHookEx直接失败。
  3. 运行注入器exe,参数不需要传,代码里写死FindWindow找CalcFrame就行。
  4. 控制台打印“注入成功”后,回到计算器点几下按钮。
  5. 输入123,按加号,再输入456,按等号,显示区域出现“五七九”。
  6. 回注入器窗口按回车,卸钩,计算器显示恢复正常。

我在多台Win7和Win10经典版环境都验证过,只要位数匹配、DLL路径绝对正确,效果稳定。

7.2 六个我实际踩过的坑

坑一:64位和32位混着注入。我第一次跑的时候,自己电脑Windows 10是64位,calc.exe是64位版本,但我的DLL工程没注意平台,编出来是32位。SetWindowsHookEx直接失败,错误码183说是“已存在”,把人绕了半天。后来才搞明白是位数不匹配。这个坑是注入类项目最容易犯的,务必先确认目标进程位数。

坑二:DllMain里弹MessageBox。那时候我想看DLL是不是加载成功了,直接在DLL_PROCESS_ATTACH里MessageBoxW。结果calc.exenum整个卡住,最后不得不强制结束进程。原因就是DllMain里弹窗会占用加载器锁,GUI消息循环没法继续处理。后来所有验证信息都改成输出到日志文件,再也不弹窗。

坑三:字符串缓冲区太小导致截断。第一次写TranslateToChinese时buffer开的是64,结果计算一个稍大的数,数字直接被截断,显示错误。这个坑其实也提醒了为什么不建议直接用栈数组,而是应该先用Original函数拿实际长度分配缓冲,Demo里为了保持简单用了固定256,但你要在自己的工程里做得严谨,长度判断必须加。

坑四:VirtualProtect忘记恢复。我开始的时候只改写了页属性就开始无限注入,后来发现卸载时偶发崩溃。仔细看代码,原来是把页属性改成了PAGE_READWRITE就没有恢复成原来的。这样iAT所在页持续可写,一旦进程其他模块向那段地址做代码完整性校验,或者杀软扫描,容易出幺蛾子。正确写法一定是后面立刻把旧属性写回去。

坑五:找不到CalcFrame窗口类名。在某些第三方精简系统里,计算器窗口类名可能不是CalcFrame。如果复现失败,可以先写个EnumWindows遍历一下,看看你的计算器主窗口类名到底是什么,把代码里的窗口类名改成对应的就行。

坑六:杀软拦截。Dll注入技术天然会被一部分安全软件标记为风险行为。我在没加白名单的机器上一次性做过注入验证,失败率明显偏高。解决方式很简单:Demo环境要么用系统自带的安全中心关掉实时保护,要么用完全离线的虚拟机。本来你自己机器上的Windows Hook研究,没必要和杀软杠。

7.3 一个兼容性建议:锁定经典版计算器

前面反复强调经典版calc.exe。如果你在Windows 11上一跑,发现FindWindow找不到CalcFrame,或者注入后毫无反应,那不是代码有问题,是目标进程变了。UWP版计算器的界面渲染不在传统Win32消息体系里,SetWindowTextW这类API根本不会以可预测的频率被调用,甚至进程本身是受保护的AppContainer,没有提权权限根本注入不进去。

所以复现这个Demo的最省心方案是准备一台Windows 7虚拟机,或者一个Windows 10 LTSC镜像的干净环境,然后以32位模式编译整个工程。32位经典calc.exe是最完美的入门靶子:无保护、无混淆、IAT结构标准、窗口类名固定。

整体看下来,这个项目最大的价值不是“让计算器说中文”这个梗本身,而是把PE导入地址表从课本上几页纸的概念还原成了肉眼可见的现象。你亲手把它改掉,再亲手把它还原,那种对“IAT就是进程内一张可被篡改的地址表”的认知,比读十篇文章都深刻。如果你以后研究程序汉化、API监控、轻量级反作弊或者框架热补丁,这套思路都会反复出现。建议你在复现成功之后,顺手把它改成一个能监控目标进程API调用的通用工具,你会发现它的扩展性比想象中高得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进