1. 前言
常用的用户态注入通常使用CreateRemoteThread通过在目标进程中创建新线程的方式来远程执行注入代码。但是,相比APC注入直接利用现有线程上下文执行,传统方式存在明显的线程/句柄/权限操作,非常容易被检测。
2. 简介
- 基本概念
APC(Asynchronous Procedure Call - 异步过程调用)是一种在目标线程"合适时机"异步安排并执行函数的机制。它允许一个线程把一个函数排队到另一个线程上,当那个线程进入可执行 APC的状态时,内核会在适当时机通过KiDeliverApc调度APC。
- 分类
按照执行上下文而言,分为用户APC(User-mode APC)和内核APC(Kernel APC)两类,也可以继续细分为普通和特殊APC。
其中,内核例程在APC_LEVEL执行,如果存在正常例程,则在PASSIVE_LEVEL执行。
| 类型 | 模式 | 普通例程 | 内核例程 | 位置 | 说明 |
|---|---|---|---|---|---|
| 普通 | 用户 | ◯ | ◯ | 队列尾部 | 可警告状态下执行单个APC |
| 特殊 | 用户 | ◯ | ◯ | 队列头部 | win10新增特性,全部顺序执行 |
| 普通 | 内核 | ◯ | ◯ | 队列尾部 | 全部顺序执行 |
| 特殊 | 内核 | --- | ◯ | 队列头部 | 全部顺序执行 |
3. 初始化
内核中通过未导出函数KeInitializeApc初始化APC,具体定义如下。
cpp
typedef enum _KAPC_ENVIRONMENT {
OriginalApcEnvironment, // 原始线程
AttachedApcEnvironment, // 附加线程
CurrentApcEnvironment, // 当前线程
InsertApcEnvironment //
} KAPC_ENVIRONMENT;
NTKERNELAPI VOID NTAPI KeInitializeApc(
_Out_ PRKAPC Apc, // APC上下文
_In_ PETHREAD Thread, // 目标线程
_In_ KAPC_ENVIRONMENT Environment,
_In_ PKKERNEL_ROUTINE KernelRoutine, // 通常用于APC正常执行时回收系统资源
_In_opt_ PKRUNDOWN_ROUTINE RundownRoutine, // 用于目标线程终止正常内核例程无法执行时回收系统资源
_In_opt_ PKNORMAL_ROUTINE NormalRoutine, // 等待执行的APC例程
_In_opt_ KPROCESSOR_MODE ApcMode,
_In_opt_ PVOID NormalContext); // 传递APC例程的第一个参数

从上图代码中可以看出,KeInitializeApc只是简单通过传参初始化APC结构,没有什么复杂业务。
其中相对重要的一个参数就是Environment,它会在插入APC队列时和线程APC状态一起决定要插入的APC队列。
4. 排队
KeInsertQueueApc的主要通过调用KiInsertQueueApc、KiSignalThreadForApc、KiExitDispatcher三个函数来实现具体的业务功能。
cpp
NTKERNELAPI BOOLEAN NTAPI KeInsertQueueApc(
PRKAPC Apc,
PVOID SystemArgument1, // 传递APC例程的第二个参数
PVOID SystemArgument2, // 传递APC例程的第三个参数
KPRIORITY Increment);

通过KiInsertQueueApc实现APC的插入的同时,同时调用KiSignalThreadForApc和KiExitDispatcher来更新线程状态和执行特定的APC。
-
KiInsertQueueApc根据线程和APC的状态决定最终插入的APC队列的位置。

其中
Thread->ApcStateIndex代表线程是否附加到其它线程,SavedApcState用于备份线程的原始APC,ApcState指向当前活跃的APC。从上图可以看到, -
当
Apc->ApcStateIndex = 0 && Thread->ApcStateIndex = 1时,将APC插入目标线程的SavedApcState中 -
当
Apc->ApcStateIndex = 0 && Thread->ApcStateIndex = 0时,将APC插入目标线程的ApcState中 -
当
Apc->ApcStateIndex != 0时,也将APC插入目标线程的ApcState队列中

-
KiSignalThreadForApc函数根据当前线程、APC信息决定是否更新线程状态,优先执行某些内核APC。



-
KiExitDispatcher函数通过调用KiDeliverApc执行内核APC。

5. 执行
内部函数KiDeliverApc负责实际投递和执行 APC。它是 APC 机制的核心调度器。
c
VOID KiDeliverApc (
IN KPROCESSOR_MODE PreviousMode,
IN PKEXCEPTION_FRAME ExceptionFrame,
IN PKTRAP_FRAME TrapFrame
)
-
在启用特殊内核APC的情况下,执行所有的内核APC

-
第一步的前提下执行普通内核APC,直到普通内核APC被禁用,或者APC正在执行

-
第一步的前提下无条件执行所有的特殊内核APC

-
执行用户模式APC时遍历APC队列,直到满足条件

-
从队列中移除APC,同时将
SpecialUserApcPending复位

-
执行用户APC内核例程

-
通过
KiInitializeUserApc修改KTRAP_FRAME获取用户APC执行时机
KiInitializeUserApc函数入口主要包含KiInitiateUserApc和KiContinueEx。
前者往往从系统调用或者APC中断来执行,而后者则一般用于执行普通APC。
两者的区别在于从KiContinueEx进入时会复用上一次执行APC时的地址,避免反复备份KTRAP_FRAME。


KeUserApcDispatcher直接执行APC,或者通过wow64转发,最后调用ZwContinueEx回到内核

KiContinueEx通过flag参数决定是否执行下一个用户APC,或者执行原始的用户线程

通过以上9个步骤,我们可以得出以下结论:
- 内核APC一次调用全部执行
系统调用/中断 KiDeliverApc
- 用户特殊APC一次调用执行一个?(此处存疑)
系统调用/中断 KiDeliverApc KiInitializeUserApc KeUserApcDispatcher KiContinueEx 原始线程
- 用户普通APC唤醒状态一次调用全部执行
Y Y 系统调用/中断 KiDeliverApc UserApcPending KiInitializeUserApc KeUserApcDispatcher KiContinueEx 是否存在用户APC 原始线程
6. 问题
- 问题1:插入用户APC后可能无法执行
当插入APC的线程处于不可Alertable状态时,APC会一直排队,直到状态变化。用户态可使用SleepEx、WaitForSingleObjectEx等函数,内核态则推荐KeDelayExecutionThread或者KeTestAlertThread。
- 问题2:使用驱动插入APC,若停止驱动时APC正在执行,则可能会导致BSOD。
当用户自定义的KernelRoutine正在执行时,驱动停止会导致函数不可用,从而引发BSOD。可以通过在KAPC中持有EX_RUNDOWN_REF来保证EX_RUNDOWN_REF完全释放前驱动程序不可卸载。