avatar

Catalog
APC挂入过程

前言

之前的文章中了解了什么是APC以及与之相关的备用APC队列,本篇来着重分析一下APC的挂入过程,为后期学习APC的执行过程打下基础。

_KAPC结构

在分析APC挂入过程之前,需要先了解一下_KAPC这个结构。每当要挂入一个APC函数时,不管是内核APC还是用户APC,内核都要准备一个KAPC的数据结构,并且将这个KAPC结构挂到相应的APC队列中。

下面来看一下KAPC结构的各个字段及含义:

Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kd> dt _kapc
nt!_KAPC
+0x000 Type //类型APC类型为0x12
+0x002 Size //本结构体的大小0x30
+0x004 Spare0 //未使用
+0x008 Thread //目标线程
+0x00c ApcListEntry //APC队列挂的位置
+0x014 KernelRoutine //指向一个函数(调用ExFreePoolWithTag释放APC)
+0x018 RundownRoutine //未使用
+0x01c NormalRoutine //用户APC总入口或者真正的内核apc函数
+0x020 NormalContext //内核APC: NULL 用户APC:真正的APC函数
+0x024 SystemArgument1 //APC函数的参数
+0x028 SystemArgument2 //APC函数的参数
+0x02c ApcStateIndex //挂哪个队列, 有四个值: 0 1 2 3
+0x02d ApcMode //内核APC:0 用户APC:1
+0x02e Inserted //表示本apc是否已挂入队列, 挂入前:0 挂入后1

+0x000 Type

在Windows下任何一种内核对象都有一个类型编号,该字段指明APC对象的类型为0x12。

+0x002 Size

该结构体(_KAPC)的大小为0x30。

+0x00C ApcListEntry

APC所插入的队列,根据类型(内核/用户Apc)的不同,插入的队列也不同。这个字段表示的队列与KTHREAD(Kapc.Thread).ApcState.ApcListHead其中的一个队列相同。

+0x014 KernelRoutine

该字段指向一个函数,在APC执行完毕后,完成释放本结构内存的操作。

+0x01C NormalRoutine

  • 该字段与NormalContext字段都要分情况讨论,下面来看。

    • 如果当前是内核APC:通过该字段可以找到内核APC函数
    • 如果当前是用户APC:则表示的是用户APC的总入口,称作总入口是因为该值会在初始化 KAPC 时赋值给 NormalContext

+0x020 NormalContext

  • 如果当前是内核APC该值为空
  • 如果当前是用户APC:该值执行用户APC函数。QueueUserApc 中会将参数 pfnAPC 传递给 NormalRoutine,之后在 KeInitializeApc 中会通过 NormalRoutine 对 Kapc.NormalContext 进行初始化

+0x02C ApcStateIndex

该字段非常重要,其名称与KTHREAD+0x165处相同,但是含义不同,包含了4个值(0,1,2,3)。下面来着重分析这个字段。

  • 值为0:原始环境
  • 值为1:挂靠环境

先来分析值为0和1的情况,在前面备用APC队列一篇中提到过如下定理:

Code
1
2
3
4
5
6
正常情况下:
ApcStatePointer[0] 指向 ApcState
ApcStatePointer[1] 指向 SavedApcState
挂靠情况下:
ApcStatePointer[0] 指向 SavedApcState
ApcStatePointer[1] 指向 ApcState

因此,当ApcStateIndex值为0时,无论正常情况下,还是挂靠情况下,都是指向原来的APC队列中;同样,当ApcStateIndex值为1时,正常情况下指向备用队列挂靠情况下指向挂靠进程的APC队列

  • 值为2:当前环境

  • 值为3:插入APC时的当前环境

上面两个值还是比较好理解的,那么这个2和3究竟是怎么来的呢?目前还不是很好解释这两个值,需要进一步分析APC挂入的代码。

QueueUserApc(kernel32.dll)

早先在介绍APC本质的时候提到过,通过3环的QueueUserApc函数可以完成将APC插入到队列的操作,那么我们就来逐步深入探索这一完整的过程。

由图,QueueUserApc函数内部并没有做任何实现,而是调用了外部的NtQueueApcThread函数。按照惯例,去导入表找,发现NtQueueApcThread属于ntdll.dll

NtQueueApcThread(ntdll.dll)

进入ntdll.dll,定位到NtQueueApcThread,同样发现在NtQueueApcThread中并没有对函数的实现,并给出了一个系统服务号:0xB4

有了这个系统服务号,我们去系统服务表里面查,就可以看到QueueUserApc在内核中的实现叫做NtQueueApcThread,该函数与ntdll.dll中的函数同名,但是实现并不相同,需要进入内核文件继续作分析。

由于本次实验基于10-10-12分页下的WindowsXP系统,所以选用的内核文件是ntoskrnl.exe

NtQueueApcThread(ntoskrnl.exe)

将ntoskrnl.exe拖入IDA中,定位到NtQueueApcThread的位置,进行观察。

