探索黑客技术攻防,实战研究与安全创新

导航菜单

小议APC(异步过程调用)

我们知道,DPC是针对处理器的,而APC是针对线程的,同时在IRQL上处于DPC的下层,也属于软件中断。在Windows的内核实现中,APC有很广泛的应用,比如在线程的创建、驱动程序完成端口的实现,甚至是进程的挂靠(Processattach)中,都有APC的身影。APC与DPC在某些方面很有类似之处,两者最重要的行为就是构造、排队(Queue)和投递(Deliver)。所谓构造就是生成对应的APC或者DPC对象体,排队就是将APC或者DPC对象放到队列中去,而投递就是在某些固定的条件下,执行APC或者DPC对象中的例程。如图1所示。

图片1.png

APC是与线程相关的,在描述线程的结构KTHREAD中,可以看到很多带APC字眼的成员。


kddtKTHREAD
nt!KTHREAD
………..
+0x028ApcState
+0x028ApcStateFill
+0x03fApcQueueable
………..
:_KAPC_STATE
:UChar
:UChar
+0x044ApcQueueLock
………..
:Uint4B
+0x070KernelApcDisable:Int2B
+0x072SpecialApcDisable:Int2B
+0x070CombinedApcDisable:Uint4B
………..
+0x11cApcStateIndex
………..
:UChar
+0x130ApcStatePointer
+0x138SavedApcState
:Ptr32_KAPC_STATE
:_KAPC_STATE
+0x138SavedApcStateFill:UChar
………..
大约有十几个这样的成员,这些成员中,最重要的是ApcState。
kddt_KAPC_STATE
nt!_KAPC_STATE
+0x000ApcListHead[2]
+0x010Process
:_LIST_ENTRY//每个指针指向_KAPC结构
:Ptr32_KPROCESS//当前进程
+0x014KernelApcInProgress:UChar//内核态APC在处理当中
+0x015KernelApcPending
+0x016UserApcPending
:UChar//有内核态APC等待处理
:UChar//有用户态APC等待处理


上面的文字出现了两种APC:用户态APC和内核态APC,实际上内核态APC还分为普通的和特殊的。_KTHREAD、_KAPC_STATE和各种模式APC的关系如图2所示。

图片2.png

图2

从图中可以看出,_KTHREAD包含了ApcState和SavedApcState两个成员,这两个成员分别指向两个都是_KAPC_STATE结构的变量(ApcState和SavedApcState之间的关系,将在后面进行说明),在_KAPC_STATE结构中,第一个成员为ApcListHead[2],第一个成员指向一个用户模式APC链表,而第二个成员指向内核模式APC链表。如果注意观察,可以看出特殊内核模式APC都排在普通内核模式APC之前。

APC结构

APC对象是Windows内核对象之一,其结构为:


kddt_KAPC
nt!_KAPC
;
+0x000Type
:Uchar//KOBJECTS枚举类型的ApcObject;
:Uchar//没有使用;
+0x001SpareByte0
+0x002Size
:Uchar//KAPC结构的大小;
:Uchar//没有使用;
+0x003SpareByte1
+0x004SpareLong0
+0x008Thread
:Uint4B//没有使用;
:Ptr32_KTHREAD;//指向此APC对象所在的线程KTHREAD对象;
:_LIST_ENTRY
+0x00cApcListEntry//ApcListEntry域是APC对象被加入到线程APC链表中的
节点对象;
+0x014KernelRoutine
:Ptr32void//一个函数指针,必须的;
+0x018RundownRoutine:Ptr32void//一个函数指针,线程终止时,如APC链表
非空,执行这个函数,可选的;
+0x01cNormalRoutine
+0x020NormalContext
:Ptr32void//一个函数指针,可选的;
:Ptr32Void//NormalRoutine执行时的上下文
+0x024SystemArgument1:Ptr32Void//KernelRoutine或NormalRoutine函数的
参数
+0x028SystemArgument2:Ptr32Void//KernelRoutine或NormalRoutine函数的参
数
+0x02cApcStateIndex
+0x02dApcMode
+0x02eInserted
:Char
:Char
:Uchar//指示了它位于线程KTHREAD对象的哪个APC


链表中//说明了APC对象的环境状态,用户态还是内

核态;//该APC对象是否已被插入到线程的APC链表中

这个结构不算长,每个成员的作用在注释中都进行了说明。下面阐述一下在学习APC的过程中经常被绕晕的两个问题:用户模式APC、特殊的内核模式APC、普通的内核模式APC之间的区别;ApcState和SavedApcState之间的区别和使用方法。

