深入理解 APC

date
Aug 31, 2023
slug
understand-apc
status
Published
tags
Windows Internals
APC
summary
这篇内容讨论了在Windows操作系统中如何使用APC(异步过程调用)来实现异步操作。它介绍了在Win32 API中使用ReadFileEx函数来演示APC的用法,并解释了在内核层面如何处理APC。文章还提到了用户APC和内核APC之间的区别,并详细描述了在内核模式和用户模式之间切换时如何执行用户APC。 总结来说,这段内容主要讨论了Windows操作系统中的APC机制以及如何在用户层调用ReadFileEx等函数来实现异步操作。它还解释了APC的执行时机和处理方式。
type
Post
💡
以下代码主要参考 reactos 和 win2k3(泄露代码)x86 代码。Win10 中有部分修改,但原理一致,这里先不进行讨论。

引入

APC(Asynchronous Procedure Calls):异步过程调用
应用程序中经常用到 APC。这里以 ReadFileEx 为例来描述下,Win32 API 中是怎么用 APC 的。
ReadFileEx 是通过 APC 机制来实现异步读取文件的功能的。先看看 ReadFileEx 的代码:
其中 lpCompletionRoutine 是指向在读取操作完成且调用线程处于可警报等待状态时调用的完成例程的指针。说人话就是,这是当文件读取完成时的回调。
我们再来看看 NtReadFile ,因为其比较长,就截取关键代码:
可以看到在 NtReadFile 中,实际上将应用层的 callback 作为 apccontext 来进行的。
ApcRoutine 是预置的一个 APC 例程,实现也比较简单,其实就是对例程的一个封装执行。运行在 APC_LEVEL
NtReadFile 中就是将用户层的 callback 打包到 irp 中,然后发给底层的驱动,待驱动调用 IoCompleteRequest 来完成这个 irp
再来看看 IoCompleteRequest ,由于 IoCompleteRequestIofCompleteRequestIopCompleteRequest ,所以这里放部分 IopCompleteRequest 的相关代码:
💡
IofCompleteRequestIopCompleteRequest 的区别是:IofCompleteRequestIopCompleteRequest 的快速调用版本,具有更优的性能。IopCompleteRequest 是原始方法。
IoCompleteRequest 中,将用户层的 callback 和上文提到的 ApcRoutine 塞进去 irp 的 APC 队列中。待返回到用户层之前,进行执行,以调用用户层的 callback 来实现异步回调。
总结下 ReadFileEx ,用户层在调用ReadFileEx 的时候,传入一个 callback,NtReadFile 将 callback 作为 apccontext 塞入 irp 给到底层驱动处理。待底层驱动处理完成后,通过调用 IoCompleteRequest 将 callback 构造成一个 apc,待返回用户层前夕进行调用,以完成异步调用。

APC 关键结构

APC 主要分为内核 APC用户 APC 。内核 APC 就是指在内核空间执行的 APC,用户 APC 就是在用户空间执行的 APC。

内核 APC

先来看看支撑内核 APC 的结构:
APC 跟线程是密不可分的,所以我们先从 KTHREAD 中看看 APC 相关的结构:
KTHREAD 部分结构如下:
KAPC_STATE 的结构如下:
最重要的 _KAPC 的结构如下:
需要注意的是:
  • KernelRoutine :无论是什么 APC,都会首先执行这个函数
  • RundownRoutine :当插入 APC 失败的时候,会执行这个函数
  • NormalRoutine :在内核 APC 下,这是 APC 例程,NormalContext 是其 context;在用户 APC 下,这是统一的例程,上述例子中是 ApcRoutineNormalContext 才是用户的 APC 函数,SystemArgument1 才是 APC 的 context。

APC 函数执行时机

我们先看看从内核返回用户时的流程:
以下是部分伪代码
从上述伪代码可以看出,内核代码执行完后切换到用户模式的时候,就会通过 KiDeliverApc 来进行扫描 APC 并执行。
通过搜索KiDeliverApc 调用,可以看到从异常、中断返回、ThreadSwap 都有调用。可以从相关函数中找到(包括但不限于):
  • KiCheckForKernelApcDelivery
  • HalpApcInterruptHandler
  • KiExitV86Trap
  • KiCheckForApcDelivery
  • KiApcInterrupt
  • KiSwapThread
  • KiExitDispatcher