可以发现,在内核中的NtQueueApcThread函数中同样没有做太多的事,仅做了一些判断,传参,调用了一些函数。值得留意的是,在NtQueueApcThread调用的函数中,有两个函数比较关键,其函数名都涉及到了Apc,分别是KeInitializeApc和KeInsertQueueApc。接下来我们进一步分析这两个函数。先从KeInitializeApc开始。

KeInitializeApc(ntoskrnl.exe)

由函数名可以猜到,该函数是用来初始化Apc的,具体是如何操作的呢?我们先来看一下函数原型:

c
1
2
3
4
5
6
7
8
9
10
11
void KeInitializeApc
(
IN PKAPC Apc, //KAPC指针 (指向一块分配好的内存,但还没初始化)
IN PKTHREAD Thread, //目标线程
IN KAPC_ENVIRONMENT TargetEnvironment, //0 1 2 3四种状态
IN PKKERNEL_ROUTINE KernelRoutine //销毁KAPC的函数地址
IN PKRUNDOWN_ROUTINE RundownRoutine OPTIONAL //未使用
IN PKNORMAL_ROUTINE NormalRoutine, //用户APC总入口或者内核apc函数
IN KPROCESSOR_MODE Mode, //要插入用户apc队列还是内核apc队列
IN PVOID Context //内核APC:NULL 用户APC:真正的APC函数
)

函数原型并不复杂,传递的每个参数,都是用来给Kapc结构赋值用的。实际上KeInitializeApc本身的作用就是用这些传入的参数给Kapc结构赋值构造并初始化一个Apc块

尽管KeInitializeApc本身的功能比较好理解,但是有一个字段的赋值,非常重要,就是对Kapc.ApcStateIndex的赋值。下面观察汇编代码:

前面提到了,Kapc.ApcStateIndex有4个值:0,1,2,3。0和1很好理解,这里就解释了为什么会有2这个值。首先这里会将传入的environment参数的值与2作比较,这个2有什么含义呢?这个2指的就是当前系统在执行这行指令时所处的环境。因为不确定此时的环境和先前的环境是否一致(可能发生挂靠),所以这里会进行判断如果environment值为2,那么接下来会跳转到另一处代码,将当前线程的ApcStateIndex(KTHREAD+0x165),赋值给dl并在之后的代码将dl赋值给Kapc.ApcStateIndex。这里,就是确保,在初始化Apc时,一定是在线程当前环境下。若值不为2,那么就和之前一样了,值为0或者1,直接赋值给Kapc.ApcStateIndex,使用先前环境的ApcStateIndex。说明线程没有发生挂靠等操作。这也就解释了,为什么Kapc.ApcStateIndex会有2这个取值。至于3何是用到,会在之后再提到。

其余部分就比较好理解,主要是通过传入的参数对Apc进行初始化的操作,并区别内核Apc和用户Apc的初始化,其中有一部分,在我分析时发现,用户Apc的函数实际上是由NormalRoutine初始化的,海哥分析的结果NormalRoutine是用户APC的总入口。这里还是有点出入的,我在这部分的分析可以参考下图。

其余部分的分析可以参考图中注解,不再赘述。

KeInsertQueueApc

接着来看KeInsertQueueApc函数,还是先看函数原型:

c
1
2
3
4
5
6
7
BOOLEAN NTAPI KeInsertQueueApc
(
IN PKAPC Apc, //KAPC指针(指向一个已经经过初始化的APC块,未初始化完成)
IN PVOID SystemArgument1, //参数1
IN PVOID SystemArgument2, //参数2
IN KPRIORITY PriorityBoost //这个参数又被称作Increment,但是好像也没用到
);

然后再来看代码:

首先可以看到一个很明显的用自旋锁上锁和解锁的函数,以确保在插入Apc时不会受到其它核的影响。然后有一个判断,根据Kapc中记录的目标线程(Kapc.Thead),访问线程结构体的ApcQueueable字段(KTHREAD+0xE8)判断目标线程是否允许像该线程的Apc队列中插入Apc。若不允许,直接跳转出去,插入失败。如果允许,则会接着用KeInsertQueueApc函数的参数去初始化Apc块的剩余字段,并调用KiInsertQueueApc函数,这个KiInsertQueueApc则是Apc插入过程的重中之重,接下来着重分析该函数。

KiInsertQueueApc

还是先看函数原型,KiInsertQueueApc和KeInsertQueueApc相差不大,只是少俩参数,被用来初始化Apc了,此时Apc也已初始化完成。

c
1
2
3
4
5
BOOLEAN NTAPI KiInsertQueueApc
(
IN PKAPC Apc, //KAPC指针(指向一个已经初始化完成的APC块)
IN KPRIORITY PriorityBoost //同KeInsertQueueApc
);

由于KiInsertQueueApc逻辑比较复杂,跳转也比较多,这里就分为3个部分来分析,先来看第一部分。

Part1:校验ApcStateIndex

首先来看划分出第一部分的代码:

这里做了两个判断,分别来看;

第一个判断比较Kapc.Inserted字段的值,如果该字段值不等于0说明该Apc已经被挂入到队列中,此时将会进行跳转,跳转后就直接return了。

