APC注入是将回调函数插入目标线程APC队列,仅当线程处于可警告等待状态(如SleepEx、WaitForSingleObjectEx)时才执行;线程若未进入该状态(如仅被SuspendThread挂起),QueueUserAPC虽成功入队但永不执行。

什么是APC注入,它为什么只对挂起线程有效
APC注入不是通用的远程代码执行手段,它依赖目标线程处于可唤醒的挂起(Suspended)或等待状态(如 WaitForSingleObject、SleepEx、MsgWaitForMultipleObjectsEx 等带 Alertable 标志的等待)。线程一旦处于 alertable wait,内核会在返回用户态前检查 APC 队列并逐个执行。如果线程正在运行(Running)或被硬挂起(如通过 SuspendThread 后未进入 alertable 状态),QueueUserAPC 会成功返回但 APC 永远不会被执行。
常见误判是:调用 SuspendThread 后立刻 QueueUserAPC,以为“挂起=可投递”,其实挂起只是暂停执行,不等于进入 alertable wait。必须让目标线程主动调用 alertable 函数,或在已处于该状态时注入。
- 典型可用场景:Explorer 的
WorkerThread、某些 GUI 线程的GetMessageEx循环、或你自己控制的目标进程主动调用SleepEx(0, TRUE) -
QueueUserAPC返回非零仅表示入队成功,不保证执行;需配合线程状态监控(如GetThreadContext查EIP是否落在 alertable 函数内) - 64 位系统下注意:APC 回调函数地址必须是 64 位有效地址,且目标进程页保护需允许执行(
PAGE_EXECUTE_READWRITE)
如何用QueueUserAPC排队 shellcode 并绕过 DEP/CFG
直接传 shellcode 地址给 QueueUserAPC 会失败——因为 APC 回调要求函数签名是 VOID CALLBACK APCProc(ULONG_PTR dwParam),且必须位于可执行内存中。DEP 会拦截堆/数据段上的代码执行,CFG 则可能拒绝非合法函数入口的跳转。
正确做法是:先用 VirtualAllocEx 分配 PAGE_EXECUTE_READWRITE 内存,写入 shellcode;再写一段极小的 stub(通常 10–20 字节),负责跳转到 shellcode 起始地址,并满足 APC 调用约定(接收一个参数、清理栈、不破坏寄存器)。
立即学习“C++免费学习笔记(深入)”;
- stub 示例(x64):
mov rax, 0x123456789abcdef0; jmp rax(地址需动态填充) - 务必用
WriteProcessMemory写入 stub 和 shellcode,两次调用之间避免目标进程调度导致内存被回收 - 若目标启用 CFG,stub 必须位于模块映像内(如
ntdll.dll中的RtlExitUserThread不行,得找 gadget 或用VirtualProtectEx临时改写已有可执行页)
QueueUserAPC 失败的三个高频原因和验证方式
返回 0 并不总意味着权限不足——它更常反映线程状态或参数问题。调试时应逐项排除:
-
ERROR_INVALID_PARAMETER:传入的线程句柄无效(已关闭)、APC 函数指针为 NULL、或参数超出ULONG_PTR范围(x64 下不能传高位非零的 64 位地址当参数) -
ERROR_ACCESS_DENIED:当前进程没有THREAD_SET_CONTEXT或THREAD_GET_CONTEXT权限(需用OpenThread时指定对应 flag,而非THREAD_ALL_ACCESS) -
ERROR_NOT_FOUND(罕见但关键):线程已退出,或虽存在但从未进入过 alertable 状态(此时GetThreadContext读出的Rip通常停在ntdll!NtTestAlert之后、或在非 alertable 函数中)
验证线程是否就绪:用 GetThreadContext 读取 Rip,查其所在模块和偏移;若在 kernel32!SleepEx 内部、或 ntdll!NtWaitForWorkViaWorkerFactory 附近,大概率可投递。
为什么 APC 注入后 shellcode 只执行一次就退出
APC 是单次触发机制:执行完回调函数后自动从队列移除,不会重复调用。如果你的 shellcode 执行完就返回,线程会继续原等待逻辑(比如再次 SleepEx),但不会再触发同一 APC。这不是 bug,是设计如此。
想实现持久化控制,必须在 shellcode 内部主动重新排队自身——即在结尾处调用 QueueUserAPC(需提前把目标线程句柄、新 APC 地址等参数带入 shellcode 上下文)。但这带来新问题:跨进程调用 QueueUserAPC 需要目标进程内有对应 DLL 导出函数地址(如 kernel32!QueueUserAPC),且你得自己解析 PEB、遍历模块导出表获取地址。
- 更稳妥的做法:shellcode 完成初始任务(如启动新线程、挂钩 API)后,直接调用
ExitThread或NtTerminateThread显式结束自身,避免残留状态干扰 - 不要依赖 APC “循环执行”——它本质是事件驱动的一次性钩子,不是线程主循环
- 调试时发现 APC 执行后线程卡死?大概率是 shellcode 破坏了栈平衡或未恢复寄存器(尤其
RSP、RIP),导致返回后跳转到非法地址
APC 注入真正难的不是排队,而是精准捕获那个几十毫秒的 alertable 窗口,以及让 shellcode 在陌生进程中不崩溃地跑起来。多数失败都发生在内存布局假设错误或线程状态误判上,而不是 API 调用本身。


















