暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

APC原理以及利用

横戈安全团队 2021-03-26
2213

MDL的底部实现:

1.概述

MDL全称为内存描述符,是内核中比较安全的一个操作内存方式。(在高版本的一些Windows中使用关CR0写保护的方式会造成BSOD)

2.使用方式

      MDL的释放方式分为三部曲:

(1)分配MDL内存描述符结构体,额外附加PFN_NUMBER---PFN数据库的标号(IoAllocateMdl)

MDL结构体

MDL分配方式

(2)将MDL所描述的虚拟内存所对应的物理内存锁在内存中(MmProbeAndLockPages)

锁物理内存其实就是修改物理内存PFN的标志位,让相关线程不要换出此页

试探所对应的虚拟地址

修改其PFN的标志位

首先保存和设置一下预读取的页错误(从这里可以看出预先读出的页数)--不清楚有什么具体的作用

对于PTE为0的大页,它是直接找到对应的PFN,并将修改其标志位锁到其内存地址空间。---如果我们使用了一个大页,并这大页换出的内存,会不会导致一定的问题???(从侧面也反映出大页一般不会换到外面去)

但是对于其他内存,它会先判断是否存在于内存中,如果不在,尝试通过页错误将其换入其中,并修改其PFN锁属性。

(3)构建程序可以访问的虚拟地址(MmMapLockedPagesSpecifyCache)

此时物理页已经被锁入到内存中了,接下来就是构建可以访问的虚拟地址。

对于内核空间,直接同SystemPTE中获取获取虚拟地址。

对于用户空间,则需要构建VAD,通过额外创建一个MI_PHYSICAL_VIEW结构体

将创建的VAD插入到VAD Tree中,以及将MI_PHYSICAL_VIEW插入到PhysicalVadRoot。


APC资料整合:

APC的使用

1.I/O管理器在初始化线程中使用APC来完成IO完成操作。

2.当进程结束时,一个特殊的APC用于打断进程的执行。

3.APC一般用于 synchronous I/O

进程创建中的APC使用:

在Vista之前Windows初始化R3的一些库环境时候都会采用APC的方式去执行。

流程为:

CreateProcess -->

|  CreateProcessInternalA -->

|  |  CreateProcessInternalW --> 创建Section映射文件内核对象,将PE映射到内存中

|  |  |  NtCreateProcessEx -->

|  |  |  |  PspCreateProcess --> 初始化EPROCESS相关字段,此时进程还是一个空壳。

|  |  |  NtCreateThread-->

|  |  |  |  PspCreateThread --> 初始化ETHREAD相关字段,此时需要初始化R3的环境(很多动态库)

|  |  |  |  |  KeInitThread --> 初始化APC相关,因为要通过APC回调到R3的ntdll!LdrInitializeThunk来初始化相关库

|  |  |  |  执行相关进程回调和线程回调(但是此时并没有初始化ETHREAD --> GrantedAccess,会导致OpenThread

PsLookupThreadByThreadId、相关函数失败)

|  |  |  |  |   ntdll!LdrInitializeThunk --> R3入口函数kernel32!BaseProcessStart

总结一下就是:用户层的代码最先执行的是User APC,UserAPC执行完后最后一次回到内核,然后将Context转换为TrapFrame此时Context.EIP = kernel32!BaseProcessStart,也就是第一个线程运行的时机。

在Vista之后,执行ntdll!LdrInitializeThunk是通过直接修改TrapFrame.EIP回到这个函数入口处。在ntdll!LdrInitializeThunk会调入_LdrpInitialize,这个函数中有NtTestAlert的函数,是用来执行APC的时机。


注入方式实现:

在vista之前,由于GrantedAccess的未初始化,导致在进程/线程回调中无法获取ETHREAD,因此无法注入APC。但是通过FaEry的帖子发现可以在模块回调中插入APC。

A盾:劫持KiFastCallEntry,在加载动态链接库的过程中调用NtQuerySection询问系统模块,以这个点为APC注入时机。

此方案:在加载动态链接库时会触发模块回调函数,在此函数中加载APC,因为此时GrantedAccess已经初始化,并这个APC不会插入到LdrInitializeThunk的User APC之前。

Vista之后,直接在进程回调中插入APC即可。

源码参考FaEry:


APC相关原理:

1.APC初始化

kd> dt _KAPC

ntdll!_KAPC

   +0x000 Type             : Int2B   //ApcObject = 0x12

   +0x002 Size             : Int2B    //结构体大小 = 0x30

   +0x004 Spare0           : Uint4B  //用作结构体对齐

   +0x008 Thread           : Ptr32 _KTHREAD  //由KeInitial    izeApc.Thread参数填充,代表所属的线程

   +0x00c ApcListEntry     : _LIST_ENTRY  //在调度时才使用的这个链表

   +0x014 KernelRoutine    : Ptr32    //每一个APC都有这个函数,并运行在APC_LEVEL等级

   +0x018 RundownRoutine   : Ptr32 

   +0x01c NormalRoutine    : Ptr32   //取决于APC类型

   +0x020 NormalContext    : Ptr32

   +0x024 SystemArgument1  : Ptr32

   +0x028 SystemArgument2  : Ptr32

   +0x02c ApcStateIndex    : Char

   +0x02d ApcMode        : Char   //取决于APC类型

   +0x02e Inserted         : UChar

如果NormalRoutine = 0, KeInitializeApc设置 KAPC.ApcMode =0,代表为内核模式,并且KAPC.NormalContext也会等于0,它们的参数会被忽略。  -- 特殊内核APC

如果NormalRoutine !=0 并且 ApcMode= 0,那么KAPC.ApcMode和KAPC.NormalContext 来自于参数。 -- 普通的内核APC,NormalRoutine是一个函数地址,将在APC派发的时候执行。

当APC被派发的时候, KernelRoutine运行在APC_LEVEL,NormalRoutine运行在PASSIVE_LEVEL,这两者都在KernelMode下运行。

KernelRoutine可以改变NormalRoutine的执行,或者改变执行的地址。

如果NormalRoutine !=0 并且 ApcMode = 1,这是一个用户APC,NormalRoutine将在用户模式下执行。对于用户态APC,KAPC.ApcMode和KAPC.NormalContext 都来自于参数,NormalContext 将在KernelRoutineNormalRoutine调用时传递给它们。

SystemArgument1  SystemArgument2  会在APC派发的过程中进行赋值。


ApcStateIndex代表了APC所处的环境,以及用作线程ApcStatePointer的数组索引。如果不是2,那么就将该值存储在KAPC结构体中。如果是2,那么用Thread的ApcStateIndex。

意味着值2表示在APC初始化函数中。

最终Inserted被设置为0,因为此时还没有将APC插入到队列中。    

2.初始化APC调度

所有类型的APC最终会被插入到它们的线程中,通过调用KeInsertQueueApc

KeInsertQueueApc (

    IN PRKAPC Apc,

    IN PVOID SystemArgument1,

    IN PVOID SystemArgument2,

    IN KPRIORITY Increment

    )