💡
HalpApcInterruptHandler 就是 apc 作为一种软中断的 ISR。
于是就可以来看看最核心的 KiDeliverApc 的调用了(简化了的):
可以看到,这个函数既处理内核 APC,也处理用户 APC。一次执行会遍历所有的内核 APC,但只会执行一次的用户 APC。
那其他的用户 APC 就不管了吗?当然不是的,其实每次尝试正式回到用户态的时候都会执行一遍。也就是循环执行,每次循环都会将内核 APC 队列执行完,然后执行一个用户 APC。
所以,总体流程是这样的:
扫描执行内核apc队列所有apc -> 执行用户apc队列中一个apc -> 再次扫描执行内核apc队列所有apc -> 执行用户apc队列中下一个apc -> 再次扫描执行内核apc队列所有apc -> 再次执行用户apc队列中下一个apc如此循环,直到将用户apc队列中的所有apc都执行掉。
上面提到这段代码是在内核模式返回用户模式之前会执行的,也就是内核模式下,那要怎么执行用户模式的代码呢?
嗯,答案就在 KiInitializeUserApc 里面了。我们先来看看它干了什么:
这段代码的核心逻辑就是修改 TrapFram 的执行地址(在这里就是返回地址)为用户空间的 KiUserApcDisatcher ,并把原 TrapFram 保存到用户空间下,然后将参数模拟压入这个函数的栈中,这样就可以模拟执行 KiUserApcDisatcher 了。
💡
可以看到这里整个执行块被一个 try 块包起来了,这是因为谁也不知道用户代码会发生什么问题,这里可不能崩溃。
于是,我们再来看看 KiUserApcDisatcher 干了啥:
每当要执行一个用户空间apc时,都会‘提前’偏离原来的路线返回用户空间的这个函数处去执行用户的 apc。在执行这个函数前,会先构造一个 seh 节点,也即相当于把这个函数的调用放在 try 块中保护。这个函数内部会调用IntCallUserApc,执行完真正的用户 apc 函数后,调用NtContinue重返内核。
💡
这里的 IntCallUserApc 跟前面提到的 ApcRoutine 是一致的。
NtContinue 代码如下:
KiContinue 代码如下:
KiContinuePreviousModeUser 代码如下:
KeTestAlertThread 代码如下:
如上,上面的函数,就把NtContinue的TrapFrame强制还原成原来的TrapFrame,以好‘正式’返回到用户空间的真正断点处。
💡
不过在返回用户空间前,又要去扫描用户 apc 队列,若仍有用户 apc 函数,就先执行掉内核 apc 队列中的所有 apc 函数,然后又偏离原来的返回路线,【提前】返回到用户空间的KiUserApcDispatcher函数去执行用户 apc,这是一个不断循环的过程。可见,NtContinue这个函数不仅含有继续回到原真正用户空间断点处的意思,还含有继续执行用户 apc 队列中下一个 apc 函数的意思。

特别地,在 KeLowerIrql 也会执行 apc,这个函数在从当前irql降到目标irql时,会按irql高低顺序执行各个软中断的isr。
比如,当调用KfLowerIrql要将 cpu 的 irql 从 CMCI_LEVEL 降低到 PASSIVE_LEVEL 时,这个函数中途会先看看当前 cpu 是否收到了CMCI_LEVEL级的软中断,若有,就调用那个软中断的 isr 处理之。然后,再检查是否收到有 DISPATCH_LEVEL级的软中断,若有,调用那个软中断的isr处理之,然后,检查是否有 APC 中断,若有,同样处理之。最后,降到目标 irql,即PASSIVE_LEVEL。 换句话说,在 irql 的降低过程中会一路检查、处理中途的软中断。Cpu 数据结构中有一个 IRR 字段,即表示当前 cpu 累积收到了哪些级别的软中断。

APC 如何插入

下面这个 Win32 API 可以用来手动插入一个 apc 到指定线程的用户 apc 队列中。
这个函数既可以给当前线程发送 apc,也可以给目标线程发送 apc。若给当前线程发送内核 apc 时,会立即请求发出一个 apc 中断。若给其他线程发送 apc 时,可能会唤醒目标线程。

© Frend Guo 2022 - 2024