1. 从“进程内存”说起为什么APC注入值得单独拎出来聊进程内存注入这个话题在Windows平台开发圈子里算是老生常谈。但真正把APC注入单独拿出来做测试、做对比、做记录的人并不多。大多数人一提到注入第一反应就是远程线程注入CreateRemoteThread因为它简单直接几行代码就能跑通。但远程线程注入有个致命问题它会在目标进程里创建一个新线程这个动作太“显眼”了稍微有点防护意识的程序都能检测到线程创建行为。APC注入的思路完全不同。它不创建新线程而是利用目标进程里已经存在的线程通过向线程的APC队列插入一个回调函数等目标线程进入“可警告等待状态”Alertable Wait State时操作系统会自动执行我们插入的回调。整个过程没有新线程产生从行为特征上看要隐蔽得多。我这次做的测试就是用易语言实现APC内存注入目标是一个自己写的模拟游戏进程。为什么要用易语言因为易语言在中文开发圈子里用户基数大很多人用它写辅助工具、写自动化脚本但网上关于易语言做APC注入的完整资料少得可怜要么是碎片化的代码片段要么是直接抄C的翻译版跑都跑不起来。我踩了整整两天的坑才把整个流程跑通这篇文章就是把我的测试过程、原理理解、代码实现和踩坑经验完整记录下来。适合谁看如果你已经了解基本的Windows进程内存操作比如OpenProcess、VirtualAllocEx、WriteProcessMemory这些API的用法但想进一步理解APC注入的机制和实现细节那这篇内容就是为你准备的。如果你完全没接触过内存注入建议先补一下远程线程注入的基础再来看APC注入否则容易一头雾水。注意本文所有测试均在本地自建的模拟游戏进程上进行目的是学习Windows内存管理机制和APC队列的工作原理。请勿将相关技术用于任何未经授权的场景。2. APC注入的底层逻辑线程队列到底是怎么被“劫持”的2.1 APC队列的本质操作系统给线程留的“待办事项列表”要理解APC注入先得搞清楚APC是什么。APC全称Asynchronous Procedure Call翻译过来叫“异步过程调用”。你可以把它想象成操作系统给每个线程维护的一个“待办事项列表”。当线程正在忙的时候你往列表里塞一条待办事项线程不会立刻处理等线程把手头的活干完主动进入“可警告等待状态”时操作系统就会提醒它“嘿你有个待办事项该处理了。”线程什么时候会进入可警告等待状态常见的场景包括调用SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsEx、SignalObjectAndWait、MsgWaitForMultipleObjectsEx这些函数并且把最后一个参数设为TRUE。另外GetMessage、PeekMessage在某些条件下也会让线程进入可警告状态。这里有个关键点APC分为“内核模式APC”和“用户模式APC”。我们做注入用的是用户模式APC因为内核模式APC需要驱动程序才能插入普通应用层代码没这个权限。用户模式APC又分为两种一种是通过QueueUserAPC API插入的需要目标线程主动进入可警告等待状态才会执行另一种是“特殊APC”通过NtQueueApcThread等底层函数插入可以强制在目标线程下一次调度时执行不需要等待可警告状态。我这次测试用的是QueueUserAPC因为它是微软公开的API调用简单稳定性好。虽然需要等目标线程进入可警告状态但对于游戏进程来说主线程通常会有消息循环GetMessage或PeekMessage的调用会让线程频繁进入可警告状态所以实际等待时间并不会太长。2.2 为什么APC注入比远程线程注入更“安静”远程线程注入的核心动作是CreateRemoteThread这个API会在目标进程里创建一个全新的线程。操作系统在创建线程时会触发一系列内核回调很多安全软件就是通过Hook这些回调来检测注入行为的。而且新线程的起始地址指向的是我们写入的Shellcode这个地址通常不在任何已知模块的范围内很容易被标记为可疑。APC注入不创建新线程它复用的是目标进程里已经存在的线程。从操作系统的角度看只是往现有线程的APC队列里插了一条记录线程数量没有变化线程的起始地址也没有变化。虽然QueueUserAPC这个API本身也可能被Hook但相比CreateRemoteThread它的行为特征要温和得多。不过要注意APC注入也不是万能的。如果目标线程长时间不进入可警告等待状态APC回调就永远不会执行。比如一个纯计算型的线程一直在跑循环从不调用任何等待函数那APC注入对它就没辙。所以实际使用中通常会选择目标进程的主线程或者某个确定有消息循环的线程作为注入对象。2.3 从“写入内存”到“插入队列”完整流程拆解APC注入的完整流程可以拆成四步第一步打开目标进程获取进程句柄。这一步需要PROCESS_VM_OPERATION、PROCESS_VM_WRITE、PROCESS_VM_READ等权限。如果目标进程有保护可能还需要调整权限。第二步在目标进程的地址空间里分配一块内存把我们的Shellcode写进去。Shellcode通常是一个动态链接库的路径或者一段可执行的机器码。我这次测试用的是加载DLL的方式因为易语言写纯机器码太麻烦了加载DLL更简单直接。第三步找到目标进程里某个线程的线程ID用OpenThread打开它获取THREAD_SET_CONTEXT权限。第四步调用QueueUserAPC把Shellcode的地址作为回调函数地址传进去。当目标线程进入可警告等待状态时操作系统就会调用我们插入的回调也就是执行我们的Shellcode。这四步看起来简单但每一步都有坑。比如第二步分配内存时用VirtualAllocEx分配的内存默认是PAGE_READWRITE权限不可执行。如果你直接跳过去执行会触发DEP数据执行保护异常。所以要么用VirtualProtectEx把内存改成PAGE_EXECUTE_READWRITE要么用VirtualAllocEx时直接指定PAGE_EXECUTE_READWRITE。我一开始就是忘了改权限调试了半天才发现问题。3. 易语言实现APC注入的完整代码路径与关键API调用3.1 环境准备与API声明易语言里那些容易写错的细节易语言调用Windows API第一步永远是声明。别看这一步简单坑特别多。首先是数据类型对应关系易语言里的“整数型”对应C的int32位但很多Windows API用的是DWORD32位无符号或者HANDLE指针大小。在32位易语言里HANDLE和DWORD都是4字节可以混用但如果你用的是64位易语言HANDLE是8字节就不能用整数型了得用“长整数型”。我这次测试用的是32位易语言目标进程也是32位的模拟游戏所以所有句柄和指针都用整数型表示。如果你要注入64位进程整个代码的数据类型都得改而且Shellcode也得是64位的复杂度会高不少。需要声明的API清单如下OpenProcess打开目标进程VirtualAllocEx在目标进程分配内存WriteProcessMemory写入ShellcodeVirtualProtectEx修改内存权限CreateToolhelp32Snapshot、Thread32First、Thread32Next枚举目标进程的线程OpenThread打开线程QueueUserAPC插入APC回调CloseHandle关闭句柄每个API的参数类型都要仔细核对。比如QueueUserAPC的声明DWORD QueueUserAPC( PAPCFUNC pfnAPC, // APC回调函数地址 HANDLE hThread, // 线程句柄 ULONG_PTR dwData // 传递给回调的参数 );在易语言里pfnAPC是一个函数指针用整数型表示hThread是线程句柄也是整数型dwData是ULONG_PTR32位下也是整数型。声明的时候参数顺序和类型都不能错否则调用会直接崩溃。3.2 枚举目标进程线程找到那个“愿意配合”的线程APC注入的前提是找到一个目标线程。不是所有线程都适合得找那种会进入可警告等待状态的线程。怎么找最笨的办法是枚举所有线程逐个尝试。但更聪明的做法是先分析目标进程的线程特征。我用的方法是CreateToolhelp32Snapshot加上Thread32First/Thread32Next遍历目标进程的所有线程。对于每个线程我可以尝试用OpenThread打开然后调用QueueUserAPC插入一个测试回调。如果插入成功说明这个线程至少是可操作的但插入成功不代表回调会执行还得等线程进入可警告状态。实际测试中我发现模拟游戏进程的主线程通常是第一个线程是最佳目标因为它有消息循环会频繁调用GetMessage或PeekMessage进入可警告状态的频率很高。子线程如果是纯计算型的可能永远不会执行APC回调。枚举线程的代码逻辑大概是这样的// 伪代码示意 snapshot CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); threadEntry.dwSize sizeof(THREADENTRY32); Thread32First(snapshot, threadEntry); while (Thread32Next(snapshot, threadEntry)) { if (threadEntry.th32OwnerProcessID targetPid) { // 找到目标进程的线程 hThread OpenThread(THREAD_SET_CONTEXT, FALSE, threadEntry.th32ThreadID); if (hThread) { // 尝试插入APC QueueUserAPC(apcAddress, hThread, 0); CloseHandle(hThread); } } }易语言里写这段逻辑需要注意THREADENTRY32结构体的定义。这个结构体在易语言里需要手动定义字段包括dwSize、cntUsage、th32ThreadID、th32OwnerProcessID、tpBasePri、tpDeltaPri、dwFlags。每个字段的类型和顺序必须和Windows SDK里的定义完全一致否则Thread32Next会返回错误。3.3 Shellcode的构造加载DLL还是执行机器码Shellcode的选择是APC注入的核心决策点。两种方案各有优劣方案一加载DLL。Shellcode是一段调用LoadLibraryA的机器码参数是DLL路径字符串的地址。这种方案的好处是逻辑都在DLL里用易语言写DLL很方便调试也简单。缺点是需要在目标进程里写入DLL路径字符串而且LoadLibraryA的地址需要动态获取因为ASLR的关系每次运行地址可能不同。方案二纯机器码。Shellcode直接是一段可执行的机器码不依赖外部DLL。这种方案更隐蔽不需要额外的文件但编写机器码非常麻烦尤其是用易语言这种不太适合写底层代码的语言。我选择了方案一因为易语言写DLL太方便了。具体做法是先用易语言编译一个DLLDLL里写一个入口函数比如“注入入口”里面放我要执行的逻辑比如弹出一个提示框或者修改游戏内存数据。然后Shellcode的任务就是调用LoadLibraryA加载这个DLL。Shellcode的机器码构造是个技术活。在32位环境下调用LoadLibraryA的Shellcode大概长这样push dllPathAddress ; DLL路径字符串的地址 mov eax, loadLibraryAddr ; LoadLibraryA的地址 call eax ; 调用LoadLibraryA ret ; 返回对应的机器码是68 [地址4字节] B8 [地址4字节] FF D0 C3。在易语言里我需要把这段机器码拼成一个字节数组然后写入目标进程内存。获取LoadLibraryA地址的方法LoadLibraryA在kernel32.dll里而kernel32.dll在每个进程里的加载基址可能不同ASLR但LoadLibraryA相对于kernel32基址的偏移是固定的。所以我需要先在当前进程里获取LoadLibraryA的地址然后计算它相对于当前进程kernel32基址的偏移再在目标进程里获取kernel32基址加上偏移就得到目标进程里LoadLibraryA的地址。这个过程听起来绕但逻辑是清晰的。易语言里可以用GetModuleHandle获取kernel32基址用GetProcAddress获取LoadLibraryA地址。3.4 写入与执行内存权限、对齐和线程等待的坑Shellcode准备好之后就要写入目标进程了。步骤是VirtualAllocEx分配内存大小是Shellcode长度加上DLL路径字符串的长度。WriteProcessMemory写入Shellcode和DLL路径字符串。VirtualProtectEx把Shellcode所在内存改成PAGE_EXECUTE_READWRITE。枚举线程QueueUserAPC插入回调。这里有几个坑我踩过坑一内存权限。VirtualAllocEx默认分配的是PAGE_READWRITE不可执行。如果忘了VirtualProtectExAPC回调执行时会触发DEP异常目标进程直接崩溃。我一开始就是漏了这一步调试的时候看到目标进程突然消失还以为是注入代码写错了查了半天才发现是权限问题。坑二地址对齐。Shellcode里的地址是4字节的写入内存时要注意字节序。易语言的字节数组操作和C不太一样需要仔细处理。我建议先把Shellcode构造成一个字节数组然后整体写入不要分多次写避免地址错位。坑三线程等待。QueueUserAPC插入成功后APC回调不会立刻执行得等目标线程进入可警告等待状态。如果目标线程一直不进入回调就永远不执行。我的做法是插入APC后用Sleep等待一段时间然后检查DLL是否被加载可以通过枚举目标进程的模块来验证。如果没加载就换一个线程再试。坑四DLL路径的编码。LoadLibraryA接受的是ANSI字符串不是Unicode。易语言默认的字符串是Unicode的需要转换成ANSI再写入。这个转换在易语言里可以用“到字节集”加上编码转换函数来实现。4. 实测过程中暴露的问题与排查链路4.1 目标进程崩溃从DEP异常到内存权限修复第一次测试的时候我信心满满地写完代码点击注入结果模拟游戏进程直接消失了。没有报错没有提示就是没了。这种“静默崩溃”是最难排查的因为你看不到任何错误信息。我的排查思路是这样的第一步确认崩溃发生在哪个阶段。我在代码里加了日志输出每执行完一个API就写一条日志。结果发现VirtualAllocEx、WriteProcessMemory、VirtualProtectEx都成功了QueueUserAPC也返回了成功。崩溃发生在APC回调执行的时候。第二步怀疑是Shellcode本身有问题。我把Shellcode单独拿出来在当前进程里用CreateThread执行结果也崩溃了。这说明Shellcode的机器码有问题。第三步用调试器附加到目标进程查看崩溃时的异常信息。异常代码是0xC0000005访问违例。再看崩溃地址正好是我分配的Shellcode地址。这说明Shellcode被执行了但执行过程中访问了非法内存。第四步逐字节检查Shellcode。我发现问题出在LoadLibraryA的地址上。我计算的是当前进程的LoadLibraryA地址但目标进程的kernel32基址和当前进程不一样ASLR所以地址是错的。修复方法是在目标进程里重新获取kernel32基址加上LoadLibraryA的偏移得到正确的地址。第五步修复地址计算后又遇到了DEP问题。Shellcode所在内存没有执行权限触发DEP异常。加上VirtualProtectEx后问题解决。整个排查过程花了将近一天但收获很大。现在我每次做注入测试都会先检查三件事内存权限、地址计算、Shellcode字节序。4.2 APC回调不执行线程状态与等待时机的博弈解决了崩溃问题后新的问题来了目标进程不崩溃了但DLL也没被加载。也就是说APC回调根本没有执行。这个问题比崩溃更隐蔽因为没有任何错误提示。我的排查步骤第一步确认QueueUserAPC是否真的成功了。返回值是非零说明插入成功。但插入成功不代表会执行。第二步检查目标线程的状态。我用OpenThread加上THREAD_QUERY_INFORMATION权限然后调用NtQueryInformationThread获取线程的等待状态。结果发现我选的那个线程一直在运行状态从来没有进入等待状态。第三步换线程。我枚举了目标进程的所有线程逐个尝试。最后发现主线程是唯一会进入可警告等待状态的线程因为它有消息循环。第四步即使主线程也不是随时都在等待状态。它可能正在处理消息或者在做其他事情。我的做法是多次插入APC每隔一段时间插入一次增加命中的概率。实测下来插入5到10次之后DLL就被加载了。这里有个经验不要只插入一次APC就等着要多插几次。因为线程进入可警告等待状态的时机是不确定的多插几次能提高成功率。但也不能插太多否则APC队列会堆积可能导致目标进程行为异常。4.3 DLL加载失败路径编码与依赖项的那些事APC回调执行了但DLL加载失败。这个问题表现在目标进程没有崩溃但DLL的功能没有生效。排查过程第一步检查DLL路径是否正确。我在Shellcode里写入的是DLL的完整路径比如“C:\test\inject.dll”。用WriteProcessMemory写入后我再用ReadProcessMemory读出来确认路径没有乱码。第二步检查DLL的依赖项。我的DLL是用易语言编译的易语言的DLL默认会依赖一些运行库。如果目标进程没有这些运行库DLL加载就会失败。解决办法是编译DLL时选择静态链接或者确保目标进程有相同的运行库。第三步检查DLL的位数。我的易语言是32位的编译出来的DLL也是32位的目标进程也是32位的位数匹配。如果位数不匹配LoadLibraryA会直接返回失败。第四步用LoadLibraryA的返回值判断。我在Shellcode里调用LoadLibraryA后把返回值保存到一个已知地址然后从外部读取这个地址看返回值是否为零。如果为零说明加载失败可以用GetLastError获取错误码。最终发现的问题是DLL路径里有中文而LoadLibraryA对中文路径的支持不太好。把DLL放到纯英文路径下问题解决。4.4 稳定性验证多次注入与不同目标进程的对比测试单个进程注入成功不代表方案稳定。我做了几轮稳定性测试第一轮对同一个模拟游戏进程连续注入10次每次注入后检查DLL是否加载。结果是8次成功2次失败。失败的原因是线程没有及时进入可警告状态。第二轮对5个不同的模拟游戏进程不同编译版本进行注入。结果是3个成功2个失败。失败的进程主线程没有消息循环从不进入可警告状态。第三轮调整APC插入策略从“插入一次”改为“每隔100毫秒插入一次共插入20次”。成功率提升到95%以上。这些测试说明APC注入的稳定性高度依赖目标线程的行为。如果目标线程有消息循环成功率很高如果没有基本没戏。所以在实际使用中选择合适的注入目标是关键。5. 从测试中沉淀下来的实操经验与避坑清单5.1 线程选择的优先级主线程、消息循环线程、其他线程经过多轮测试我总结出线程选择的优先级优先级线程类型成功率原因1主线程有消息循环95%以上频繁调用GetMessage/PeekMessage进入可警告状态频率高2有WaitForSingleObjectEx调用的线程70%左右等待时可能进入可警告状态但频率不确定3纯计算线程低于10%几乎不进入可警告状态4已挂起的线程0%挂起的线程不会执行APC所以如果目标进程有消息循环优先选主线程。如果没有就得找其他有等待操作的线程。实在找不到APC注入就不适合这个目标得换其他注入方式。5.2 Shellcode编写的三个铁律地址、权限、字节序写Shellcode的时候有三条铁律必须遵守铁律一地址必须动态计算。不要硬编码任何地址因为ASLR会让每次运行的地址都不同。LoadLibraryA的地址要通过GetProcAddress获取然后计算相对于kernel32基址的偏移再在目标进程里重新计算。铁律二内存权限必须显式设置。VirtualAllocEx分配的内存默认不可执行必须用VirtualProtectEx改成PAGE_EXECUTE_READWRITE。这一步不能省。铁律三字节序必须正确。x86架构是小端序地址的低字节在前高字节在后。易语言里构造字节数组时要注意把地址按小端序排列。我建议写一个辅助函数把整数转换成4字节的小端序字节数组这样不容易出错。5.3 注入失败时的排查顺序从API返回到线程状态注入失败时按照以下顺序排查检查每个API的返回值。OpenProcess、VirtualAllocEx、WriteProcessMemory、VirtualProtectEx、OpenThread、QueueUserAPC任何一个返回失败都要用GetLastError获取错误码。检查内存权限。用VirtualQueryEx查询目标内存的保护属性确认是可执行的。检查Shellcode。把Shellcode写入后用ReadProcessMemory读出来逐字节对比确认没有写错。检查线程状态。用NtQueryInformationThread查询线程是否在等待状态。检查DLL路径。确认路径是ANSI编码没有中文目标进程有访问权限。检查DLL依赖。用Dependency Walker之类的工具查看DLL的依赖项确认目标进程能满足。这个排查顺序是从外到内从简单到复杂能覆盖90%以上的失败情况。5.4 关于合法性与安全边界的个人理解最后聊一下合法性的问题。APC注入本身是一项Windows系统编程技术它本身没有善恶之分。关键在于你用在哪里、用来做什么。我这次测试的目标是自己写的模拟游戏进程目的是学习Windows内存管理和APC队列的工作机制。如果你要在自己的程序里做插件系统、做热更新、做调试工具APC注入是一个可选的方案。但如果你把它用在别人的程序上尤其是未经授权的商业软件上那就越界了。技术本身是中性的但使用技术的人需要有自己的底线。我在学习这类技术的时候始终坚持一个原则只在完全可控的环境里做测试不碰任何未经授权的目标。这个原则让我在探索技术边界的同时也能保持心安。另外从防御的角度看了解APC注入的原理也有助于写出更好的防护方案。比如你可以在自己的程序里检测APC队列是否被异常插入或者监控QueueUserAPC的调用。知己知彼才能更好地保护自己的程序。提示如果你对APC注入的防御感兴趣可以研究一下“APC队列监控”和“线程状态异常检测”这两个方向。很多安全软件就是通过监控线程的APC队列来发现注入行为的。
阅读完成 · 觉得有帮助?