首先该函数会为目标线程获取一个自旋锁,并将IRQL提升到0x1B(配置文件级别),此时只有IPI和时钟可以打断其它的操作。

其次判断Thread中的ApcQueueable是否被设置,没有设置的话,则给调用者返回False.

接着判断Apc中的 Inserted field 是否为1,如果是1的话,也返回False。表示这里是一个且的关系。两者检查条件检查成功才会进行下面的步骤。

首先会将参数的SystemArgument1和SystemArgument2拷贝到APC相应的字段中。这个值将被传递到APC回调中,当APC被调度时,为它们提供上下文环境,这个与NormalContext    不一样,因为NormalContext是在APC初始化函数中完成赋值的。

最终会将 Inserted field 设置为TRUE,并调用KiInsertQueueApc函数,这个函数将完成APC调度的大部分。


特殊和常规的内核APC:

调度方式:

KiInsertQueueApc可以分为两个部分:

(1)将_KAPC插入到挂起的APC队列中去。

首先会检查_KAPC.ApcStateIndex,这个值来自于Environment参数,其中值2是一个伪变量,就是为了将其设置为调用时的当前环境。

值3,代表的是将_KAPC.ApcStateIndex设置为调用时Thread的ApcStateIndex。

因此2代表nt!KeInitializeApc,3代表nt!KiInsertQueueApc.

_KAPC.ApcStateIndex的最终值是用于选择_KAPC所链接的环境。即:_KAPC.ApcStateIndex作为数组的索引来选择正确的指针,选择_KAPC最终所链接的_KAPC_STATE。

kd> dt _KAPC_STATE

ntdll!_KAPC_STATE

   +0x000 ApcListHead      : [2] _LIST_ENTRY  //apcMode用来选择链表

ApcListHead[0]表示内核,ApcListHead[1]代表用户

ApcListHead[0]既包含特殊的也包含普通的,一般特殊的在前,普通的在后。

   +0x010 Process          : Ptr32 _KPROCESS

   +0x014 KernelApcInProgress : UChar

   +0x015 KernelApcPending : UChar

   +0x016 UserApcPending   : UChar

(2)为目标线程更新控制变量,以便于_KAPC被转移到处理正在等待APC中去。


指示线程执行APC:

首先检查_KAPC.ApcStateIndex是否等于_KTHREAD.ApcStateIndex(即:当前环境),

如果两者不同,那么就直接返回了,这意味着只有特定的线程所属的才能执行APC。

当前线程的内核APC:

对于这种情况,nt!KiInsertQueueApc会将Thread中的KernelApcPending设置为1,

这里为什么可以直接操作呢?因为我们已经知道ApcState代表了当前活跃的APC环境,所以我们可以直接通过ApcState来访问,而不是通过指针。KernelApcPending是一个很关键的标志,它是打断线程执行交付APC的一个重要标志。

SpecialApcDisable标志:

设置此标志后,nt!KiInsertQueueApc会检查这个表示是否为1,如果为1则返回。

这表示,通过这个标志可以禁用所有内核模式的APC,包括特殊的APC。

那么APC在什么时候被调度呢?可以在SwapContext中找到答案。SwapContext在选择要加载的线程上下文后,并检查KernelApcPending标志是否被设置,并且SpecialApcDisable标志为0,此时它将触发APC的调度。KernelApcPending决定的是下次线程运行时调用APC的时机,但是此期间SpecialApcDisable要为0。说明SpecialApcDisable比KernelApcPending具有优先权。

此时提出一个问题:如果当前线程将SpecialApcDisable设置为0,那么挂起的APC必须等待它通过nt!SwapContext还是它们立即被激活???其实没有单独的“EnableAPC”函数去清除它,实际是通过很多函数。

通过对SpecialApcDisable设置写断点会发现,会有一些函数来更改这个字段,并且它们满足如下的规律,如果要禁用APC,则通过对SpecialApcDisable进行减1,开启的话,则通过加1,当SpecialApcDisable =0 时,会判断是否有挂起的内核APC,则调用

KiCheckForKernelApcDelivery函数。

这个为当前线程触发内核APC调度,如果IRQL == Passive_LEVEL,则这个函数提升IRQL并调用KiDeliverApc进行派发。否则将KernelApcPending 设置为TRUE,并请求APC中断,当IRQL低于APC_LEVEL之后,又会调用KiDeliverApc。

至少,(nt!NtQueryInformationFile,nt!KeLeaveGuardedRegion,nt!IopParseDevice)这些函数,如果IRQL满足的话,一旦SpecialApcDisable =0,内核APC就会被派发。

APC中断:

在nt!KiInsertQueueApc中,如果SpecialApcDisable=0,则通过

hal!HalRequestSoftwareInterrupt,请求一个APC中断,然后退出。

KiInsertQueueApc,我们可以发现这个函数不管是常规的还是特殊的内核APC,都是针对当前线程的。因此对常规APC的限制,是实现在nt!KiDeliverApc函数中的。

APC中断限制不能被处理,因为现在KiInsertQueueApc所处的IRQL等于0x1b,至于当IRQL降低时,nt!KiDeliverApc才能被调用。

这使得我们想到了一个比较有趣的时候,就是中断请求和APC被派发时,计时器不断中断处理器,此时IRQL等于0x1c,高于当前IRQL,因此这个中断例程可以检测到当前线程的时间片已经到期,并调用DPC软件中断来调用线程切换。

当IRQL降低到PASS_LEVEL时,此时有两个中断请求,一个是DPC一个是APC,由于DPC的请求级别比较高,所以DPC先运行,并切换了一个别的线程,当IRQL最终降低到APC_LEVEL后,APC中断将被触发,此时在错误的线程上下文中有两种办法可以挽救局面:  

1.KiDeliverApc它被APC中断所调用,此时在内部判断是否有处理的APC,没有则直接返回。

2.此时可能认识失去了执行APC的风险,因为中断消失了,其实不然,只有

KernelApcPending标志置位,那么在下一次SwapContext依旧可以派发APC。APC的真正出触发调试是KernelApcPending,APC中断只是提供一个时机。


对于其他线程的内核APC

对于这种情况nt!KiInsertQueueApc获取一个自旋锁来保护_KPCRB,注意,此时在这个函数中已经获取了一个保护APC队列的锁,先我们也同步对处理器结构的同步。接着设置ApcState.KernelApcPending以确保APC可以在某个点触发。接着他会检查目标线程的状态,在这个场景中,目标线程不是在执行KiInsertQueueApc函数。

线程的状态存放在 THREAD的State字段中,它被文档话的有READY (1), RUNNING (2), WAIT (5),也有未文档化的DEFERREDREADY(7- 表示对于一个准备运行的线程必须找到一个处理器)GATEWAIT(8- 一种不同的等待状态)

下面我们来介绍,KiInsertQueueApc对于不同状态的的行为:

APC的目标线程正在运行

