学习一下 Windows 的 APC 机制

APC简介

基本介绍

异步过程调用 (APC) 是异步执行的函数。 APC 类似于延迟过程调用 (DPC),但与 DPC 不同,APC 在特定线程的上下文中执行。 除文件系统和文件系统筛选器驱动程序以外的驱动程序不直接使用 APC,但操作系统的其他部分使用 APC,因此你需要了解 APC 的工作原理[1]。

过程调用可以理解为C语言当中的函数,而异步就是它区别于一般过程调用的特征(先说一句废话)。

结构体分析

那么先来看看 APC 的内核结构体[3]。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef struct _KAPC {
UCHAR Type;
UCHAR SpareByte0;
UCHAR Size;
UCHAR SpareByte1;
ULONG SpareLong0;
struct _KTHREAD *Thread;
LIST_ENTRY ApcListEntry;
#ifdef _NTSYSTEM_
PKKERNEL_ROUTINE KernelRoutine;
PKRUNDOWN_ROUTINE RundownRoutine;
PKNORMAL_ROUTINE NormalRoutine;
#else
PVOID Reserved[3];
#endif
PVOID NormalContext;
PVOID SystemArgument1;
PVOID SystemArgument2;
CCHAR ApcStateIndex;
KPROCESSOR_MODE ApcMode;
BOOLEAN Inserted;
} KAPC, *PKAPC, *RESTRICTED_POINTER PRKAPC;

拿到一个结构体不一定先理解它各个成员的含义,可以先按照自己的想法给它予以一些成员,再通过比对去更完善地认识这些成员。
计算机执行的最小单位就是线程,而一个线程可以调用多个过程,因此成员中有一个 _KTHREAD * 类型的结构体指示了这个过程所属的线程。一个过程你应当告知它从何处开始,其实这里跟线程差不多,线程也有 StartRoutine 这个成员标识,而 APC 会有类似的三个变量(KernelRoutine,RundownRoutine,NormalRoutine)来标识它的起点。

Normal 和 Kernel 应该会标识这个 APC 在用户模式下和内核模式下的入口。

至于 RundownRoutine,这里参考一篇外文文献。

In general, every APC object must contain a valid KernelRoutine function pointer, whatever its kind. This driver-defined routine will be the first one to run when the APC is successfully delivered and executed by the NT’s APC dispatcher. User-mode APCs must also contain a valid NormalRoutine function pointer, which must reside in user memory. Likewise, regular kernel-mode APCs contain a valid NormalRoutine, which runs in kernel mode just like KernelRoutine. Optionally, either kind of APC may define a valid RundownRoutine. This routine must reside in kernel memory and is only called when the system needs to discard the contents of the APC queues, such as when the thread exits. In this case, neither KernelRoutine nor NormalRoutine are executed, just the RundownRoutine. An APC without such a routine will be deleted[4].

这一段主要讲述了,不论是用户 APC 还是内核 APC,都需要定义一个正确的KernelRoutine,用户模式的APC需要额外定义 NormalRoutine。当线程退出且该APC结构没有被执行时,会执行 RundownRoutine 所指向的处理函数。没有定义 RundownRountine 的 APC(即 RundownRoutine==NULL 且没有被执行的 APC)会被系统直接释放(执行 ExFreePool(APC))。

如果程序员没有使用 ExAllocatePool(NonPagedPool,sizeof(KAPC)) 的方式去分配内存时,则必须定义 RundownRoutine 去指示系统释放该 APC。这里存疑一下,如果线程 APC 被执行完毕需要释放,那么是否需要通过 RundownRoutine 去告知系统调用该函数去释放该 APC。

再来看另一个结构体 KAPC_STATE,它的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef struct _KAPC_STATE {
LIST_ENTRY ApcListHead[MaximumMode];
struct _KPROCESS *Process;
union {
UCHAR InProgressFlags;
struct {
BOOLEAN KernelApcInProgress : 1;
BOOLEAN SpecialApcInProgress : 1;
};
};

BOOLEAN KernelApcPending;
union {
BOOLEAN UserApcPendingAll;
struct {
BOOLEAN SpecialUserApcPending : 1;
BOOLEAN UserApcPending : 1;
};
};
} KAPC_STATE, *PKAPC_STATE, *PRKAPC_STATE;

里面有很明显的双向链表结构,并且有两个,对应了用户和内核的 APC 队列,它包含了 KAPC 结构体。其实这里看到这个结构体我突然想起来内核附加进程读取进程的虚拟内存的函数 KeStackAttachProcess 函数似乎有一个参数保存了这个结构体。它可以理解为是保存了一个APC队列和其它一些APC的信息,并且内核中的线程结构体 _KTHREAD 中存在一个成员就是 _KAPC_STATE。

  • KernelApcInProgress:指示内核APC是否正在执行。
  • KernelApcPending:指示是否有正在等待执行的内核APC
  • UserApcPending:指示是否有正在等待执行的用户APC

线程的一些小tips

这里有一些概念可能对于刚接触这些知识的人(比如我)有一些小小的震撼,比如:

线程执行时独占CPU,线程不能被结束,挂起,恢复,一切的操作都是它自己主动调用的。举个极端的例子,假设一个线程屏蔽中断,代码保证不出现异常,如果不提供其它机制改变线程的行为,那么线程将永久占据 CPU。