第二个判断就非常重要了,它做了一个判断Kapc.ApcStateIndex的值是否等于3,注意,这里出现了该字段的第4个值:3。前面提到过,该字段可以取值(0,1,2,3),前3个取值都已经作过分析,这里出现了第4个取值。这个3代表的就是在插入Apc时的环境(执行KiInsertQueueApc时的环境),这时,在插入Apc之前,会再做最后一次判断,此时的环境发生变化没有(即是否发生线程挂靠等行为),有人可能会好奇,在执行KiInsertQueueApc不是已经上了自旋锁了吗?但是在上锁之前,Apc初始化之后,环境还是可能会发生变化,所以这里会再进行一次判断。由于在初始化时,就已经确定了目标线程。因此这里的操作和KeInitializeApc中一样,将KTHREAD.ApcStateIndex赋值给Kapc.ApcStateIndex。完成插入Apc前最后一次对ApcStateIndex的校验。

Part2:将内核/用户Apc挂入到相应队列

观察这部分代码,执行的流程已经在图中注明,每条指令的具体含义可以参考批注,这里做简要梳理一下这部分的执行流程。

(此处于2023-04-10修改)先看红色框住的部分,这里作了判断Kapc.NormalRoutine是否有值,若不为0,则会跳转。跳转到橙色框住的部分,这里又做了一个判断,判断dl的值,也就是先前传入的Kapc.ApcNode的值是否为0若等于0,也就该Apc是内核Apc,此时会跳转至下方棕色框住的代码,这部分做的事情就是把Kapc结构挂到对应的队列中(Kapc.ApcListEntry),注意,用户APC挂入队列同样也是执行这块代码。最后跳转回KiInsertQueueApc主体代码中。在前面,若dl的值不等于0,则会判断该APC的KernelRoutine对应的函数是不是PsExitSpecialApc;如果是,那么会跳转到另一处代码块,并将该APC块挂入到对应的队列中。注意,这里在插入APC块之前会将Apc_State结构体中的成员UserApcPending的值设置为1(否则,对于用户APC而言需要满足3个条件后,才会将UserApcPending置1),执行后会跳转回主程序。如果Kapc.ApcNode不为0,且KernelRoutine对应的函数不是PsExitSpecialApc,那么说明是用户APC,接下来会将该APC插入对应的APC队列中。因此这里内核APC与用户APC插入时共用同一份代码,图中针对此处的分析有误。

所以这部分,主要做的就是对Apc进行挂入的操作,至于内核与用户Apc挂入的区别,将会在后续处理上有所不同,接着来看第三部分。

Part3:唤醒线程的条件

在Part2中完成了Apc块的插入后,在这部分,会将Kapc.Inserted的值置1,表明该Apc已经插入到队列中。然后这里会有一个判断,判断Kapc.ApcStateIndex的值与KTHREAD.ApcStateIndex的值是否相同若不同,说明挂入Apc时出现问题,给al置1后,函数直接返回。如果相同,接着来看,会先根据Kapc.ApcMode的值判断该Apc为用户Apc还是内核Apc。若是用户Apc,则会跳转并依次判断KTHREAD.State,KTHREAD.WaitMode以及KTHREAD.Alertable的值,其含义分别为判断线程状态是否为等待,判断是否是用户导致的等待以及判断是否可以吵醒线程若以上条件均满足,则会执行KiUnwaitThread函数,将当前线程从等待链表里取出,挂到就绪链表中,也就是把当前线程唤醒,遂有机会执行Apc函数。若条件不满足,那么Apc函数就无法得到执行。当然还有一种情况,如果当前Apc因条件不满足而没法执行,但是它已经位于Apc队列中,如果下一个Apc插入时,满足唤醒线程的条件的,有可能就两个Apc依次得到执行。内核Apc与用户Apc类似,但是不需要判断这么多,并且内核Apc在执行前会先将KapcState结构中KernelApcPending置1,表示有等待执行的内核Apc,并调用KiUnwaitThread函数唤醒线程。用户Apc只有在满足3个执行条件后,才会修改KapcState结构中的UserApcPending的值,将其置1

小结

至此,Apc挂入过程的函数已全部分析完成,可以总结出如下执行流程(海哥的图)

这其中的每个函数,在本文中均有分析,可以结合图中IDA上的批注一起看。目前已经对APC挂入过程有所了解,后面将会分别分析内核APC与用户APC的执行流程,也是APC专题的核心内容。

参考链接

  1. https://www.bilibili.com/video/BV1NJ411M7aE?p=71 (滴水预习班-APC挂入过程)
  2. https://blog.csdn.net/qq_41988448/article/details/104240902 (CSDN-lzyddf笔记)
  3. https://blog.csdn.net/qq_38474570/article/details/104326170 (CSDN-鬼手笔记)
  4. https://blog.csdn.net/weixin_42052102/article/details/83310341 (CSDN-My classmates笔记)
Author: cataLoc
Link: http://cata1oc.github.io/2020/07/19/APC%E6%8C%82%E5%85%A5%E8%BF%87%E7%A8%8B/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
    微信
  • 支付寶
    支付寶