如果线程的状态是运行RUNNING(2),那么它肯定运行在另一个处理器中,因为我们知道它与正在运行的KiInsertQueueApc不是同一个。因此一个IPI(处理器中断)发送到它的处理器,为它的线程触发一个APC中断。在前面我叙述到,这个中断不一定会为目标线程服务,但是KernelApcPending变量将一直跟踪着APC。

Ecx =一个位掩码,对应于目标线程的_KTHREAD的NextProcessor字段中存储的处理器编号这表明该字段存储运行线程的处理器的标识符(nt!kiIdeferredreadythread,该函数负责为准备好的线程选择处理器)。

Edx = 1。假设正在请求一个APC中断,APC IRQL为1,这表明edx存储IRQL,以便将中断发送到目标处理器.

在请求IPI之前,nt!KiInsertQueueApc释放当前处理器的自旋锁;在调用nt!KiIpiSend,它返回


APC的目标线程处于等待状态

正在等待的线程没有资格运行,因为它进入了等待状态。在一定条件下,nt!KiInsertQueueApc唤醒它并发送它来执行APC。因此,如果可能的话,APC不必等到线程进入要调度的运行状态

nt !KiInsertQueueApc首先比较目标_KTHREAD的waitrql成员与0,即PASSIVE_IRQL。如果waitrql不为0,则nt!KiInsertQueueApc退出.

这让我们对_KTHREAD成员有了一个有趣的了解:当一个线程进入等待状态时,这个成员记录了它在转换为等待之前它正在运行的IRQL