1)各种模式APC的区别

欲知用户模式APC、特殊的内核模式APC和普通的内核模式APC之间的区别,请先看下表。

图片3.png

通过上表可以比较清楚的看出这几种模式APC之间的区别,另外有几点需要注意的是:

①如果mode为UserMode,但是NormalRoutine为NULL,那么实际的模式变为Kernelmode,很简单,因为没有用户空间的APC可以使用;

②每种APC都有内核模式的例程;

③特殊的内核模式APC与普通的内核模式APC区别在于Normalroutine是否为空;

④当普通的内核模式APC例程被执行时,先执行KernelRoutine例程,再执行NormalRoutine例程(除非执行过KernelRoutine例程后,NormalRoutine例程被清除为NULL)。

2)ApcState和SavedApcState之间的区别和使用方法

当线程1从进程A挂靠到进程B时,线程所在的地址空间发生了变化,线程原来的APC就不会执行,因为线程1仍然会接到APC请求(在进程B的环境里),所以系统会把在进程A环境里APCState队列的内容转移到进程B环境里的SavedApcState队列中,并使用在进程B的环境里正常使用的ApcState队列,用来在进程B中执行APC,结合图3就会明白。当线程从进程B中脱离时,当前ApcState队列中的APC将被投递,即执行,然后将进程A环境中线程ApcState指向进程B环境中的SaveApcState,恢复挂靠前的状态。

图片4.png

图3

为了操作灵活,在_KTHREAD结构中,还有一个指针数组ApcStatePointer[2]和ApcStateIndex的成员,其使用如下表所示。

图片5.png

ApcStateIndex、ApcStatePointer[0]和ApcStatePointer[1]的使用从图3中也可以看出。

APC机制的使用

APC的实现机制与DPC类似,使用也比较规范,一般来说会是下面几个步骤:APC对象的建立、KeInitializeApc(初始化APC对象)和KeInsertQueueApc(插入APC队列)。我们分别叙述之:

①使用ExAllocatePoolWithTag在非换页区分配一段APC结构大小的内存:

Apc=ExAllocatePoolWithTag(NonPagedPool,sizeof(KAPC),TAG_APC);

②指定针对哪个线程插入APC对象,并设定该APC对象执行的环境、KernelRoutine、RundownRoutine和NormalRoutine等参数。


NTKERNELAPI
VOID
KeInitializeApc(
__outPRKAPCApc,
__inPRKTHREADThread,
__inKAPC_ENVIRONMENTEnvironment,
__inPKKERNEL_ROUTINEKernelRoutine,
__in_optPKRUNDOWN_ROUTINERundownRoutine,
__in_optPKNORMAL_ROUTINENormalRoutine,
__in_optKPROCESSOR_MODEProcessorMode,
__in_optPVOIDNormalContext
);


③KeInsertQueueApc(插入APC队列)。


NTKERNELAPI
BOOLEAN
KeInsertQueueApc(
__inoutPRKAPCApc,
__in_optPVOIDSystemArgument1,
__in_optPVOIDSystemArgument2,
__inKPRIORITYIncrement
);


这个函数完成上面生成的APC对象的插入,在APC具体实现中,主要是依据Apc-ApcMode,如果是UserMode,就插入用户模式队列的最前面,否则就插入内核模式的队列中。在内核模式的队列中,特殊内核模式的APC会置于普通内核模式APC之前,这点我们通过这两种APC的放置策略可以明显看出,当放置一个特殊内核模式的APC,KeInsertQueueApc会从头搜索内核模式队列,找到最后一个特殊内核模式的APC,然后放于之后,如果是放置一个普通内核模式APC,就会直接放于内核模式队列末尾。这种实现方式保证了特殊内核模式APC会在普通内核APC之前执行。

图片6.png

APC的执行

触发APC,一定会调用KiDeliverApc,哪些地方会调用这个函数呢?

①离开临界区或者守护区;

②经过一次线程切换,有内核模式APC需要交付;

③系统服务或异常处理函数返回到用户模式;

④系统将IRQL降到Passive_Level时。

当前线程的APC被交付是在KiDeliverApc函数中完成的,KiDeliverApc函数的调用界面为:


VOIDNTAPIKiDeliverApc(INKPROCESSOR_MODEDeliveryMode,INPKEXCEPTION_FRAME
ExceptionFrame,INPKTRAP_FRAMETrapFrame)


其实现可以分为三大部分,伪代码如下:


KiDeliverApc()
{
特殊内核模式APC对象的处理;
普通内核模式APC对象的处理;
用户模式APC对象的处理;
}


