Windows内核(4)APC
1. APC的作用:改变线程行为
线程在运行的时候是不受其它线程控制的,不会被结束、挂起、恢复。如果想要改变线程行为,可以通过给线程提供一个函数,让线程自己去调用的形式来实现。这个函数就是APC(Asyncroneus Procedure Call,异步过程调用)
2. _KTHREAD结构体中APC相关成员
- ApcState:线程的Apc队列
- SavedApcState:备用APC队列。对于A进程中的线程,其APC函数所使用的内存空间是A进程的内存空间,但是当线程挂靠到B进程之后,CR3切换导致内存空间切换,APC函数中使用的地址将会出现错误。所以在发生进程挂靠之后,会将ApcState中的内容存储到SavedApcState(备用APC队列)中。这时线程的所属进程为B进程,这时候再往线程中插入APC,插入的是ApcState(ApcState.Process也是指向B进程)。等线程回到A进程之后,会再将SavedApcState中内容恢复到ApcState中。
2.1. APC寻址
Windows使用ApcStatePointer和ApcStateIndex联合进行APC寻址,无论线程处于正常情况下还是挂靠情况下,ApcStatePointer[ApcStateIndex]均指向ApcState,ApcState则总是表示线程当前使用的APC状态
3. APC的挂入过程
每当要挂入一个APC的时候,无论是内核APC还是用户APC,内核都要准备一个_KAPC的数据结构,并且将这个KAPC结构挂入相应的APC队列。
3.1. 挂入流程
QueueUserAPC(位于Kernel32.dll),该函数会调用NtQueueApcThread(位于ntoskrnl.exe)函数,该函数会分配KAPC的空间,然后调用KeInitializeAPC(位于ntoskrnl.exe)来初始化KAPC结构体,然后调用KeInsertQueueApc,该函数会调用KiInsertQueueApc将KAPC插入指定队列。三环插入APC的时候调用QueueUserAPC,而内核插入APC的时候可能会直接调用KeInitializeAPC和KiInsertQueueApc来插入APC。
3.2. KeInitializeAPC
3.2.1. 函数定义
1 | VOID KeInitializeApc ( |
3.2.2. TargetEnvironment
- 0:原始环境,即希望APC挂到原始线程的APC队列,ApcStatePointer[0]
- 1:挂靠环境,即希望APC挂到挂靠线程的APC队列,ApcStatePointer[1]
- 2:初始化APC时的当前环境,即希望APC挂到当前线程(取初始化时的当前线程即在KeInitializeApc中获取)的APC队列,ApcStatePointer[KTHREAD.ApcStateIndex]
- 3:插入APC时的当前环境,即希望APC挂到当前线程(取插入时的当前线程即在KeInsertQueueAPC -> KiInsertQueueAPC中获取,从初始化到插入的过程中,可能发生挂靠或者接触挂靠)的APC队列,ApcStatePointer[KTHREAD.ApcStateIndex]
3.3. KeInsertQueueAPC
- 根据KAPC结构中的ApcStateIndex找到对应的APC队列
- 根据KAPC结构中的ApcMode确定是内核APC还是用户APC
- 将KAPC挂到相应队列(挂到KAPC的ApcListEntry处)
- 将KAPC结构中的Inserted置1,标识当前APC已经插入
- 修正KAPC_STATE结构中的KernelApcPending和UserApcPending。如果APC为内核APC,将KernelApcPending置1。如果APC为用户APC且目标线程位于等待状态、而且是用户(如用户程序自己Sleep、WaitForSingleObject)导致的等待(非内核程序让它等待)、而且可以被唤醒,将UserApcPending置1并唤醒目标线程(从等待链表中摘除,放到调度链表),其它情况下UserApcPending不修正。如果UserApcPending本来为0,又未得到修正,且之后没有其它用户APC的插入导致UserApcPending被置1,该APC会无法得到执行时机
4. APC的执行
4.1. 内核APC的执行时机
4.1.1. 线程切换时
SwapContext会判断是否存在内核APC,如果存在,返回KiSwapThread之后,会调用KiDeliverApc来执行内核APC函数
4.1.2. 系统调用、中断或者异常(_KiServiceExit)
KiServiceExit函数是系统调用、异常或者中断返回用户空间的必经之路,在这个函数中,会通过UserApcPending判断是否存在用户APC,如果存在,会设定第一个参数为1调用KiDeliverApc函数,如果不存在会直接返回。
4.2. 用户APC的执行
用户APC的执行时机是系统调用、中断或者异常(_KiServiceExit)。用户APC在用户空间执行,所以需要从内核空间返回用户空间(每执行一个用户APC都要经历内核->用户->回到内核)。内核空间返回用户空间,ESP、EIP等信息从TrapFrame中取得,所以在返回用户空间之前,需要先备份TrapFrame的值(之后返回的时候要用),然后改写TrapFrame结构体,控制返回用户空间后的地址、堆栈、寄存器为执行APC所需要的地址
4.3. 执行APC的函数:KiDeliverApc
4.3.1. 函数参数
第一个参数为1代表执行内核和用户APC(先执行内核APC函数,再执行用户APC函数),为0代表只执行内核APC。
4.3.2. 执行流程
- 内核APC部分
- 判断第一个链表(内核APC链表)是否为空,否则继续
- 判断是否正在执行内核APC(KTHREAD.ApcState.KernelApcInProgress),否则继续
- 判断是否禁用内核APC(KTHREAD.KernelApcDisabled),否则继续
- 将当前KAPC从链表中摘除
- 执行KAPC.KernelRoutine,释放KAPC所占空间
- 更改标识,正在执行内核APC(KTHREAD.ApcState.KernelApcInProgress)
- 执行KAPC.NormalRoutine,真正执行内核APC函数
- 更改标识,没有执行内核APC(KTHREAD.ApcState.KernelApcInProgress)
- 用户APC部分
- 判断用户APC列表是否为空
- 判断第一个参数是否为1
- 判断ApcState.UserApcPending是否为1
- 设置ApcState.UserApcPending为0
- 将APC从队列中摘除
- 执行KAPC.KernelRoutine,释放KAPC所占空间
- 调用KiInitializeUserApc初始化用户APC执行环境
4.3.3. 初始化用户APC执行环境:KiInitializeUserApc函数
- 调用KeContextFromKframes备份原来TrapFrame的值到CONTEXT结构体中(临时变量)
- 将三环堆栈提升至1000对齐的位置
- 复制临时变量中CONTEXT结构体至三环堆栈
- 将APC的第二个参数、APC的第一个参数、NormalContext、NormalRoutine依次压入三环堆栈
- 准备用户层执行环境(修改TrapFrame)
- 修改段寄存器SS、DS、FS、GS
- 修改eflags寄存器
- 修改esp
- 修改eip为KeUserApcDispatcher(该值来源于ntdll!KiUserApcDispatcher,在系统启动的时候就已经被赋值)
- 返回
4.3.4. ntdll!KiUserApcDispatcher
这个函数会取出Context作为参数,然后调用NormalRoutine(用户APC总入口),之后调用ZwContinue函数
4.3.5. 用户APC总入口:NormalRoutine<-BaseDispatchAPC
而当用户在三环调用QueueUserAPC来插入APC时,无需提供NormalRoutine,这个参数是在QueueUserAPC内部指定的,为BaseDispatchAPC,BaseDispatchAPC函数会负责调用存储在NormalContext中的真正的用户APC。
4.3.6. ZwContinue
返回内核,判断是否有下一个用户APC,如果有,会重复上述过程,再次回到用户空间执行该用户APC;如果没有,会将Context赋值给TrapFrame(这意味着之后返回用户空间,会回到最初进入内核的地方,ZwContinue后面的代码不会得到执行)
4.4. 一些要点
- 由于线程切换时会执行内核APC,所以内核APC一旦插入,很快就会因为线程切换而得到执行
- 内核APC在内核执行,无需换栈,一个循环就可以全部执行完毕