根据 Linux 迁移过来的一些知识点,一个进程(没有探究过线程,就类比了一下)被结束是因为某个进程调用了 kill 给进程发送了 9 号信号(SIG_KILL),内核循环遍历每个进程信号的时候,发现 SIG_KILL 信号就会强制中断该进程。

而 Windows 的线程如果是被结束,那么是被其它进程或线程提供给了它一个函数,让它自己执行,这个函数就是 APC(异步过程调用)了。

比如结束一个线程,就是将 exit 函数(Maybe)挂到了对应线程的 APC_STATE 里面的链表当中执行。在某些时刻(先留下疑惑),内核会检查链表中的内容,便会执行 APC 链表中的函数。这样看起来就好像是别的线程给它结束的了,但是其实是它自己调用了结束线程的函数。

如何向线程插入APC

理论如上所示了,下面演示下如何插入 APC 执行。用户层下,插入 APC 的 API 为 QueueUserAPC 和 QueueUserAPC2。

QueueUserAPC

将用户模式异步过程调用(APC)对象添加到指定线程的 APC 队列。

1
2
3
4
5
DWORD QueueUserAPC(
[in] PAPCFUNC pfnAPC,
[in] HANDLE hThread,
[in] ULONG_PTR dwData
);

三个参数也很好记:

  • pfnAPC:APC 函数指针
  • hThread:要插入 APC 函数的线程的线程句柄
  • dwData:函数的参数

QueueUserAPC2

这个函数可以约等于上个函数的扩展。

1
2
3
4
5
6
BOOL QueueUserAPC2(
PAPCFUNC ApcRoutine,
HANDLE Thread,
ULONG_PTR Data,
QUEUE_USER_APC_FLAGS Flags
);

多了一个参数

  • Flags:用于修改用户模式 APC 的行为。

它的参数类型是一个枚举类型。

1
2
3
4
5
typedef enum _QUEUE_USER_APC_FLAGS {
QUEUE_USER_APC_FLAGS_NONE,
QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC,
QUEUE_USER_APC_CALLBACK_DATA_CONTEXT
} QUEUE_USER_APC_FLAGS;

线程何时执行APC

根据 MSDN 的说法,特殊的用户模式 APC 严格在用户模式下运行,并且始终执行,即使目标线程不处于可警报等待(alertable)状态。

正常 APC 仅在线程处于可警报等待(alertable)状态时,才会执行 APC。这里就体现出了这个异步了,即插入 APC 动作是线程 A 完成的,什么时候执行由线程 B 完成的。

对于内核 APC,普通内核 APC 在 IRQL=PASSIVE_LEVEL 内核模式运行,特殊内核 APC 在 IRQL=PASSIVE_LEVEL,IRQL 即 Windows 实现的软件中断优先级。从低到高依次为:

  • PASSIVE_LEVEL:IRQL 最低级别,没有被屏蔽的中断,在这个级别上,线程执行用户模式,可以访问分页内存。
  • APC_LEVEL:在这个级别上,只有APC级别的中断被屏蔽,可以访问分页内存。当有APC发生时,处理器提升到APC级别,这样,就屏蔽掉其它APC,为了和APC执行一些同步,驱动程序可以手动提升到这个级别。
  • DISPATCH_LEVEL:屏蔽关闭的中断 - DISPATCH_LEVEL中断和APC_LEVEL中断被屏蔽。 可能会发生设备、时钟和电源故障中断。
  • DIRQL:IRQL < 处的所有中断 = 驱动程序中断对象的 DIRQL。 可能会发生具有较高 DIRQL 值的设备中断,以及时钟和电源故障中断。

有一个 API 可以直接将线程设置为 alertable 的状态,就是 SleepEx。

1
2
3
4
DWORD SleepEx(
[in] DWORD dwMilliseconds,
[in] BOOL bAlertable
);

相比于 Sleep 函数,多了一个 bAlertable 参数,如果将这个参数置为 1,则线程变为 alertable 状态,休眠期间若发生 I/O 完成回调,则函数立刻返回,或者当前线程存在 APC,则被中断直接调用 APC 函数,调用完毕之后直接返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <windows.h>
HANDLE hThread = NULL;
DWORD subthreadid;

void shellcode1() {
printf("shellcode1\n");
SleepEx(INFINITE,1);
}
void shellcode2() {

printf("shellcode2\n");
}

int main()
{
hThread = GetCurrentThread();
QueueUserAPC((PAPCFUNC)shellcode1, hThread, 0);
QueueUserAPC((PAPCFUNC)shellcode2, hThread, 0);
printf("helloworld\n");
SleepEx(INFINITE,1);
}

这段示例中也可以看出来,APC 可以被另一个 APC 中断。并且这里发现,如果删掉 shellcode1 中的 SleepEx 语句,则 shellcode2 同样会被执行。说明当线程处于 alertable 状态时,线程会尝试执行完所有的 APC(用户状态下)。

如果在执行 APC 函数的情况下插入了另一个 APC,则按照先进先出的顺序,直到执行完所有的 APC。当然,在第一个 SleepEx 返回之后,线程会从 alertable 状态中取消,在这之后插入 APC 则需要再次等待线程进入 alertable 状态。

总结

本篇文章大概探索了一下 APC 这个有趣的机制,学跑先学走,学走先学爬。这里先简单了解一下用户 APC 的一些小东西,熟悉一下大概的机制,后面再去深入研究一些线程相关 API 和内核 APC 等。

参考文献