很简单,就是三种APC对象的处理,下面我们对它们的实现分别进行讲解。

1)特殊内核模式APC对象的处理

前面已经说过,特殊内核模式APC对象中并没有NormalRoutine例程,因此我们只需处理每个APC对象中的KernelRoutine例程。其伪代码可以归纳如下:


//处理流程
提升IRQL至Dispatch_Level;
锁住APC链表;
操作APC链表;
解锁APC链表;
恢复IRQL;
执行KernelRoutine例程;


2)内核模式普通APC对象的处理

在这种模式的APC对象中,既有KernelRoutine例程,也有NormalRoutine例程。和上述APC对象的处理略有不同,其伪代码如下:


//处理流程
If(ApcState-KernelApcInProgress==0);
If(KernelApcDisable==0);


以NormalRoutine成员指针作为参数,调用APC对象中的KernelRoutine例程,此时该例程工作于APC_LEVEL;

KernelRoutine例程返回后若NormalRoutine非空,则调用NormalRoutine例程,此时该例程工作于PASSIVE_LEVEL;

3)用户模式APC对象的处理

看Windows内核多了,会发现处理用户态的东西相较内核态的要复杂很多,这也是很正常的,因为我们的内核代码工作在内核态,同一个状态的代码当然要较其它状态的代码处理简单一些。

在介绍用户态APC的执行之前,我们要先了解一个概念,什么叫Context,也就是我们经常见到的上下文。按照我的理解,Context指的就是在某个时点,CPU运行的环境,这个环境主要就是那个时点CPU各个寄存器的内容以及堆栈中的内容。

KiDeliverApc()这个函数里有一个重要的参数就是DeliveryMode,表示执行的APC是内核态,还是用户态。内核态APC的执行是没有条件的,而用户态APC的执行是有条件的,条件有三:

①调用参数DeliveryMode为Usermode;

②ApcState中的UserApcPending为TRUE,表示有用户模式的APC等待交付。

③仅当线程在“可报警等待状态”(alertablewaitstage)时,用户态APC才可以交付给该线程。在满足以上三个条件后,正式进入用户态APC对象的处理。

//处理流程

以NormalRoutine成员指针作为参数,调用APC对象中的KernelRoutine例程;

若NormalRoutine不为空,则调用KiInitializeUserApc函数;

在这个时间点上,我们需要很清楚的认识到,目前我们所面临的问题,由于要执行的用户模式APC的NormalRoutine函数是在用户态下,即地址空间在2G空间以下,而目前执行的KiInitializeUserApc函数处于内核态下,如何成功完成跨越,是一个很有技术含量的事情!

下面我们看看KiInitializeUserApc函数是如何解决这一问题的。如图5所示。

图片7.png

当系统进入内核态时,会将进入前CPU执行的环境放于一个叫做TRAP_FRAME(处于内核地址空间)的结构中,从图中可以看到,TRAP_FRAME中很多寄存器的信息,以便返回时恢复当时的CPU环境。如果按照正常的流程,当系统要退出内核态时,会将TRAP_FRAME中的内容恢复到各个寄存器中,但是为了完成我们执行NormalRoutine的目的,KiInitializeUserApc这个函数执行了下面三个步骤,如图6所示。

①通过KeTrapFrameToContext函数将此时的自陷框架(TRAP_FRAME)转化为Context;

②将Context、SystemArgument1、SystemArgument2、Normalroutine、NormalContext复制到用户空间的堆栈上(还包括一个异常结构,用于异常处理);

③修改当前自陷框架中的部分内容,将EIP的值修改为指针KeUserApcDispatcher,修改ESP,使其反映当前用户空间堆栈的变化。


图片8.png

完成以上三个步骤之后,系统从内核态返回用户态,这时系统会执行EIP=KiUserApcDispatcher函数,在这个函数里会调用目前处于堆栈中NormalRoutine例程,实际上就是执行用户投递APC对象中的例程。在NormalRoutine例程执行完成后,调用_ZwContinue,目的是将用户空间的CONTEXT结构拷贝回系统空间的自陷框架(TRAP_FRAME)中,恢复成图5的情形,这时完成一个用户态APC的投递,事情还没有结束,如果用户模式APC队列非空,则继续DeliverAPC,完成上述的①~③步骤,直到APC队列中的例程都得到执行。

小结

实际上,APC的内容还是很多的,仅是其应用可能就要几十页的篇幅并结合实例才能讲明白,本文就是起到抛砖引玉的作用,使大家能够对APC的概念、使用和实现有一个整体的了解。