对nt!KeWaitForSingleObject的分析进一步证实了这一点,(在调用调度程序程序将线程置于等待状态之前将当前IRQL存储到这个成员中


因此,尽管IRQL确实与线程无关,但它是处理器的一个属性(在任何给定时间,处理器在给定的IRQL上运行),这里也确实记录了线程进入等待状态时当前的IRQL


因此,nt!KiInsertQueueApc很简单:如果线程不是在PASSSIVE_LEVEL下运行,那么它是在APC上运行的,因此APC中断会为它屏蔽。因此,它不能被发送给APC中断服务,因为这将打破基于irql的中断屏蔽规则。

附注:对于等待的线程,waitrql应该是PASSIVE_LEVEL的或者是APC,因为运行在IRQL大于APC的线程不能进入等待状态假设当前等待线程处于DISPATCH等级,那么它的无法逃离了被调度因为调度程序的IRQL=DISPATCH。

一如既往,事实上,设置了KernelApcPending可以保证当正确的条件适用时,APC最终会被交付

如果线程确实处于PASSIVE_LEVEL等待状态,nt!KiInsertQueueApc检查SpecialApcDisable:如果它不是0,它会在不唤醒线程的情况下退出

另一方面,如果APC是启用的,这是一个特殊的内核模式APC, nt!KiInsertQueueApc唤醒目标线程;它将在nt!SwapContext中恢复并将分派APC


如果这是一个常规的内核模式APC,则会执行两个额外的检查首先,检查目标_KTHREAD的KernelApcDisable成员:如果它不是0,那么nt!KiInsertQueueApc退出时不会唤醒线程。因此,KernelApcDisable的行为类似于SpecialApcDisable,但仅适用于常规apc


第二个检查涉及到ApcState的KernelApcInProgress成员:同样,一个非零值会导致nt!KiInsertQueueApc退出


我们将在nt!KiDeliverApc看到在调用普通内核APC的normal routine之前设置这个标志。这个测试意味着如果线程在一个普通内核APC中进入等待状态,它不会被另一个普通APC劫持。换句话说,常规APC调用不能嵌套


我们在前面看到,当APC针对当前线程时,nt!KiDeliverApc被调用(通过中断),不管APC的类型是普通的还是特殊的,这可能看起来与此行为相反。然而,nt !KiDeliverApc执行相同的检查,并避免调度一个常规的APC,如果设置了这两个标志之一。这意味着不唤醒线程是一个性能优化:即使线程已经被唤醒,它不会分派常规的APC


nt!KiUnwaitThread和线程唤醒



目标线程处于GATEWAIT

GATEWAIT状态,其值为8,似乎是另一种等待状态

对于这样一个线程,nt!KiInsertQueueApc首先在目标线程的ThreadLock成员上进行同步。这就像一个自旋锁一样使用,而不需要实际调用常规的自旋锁函数:处理器在循环中旋转,直到它成功地修改了它的值


nt!KiInsertQueueApc获得了锁,检查线程状态是否仍然是GATEWAIT或已经更改,如果后者为真,则退出(释放所有锁)。这与除了RUNNING、WAIT和GATEWAIT之外的状态nt!KiInsertQueueApc所做的只是设置KernelApcPending(在这一点上已经完成了)


这表明线程锁保护线程状态:函数只有在获得锁后才会改变状态

后来,nt !KiInsertQueueApc执行与等待线程相同的检查:waitrql必须是PASSIVE_LEVEL的,SpecialApcDisable必须是clear的,这是一个特殊的APC或KernelApcDisable是clear的,还有KernelApcInProgress


如果所有的检查都通过了,nt!KiInsertQueueApc自己完成了一种不等待线程的操作。它通过WaitBlock[0]成员将线程从链表中释放出来,并将其置于DEFERREDREADY状态,如nt!KiUnwaitThread用于等待线程。

nt !KiInsertQueueApc也将_KTHREAD的WaitStatus成员设置为0x100,即传递给nt!KiUnwaitThread相同的“等待返回”值用于等待线程。nt !KiUnwaitThread做了一些类似的事情:将它接收到的值存储到WaitStatus中,尽管是通过将新值(传递给edx)与当前值进行操作。可能在WaitStatus字段中有更高的位,这是nt!KiUnwaitThread需要保存



此外,nt !KiInsertQueueApc将线程链接到一个列表中,该列表由正在执行的处理器的_KPRCB结构的deferredreadylishead成员指向。


nt!KiReadyThread和nt !KiDeferredReadyThread表明,DEFERREDREADY是在选择要运行的处理器之前,放置准备运行的线程的状态;如果选择的处理器正在忙于运行另一个线程,则从DEFERREDREADY进入Ready状态;如果处理器可以立即开始执行线程,则进入Standby状态。


因此,nt!KiUnwaitThread和nt !KiInsertQueueApc启动同样的转换,这将导致线程开始运行并调度它的apc


WAIT和GATEWAIT比较

nt!KiUnwaitThread 用于WAIT状态,nt!KiInsertQueueApc用于GATEWAIT状态


对于等待线程,nt!KiUnwaitThread解除线程与它所等待的对象的链接。线程通过WaitBlockList成员指向的列表链接到它们,因此nt!KiUnwaitThread遍历这个列表并解除每个节点与其对象之间的链结


这些等待块,在[2]第164页中有解释。它们用于将线程连接到它正在等待的对象(事件、互斥对象等),允许一个线程可以等待多个对象,每个对象可以有多个线程在等待它


为了实现这个m:n关系,每个线程都有一个等待块列表,其中每个节点代表线程正在等待的一个对象。等待块是一个_KWAIT_BLOCK结构,其布局如下:


         +0x000 WaitListEntry    : _LIST_ENTRY

   +0x008 Thread           : Ptr32 _KTHREAD

   +0x00c Object           : Ptr32 Void

   +0x010 NextWaitBlock    : Ptr32 _KWAIT_BLOCK

   +0x014 WaitKey          : Uint2B

   +0x016 WaitType         : UChar

   +0x017 SpareByte        : UChar

NextWaitBlock字段将单个线程的等待块相互链接

Object字段指向线程正在等待的对象。

当有多个线程在等待同一个对象时,通过WaitListEntry成员将指向该对象的等待块链接在一个列表中。


换句话说,一个等待块可能是两个列表的一部分:线程的等待块列表和对象的等待块列表。

nt !KiUnwaitThread遍历waitblock成员的线程列表下一个,对象列表中的WaitListEntry每个_KWAIT_BLOCK并解链


nt !KiInsertQueueApc对GATEWAIT线程做了一些不同的事情

它使用_KTHREAD的GateObject成员,该成员指向_KGATE结构。而_KGATE则只有一个成员:Header,它是一个_DISPATCHER_HEADER。


Header中的第一个字段用于与其他处理器同步,就像自旋锁一样函数在循环中旋转,直到它成功地更新它。因此,_KGATE被用作同步锁。


获得了锁,nt!KiInsertQueueApc通过它的WaitBlock[0]成员将线程从链表中解除链接因此,GATEWAIT线程也是列表的一部分,但由_KTHREAD的另一个成员指向


WaitBlock[0]本身的类型是_KWAIT_BLOCK,即与等待线程使用的类型相同。然而,在这里,WaitBlock[0]嵌入到_KTHREAD中,而对于等待线程,_KWAIT_BLOCK的列表是由_KTHREAD. waitblocklist指向的。


此外,GATEWAIT线程可以简单地从WaitBlock[0]. waitlistentry所指向的列表中解除链结。对于WAIT线程,将遍历整个等待块列表(通过NextWaitBlock),并将每个节点从目标对象的列表中解放出来。


值得注意的是,_KTHREAD。用于访问同步门的GateObject成员实际上是存储_KTHREAD.WaitBlockList的同一成员: _KTHREAD的定义将两个字段放在union中的偏移量为0x64.


我们可以理解nt!KiInsertQueueApc将偏移量0x64视为指向_KGATE的指针,因为它使用第一个字节进行同步。如果指针是指向_KWAIT_BLOCK的,第一个字节将是指针变量的一部分;

相反,对于_KGATE,第一个字节是一个名为Type的字节值(它实际上是嵌入式_DISPATCHER_HEADER的第一个字节)


这意味着同一个指针有两个不同的含义,这取决于线程的状态。



线程优先级提升:

nt !KiUnwaitThread也对thread应用了优先级提升;nt !KiInsertQueueApc没有为GATEWAIT线程做类似的事情

检查换出的进程和堆栈:

nt !KiUnwaitThread通过调用nt!KiReadyThread使线程退出WAIT状态。这个函数不仅仅是盲目地将状态更改为DEFERREDREADY。它检查所拥有的进程是否被换出,如果是这种情况,则启动换入操作。


如果进程是常驻的,则nt!KiReadyThread通过测试_KTHREAD.KernelStackResident来检查线程堆栈是否被换出如果不是,TRANSITION到线程,将其排队以进行将堆栈换入其中。

否则,如果堆栈是常驻的,则nt!KiReadyThread将线程(最终!)放入DEFERREDREADY。


另一方面,nt!KiInsertQueueApc不需要对GATEWAIT线程进行任何检查:它只是将它们的状态设置为DEFERREDREADY.。这意味着GATEWAIT线程不能将其堆栈进行换出,也不能成为换出进程的一部分


对于其他状态的线程

对于其他状态的线程,nt!KiInsertQueueApc退出,但是,它是在已经设置了KernelApcPending标志之后才这样做的。


触发线程派发:

正如我们前面看到的,nt!KeInsertQueueApc是用于调度APCs的函数,它在内部调用nt!KiInsertQueueApc当后者返回时,还有一些工作要做


我们看到nt!KiInsertQueueApc了可能会唤醒处于WAIT或GATEWAIT状态的线程。当发生这种情况时,线程被放置到DEFERREDREADY状态,并被链接到正在执行的处理器的延迟就绪线程列表(_kprc . deferredreadylishead)。这意味着线程可以运行,也就是说没有东西阻塞它,但是必须选择一个处理器来运行它。为了实现这个目标,nt!KeInsertQueueApc调用nt ! KiExitDispatcher。


nt!KiExitDispatcher的逻辑这里不详细介绍,因为它本身就可以填满一篇文章

然而,这个函数对于延迟就绪列表中的每个线程调用了nt!KiDeferredReadyThread,将其分配给一个处理器

这些线程中的一些可能被分配给当前正在执行另一个线程的处理器,该线程的优先级低于被分配的线程

如果是这种情况,则必须抢占前一个线程,因此,nt!kiIdeferredreadythread向所选择的处理器发送一个IPI(处理器间中断)。

也可能发生nt!kiideferredreadythread将被唤醒的线程分配给正在执行的处理器(正在执行nt!KeInsertQueueApc),确定当前线程必须被抢占

这是由nt!KiExitDispatcher的剩余部分处理的在本例中,它调用nt!KiSwapContext(它反过来调用nt!SwapContext)。


总体效果是,线程调度程序重新评估哪些线程将运行,以说明DPCs唤醒的线程


Delivery- 派发:

nt!KeInsertQueueApc运行后,nt!KiDeliverApc最终被调用用来处理APC

这可能是因为APC软件中断被请求了或者nt!SwapContext检测它正在    恢复一个线程ApcStateKernelApcPending 1 and SpecialApcDisable 0.


对于后一种情况,nt !SwapContext检查即将恢复的线程挂起时正在运行的IRQL这个IRQL值存储在线程堆栈中,所以nt!SwapContext在恢复线程上下文时,从那里弹出它


弹出的IRQL可以是PASSSIVE_LEVEL的,也可以是APC(线程在APC之上运行时不能被抢占)


如果nt!SwapContext发现线程正在返回PASSIVE,返回1,这告诉它的调用者马上调用nt!KiDeliverApc.另一方面,如果线程返回APC, nt!SwapContext返回0,但是在离开之前请求APC中断.

当IRQL将下降到PSSIVE_LEVEL,APC中断将启动和nt!KiDeliverApc将被调用。


在线程被再次抢占后,APC中断可能仍然会触发但是,这不是问题,因为下一次nt!SwapContext将恢复线程,处理过程将重复。


nt !KiDeliverApc在APC IRQL中被调用,并接受三个堆栈参数。我们将它们命名为PreviousMode、Reserved和TrapFrame,就像[1]中那样。对于内核模式的APCs,只有第一个被使用,它被设置为KernelMode(即0)来告诉nt!KiDeliverApc它被调用来交付内核模式apc。


nt!KiDeliverApc第一件事是所做的,就是将当前进程上下文的_KPROCESS的地址复制到本地。在离开之前,它会将保存的值与从_KTHREAD获取的当前值进行比较,如果它们不相同,则会使用错误检查代码INVALID_PROCESS_ATTACH_ATTEMPT使系统崩溃

 _KTHREAD.SpecialApcDisable被设置的影响


nt !KiDeliverApc首先将ApcState.KernelApcPending归零之后,它才检查_KTHREAD.SpecialApcDisable是否设置,在这种情况下,它返回时没有调度apc和离开ApcState.KernelApcPending设置为0


这是一个重要的问题:当KernelApcPending为0时,nt!SwapContext不调用nt!KiDeliverApc了。即使_KTHREAD.SpecialApcDisable稍后被清除,挂起的APC不会被交付,直到有人再次设置KernelApcPending(或请求APC中断)


ApcDisableTest这个函数(自己写的测试驱动中的函数)初始化一个特殊的内核模式APC,然后将它链接到APC列表并设置_KTHREAD.ApcState.KernelApcPending,而不调用nt!KiInsertQueueApc(它直接更新这些数据结构)。它还设置了_KTHREAD.SpecialApcDisable


ApcDisableTest然后调用一个等待函数,以确保线程分派发生和nt!SwapContext获得一个为线程运行的机会。


APC内核例程在调试器控制台写一条消息,因此我们看到在等待期间APC没有被交付,这是由于设置了SpecialApcDisable


在等待之后,ApcDisableTest显示KernelApcPending仍然被设置,这与nt!KiDeliverApc这个线程甚至没有调用。(说明根本没有调用KiDeliverApc


ApcDisableTest通过显式调用nt!KiDeliverApc继续运行。我们看到APC没有交付,但在nt!KiDeliverApc之后返回,KernelApcPending是clear的。因此,我们的情况是,直到有人再次设置KernelApcPending否则APC将不会被交付


这被事实证实,ApcDisableTest清除SpecialApcDisable,然后等待几秒钟;等待期间,APC未交付


最后,ApcDisableTest,设置KernelApcPending并再次等待。我们看到内核例程消息告诉我们APC已经被交付


这个测试也确认当设置了KernelApcPending, APC交付发生时甚至不需要请求APC中断,前提是APC没有被禁用


通常,当设置SpecialApcDisable时,nt!KiDeliverApc甚至没有被调用。

例如,nt !SwapContext不会触发nt!KiDeliverApc在这种情况下。测试证实了这一点,当我们看到在第一次等待之后,KernelApcPending仍然被设置。


但是,nt!KiDeliverApc检查SpecialApcDisable的事实表明这种情况可能发生.


在这种情况下,挂起的apc会一直待在那里,直到有东西再次设置ApcState.KernelApcPending。


在上一节的SpecialApcDisable标记中,我们看到了函数的行为,它清除SpecialApcDisable与这里的标记是一致的。这些函数通过检查列表来检查是否有内核apc挂起,而不是通过检查KernelApcPending.


这解释了这个标志可能已经被nt!KiDeliverApc清除的事实。此外,这些函数调用nt!KiCheckForKernelApcDelivery调用nt!KiDeliverApc或引发APC中断。在后一种情况下,函数在请求中断之前设置KernelApcPending.


这背后的逻辑是,当SpecialApcDisable被清除时,会对KernelApcPending进行适当的设置,以防它已经被nt!KiDeliverApc清除.

最后要注意的是,当nt!KiCheckForKernelApcDelivery对直接调用nt!KiDeliverApc,不麻烦设置KernelApcPending,因为它是无用的:

当我们想要nt!SwapContext在稍后的时间调用nt!KiDeliverApc,后者在其早期阶段清除它。说明通过线程切换的方式滞后调用KiDeliverApc说明KernelApcPending可能早被清楚了,所以需要重设置一下KernelApcPending



SpecialApcDisable 清除时,内核APC派发


nt !KiDeliverApc通过检查内核模式的列表中是否真的有apc (apcstate . apclishead [KernelMode])。如果列表是空的,它就会返回(假设它已经被PreviousMode = KernelMode调用)。

否则,函数将获得存储在_KTHREAD上的自旋锁ApcQueueLock(针对当前线程),确保仍然有挂起的apc,如果没有则离开


然后它继续复制KernelRoutine, NormalRoutine, NormalContext, SystemArgument1, SystemArgument2到本地堆栈变量


如果这是一个特殊的APC,那么它将从列表中解放出来;之后,自旋锁被释放,KernelRoutine被调用,指针指向APC参数的本地副本

因为APC已经被解除了链接,传递给KernelRoutine的指针是APC数据的本地副本,内核例程可以安全地释放_KAPC实例


然后,再次检查内核APC列表,看看是否有更多的APC在等待,如果有,进程会从自旋锁获取过程中重复自己


相反,如果找到了一个常规内核模式的APC,在从_KTHREAD列表中解除它的链接之前。_KTHREAD.KernelApcInProgress _KTHREAD.KernelApcDisable被检查。如果有一个不为0,函数终止。我们必须记住nt!KiDeliverApc当找到一个常规apc时,它可以假设没有更多的特殊apc,因为这些特殊apc被插入到特殊apc后面的列表中,因此函数可以终止


稍后我们将继续讨论这一点,但是如果我们先看看这两个标记clear时发生了什么,那么整体逻辑就更容易理解了.


就像特殊的APC一样,常规的APC被从列表中解除链结,自旋锁被释放,内核例程用APC参数的本地副本地址调用。实际上,普通apc有一个内核例程,就像特殊的apc一样.


当KernelRoutine返回时,nt!KiDeliverApc检查是否将NormalRoutine指针置零(内核例程收到了指针地址,所以可以随意修改它)。如果这发生了,nt!KiDeliverApc循环到列表中的下一个APC,所以对于这个APC没有做更多的事情.


否则,nt !KiDeliverApc 设置_KTHREAD.KernelApcInProgress(前面测试过的两个标志之一),降低IRQL为被动,并调用NormalRoutine,将其NormalContext, SystemArgument1和SystemArgument2传递给NormalRoutine。


有趣的是,NormalRoutine的地址来自于传递给KernelRoutine的相同指针,因此,内核例程有机会改变指针并导致不同的NormalRoutine被执行


当NormalRoutine返回时,nt!KiDeliverApc抛出IRQL回APC,清除KernelApcInProgress并循环到下一个APC,如果没有则离开


让我们回到对KernelApcDisable和KernelApcInProgress的两个检查上

如果有一个被设置,那么nt!KiDeliverApc清除ApcState.KernelApcPending并返回,因此,APC的交付不会再次发生,直到有人设置它。


如果发生这种情况是因为设置了KernelApcDisable,这意味着清除标志的代码可能会再次负责设置ApcState.KernelApcPending


如果发生这种情况是因为设置了KernelApcInProgress,那么场景就有点不同了。

在调用APC normal routine之前,这个函数已经设置了KernelApcInProgress,当我们发现它已经设置了,这意味着我们已经重新进入了nt!KiDeliverApc当它在一个正常的APC调度的中间


因此,我们从这个嵌套调用返回到nt!KiDeliverApc,外部调用将在某个点被恢复,APC调度循环将继续。换句话说,将KernelApcPending保留为clear并不重要,因为我们仍然在nt!KiDeliverApc的中间-外部调用


但是为什么nt!KiDeliverApc可以重新进入吗?为什么不受IRQL规则的保护?毕竟,调用nt!KiDeliverApc只在IRQL低于APC时才这样做,这应该可以防止重入问题


重点是,正如我们刚才看到的,nt!KiDeliverApc降低IRQL为PSSIVE_LEVEL调用NormalRoutine,,因此不保护自己免受嵌套APC中断的影响。当NormalRoutine在PASSIVE状态下执行时,任何事情都可能发生:没有IRQL保护.


在这个nt!KiDeliverApc非常特别:一个函数将IRQL值降低到它被调用时的值以下的情况并不常见。通常情况下,函数可能会提升它,然后将它带回到它的值。将IRQL设置为低于初始值,意味着揭示函数的调用者可能没有预料到的中断。显然,Windows是为了让nt!KiDeliverApc总是在将IRQL完全降低到PASSIVE的情况下调用


考虑到这是如何工作的,KernelApcInProgress标志看起来像是一种保护NormalRoutine不被重入的方法:

即使IRQL没有保护它们(毕竟它们以PASSIVE的方式运行),nt!KiDeliverApc。一个NormalRoutine作者可以有把握地假定他的例程不会在同一线程的上下文中重新进入。尽管如此,它还是有可能在另一个线程的上下文中重新进入,因为在PASSIVE线程调度是可以自由发生和ApcState.KernelApcInProgress是每个线程的标志.


另一个有趣的地方是,KernelApcDisable在调度循环的中间被检查,这意味着APC内核例程或普通例程可能会设置它,并导致循环被中止


最后,值得注意的是,特殊APC被链接在常规APC之前,因此,当我们发现第一个常规APC时(这可能会因为设置了KernelApcDisable而导致循环中止),所有的特殊APC都已经被处理了


当一个常规APC的NormalRoutine正在进行时,(ApcState.KernelApcInProgress != 0),其他常规apc不会被发送到同一个线程。即使线程捕捉到APC中断并进入nt!KiDeliverApc


当ApcState。KernelApcDisable !=0,常规apc不派发。



常规和特殊内核APC

关于常规APC有很多限制。在nt!KiDeliverApc函数中决定了APC如何派发问题。


当一个常规APC的NormalRoutine正在进行时,(ApcState.KernelApcInProgress != 0),其他常规apc不会被发送到同一个线程。即使线程捕捉到APC中断并进入nt!KiDeliverApc。防止重入


当ApcState.KernelApcDisable !=0时,常规APC不会派发

如果ApcState.KernelApcDisable != 0,nt!KiDeliverApc被调用, KernelApcPending被置0,这将防止在上下文切换时进一步调用该函数。

当设置了KernelApcPending时,nt !KiDeliverApc将被再次调用,可能是通过插入到另一个APC。


常规APC具有KernelRoutine和NormalRoutine,KernelRoutine运行在APC_LEVEL中,NormalRoutine运行PASSIVE_LEVEL中。

在KernelRoutine例程中可以随意改变NormalRoutine的地址。



此外,nt!KiInsertQueueApc意味着:

以下情况,常规APC被插入到等待线程中时,不会被唤醒:

1.另一个常规APC的NormalRoutine正在线程上进行

(ApcState.KernelApcInProgress ! = 0)

这意味着NormalRoutine可以进入等待状态,并确定线程在等待期间不会为其他常规apc服务

2.KernelApcDisable不为0时。


有趣的是,DDK是如何说明等待线程接收常规apc的条件的

“线程不在APC中,线程不在临界区”

因此,“在临界区中”KernelApcDisable被设置为一个非零值




用户APC

用户APC和内核APC的主要区别在于,用户APC的NormalRoutine在于R3,因此

在调用NormalRoutine之前,必须切换回R3。


调度:

对于内核模式APCs,由nt!KiInsertQueueApc可以分为两个主要步骤:将_KAPC链接到一个挂起的APC列表,更新目标线程的控制变量,以便在合适的条件适用时,它将处理正在等待的APC


链接 _KAPC到它的链表中:

选择APC环境的方式与选择内核模式的APC是一样的:这里也是ApcStateIndex = 3指定当前的环境,当nt!KiInsertQueueApc执行。然后,APC被链接到所选环境的列表尾部(除了稍后详细介绍的特殊用户APC)。注意,每个环境都有两个不同的列表:一个用于内核模式APCs,另一个用于用户模式APCs。


指示线程执行APC:

对于内核模式APCs,这个阶段首先检查APC是否适合当前环境,如果不适合,函数就离开。指向另一个环境的用户模式apc仍然挂起,可能直到下一次环境切换。(说明内核APC和用户APC的派发条件一致。


如果APC是针对当前线程的,结果是一样的:nt!KiInsertQueueApc就直接退出。当用户模式apc被分派时,这是有意义的:当一个线程正在等待警报alertable wait的状态。因为当前线程正在执行nt!KiInsertQueueApc,它显然没有等待,所以此时不能执行任何操作(用户APC执行的条件的是:线程处于等待状态,线程的WaitMode= UserMode)


这就引出了一个问题:如果这个线程试图在nt!KiInsertQueueApc返回后处于警报状态等待状态吗?很有可能,等待函数将不得不考虑这种可能性,否则线程可能会保持警觉地等待,但仍有挂起的apc。我们将在后面的一节中看到如何处理这个场景


如果APC是另一个线程,nt!KiInsertQueueApc通过在正在执行的处理器的_KPRCB的LockQueue处获取自旋锁来继续执行,并检查目标线程是否处于等待状态。对于任何其他线程状态,nt!KiInsertQueueApc 离开。这再次表明,只有当目标线程在等待时,用户APCs才会立即生效,否则它们就会被挂起。


然而,如果线程正在等待,WaitMode字段将与1UserMode进行比较:如果它有不同的值,则nt!KiInsertQueueApc停止。事实上,DDK声明一个等待线程只有在用户模式(在wdm中定义为1)中等待时才会接收用户模式APCs,所以我们看到的比较实现了这个标准。


下一步是检查是否设置了_KTHREAD的MiscFlags成员中的Alertable位:如果为true,则_KTHREAD.UserApcPending被设置为1,线程被调用nt!KiUnwaitThread,与edx设置为0c0h (STATUS_USER_APC);

nt !KiUnwaitThread也接收到传递给nt!KiInsertQueueApc,它将被应用到被唤醒的线程.


以上步骤意味着,当线程处于用户模式并处于可警报状态时,它会被设置为1的UserApcPending唤醒。正如我们即将看到的,这个标志将触发APC交付过程



nt!PsExitSpecialApc 和特殊的用户APC

nt!KiInsertQueueApc以一种特殊的方式对待KernelRoutine=nt!PsExitSpecialApc的用户APC。

首先,设置_KTHREAD.ApcState.UserApcPending为1,不管线程的状态如何

我们稍后将看到,当线程进入用户模式时,这个变量是如何将线程从它的执行路径转移的,并将它发送给用户APCs。然而,对于其他用户apc,只有当线程处于可警报等待状态并被唤醒时,才设置此变量。而对于这个特定的KernelMode例程,变量的设置与线程状态无关,这意味着一旦它从内核模式切换到用户模式,它就会被劫持


因此,这个APC特殊的用户APC被允许异步进入目标线程,这是用户APCs通常不能做的

此外,对于这个KernelRoutine,,APC被排队在挂起的用户APC列表的前面,而其他KernelRoutine的APC则按照FIFO顺序被链接起来


最后,对于这个APC,如果nt!KiInsertQueueApc发现目标线程处于WAIT状态WaitMode = UserMode,将唤醒该线程,而不管Alertable位的值是多少。记住,在普通的用户模式APCs中,线程只有在Alertable位设置后才会被唤醒。




这些特殊的步骤允许APC尽快被发送:如果线程可以在不破坏内核模式规则的情况下被唤醒(例如,WaitMode = KernelMode的等待不能被中止,无论如何),那么它就是。无论如何,设置了UserApcPending后,一旦线程从内核模式切换到用户模式,它就会为它的apc服务


触发线程调度:

就像内核模式APCs一样,nt!KiInsertQueueApc返回nt!KeInsertQueueApc,它调用线程分派器的代码nt!KeInsertQueueApc对于内核和用户apc都是相同的,在内核模式的对应章节中已经介绍了它


派发:


nt!KiDeliverApc 的调用时机:


我们在上一节中看到,对于用户模式APC,没有请求中断,也没有设置KernelApcPending,所以我们必须问自己nt!KiDeliverApc如何被调用?


为了回答这个问题,我们必须提到一些关于系统服务调度程序的事情

Windows API的许多函数需要调用执行服务来完成它们的工作,并通过调用系统服务调度器来完成。系统服务调度器的任务是将处理器切换到0环,并找出要调用的服务例程。


SSD通过执行sysenter指令切换到0环,该指令将控制权转移到nt!KiFastCallEntry。在进行sysenter-ing之前,eax被加载一个编号,标识要调用的执行服务


nt !KiFastCallEntry调用相关的执行服务程序,然后通过sysexit指令继续执行ring 3。

在此之前,nt!KiFastCallEntry检查_KTHREAD.ApcStat.UserApcPending,如果设置了,则调用nt!KiDeliverApc在恢复用户模式执行上下文之前交付用户apc


因此,如果设置了UserApcPending,它会在线程即将返回到ring 3时劫持线程。


例如,通过调用WaitForSingleObjectEx从用户模式进入alertable等待状态的线程会导致执行服务的调用。当线程未被nt!KiInsertQueueApc等待(说明此时当前线程没有执行nt!KiInsertQueueApc,它通过上面描述的SSD代码返回到ring 3,然后发送给处理用户APCs,最后从等待函数返回


用户模式APCs对内核等待功能的影响

上一节介绍了一个关于文档中DDK等待函数的有趣细节

例如,考虑关于KeWaitForSingleObject的DDK帮助:在可能的返回值中,列出了STATUS_USER_APC,描述如下

等待被中断,以便向调用线程交付用户异步过程调用(APC)。


关键是,用户APC还没有交付:当线程将重新进入用户模式时,它将交付

因此,当KeWaitForSingleObject返回时,APC仅仅中止了等待,但它仍然需要被交付。等待已经中止,因此线程可以返回到用户模式,并且由于设置了UserApcPending,交付APC


关于用户APC和内核等待功能的结论

DDK帮助解释了由内核等待函数(如KeWaitForSingleObject)发起的等待如何不会在内核APC中中止apc被交付,如果合适的条件适用,那么线程将继续等待。另一方面,同样的文档详细说明了这些等待函数如何中止对用户apc的等待


现在我们理解了为什么等待会被中止:在等待函数内部,线程不会交付用户APCs。相反,它从等待函数返回,这样跟随等待的代码可以返回到用户模式,在那里APCs将实际交付


这样做可能是因为在等待函数内部交付用户模式APCs太复杂了。毕竟,我们讨论的是用户APCs,它的NormalRoutine必须在用户模式下调用。内核必须以某种方式切换到用户模式,交付APC,然后切换回内核模式并恢复等待函数——这不是一件容易的事情


这意味着内核模式代码(例如驱动程序)必须“知道”这个,也就是说,它必须被写来处理STATUS_USER_APC返回代码和事实,它必须实际上返回到ring 3来允许用户APCs被服务


另一方面,内核模式apc可以在不中断等待函数的情况下交付,因为它们不需要从内核模式切换到用户模式


nt!KiDeliverApc对于用户APC:

我们现在要看看nt!KiDeliverApc最终被用户apc调用

首先,我们需要记住它有三个堆栈参数:PreviousMode、Reserved和TrapFrame。

当它被用户APCs调用时,PreviousMode被设置为UserMode,即1,并且TrapFrame指向一个_KTRAP_FRAME结构来存储线程的ring 3上下文。


第一部分的nt!KiDeliverApc执行内核APC调度,而不管PreviousMode的值是多少。换句话说,每当调用这个函数时,如果有内核模式的apc,它就会分派,然后,只有在PreviousMode = UserMode时,才会检查用户模式的apc。

内核模式APCs交付一节中解释的所有操作都被执行了。如果_KTHREAD。设置了SpecialApcDisable,既不交付内核也不交付用户apc。

样,如果nt!KiDeliverApc发现常规内核模式的APCs交付,然后找到KernelApcDisable或KernelApcInProgress 被设置,它停止交付APCs并返回,不处理用户APCs。


然而,当所有的内核apc都交付了,并且它们的列表是空的,nt!KiDeliverApc设置用于用户的apc。


首先,它检查_KTHREAD.ApcState.UserApcPending:如果它等于0,函数返回。

否则,nt !KiDeliverApc获取存储在_KTHREAD.ApcQueueLock的自旋锁。


然后它设置UserApcPending为0,并将APC的参数KernelRoutine,、NormalRoutine,、NormalContext、SystemArgument1、SystemArgument2复制给局部变量。然后,它从它的列表中释放APC。


现在_KAPC结构体不能被其他任何人访问,因为它已经不在列表中了,所以nt!KiDeliverApc释放自旋锁。


之后,使用NormalRoutine、NormalContext、SystemARgument1和SystemArgument2的地址调用内核例程.


当内核例程返回时,它可能已经将指针更改为NormalRoutine,,所以nt!KiDeliverApc检查它是否等于0。首先,让我们看看如果不是这样会发生什么。


这个函数调用nt!KiInitializeUserApc传递给它输入_KTRAP_FRAME地址,NormalRoutine, NormalContext, SystemArgument1和SystemArgument2。nt !KiInitializeUserApc将改变存储在_KTRAP_FRAME中的线程用户模式上下文,并发送它来交付APC。这个过程将在后面的部分中进行分析。在这个调用之后,nt!KiDeliverApc退出。


我们现在可以突出一个重要的细节:nt!KiDeliverApc只向一个用户提供APC;它不会在一个循环中传递名单上的所有apc。此外,它让UserApcPending保持clear,除非有人再次设置它,否则SSD将不会调用nt!KiDeliverApc。


这显然与MSDN关于APCs的Win32 api文档相矛盾。以下摘录是针对QueueUserAPC的:

当一个用户模式的APC被排队时,线程不会被指示去调用APC函数,除非它处于alertable状态。当线程处于alert状态后,线程会按照先进先出(FIFO)的顺序处理所有挂起的apc,并且等待操作返回WAIT_IO_COMPLETION


稍后我们将看到它是如何工作的


现在,让我们回到调用KernelRoutine的地方,看看当内核例程将指向NormalRoutine:的指针置零时会发生什么:对于当前的APC,没有更多的事情要做,所以nt!KiDeliverApc检查是否有其他用户APCs挂起,如果有,将UserApcPending设为1,然后退出。再次,nt !KiDeliverApc不会循环遍历所有用户apc。然而,这种情况与NormalRoutine运行时不同,因为UserApcPending被设置为1。


通过检查SSD代码,我们看到,在nt!KiDeliverApc返回,SSD检查UserApcPending,如果设置,调用nt!KiDeliverApc。因此,如果有更多的APC需要处理,下一辆就轮到它了


Nt!KiInitializeUserApc和用户模式上下文


nt!KiInitializeUserApc的工作是修改线程的用户模式上下文,以便当它返回到用户模式时,它被劫持来调用NormalRoutine.。用户模式上下文存储在_KTRAP_FRAME中,其地址被传递给nt!KiInitializeUserApc。


做好本职工作,nt!KiInitializeUserApc为ring 3堆栈上的_ CONTEXT结构分配空间,其esp存储在_KTRAP_FRAME中。在此过程中,它会使用已记录的ProbeForWrite函数探测用户模式堆栈,以确保可以写入该堆栈。


如果堆栈不可写,ProbeForWrite会引发异常,由nt!_ except _ handler4处理。我想它中止了这个过程,但我没有分析它。


然后用取自_KTRAP_FRAME的用户模式上下文填充_ CONTEXT结构。


nt!KiInitializeUserApc实际上在用户模式堆栈上保留了更多的空间,在_ CONTEXT结构下面,它将在离开之前存储以下数据:SystemArgument2、SystemArgument1、NormalContext、pNormalRoutine(按地址递减顺序列出)。


为了保持事物的一致性,nt!KiInitializeUserApc将用户模式esp更新为_KTRAP_FRAME,使其指向已分配数据的开头。这样,就好像用户模式代码将新数据推送到堆栈上一样

然后,它将存储在_KTRAP_FRAME内部的ring 3 eip设置为用户模式函数ntdll!KiUserApcDispatcher的地址离开,返回nt!KiDeliverApc


需要注意的是,现在我们有两个环3 eip的值:一个是_KTRAP_FRAME 内部的,它指向ntdll!KiUserApcDispatcher另一个是,在用户模式堆栈的_ CONTEXT内部,它有原始的用户模式eip



eip _KTRAP_FRAME的内部拷贝,是当执行从内核模式返回到用户模式时最重要的,所以线程将在ntdll!KiUserApcDispatcher内恢复ring 3执行。用户模式堆栈上剩余的_ CONTEXT将由ntdll!KiUserApcDispatcher使用在调用NormalRoutine后,稍后恢复原始用户模式上下文。

添加填充是为了将堆栈的其余部分与4个字节的边界对齐。

nt!KiInitializeUserApc返回nt!KiDeliverApc,依次返回SSD。然后SSD恢复用户模式执行,导致ntdll!KiUserApcDispatcher要调用的


通过ntdll!KiUserApcDispatcher的用户模式APC交付


这个用户模式函数弹出NormalRoutine的地址并调用它。正常例程在堆栈上查找NormalContext,、SystemArgument1和SystemArgument2 ,就好像它们是由调用推动的一样





在实际调用NormalRoutine,ntdll!KiUserApcDispatcher使用user mode堆栈上的8个空闲字节_ CONTEXT上的来构建异常处理程序注册,该注册链接在处理程序链的头部。此注册存储ntdll!KiUserApcExceptionHandler的地址,它将处理由NormalRoutine.引起的任何异常


当NormalRoutine返回时,ntdll!KiUserApcDispatcher从处理程序链中删除异常注册,并调用ntdll!NtContinue,它将从堆栈上的_ CONTEXT结构设置用户模式上下文。由于_ CONTEXT内部的eip值是APC之前的原始eip,用户模式执行将在APC交付之前的某个时间点恢复。


ntdll!NtContinue和用户模式上下文


ntdll!NtContinue实际上是作为一个执行服务来实现的,所以它只是将服务号(0x37)加载到eax中,并调用SSD,SSD反过来将控制权转移给内核模式函数nt!NtContinue.


通常,调用SSD会导致创建一个存储用户模式上下文的_KTRAP_FRAME。nt!NtContinue修改这个 _KTRAP_FRAME,以便在返回用户模式时,实际加载的上下文将是从原始_ CONTEXT结构传递给ntdll!NtContinue的上下文。


在大多数情况下,执行将在等待功能的用户模式部分内恢复,这导致APC首先被交付


我们终于准备好了解在实际从等待功能返回之前,Windows是如何交付所有用户APC的。


在恢复了用户模式上下文之后,就在返回用户模式之前,nt!NtContinue检查线程列表中是否有用户模式的APC,如果有,则将控制权转移回SSD。


因此,SSD会出现以下情况

用户模式上下文是APC之前的原始上下文

如果仍有APC需要交付,则设置用户APC交付.


因此,线程处于与交付第一个APC之前相同的情况。如果设置了UserApcPending,SSD会再次调用nt!KiDeliverApc和整个Apc交付过程将重复进行。


这一循环将一直持续到最终所有用户模式APC都已交付,SSD发现用户模式APC清除并最终恢复原始用户模式上下文



乍一看,这个过程可能看起来不必要的复杂:为什么没有nt!NtContinue再次设置UserApcPending,而不是简单地处理nt!KiDeliverApct内的所有APC

毕竟这是内核模式APC的情况。




它必须调用nt!KiInitializeUserApc来改变用户模式上下文,然后它必须允许执行返回到用户模式,然后才能处理下一个APC。毕竟,只有一个_KTRAP_FRAME存储用户模式上下文,因此,一旦它被操纵来交付第一个APC,就不能再为第二个APC更改它。相反,执行必须允许返回到用户模式来交付APC,只有在有更多APC的情况下,这个过程才可以重复。






















































































文章转载自横戈安全团队,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论