1. 项目缘起为什么要在易语言里折腾CreateWindowExA的Hook如果你用易语言写过一些需要和Windows窗口打交道的程序比如自动化工具、界面增强插件或者想对特定窗口的行为进行监控和修改那你大概率绕不开一个核心的Windows APICreateWindowExA。这个函数是Windows窗口创建的最终入口无论是记事本、浏览器还是你自己写的易语言程序当它想要在屏幕上显示一个窗口时最终都会调用到这个函数。那么Hook它有什么用想象几个场景你想在某个游戏窗口创建时自动给它嵌入一个辅助面板你需要拦截并修改一个第三方软件弹出的对话框的样式或行为或者你希望对你自己的易语言程序创建的所有窗口进行统一的审计或附加处理。直接修改目标程序的源代码几乎不可能这时候API Hook就成了“外科手术”式的精准工具。通过HookCreateWindowExA你可以在窗口真正诞生前的那一刻介入修改它的类名、标题、样式、位置尺寸甚至直接阻止它的创建。这在易语言生态里是实现高级窗口控制、界面劫持、自动化集成乃至安全监控的底层关键技术。我最初接触这个需求是因为要做一个多开器的窗口差异化工具。同一个程序多开窗口标题和类名都一样靠FindWindow很难精准定位。思路就是在每个进程里Hook住CreateWindowExA在创建窗口时给窗口标题附加一个唯一标识符。听起来简单但在易语言里实现一个稳定可靠的Inline Hook内联钩子并且处理好32位环境下的调用约定、参数传递和异常处理里面门道不少。网上很多易语言的Hook例子要么过于简单只演示原理要么用了不稳定的方法比如修改代码页导致内存访问异常真正能投入生产环境、考虑周全的分享并不多。所以我想结合自己的踩坑经历把从原理到稳定实现的完整链条拆解清楚。2. 核心原理拆解易语言如何实现稳定的API Hook在深入代码之前我们必须把Hook特别是针对CreateWindowExA这种关键API的Hook其底层原理和风险点搞清楚。易语言虽然封装了Windows编程的很多细节但玩Hook这种底层操作必须对Windows内存管理和x86汇编有清晰的认识。2.1 Windows API Hook的几种常见方式对于CreateWindowExA这样的导出函数常见的Hook方式主要有三种IAT Hook导入地址表钩子修改目标进程导入表中CreateWindowExA的地址指向我们的函数。这种方法相对简单安全但只对通过导入表调用该API的模块有效。如果目标程序动态获取API地址比如通过GetProcAddress或者我们想Hook自身进程内所有模块的调用IAT Hook就力不从心了。EAT Hook导出地址表钩子修改DLL这里是user32.dll的导出表让所有获取CreateWindowExA地址的调用都拿到我们的函数地址。这影响范围太广属于全局Hook极易导致系统不稳定一般不推荐。Inline Hook内联钩子这是我们本次重点讨论的方法。它的原理是直接修改CreateWindowExA函数在内存中的前几个字节写入一条跳转指令JMP使其直接跳转到我们自定义的代理函数Detour Function。执行完我们的逻辑后再跳转回原函数继续执行。为什么选择Inline Hook因为它最直接、最通用。它不关心调用来自哪里IAT、动态获取、甚至硬编码地址只要执行流到了CreateWindowExA的函数体就会被我们截获。这对于Hook自身进程内的API调用也是易语言最常用的场景来说是最可靠的方法。2.2 Inline Hook的技术细节与风险实现一个稳定的Inline Hook关键在于处理好以下五个步骤第一步计算跳转偏移量。这是最容易出错的地方。我们要在CreateWindowExA的开头写入一条JMP指令。在32位环境下JMP指令的机器码是E9后面跟一个4字节的相对偏移量。这个偏移量的计算公式是目标地址 - 源地址 - 5其中“目标地址”是我们自定义代理函数的地址“源地址”是CreateWindowExA的地址加上我们已覆盖的指令长度通常是5字节即E94字节偏移后的下一条指令地址。很多教程算错就是因为没理解这个“-5”是减去JMP指令本身的长度。第二步备份原函数字节。在覆盖之前必须把CreateWindowExA函数开头至少5个字节因为JMP占5字节的原始机器码备份下来。这有两个用途一是在我们的代理函数里如果需要调用原功能可以临时恢复这些字节执行原指令二是最后卸载Hook时需要把这些字节写回去。第三步修改内存保护属性。user32.dll的代码段默认是只读可执行的PAGE_EXECUTE_READ。我们不能直接往里面写数据。必须先用VirtualProtect函数将目标地址所在内存页的保护属性临时改为PAGE_EXECUTE_READWRITE允许我们写入跳转指令。写入完成后最好再改回原来的属性这是一个好习惯。第四步写入跳转指令。构造好E9字节和计算正确的4字节偏移量将它们写入CreateWindowExA函数的开头。第五步处理被覆盖的指令。我们覆盖了原函数开头的5个字节。如果这5个字节恰好是一条完整的指令那没问题。但Windows API的函数开头编译器为了内存对齐有时会插入一些NOP空操作或短指令。我们覆盖的5个字节可能会拆散一条原有的指令。例如原指令可能是8B FF 55 8B EC两条指令我们覆盖了前5字节就会破坏第二条指令。如果直接跳回原函数5的位置执行程序会崩溃。 因此必须在我们的代理函数里手动执行这被覆盖的5个字节对应的原始指令然后再跳转到原函数5的地址继续执行。这通常需要写一小段汇编代码在易语言中可以借助“置入代码”功能。注意这就是很多简易Hook示例不稳定的根源。它们只做了跳转没有处理被覆盖的原始指令在部分系统或环境下运行必然崩溃。2.3 易语言实现的特殊考量易语言是32位程序调用约定是stdcall参数从右向左压栈由被调用者清理堆栈。CreateWindowExA也是stdcall。这简化了我们的代理函数设计因为调用约定一致我们不需要处理复杂的参数搬运。但是易语言对指针和内存操作的支持不如C灵活。我们需要用到以下几个核心支持库命令和API取变量地址()、指针到整数()用于获取函数地址和进行指针运算。VirtualProtect来自kernel32.dll修改内存保护属性。RtlMoveMemory或直接使用易语言的写到内存用于安全地读写内存数据。写到内存虽然方便但在进行这种底层操作时使用APIRtlMoveMemory更显严谨。置入代码()用于在代理函数中执行那被覆盖的5字节原始指令。这是实现稳定Hook的关键。3. 手把手实现易语言Inline Hook CreateWindowExA理论讲完了我们上代码。我会把关键步骤拆解并解释每一行代码的意图。3.1 定义原函数与代理函数的类型首先我们需要正确定义CreateWindowExA的函数原型以便获取它的地址和调用它。.版本 2 .DLL命令 CreateWindowExA, 整数型, “user32.dll”, “CreateWindowExA” .参数 dwExStyle 整数型 .参数 lpClassName 文本型 .参数 lpWindowName 文本型 .参数 dwStyle 整数型 .参数 x 整数型 .参数 y 整数型 .参数 nWidth 整数型 .参数 nHeight 整数型 .参数 hWndParent 整数型 .参数 hMenu 整数型 .参数 hInstance 整数型 .参数 lpParam 整数型接着定义我们自己的代理函数。它的参数列表必须和原函数完全一致。.子程序 MyCreateWindowExA 整数型 公开 .参数 dwExStyle 整数型 .参数 lpClassName 文本型 .参数 lpWindowName 文本型 .参数 dwStyle 整数型 .参数 x 整数型 .参数 y 整数型 .参数 nWidth 整数型 .参数 nHeight 整数型 .参数 hWndParent 整数型 .参数 hMenu 整数型 .参数 hInstance 整数型 .参数 lpParam 整数型 这里是我们的Hook逻辑 1. 可以在这里修改参数例如给窗口标题加上后缀 .如果真lpWindowName ≠ “” lpWindowName lpWindowName “ [Hooked]” .如果真结束 2. 调用原始的函数功能。 注意我们不能直接“CreateWindowExA(...)”这样调用因为它的开头已经被我们改了会跳回自己导致无限递归。 我们需要通过一个“跳板”来调用未被Hook的原始代码。这个跳板就是“原函数地址5”的位置并且需要先执行被我们覆盖的5字节指令。 这部分逻辑我们会在另一个子程序执行原始指令并跳转里用汇编实现这里先返回0示意。 返回03.2 核心Hook安装过程这是最核心的部分。我们创建一个子程序InstallHook。.版本 2 .子程序 InstallHook 逻辑型 公开 .局部变量 pOrigFunc 整数型 原始函数地址 .局部变量 pDetourFunc 整数型 我们的代理函数地址 .局部变量 jmpCode[5] 字节型 存储JMP指令的5个字节 .局部变量 oldProtect 整数型 保存旧的内存保护属性 .局部变量 dwJmpOffset 整数型 跳转偏移量 1. 获取原始函数和代理函数的地址 pOrigFunc 取函数地址“user32.dll” “CreateWindowExA” 这是一个自定义函数需用GetProcAddress实现 pDetourFunc 取变量地址MyCreateWindowExA 获取代理函数地址 .如果真pOrigFunc 0 或 pDetourFunc 0 输出调试文本“获取函数地址失败” 返回假 .如果真结束 2. 备份原始函数的前5个字节到全局变量供后续使用 假设 g_OriginalBytes 是一个全局的5字节数组 RtlMoveMemory取变量地址g_OriginalBytes pOrigFunc 5 3. 计算从原函数跳转到代理函数的偏移量 公式偏移量 目标地址 - 源地址 - 5 源地址是 pOrigFunc 5因为JMP指令占5字节执行完JMP后EIP指向下一条指令 dwJmpOffset pDetourFunc pOrigFunc 5 4. 构造JMP指令E9 偏移量 jmpCode[1] 233 E9 的十进制是 233 将32位偏移量按小端序低位在前写入字节数组 写到内存dwJmpOffset 取变量地址jmpCode 1 4 5. 修改内存保护属性为可写 .如果真VirtualProtectpOrigFunc 5 #PAGE_EXECUTE_READWRITE 取变量地址oldProtect 0 输出调试文本“修改内存保护属性失败” 返回假 .如果真结束 6. 写入JMP指令 RtlMoveMemorypOrigFunc 取变量地址jmpCode 5 7. 恢复内存保护属性可选但建议 VirtualProtectpOrigFunc 5 oldProtect 0 8. 生成并准备“跳板函数”的代码 跳板函数的作用是先执行被覆盖的5字节原始指令然后跳转到原函数5处继续执行。 我们需要将这段汇编代码写入一个可执行的内存区域。 准备跳板函数g_OriginalBytes pOrigFunc 5 g_TrampolineFuncAddr 保存跳板函数地址 输出调试文本“Hook安装成功” 返回真这里有几个关键点需要外部支持取函数地址需要自己用LoadLibrary和GetProcAddress实现。准备跳板函数这是整个Hook稳定性的灵魂。它需要动态生成一小段汇编代码。3.3 构造稳定的跳板函数Trampoline跳板函数是一段机器码它的作用是执行我们备份下来的那5字节原始指令。无条件跳转到pOrigFunc 5的地址继续执行CreateWindowExA剩余的部分。我们需要在内存中申请一块可读可写可执行PAGE_EXECUTE_READWRITE的区域把这段机器码写进去。假设被覆盖的5字节是8B FF 55 8B EC典型的函数开头跳板函数的机器码大致如下8B FF 55 8B EC ; 这是执行被覆盖的5字节原始指令 E9 xx xx xx xx ; 这是一条JMP指令跳转到 (pOrigFunc 5)在易语言中我们可以用VirtualAlloc申请内存然后用置入代码()的思路来构造这段代码但更通用的方法是直接用字节数组拼装。由于原始指令是动态的我们需要动态生成这段代码。.子程序 准备跳板函数 逻辑型 .参数 原始字节 字节型 数组 5 .参数 目标地址 整数型 即 pOrigFunc 5 .局部变量 pTrampoline 整数型 .局部变量 codeSize 整数型 .局部变量 offset 整数型 跳板代码大小 5字节原始指令 5字节JMP指令 10字节 codeSize 10 pTrampoline VirtualAlloc0 codeSize #MEM_COMMIT #PAGE_EXECUTE_READWRITE .如果真pTrampoline 0 返回假 .如果真结束 写入原始指令 RtlMoveMemorypTrampoline 取变量地址原始字节 5 计算跳转到目标地址的偏移量 此时源地址是 pTrampoline 5执行完原始指令后下一条指令的地址 offset 目标地址 pTrampoline 5 5 注意这里要减去即将写入的JMP指令长度5 构造JMP指令 写到内存233 pTrampoline 5 1 写入 E9 写到内存offset pTrampoline 6 4 写入偏移量小端序 将内存保护属性改为可执行可选因为申请时已经是 VirtualProtectpTrampoline codeSize #PAGE_EXECUTE_READ 0 将跳板函数地址保存到全局变量 g_pTrampolineFunc g_pTrampolineFunc pTrampoline 返回真现在在我们的代理函数MyCreateWindowExA里要调用原功能时就不能直接调用CreateWindowExA了而应该调用这个跳板函数。我们需要把跳板函数也定义成一个DLL命令的调用形式或者更直接地用调用子程序配合函数指针来执行。由于跳板函数参数和原函数一致我们可以用汇编call或者易语言的调用子程序 g_pTrampolineFunc ...方式需要将跳板函数地址转换为子程序指针这涉及易语言底层较为复杂。一个更实用的方法是在代理函数里用置入代码手动压栈参数然后call跳板函数地址。3.4 修改后的代理函数实现为了让代理函数能正确调用原始功能我们需要用内联汇编来调用跳板函数。这要求我们对stdcall调用约定和栈操作非常熟悉。.子程序 MyCreateWindowExA 整数型 .参数 dwExStyle 整数型 ... 其他参数 .局部变量 hWnd 整数型 //////// Hook逻辑开始 //////// 示例修改窗口标题 .如果真lpWindowName ≠ “” 注意直接修改传入的文本型参数可能有问题因为API期望的是常量字符串指针。 更安全的做法是创建一个新的字符串并修改lpWindowName参数指向它。 但这里为了演示简单假设我们只是附加内容且原字符串缓冲区足够大这通常不成立。 安全做法见后面的“避坑指南”。 .如果真结束 //////// Hook逻辑结束 //////// //////// 调用原始函数 //////// 通过跳板函数调用原始CreateWindowExA功能 使用置入代码进行调用手动处理栈帧 置入代码{ } 这里需要填入汇编代码将12个参数按从右向左顺序压栈然后 CALL g_pTrampolineFunc 由于易语言置入代码是字节集且编写复杂这里用伪代码描述思路 push [lpParam] push [hInstance] ... 依次压入所有参数 ... push [dwExStyle] call [g_pTrampolineFunc] mov [hWnd] eax 将返回值窗口句柄保存到局部变量 上述置入代码执行后原始函数已被调用返回值在EAX中。 我们需要将这个值作为本代理函数的返回值。 返回hWnd编写这段汇编是Hook实现中最繁琐的部分需要对x86栈帧和易语言参数布局非常清楚。一个常见的简化方法是我们不在代理函数里调用原始功能而是让Hook只做监控不拦截。即我们修改参数后仍然希望窗口被创建。那么我们可以选择在跳板函数中调用pOrigFunc5而在代理函数中我们不调用跳板函数而是直接修改参数后用call跳转到pOrigFunc5这不行因为被覆盖的5字节指令还没执行。所以必须通过跳板函数来调用原始逻辑。由于易语言内联汇编的复杂性很多实践者会采用另一种设计“先跳转后处理”。即Hook安装时让CreateWindowExA跳转到我们的代理函数。在代理函数里我们先执行被覆盖的指令通过嵌入那5字节机器码然后执行我们的Hook逻辑修改参数最后再用一个JMP跳转到pOrigFunc5。这样就不需要单独的跳板函数了。但这就要求代理函数本身是用汇编或能精确控制指令的“裸函数”来写在易语言中实现同样困难。因此一个在易语言中更可行的、稳定的方案是使用一个外部的、用C/C编写的DLL来完成复杂的Hook安装和跳板函数生成易语言只负责定义代理函数逻辑和调用这个DLL的接口。这超出了纯易语言的范畴但却是工程上更稳健的选择。4. 避坑指南与实战经验纸上得来终觉浅绝知此事要躬行。下面是我在多次实现和调试中积累的一些关键经验很多是你在官方文档里找不到的。4.1 参数修改的“雷区”在代理函数里修改lpWindowName或lpClassName这样的文本参数是高频需求但也是极易崩溃的地方。错误做法lpWindowName lpWindowName “ [Hooked]” 直接修改易语言文本变量为什么错CreateWindowExA接收的是一个LPCSTR指向常量字符串的指针。我们拿到的lpWindowName可能是指向只读内存区如字符串常量的指针。直接往里面写内容会导致访问违规。即使它指向可写内存追加字符串也可能造成缓冲区溢出。正确做法仅监控不修改如果只是为了获取信息不要修改指针内容。必须修改时创建新字符串在堆上分配新的内存来存放修改后的字符串并将新的指针传递给原始函数。并且你必须负责在合适的时候释放这块内存否则会造成内存泄漏。.局部变量 newWindowName 文本型 .局部变量 pNewWindowName 整数型 newWindowName lpWindowName “ [Hooked]” 易语言文本型转为API需要的以0结尾的C字符串指针 pNewWindowName 申请内存取文本长度newWindowName 1 写到内存newWindowName pNewWindowName 取文本长度newWindowName 1 然后将 pNewWindowName 作为参数传递给跳板函数 问题如何替换参数需要修改栈上的参数值。这又需要代理函数有修改栈的能力通常需要汇编介入。这引出了另一个复杂点修改栈上参数。在stdcall中参数由调用者压栈。在我们的代理函数此时是“被调用者”内部EBP8, EBP0Ch... 这些位置存放着参数。我们需要用汇编代码去修改这些内存位置的值。这进一步证明了纯易语言实现完整Hook的复杂性。4.2 线程安全与递归调用CreateWindowExA可能被多个线程同时调用。我们的Hook代码必须是线程安全的。避免在Hook函数中使用全局变量而不加锁易语言本身对多线程支持有限需谨慎。更关键的是防止递归调用。递归调用场景你在代理函数里调用了MessageBox或OutputDebugString来输出调试信息。这些函数内部可能也会创建窗口从而再次调用CreateWindowExA再次进入你的Hook函数形成无限递归最终栈溢出崩溃。解决方案使用一个线程局部变量TLS或简单的全局标志位来标记当前线程是否已经处于Hook处理中。.全局变量 g_bInHook 逻辑型 “静态”或通过TLS实现 .子程序 MyCreateWindowExA .如果真g_bInHook 直接调用跳板函数不执行任何Hook逻辑避免递归 ...调用跳板函数的代码... 返回 .如果真结束 g_bInHook 真 ... 执行Hook逻辑 ... ... 调用跳板函数 ... g_bInHook 假注意在多线程下对g_bInHook的读写需要原子操作或临界区保护易语言中可用进入临界区、退出临界区需借助API或支持库。4.3 卸载Hook的正确姿势有安装就要有卸载。卸载Hook不仅仅是把备份的5字节写回去那么简单。时机必须在所有线程都不可能再调用CreateWindowExA的时候进行卸载。通常是在程序退出前。如果在Hook期间有线程正在执行被覆盖的代码你突然把字节改回去会导致该线程执行错误指令而崩溃。操作 a. 再次使用VirtualProtect将pOrigFunc处的内存改为可写。 b. 用RtlMoveMemory将备份的g_OriginalBytes写回pOrigFunc。 c. 恢复内存属性。 d.释放跳板函数内存用VirtualFree释放之前申请的跳板函数内存g_pTrampolineFunc。忘记这一步会导致内存泄漏。同步确保在卸载过程中没有线程正在进入我们的代理函数或跳板函数。这通常需要更复杂的同步机制对于简单程序可以在确定无窗口操作后如主窗口已销毁进行卸载。4.4 调试技巧如何知道Hook成功了输出调试信息在代理函数开始处用OutputDebugStringA输出信息。使用DebugView工具查看。注意防止递归见上文。修改窗口标题最简单的验证。Hook后尝试创建一个窗口看标题是否被附加了你的标记。注意参数修改的安全问题。使用调试器在CreateWindowExA开头设断点单步跟踪看是否跳转到你的代码。这是最可靠的方法。检查返回值在代理函数中记录下创建的窗口句柄并与直接调用原函数返回的句柄进行对比在无其他干扰下。5. 进阶思考超越简单的Hook当你掌握了基础的Inline Hook后可以思考更多Hook其他APICreateWindowExW宽字符版本、ShowWindow、SetWindowText等。原理完全相同但要注意UNICODE和ANSI版本的区别。进程外Hook如何Hook其他进程的CreateWindowExA这就需要用到DLL注入技术如CreateRemoteThread、SetWindowsHookEx等将你的Hook代码注入到目标进程空间然后在目标进程内执行上述安装步骤。复杂度陡增。使用成熟库考虑到自行实现的所有复杂性在实际项目中如果条件允许可以考虑使用微软官方提供的Detours库商业用途需授权或其开源替代品如MinHook。这些库经过了大量测试处理了各种边界情况如指令重定位、线程安全等。你可以在易语言中调用这些库的C接口DLL。Hook的伦理与安全API Hook是一把双刃剑。它用于软件增强、调试、兼容性修复是合法的。但用于恶意软件、游戏外挂、侵犯他人软件权益则可能违法。请务必在合法合规的范围内使用该技术。最后我必须强调本文描述的纯易语言实现是一个教学演示版本它揭示了原理和核心步骤。在追求高稳定性和复杂功能的实际生产环境中强烈建议将核心的Hook引擎如指令备份、跳板生成、内存操作用C/C等更贴近系统的语言编写成DLL由易语言调用。这样既能利用易语言快速开发界面和逻辑的优势又能保证底层Hook的稳定和高效。
易语言实现CreateWindowExA的Inline Hook:原理、实现与避坑指南
1. 项目缘起为什么要在易语言里折腾CreateWindowExA的Hook如果你用易语言写过一些需要和Windows窗口打交道的程序比如自动化工具、界面增强插件或者想对特定窗口的行为进行监控和修改那你大概率绕不开一个核心的Windows APICreateWindowExA。这个函数是Windows窗口创建的最终入口无论是记事本、浏览器还是你自己写的易语言程序当它想要在屏幕上显示一个窗口时最终都会调用到这个函数。那么Hook它有什么用想象几个场景你想在某个游戏窗口创建时自动给它嵌入一个辅助面板你需要拦截并修改一个第三方软件弹出的对话框的样式或行为或者你希望对你自己的易语言程序创建的所有窗口进行统一的审计或附加处理。直接修改目标程序的源代码几乎不可能这时候API Hook就成了“外科手术”式的精准工具。通过HookCreateWindowExA你可以在窗口真正诞生前的那一刻介入修改它的类名、标题、样式、位置尺寸甚至直接阻止它的创建。这在易语言生态里是实现高级窗口控制、界面劫持、自动化集成乃至安全监控的底层关键技术。我最初接触这个需求是因为要做一个多开器的窗口差异化工具。同一个程序多开窗口标题和类名都一样靠FindWindow很难精准定位。思路就是在每个进程里Hook住CreateWindowExA在创建窗口时给窗口标题附加一个唯一标识符。听起来简单但在易语言里实现一个稳定可靠的Inline Hook内联钩子并且处理好32位环境下的调用约定、参数传递和异常处理里面门道不少。网上很多易语言的Hook例子要么过于简单只演示原理要么用了不稳定的方法比如修改代码页导致内存访问异常真正能投入生产环境、考虑周全的分享并不多。所以我想结合自己的踩坑经历把从原理到稳定实现的完整链条拆解清楚。2. 核心原理拆解易语言如何实现稳定的API Hook在深入代码之前我们必须把Hook特别是针对CreateWindowExA这种关键API的Hook其底层原理和风险点搞清楚。易语言虽然封装了Windows编程的很多细节但玩Hook这种底层操作必须对Windows内存管理和x86汇编有清晰的认识。2.1 Windows API Hook的几种常见方式对于CreateWindowExA这样的导出函数常见的Hook方式主要有三种IAT Hook导入地址表钩子修改目标进程导入表中CreateWindowExA的地址指向我们的函数。这种方法相对简单安全但只对通过导入表调用该API的模块有效。如果目标程序动态获取API地址比如通过GetProcAddress或者我们想Hook自身进程内所有模块的调用IAT Hook就力不从心了。EAT Hook导出地址表钩子修改DLL这里是user32.dll的导出表让所有获取CreateWindowExA地址的调用都拿到我们的函数地址。这影响范围太广属于全局Hook极易导致系统不稳定一般不推荐。Inline Hook内联钩子这是我们本次重点讨论的方法。它的原理是直接修改CreateWindowExA函数在内存中的前几个字节写入一条跳转指令JMP使其直接跳转到我们自定义的代理函数Detour Function。执行完我们的逻辑后再跳转回原函数继续执行。为什么选择Inline Hook因为它最直接、最通用。它不关心调用来自哪里IAT、动态获取、甚至硬编码地址只要执行流到了CreateWindowExA的函数体就会被我们截获。这对于Hook自身进程内的API调用也是易语言最常用的场景来说是最可靠的方法。2.2 Inline Hook的技术细节与风险实现一个稳定的Inline Hook关键在于处理好以下五个步骤第一步计算跳转偏移量。这是最容易出错的地方。我们要在CreateWindowExA的开头写入一条JMP指令。在32位环境下JMP指令的机器码是E9后面跟一个4字节的相对偏移量。这个偏移量的计算公式是目标地址 - 源地址 - 5其中“目标地址”是我们自定义代理函数的地址“源地址”是CreateWindowExA的地址加上我们已覆盖的指令长度通常是5字节即E94字节偏移后的下一条指令地址。很多教程算错就是因为没理解这个“-5”是减去JMP指令本身的长度。第二步备份原函数字节。在覆盖之前必须把CreateWindowExA函数开头至少5个字节因为JMP占5字节的原始机器码备份下来。这有两个用途一是在我们的代理函数里如果需要调用原功能可以临时恢复这些字节执行原指令二是最后卸载Hook时需要把这些字节写回去。第三步修改内存保护属性。user32.dll的代码段默认是只读可执行的PAGE_EXECUTE_READ。我们不能直接往里面写数据。必须先用VirtualProtect函数将目标地址所在内存页的保护属性临时改为PAGE_EXECUTE_READWRITE允许我们写入跳转指令。写入完成后最好再改回原来的属性这是一个好习惯。第四步写入跳转指令。构造好E9字节和计算正确的4字节偏移量将它们写入CreateWindowExA函数的开头。第五步处理被覆盖的指令。我们覆盖了原函数开头的5个字节。如果这5个字节恰好是一条完整的指令那没问题。但Windows API的函数开头编译器为了内存对齐有时会插入一些NOP空操作或短指令。我们覆盖的5个字节可能会拆散一条原有的指令。例如原指令可能是8B FF 55 8B EC两条指令我们覆盖了前5字节就会破坏第二条指令。如果直接跳回原函数5的位置执行程序会崩溃。 因此必须在我们的代理函数里手动执行这被覆盖的5个字节对应的原始指令然后再跳转到原函数5的地址继续执行。这通常需要写一小段汇编代码在易语言中可以借助“置入代码”功能。注意这就是很多简易Hook示例不稳定的根源。它们只做了跳转没有处理被覆盖的原始指令在部分系统或环境下运行必然崩溃。2.3 易语言实现的特殊考量易语言是32位程序调用约定是stdcall参数从右向左压栈由被调用者清理堆栈。CreateWindowExA也是stdcall。这简化了我们的代理函数设计因为调用约定一致我们不需要处理复杂的参数搬运。但是易语言对指针和内存操作的支持不如C灵活。我们需要用到以下几个核心支持库命令和API取变量地址()、指针到整数()用于获取函数地址和进行指针运算。VirtualProtect来自kernel32.dll修改内存保护属性。RtlMoveMemory或直接使用易语言的写到内存用于安全地读写内存数据。写到内存虽然方便但在进行这种底层操作时使用APIRtlMoveMemory更显严谨。置入代码()用于在代理函数中执行那被覆盖的5字节原始指令。这是实现稳定Hook的关键。3. 手把手实现易语言Inline Hook CreateWindowExA理论讲完了我们上代码。我会把关键步骤拆解并解释每一行代码的意图。3.1 定义原函数与代理函数的类型首先我们需要正确定义CreateWindowExA的函数原型以便获取它的地址和调用它。.版本 2 .DLL命令 CreateWindowExA, 整数型, “user32.dll”, “CreateWindowExA” .参数 dwExStyle 整数型 .参数 lpClassName 文本型 .参数 lpWindowName 文本型 .参数 dwStyle 整数型 .参数 x 整数型 .参数 y 整数型 .参数 nWidth 整数型 .参数 nHeight 整数型 .参数 hWndParent 整数型 .参数 hMenu 整数型 .参数 hInstance 整数型 .参数 lpParam 整数型接着定义我们自己的代理函数。它的参数列表必须和原函数完全一致。.子程序 MyCreateWindowExA 整数型 公开 .参数 dwExStyle 整数型 .参数 lpClassName 文本型 .参数 lpWindowName 文本型 .参数 dwStyle 整数型 .参数 x 整数型 .参数 y 整数型 .参数 nWidth 整数型 .参数 nHeight 整数型 .参数 hWndParent 整数型 .参数 hMenu 整数型 .参数 hInstance 整数型 .参数 lpParam 整数型 这里是我们的Hook逻辑 1. 可以在这里修改参数例如给窗口标题加上后缀 .如果真lpWindowName ≠ “” lpWindowName lpWindowName “ [Hooked]” .如果真结束 2. 调用原始的函数功能。 注意我们不能直接“CreateWindowExA(...)”这样调用因为它的开头已经被我们改了会跳回自己导致无限递归。 我们需要通过一个“跳板”来调用未被Hook的原始代码。这个跳板就是“原函数地址5”的位置并且需要先执行被我们覆盖的5字节指令。 这部分逻辑我们会在另一个子程序执行原始指令并跳转里用汇编实现这里先返回0示意。 返回03.2 核心Hook安装过程这是最核心的部分。我们创建一个子程序InstallHook。.版本 2 .子程序 InstallHook 逻辑型 公开 .局部变量 pOrigFunc 整数型 原始函数地址 .局部变量 pDetourFunc 整数型 我们的代理函数地址 .局部变量 jmpCode[5] 字节型 存储JMP指令的5个字节 .局部变量 oldProtect 整数型 保存旧的内存保护属性 .局部变量 dwJmpOffset 整数型 跳转偏移量 1. 获取原始函数和代理函数的地址 pOrigFunc 取函数地址“user32.dll” “CreateWindowExA” 这是一个自定义函数需用GetProcAddress实现 pDetourFunc 取变量地址MyCreateWindowExA 获取代理函数地址 .如果真pOrigFunc 0 或 pDetourFunc 0 输出调试文本“获取函数地址失败” 返回假 .如果真结束 2. 备份原始函数的前5个字节到全局变量供后续使用 假设 g_OriginalBytes 是一个全局的5字节数组 RtlMoveMemory取变量地址g_OriginalBytes pOrigFunc 5 3. 计算从原函数跳转到代理函数的偏移量 公式偏移量 目标地址 - 源地址 - 5 源地址是 pOrigFunc 5因为JMP指令占5字节执行完JMP后EIP指向下一条指令 dwJmpOffset pDetourFunc pOrigFunc 5 4. 构造JMP指令E9 偏移量 jmpCode[1] 233 E9 的十进制是 233 将32位偏移量按小端序低位在前写入字节数组 写到内存dwJmpOffset 取变量地址jmpCode 1 4 5. 修改内存保护属性为可写 .如果真VirtualProtectpOrigFunc 5 #PAGE_EXECUTE_READWRITE 取变量地址oldProtect 0 输出调试文本“修改内存保护属性失败” 返回假 .如果真结束 6. 写入JMP指令 RtlMoveMemorypOrigFunc 取变量地址jmpCode 5 7. 恢复内存保护属性可选但建议 VirtualProtectpOrigFunc 5 oldProtect 0 8. 生成并准备“跳板函数”的代码 跳板函数的作用是先执行被覆盖的5字节原始指令然后跳转到原函数5处继续执行。 我们需要将这段汇编代码写入一个可执行的内存区域。 准备跳板函数g_OriginalBytes pOrigFunc 5 g_TrampolineFuncAddr 保存跳板函数地址 输出调试文本“Hook安装成功” 返回真这里有几个关键点需要外部支持取函数地址需要自己用LoadLibrary和GetProcAddress实现。准备跳板函数这是整个Hook稳定性的灵魂。它需要动态生成一小段汇编代码。3.3 构造稳定的跳板函数Trampoline跳板函数是一段机器码它的作用是执行我们备份下来的那5字节原始指令。无条件跳转到pOrigFunc 5的地址继续执行CreateWindowExA剩余的部分。我们需要在内存中申请一块可读可写可执行PAGE_EXECUTE_READWRITE的区域把这段机器码写进去。假设被覆盖的5字节是8B FF 55 8B EC典型的函数开头跳板函数的机器码大致如下8B FF 55 8B EC ; 这是执行被覆盖的5字节原始指令 E9 xx xx xx xx ; 这是一条JMP指令跳转到 (pOrigFunc 5)在易语言中我们可以用VirtualAlloc申请内存然后用置入代码()的思路来构造这段代码但更通用的方法是直接用字节数组拼装。由于原始指令是动态的我们需要动态生成这段代码。.子程序 准备跳板函数 逻辑型 .参数 原始字节 字节型 数组 5 .参数 目标地址 整数型 即 pOrigFunc 5 .局部变量 pTrampoline 整数型 .局部变量 codeSize 整数型 .局部变量 offset 整数型 跳板代码大小 5字节原始指令 5字节JMP指令 10字节 codeSize 10 pTrampoline VirtualAlloc0 codeSize #MEM_COMMIT #PAGE_EXECUTE_READWRITE .如果真pTrampoline 0 返回假 .如果真结束 写入原始指令 RtlMoveMemorypTrampoline 取变量地址原始字节 5 计算跳转到目标地址的偏移量 此时源地址是 pTrampoline 5执行完原始指令后下一条指令的地址 offset 目标地址 pTrampoline 5 5 注意这里要减去即将写入的JMP指令长度5 构造JMP指令 写到内存233 pTrampoline 5 1 写入 E9 写到内存offset pTrampoline 6 4 写入偏移量小端序 将内存保护属性改为可执行可选因为申请时已经是 VirtualProtectpTrampoline codeSize #PAGE_EXECUTE_READ 0 将跳板函数地址保存到全局变量 g_pTrampolineFunc g_pTrampolineFunc pTrampoline 返回真现在在我们的代理函数MyCreateWindowExA里要调用原功能时就不能直接调用CreateWindowExA了而应该调用这个跳板函数。我们需要把跳板函数也定义成一个DLL命令的调用形式或者更直接地用调用子程序配合函数指针来执行。由于跳板函数参数和原函数一致我们可以用汇编call或者易语言的调用子程序 g_pTrampolineFunc ...方式需要将跳板函数地址转换为子程序指针这涉及易语言底层较为复杂。一个更实用的方法是在代理函数里用置入代码手动压栈参数然后call跳板函数地址。3.4 修改后的代理函数实现为了让代理函数能正确调用原始功能我们需要用内联汇编来调用跳板函数。这要求我们对stdcall调用约定和栈操作非常熟悉。.子程序 MyCreateWindowExA 整数型 .参数 dwExStyle 整数型 ... 其他参数 .局部变量 hWnd 整数型 //////// Hook逻辑开始 //////// 示例修改窗口标题 .如果真lpWindowName ≠ “” 注意直接修改传入的文本型参数可能有问题因为API期望的是常量字符串指针。 更安全的做法是创建一个新的字符串并修改lpWindowName参数指向它。 但这里为了演示简单假设我们只是附加内容且原字符串缓冲区足够大这通常不成立。 安全做法见后面的“避坑指南”。 .如果真结束 //////// Hook逻辑结束 //////// //////// 调用原始函数 //////// 通过跳板函数调用原始CreateWindowExA功能 使用置入代码进行调用手动处理栈帧 置入代码{ } 这里需要填入汇编代码将12个参数按从右向左顺序压栈然后 CALL g_pTrampolineFunc 由于易语言置入代码是字节集且编写复杂这里用伪代码描述思路 push [lpParam] push [hInstance] ... 依次压入所有参数 ... push [dwExStyle] call [g_pTrampolineFunc] mov [hWnd] eax 将返回值窗口句柄保存到局部变量 上述置入代码执行后原始函数已被调用返回值在EAX中。 我们需要将这个值作为本代理函数的返回值。 返回hWnd编写这段汇编是Hook实现中最繁琐的部分需要对x86栈帧和易语言参数布局非常清楚。一个常见的简化方法是我们不在代理函数里调用原始功能而是让Hook只做监控不拦截。即我们修改参数后仍然希望窗口被创建。那么我们可以选择在跳板函数中调用pOrigFunc5而在代理函数中我们不调用跳板函数而是直接修改参数后用call跳转到pOrigFunc5这不行因为被覆盖的5字节指令还没执行。所以必须通过跳板函数来调用原始逻辑。由于易语言内联汇编的复杂性很多实践者会采用另一种设计“先跳转后处理”。即Hook安装时让CreateWindowExA跳转到我们的代理函数。在代理函数里我们先执行被覆盖的指令通过嵌入那5字节机器码然后执行我们的Hook逻辑修改参数最后再用一个JMP跳转到pOrigFunc5。这样就不需要单独的跳板函数了。但这就要求代理函数本身是用汇编或能精确控制指令的“裸函数”来写在易语言中实现同样困难。因此一个在易语言中更可行的、稳定的方案是使用一个外部的、用C/C编写的DLL来完成复杂的Hook安装和跳板函数生成易语言只负责定义代理函数逻辑和调用这个DLL的接口。这超出了纯易语言的范畴但却是工程上更稳健的选择。4. 避坑指南与实战经验纸上得来终觉浅绝知此事要躬行。下面是我在多次实现和调试中积累的一些关键经验很多是你在官方文档里找不到的。4.1 参数修改的“雷区”在代理函数里修改lpWindowName或lpClassName这样的文本参数是高频需求但也是极易崩溃的地方。错误做法lpWindowName lpWindowName “ [Hooked]” 直接修改易语言文本变量为什么错CreateWindowExA接收的是一个LPCSTR指向常量字符串的指针。我们拿到的lpWindowName可能是指向只读内存区如字符串常量的指针。直接往里面写内容会导致访问违规。即使它指向可写内存追加字符串也可能造成缓冲区溢出。正确做法仅监控不修改如果只是为了获取信息不要修改指针内容。必须修改时创建新字符串在堆上分配新的内存来存放修改后的字符串并将新的指针传递给原始函数。并且你必须负责在合适的时候释放这块内存否则会造成内存泄漏。.局部变量 newWindowName 文本型 .局部变量 pNewWindowName 整数型 newWindowName lpWindowName “ [Hooked]” 易语言文本型转为API需要的以0结尾的C字符串指针 pNewWindowName 申请内存取文本长度newWindowName 1 写到内存newWindowName pNewWindowName 取文本长度newWindowName 1 然后将 pNewWindowName 作为参数传递给跳板函数 问题如何替换参数需要修改栈上的参数值。这又需要代理函数有修改栈的能力通常需要汇编介入。这引出了另一个复杂点修改栈上参数。在stdcall中参数由调用者压栈。在我们的代理函数此时是“被调用者”内部EBP8, EBP0Ch... 这些位置存放着参数。我们需要用汇编代码去修改这些内存位置的值。这进一步证明了纯易语言实现完整Hook的复杂性。4.2 线程安全与递归调用CreateWindowExA可能被多个线程同时调用。我们的Hook代码必须是线程安全的。避免在Hook函数中使用全局变量而不加锁易语言本身对多线程支持有限需谨慎。更关键的是防止递归调用。递归调用场景你在代理函数里调用了MessageBox或OutputDebugString来输出调试信息。这些函数内部可能也会创建窗口从而再次调用CreateWindowExA再次进入你的Hook函数形成无限递归最终栈溢出崩溃。解决方案使用一个线程局部变量TLS或简单的全局标志位来标记当前线程是否已经处于Hook处理中。.全局变量 g_bInHook 逻辑型 “静态”或通过TLS实现 .子程序 MyCreateWindowExA .如果真g_bInHook 直接调用跳板函数不执行任何Hook逻辑避免递归 ...调用跳板函数的代码... 返回 .如果真结束 g_bInHook 真 ... 执行Hook逻辑 ... ... 调用跳板函数 ... g_bInHook 假注意在多线程下对g_bInHook的读写需要原子操作或临界区保护易语言中可用进入临界区、退出临界区需借助API或支持库。4.3 卸载Hook的正确姿势有安装就要有卸载。卸载Hook不仅仅是把备份的5字节写回去那么简单。时机必须在所有线程都不可能再调用CreateWindowExA的时候进行卸载。通常是在程序退出前。如果在Hook期间有线程正在执行被覆盖的代码你突然把字节改回去会导致该线程执行错误指令而崩溃。操作 a. 再次使用VirtualProtect将pOrigFunc处的内存改为可写。 b. 用RtlMoveMemory将备份的g_OriginalBytes写回pOrigFunc。 c. 恢复内存属性。 d.释放跳板函数内存用VirtualFree释放之前申请的跳板函数内存g_pTrampolineFunc。忘记这一步会导致内存泄漏。同步确保在卸载过程中没有线程正在进入我们的代理函数或跳板函数。这通常需要更复杂的同步机制对于简单程序可以在确定无窗口操作后如主窗口已销毁进行卸载。4.4 调试技巧如何知道Hook成功了输出调试信息在代理函数开始处用OutputDebugStringA输出信息。使用DebugView工具查看。注意防止递归见上文。修改窗口标题最简单的验证。Hook后尝试创建一个窗口看标题是否被附加了你的标记。注意参数修改的安全问题。使用调试器在CreateWindowExA开头设断点单步跟踪看是否跳转到你的代码。这是最可靠的方法。检查返回值在代理函数中记录下创建的窗口句柄并与直接调用原函数返回的句柄进行对比在无其他干扰下。5. 进阶思考超越简单的Hook当你掌握了基础的Inline Hook后可以思考更多Hook其他APICreateWindowExW宽字符版本、ShowWindow、SetWindowText等。原理完全相同但要注意UNICODE和ANSI版本的区别。进程外Hook如何Hook其他进程的CreateWindowExA这就需要用到DLL注入技术如CreateRemoteThread、SetWindowsHookEx等将你的Hook代码注入到目标进程空间然后在目标进程内执行上述安装步骤。复杂度陡增。使用成熟库考虑到自行实现的所有复杂性在实际项目中如果条件允许可以考虑使用微软官方提供的Detours库商业用途需授权或其开源替代品如MinHook。这些库经过了大量测试处理了各种边界情况如指令重定位、线程安全等。你可以在易语言中调用这些库的C接口DLL。Hook的伦理与安全API Hook是一把双刃剑。它用于软件增强、调试、兼容性修复是合法的。但用于恶意软件、游戏外挂、侵犯他人软件权益则可能违法。请务必在合法合规的范围内使用该技术。最后我必须强调本文描述的纯易语言实现是一个教学演示版本它揭示了原理和核心步骤。在追求高稳定性和复杂功能的实际生产环境中强烈建议将核心的Hook引擎如指令备份、跳板生成、内存操作用C/C等更贴近系统的语言编写成DLL由易语言调用。这样既能利用易语言快速开发界面和逻辑的优势又能保证底层Hook的稳定和高效。