返回
顶部

reference:

本文附带的测试驱动文件

APC基本概念

APC可以使得一个线程从原先的执行流转而去执行我们指定的另一块代码

APC最重要的东西是他是针对一个特定的线程的

APC的文档很少,内核APC对他们的使用并不是公开的,而且他们内部的工作原理也只有很少一部分被外界知悉

APC之所以令我们感兴趣是因为他和windows的线程分发是密切相关的,通过分析他们我们可以更好地了解这个Windows核心特性

Widows Internal相关的书籍总是提及说APC是通过各种软件中断来被调度的

这就引起我们的一个疑问,系统是如何保证这个中断是发生在特定的线程上下文中的

这个其实就是APC的终极目标,软件中断可以中断到当前线程上下文中,而当前线程到底是哪一个是随机的

通过APC执行的代码,根据APC类型的不同,可以运行在特定的IRQL等级中,这个等级就叫做APC level

那么说了半天,APC到底是干啥的:

  • IO管理器使用APC来在发起该IO操作的线程上下文中完成IO操作
  • special APC将会在一个进程必须要terminate的时候进入到其执行流中
  • 内核APC的实现隐藏在诸如QueueUserAPC和ReadFileEx/WriteFileEx这种Windows API函数中

APC进程上下文

通常情况下,Windows线程执行在创建了自己的那个进程的上下文中,尽管如此,线程也有一个将自己附着在另一个进程中,也就是说在另一个进程的上下文中执行(什么情况下会这样,我怎么没听说过

Windows在管理APC的时候考虑到了这种情况,APC可能会被调度到拥有该线程的进程上下文中,也有可能被调度到当前线程所附着到的进程的上下文中

Windows通过_KAPC_STATE结构体来维护所有等待执行的APC的状态:

kd>  dt nt!_kapc_state
   +0x000 ApcListHead      : [2] _LIST_ENTRY
   +0x010 Process          : Ptr32 _KPROCESS
   +0x014 KernelApcInProgress : UChar
   +0x015 KernelApcPending : UChar
   +0x016 UserApcPending   : UChar

_KTHREAD结构体中有两个类型为_KAPC_STATE的字段,一个是ApcState,另一个是SavedApcState,他们被称作APC环境

ApcState是当前线程中的APC环境,不管这个线程是在自己的进程还是别的进程上下文中,这个字段中保存着可以被交付的APC

SavedApcState字段存储的是当前上下文暂时不可用,必须等待的APC

举例来说,如果线程附着到别的进程中,此时有针对当前线程的APC,那么这些APC就会被保存到SavedApcState中,他们会等待当前线程从其他进程上下文中剥离出来

根据上面的解释,我们可以理解当一个线程attach自己到另外一个进程的时候,ApcState会被拷贝到SavedApcState中,并被重新初始化

当线程脱离的时候,ApcState将会从SavedApcState字段中恢复,SavedApcState会被清空

同时,我们可以看到负责分发APC的内核模块总是查看ApcState来搜索可以传递的APC

_KTHREAD结构体中有一个数组,这个数组只有两个元素,其实就是两个指针,一个指向当前_KTHREAD的ApcState字段,另一个指向SavedApcState字段,其中[0]号指针总是指向线程所属的进程环境,[1]号指针总是指向当前线程附着到的进程环境

举例来说,如果当前线程没有附着在其他进程,当前线程所属的进程的环境处于活跃状态,那么这个环境信息会被保存到ApcState字段,然后ApcStatePointer[0]保存ApcState的地址

如果当前线程附着到了其他的进程,那么当前线程所属的进程环境会被保存到SavedApcState中,ApcStatePointer[0]指向SavedApcState地址

最后,_KTHREAD.ApcStateIndex存储着AcpStatePinter数组的index,这个index永远指向当前线程所处的进程的环境,也就是说在上面的例子中,第一种情况下,ApcStateIndex=0,第二种情况下,ApcStateIndex=1

当我们schedule一个APC的时候,我们可以指定将其添加到线程所属进程的环境中(ApcStatePointer[ApcStateIndex])还是当前处于活跃状态的环境中(ApcState)

APC类型

APC有三种类型

SpecialKernelModeApc,简称SKApc,这种APC运行在APC IRQL下,他是正儿八经的异步事件,他会将线程从原本的执行流改到内核模式的代码中去执行,这段代码叫做KernelRoutine

如果SKAPC被插入到线程队列中,如果该线程进入了等待状态,线程可以通过以下4个APC调用进入等待状态

  • KeWaitForSingleObject
  • KeWaitForMultipleObjects
  • KeWaitForMutexObject
  • KeDelayExecutionThread

此时线程就会被唤醒,然后执行KernelRoutine,之后重新进入等待状态

通常情况下,SKAPC会在目标线程IRQL降低至APC level(IRQL=1)时被尽快传递给目标线程,尽管如此,APC传递可以被线程禁用

_KTHREAD的SpecilaApcDisable成员被设置为非0值时,APC的传递将会被彻底禁用

第二种APC类型是普通内核APC,regular kernel APC

简称RKAPC,这种APC运行在PASSIVE IRQL(IRQL=0),和SKAPC一样,他们会打破线程原有的执行流,将线程从等待状态唤醒从而执行APC中的代码,尽管如此,RKAPC的执行拥有更多的限制条件,后面我们会再讨论到这个东西

第三种是用户模式APC,简称UMAPC,这种APC的运行存在更多的限制

只在线程进入alertable状态时被分发,在线程调用以下四个API的时候会进入到alertable状态:

  • KeWaitForSingleObject
  • KeWaitForMultipleObjects
  • KeWaitForMutexObject
  • KeDelayExecutionThread

调用这四个API的时候需要指定Alertable参数为true,指定WaitMode参数为User

因此,正常情况下,UMAPC不会异步地进入到线程的执行流中,他们更像是一个可以被队列的workitem,他们可以在任意时间被插入到一个线程中,由线程代码决定什么时候来处理他们

APC初始化

APC由_KAPC结构体代表

kd> dt nt!_KAPC
   +0x000 Type             : UChar
   +0x001 SpareByte0       : UChar
   +0x002 Size             : UChar
   +0x003 SpareByte1       : UChar
   +0x004 SpareLong0       : Uint4B
   +0x008 Thread           : Ptr32 _KTHREAD
   +0x00c ApcListEntry     : _LIST_ENTRY
   +0x014 KernelRoutine    : Ptr32     void 
   +0x018 RundownRoutine   : Ptr32     void 
   +0x01c NormalRoutine    : Ptr32     void 
   +0x020 NormalContext    : Ptr32 Void
   +0x024 SystemArgument1  : Ptr32 Void
   +0x028 SystemArgument2  : Ptr32 Void
   +0x02c ApcStateIndex    : Char
   +0x02d ApcMode          : Char
   +0x02e Inserted         : UChar

_KAPC结构体由KeInitializeApc函数初始化,函数签名如下(undocumented)

NTKERNELAPI VOID KeInitializeApc (
IN PRKAPC Apc,
IN PKTHREAD Thread,
IN KAPC_ENVIRONMENT Environment,
IN PKKERNEL_ROUTINE KernelRoutine,
IN PKRUNDOWN_ROUTINE RundownRoutine OPTIONAL,
IN PKNORMAL_ROUTINE NormalRoutine OPTIONAL,
IN KPROCESSOR_MODE ApcMode,
IN PVOID NormalContext
);

KeInitializeApc并不会schedule APC,而只是填充_KAPC结构体的一部分字段,要想真正的schedule一个APC,还有更多的步骤要做

KeInitilizeApc会将_KAPC的Type字段设置为一个常量0x12,将当前结构体标识为一个APC对象,并设置Size字段

线程字段Thread会被设置为该函数的第二个参数的值,即_KTHREAD指针

KeInitializeApc函数并不会设置ApcListEntry字段,它会在APC真正被调度的时候被用到,它用于把当前APC链接到目标线程所有的处于等待状态的APC中

KernelRoutine字段,由函数中的KernelRoutine参数来进行设置,这个函数是APC真正会执行的代码,每一个APC都有一个KernelRoutine,根据APC的类型,还可能会有一个NornalRoutine

由KernelRoutine字段指向的函数将会在APC IRQL下被调用

NormalRoutine和ApcMode参数决定了APC的类型

如果NormalRoutine为0,那么这就是一个SKAPC

对于这种类型的APC,KeInitializeApc将会设置_KAPC.ApcMode为0,表示内核模式,并将_KAPC.NormalContext设置为0,对应的函数参数将会被忽略,也就是说如果你调用KeInitializeApc函数的时候,将NormalRoutine参数设为0,那么即使你提供了一个NormalContext也会被该函数忽略掉

如果NormalRoutine不为0且ApcMode为0,那么这就是一个RKAPC,_KAPC.ApcMode_KAPC.NormalContext将根据函数参数进行设置

RKAPC也是执行内核代码的,因为ApcMode=0表示内核模式,但是他的特权比SKAPC要低,也就意味着他必须要在某些限制下被执行,后面会讲

NormalRoutine将会被存储到_KAPC对应的字段中,他将会在APC被传达的时候被执行

RKAPC被传达的时候,首先执行的就是KernelRoutine,在APC IRQL(IRQL=1),随后NormalRoutine会在PASSIVE IRQL(IRQL=0)下被执行,这两个Routine都是在内核模式下执行的

正如我们随后将会看到的那样,KernelRoutine有机会在NormalRoutine真正执行之前阻止NormalRoutine的执行,并且可以把NormalRoutine的地址修改掉

如果NormalRoutine不为0且ApcMode为1,那么这就是一个UMAPC,那么NormalRoutine就会在用户模式下执行

对于用户模式的APC,KeInitializeApc会将_KAPC.ApcMode_KAPC.NormalContext设置为函数参数指定的值

NormalContext参数会被同时传送给KernelRoutine和NnormalRoutine

SystemArgument1和2不会由KeInitializeApc来设置,而是在APC被调度时他们会被传递给回调routine,我们后面将会看到更多相关的细节

Environment参数,_KAPC.ApcStateIndex决定了APC的环境,会在后面作为ApcStatePointer数组的index来选定用于存储APC状态的_KAPC_STATE的地址,如果这个字段的值为2,那么它将会被设置为KeInitializeApc函数的第二个参数,即KTHREAD的ApcStateIndex的值,由于KTHREAD的ApcStateIndex字段总是指向ApcStatePointer数组中保存当前进程环境的指针的index,也就意味着这个APC会在任意环境下被调度

最后Inserted字段被设置为0以表示当前APC还未被插入到线程中,KeInsertQueueApc负责调度APC,该函数会将该字段设置为1

初始APC调度

所有类型的APC都是由KeInsertQueueApc来插入到队列中的,对于所有的APC来说,该函数的操作都是一样的

这个函数接收4个参数

  • 指向_KAPC的指针
  • 两个用户自定义的参数,SystemArgument1和2
  • 一个优先级增量

KeInsertQueueApc首先会请求一个自旋锁,这个锁存储在_KTHREAD中,然后将IRQL提升至0x1B,这样一来,他的后续操作就只能被时钟和IPI(IPI (Inter-Processor Interrupt) is a special interrupt used in multiprocessor (SMP) systems)打断

然后他会查看_THREAD的0xB8偏移,这个位置是一个1字节的flag位,他会检查第六个bit(从0开始数就是position 5)是否设置,即ApcQueueable是否被set,否则他将会立即终止并返回0

   +0x0b8 AutoAlignment    : Pos 0, 1 Bit
   +0x0b8 DisableBoost     : Pos 1, 1 Bit
   +0x0b8 EtwStackTraceApc1Inserted : Pos 2, 1 Bit
   +0x0b8 EtwStackTraceApc2Inserted : Pos 3, 1 Bit
   +0x0b8 CalloutActive    : Pos 4, 1 Bit
   +0x0b8 ApcQueueable     : Pos 5, 1 Bit
   +0x0b8 EnableStackSwap  : Pos 6, 1 Bit
   +0x0b8 GuiThread        : Pos 7, 1 Bit
   +0x0b8 UmsPerformingSyscall : Pos 8, 1 Bit
   +0x0b8 VdmSafe          : Pos 9, 1 Bit
   +0x0b8 UmsDispatched    : Pos 10, 1 Bit
   +0x0b8 ReservedFlags    : Pos 11, 21 Bits
   +0x0b8 ThreadFlags      : Int4B

然后KeInsertQueueApc会检查输入的_KAPC的Inserted字段是否被设置为1,如果为1,则返回FALSE(已经被插入了)

如果上面这两个检查都通过了的话,那么APC就会被按照如下步骤被调度

该函数首先会拷贝输入的SystemArgument1和2到_KAPC结构体的对应字段中,这些值将会被传递到APC回调Routine从而提供了一种给APC提供了上下文信息的通道,和NormalContext所不同的是他们的赋值时机,NormalContext是在APC初始化阶段就被赋值了的

之后,该函数会将_KAPC.Insereted设为1并调用nt!KiInsertQueueApc函数,其第二个参数将会被设置为优先级增量,第一个参数被设置为_KAP

nt!KiInsertQueueApc负责了APC调度的实际工作,这个函数后面会被我们再次提及

SKAPC和RKAPC

调度

由KiInsertQueueApc负责的调度可以被分为两大步骤:

  • _KAPC连接到等待中的APC
  • 更新目标线程的控制变量,从而使得线程可以转而去处理等待中的APC

链接_KAPC到链表

KiInsertQueueApc首先检查_KAPC.ApcStateIndex,到这里我们知道当KeInitializeApc被调用的时候,这个字段已经通过Environment参数设置过了,值2是一个pseudo-value,它的效果是设置环境为当前可用环境,不管是线程所属进程环境还是所处进程环境

KiInsertQueueApc允许另一个pseudo-value 3,这个值拥有和2类似的效果,如果ApcStateIndex值为3,那么它将会被替换为_KTHREAD.ApcStateIndex的值

因此2意味着KeInitializeApc被调用时的环境,3意味着KiInsertQueueApc被调用时所处的环境

_KAPC.ApcStateIndex的最终值被用于选择_KAPC将会被链接到的环境

KiInsertQueueApc访问目标线程的_KTHREAD的0x168偏移,即ApcStatePointer数组,由ApcStateIndex决定选择0号_KAPC_STATE还是1号,之后_KAPC将会被链接到上面选择的_KAPC_STATE

kd> dt nt!_KAPC_STATE
   +0x000 ApcListHead      : [2] _LIST_ENTRY
   +0x010 Process          : Ptr32 _KPROCESS
   +0x014 KernelApcInProgress : UChar
   +0x015 KernelApcPending : UChar
   +0x016 UserApcPending   : UChar

ApcListHead数组包含了两个链表的Head,一个是kernelmode另一个是usermode,KiInsertQueueApc使用_KAPC.ApcMode作为该数组的index,也就是说ApcListHead[0]表示内核APC,[1]表示用户APC

内核模式的APC链表被SKAPC和RKAPC共用,SKAPC会被链接到RKAPC前面,如果里面已经有了SKAPC,那么新的SKAPC会连接到他后面(仍然位于RKAPC前面)

命令线程去执行APC

本节我们将会讲解目标线程是如何从原来的执行流转而执行APC代码的,此时就会用到APC软件中断,正如我们即将看到的,这个中断只会用在特殊的场景下,控制变量也会被用到

处理过程的第一步就是检查由_KAPC.ApcStateIndex所指示的APC环境是否和当前环境一致

如果不一致,那么这个函数会直接返回,这个动作看起来非常有趣,一方面,他很合理,只要目标线程还附着在其他的进程环境中,他就无法处理APC,另一方面,这也意味着负责将线程切换到其他进程环境中的代码需要检查链表中的APC并尝试让线程去执行他们

这是众多APC interrupt不会被使用的场景之一

这个逻辑意味着内核模式的APC,即使是SKAPC,会一直处于等待状态,直到当前进程环境和_KAPC所指定的保持一致

如果环境一致,KiInsertQueueApc会继续检查APC是否是针对当前正在执行的线程的

当前线程的内核APC

在这种情况下,KiInsertQueueApc将目标_KTHREAD的ApcState的KernelApcPending字段设为1

ApcState字段存储了APC环境信息,由于我们已经决定了我们的APC是活动环境,我们直接从ApcState访问控制变量而不是从KTHREAD的ApcStatePointer来访问

KernelApcPending是一个非常重要的Flag,他在进入线程执行流的过程中扮演着非常重要的角色

SepcialApcDisable标志

如果这个标志位处于set状态,KiInsertQueueApc会检查_KTHREAD.SpecialApcDisable

+0x086 SpecialApcDisable : Int2B

如果他不为0,那么KiInsertQueueApc会直接返回

这就意味着SpecialApcDisable可以禁用所有类型的内核APC,包括SKAPC

APC是什么时候被分发的呢?一部分答案可以从nt!SwapContext函数中找到

这个函数用于加载一个线程的上下文,在他被选择运行和检查KernelApcPending标志位的时候,如果他是set状态,且SpecialApcDisable为0,那么他就会触发APC的分发

我们后面将会看到更多细节,现在我们暂且先记住一件事,那就是KernelApcPending的set状态会确保APC在线程下次被调度以运行的时候被分发(且此时SpecialApcDisable为0)

KiCheckForKernelApcDelivery会触发当前线程的内核APC的delivery,如果IRQL是PASSIVE,这个函数会将其升高至APC level并调用KiDeliverApc函数,这个函数负责deliver APC

否则他将会设置_KTHREAD.ApcState.KernelApcPending为1然后请求一个APC interrupt,这个中断将会在IRQL降低至APC level以下的时候调用KiDeliverApc,后面我们会讲到这个中断的细节

APC中断

回到KiInsertQueueApc函数中,我们已经分析了如果_KTHREAD.SpecialApcDisable非0时会发生什么,如果他是0,该函数会通过调用hal!HalRequestSoftwareInterrupt函数来请求一个APC软件中断,然后退出

在探索这个中断的效果之前,我们先记下来一件事情,那就是KiInsertQueueApc函数对APC所进行的操作是针对当前线程的

因此对于RKAPC的限制,也就是只有在特殊条件下才会被分发,也实现到了KiDeliverApc函数中,我们后面将会看到

APC中断不能在现在立刻处理,因为在KiInsertQueueApc内部,IRQL已经被升高到了0x1b,但是最终在IRQL被降低的时候会被处理,此时KiDeliverApc会被调用

这就让我们引出一个问题,在中断请求和中断处理中间这段时间,时钟仍然会继续中断处理器,由于时钟的IRQL是0x1c,比当前的0x1b要高

该中断的处理程序可以检测当前线程的时间片(quantum)已到期,并请求触发一个 DPC(延迟过程调用)软件中断,以调用线程调度器。

在这种情况下,当IRQL被降低至PASSIVE等级的时候,还有两个软件中断,一个是DPC,一个是APC,前者的IRQL等级更高,因此会先执行

所以线程调度器被执行,他可能会选择一个别的线程来运行,最终IRQL会降低到PASSIVE,APC中断会被处理,但是在错误的线程上下文中,尽管如此,还有两个处理APC的步骤可以挽救这个错误

第一个是APC分发器,KiDeliverApc函数,最终会被APC中断所调用,并允许其发现当前没有APC在列表中,如果是这种情况,他会直接返回,因此在错误的线程中执行并不会引发任何问题

尽管如此,我们可能会想我们存在失去正确线程的APC的风险,因为中断已经处理完了,但是事实并不是这样的,由于KerneApcPending标志位仍然处于set状态,并且SwapContext会查看原始线程并给予他再次运行的机会,重新触发KiDeliverApc函数

APC中断真的会break into到错误的线程中吗

测试代码

我专门为这篇文章写了一个测试驱动,确认了上面讲到的情况确实是有可能发生的,这个测试的测试函数是ApcSpuriousIntTest

这个函数在SwapContext函数的KiDeliverApc函数安装了一个hook,来追踪这两个函数什么时候被调用,以及调用时的一些数据,然后他会升高IRQL到DISPATCH level(IRQL=2,比APC level高一级),调度APC并浪费时间来等待DISPATCH中断被请求,之后将IRQL降低到PASSIVE,此时APC和DPC都可以被处理,这个驱动会向调试器控制台打印trace信息

下面是我捕捉的一个trace信息

ffffd0823e0a5080是我们降低IRQL之前的线程,我们在将irql降低至passive之后,执行流就会被swapcontext抢占,因为此时存在dpc中断,swapcontext会进行线程调度,从trace中可以看到即使是kernelapcpending为1,如果不是将要切换到ffffd0823e0a5080线程,kideliverapc也不会deliver apc,直到new thread是ffffd0823e0a5080的时候,kideliverapc才会真正的deliver apc,证据就是在kideliverapc trace后面就是我们一开始插入的kernelapc的ApcKernelRoutineSIT的trace打印

另外我们还可以注意到的一点就是,我们在降低irql之后,此时是同时存在dpc和apc中断的,你可以看到swapcontext trace之后就是kideliverapc trace,因为dpc中断处理完了,就是处理apc中断,但是此时新线程并不是ffffd0823e0a5080,即非目标线程,所以他是不会deliver apc的

针对其他线程的kernel apc

在这种情况下,kiinsertqueueapc函数需要另外一个用于保护KPRCB的自旋锁

该函数会检查目标线程的状态,_kthread的state字段存储了线程状态信息,一共有以下几种状态

  • ready 1
  • running 2
  • wait 5
  • deferredready 7
  • gatewait 另一种等待状态

运行状态的thread

如果线程状态是running,那么他肯定是运行在另一个处理器上的,因为我们已经确定他是另一个线程了,而当前处理器正在运行当前线程(kiinsertqueueapc),如果另一个线程还处于running状态,那么只有一种可能,那就是他运行在另一个处理器中

那么就会产生一个IPI中断(interprocessor interrupt)(处理器间中断),这个中断会被发送到目标线程所在的处理器,从而触发针对目标线程的APC中断

线程处于等待状态

在某些特定条件下,kiinsertqueueapc会唤醒目标线程并执行apc

kthread的waitirql字段保存了他处于运行状态时的irql

如果kiinsertqueueapc发现目标线程的waitirql不为0,就会退出,(为甚么会退出,这两者有什么逻辑关系吗?)

这就让我们对这个字段开始感兴趣了,当一个线程进入到一个等待状态,这个字段记录运行时的irql,即转换到wait状态前的irql等级

这个观点可以由对kewaitforsingleobject函数的分析来确认,该函数会将当前irql存储到这个字段中

因此,kiinsertqueueapc背后的逻辑很简单,如果线程没有运行在passive等级,那么apc中断就无法触发,因为apc中断等级是1,如果线程不是运行在passive等级,即irql=0的等级,那至少就是运行在irql=1等级,apc中断也是1,那么就无法抢占目标线程的执行流,这也是为什么前面kiinsertqueueapc在发现waitirql不为0就会退出的原因

另外还有一点需要注意的是,处于等待状态的线程的waitirql其实只可能是两个值,要么0要么1,因为高于apc的线程是无法被调度的,dpc等级也就只有2而已

和往常一样,kernelapcpending字段被设置,来保证当时机合适时,apc将会最终被delivered

如果线程确实是在passive等级下等待,那么kiinsertqueueapc会检查specialapcdisable字段,如果该字段不为0,他会直接退出,不会尝试唤醒线程

如果apc是启用状态,且是一个SKAPC,那么kiinsertqueueapc函数会唤醒目标线程,该线程会由swapcontext直接唤醒并派遣apc

如果这是一个RKAPC(regular kernel apc),那么将会进行两个额外的检查

首先,如果kernelapcdisable不为0,kiinsertqueueapc会直接退出,不会唤醒目标线程,因此kernelapcdisable和specialapcdisable字段的作用类似,只不过他是针对RKAPC的

第二个检查是针对apcstate字段的kernelapcinprogress字段的,如果该字段不为0,那么kiinsertqueueapc就会退出

nt!_KAPC_STATE
   +0x000 ApcListHead      : [2] _LIST_ENTRY
   +0x020 Process          : Ptr64 _KPROCESS
   +0x028 InProgressFlags  : UChar
   +0x028 KernelApcInProgress : Pos 0, 1 Bit
   +0x028 SpecialApcInProgress : Pos 1, 1 Bit
   +0x029 KernelApcPending : UChar
   +0x02a UserApcPendingAll : UChar
   +0x02a SpecialUserApcPending : Pos 0, 1 Bit
   +0x02a UserApcPending   : Pos 1, 1 Bit

我们将会看到在kideliverapc函数中,他会设置kernelapcinprogress标志位,在调用RKAPC的normal routine之前

这个检测意味着如果线程进入等待状态,在处理RKAPC的过程中进入等待状态,那么我们可以保证它不会被其他的RKAPC所劫持,换句话说,RKAPC不会套娃(nested)

我们在前面已经看到过,当APC是针对当前线程的时候,kideliverapc会被调用,不管是什么类型的APC,不管是RKAPC还是SKAPC

线程唤醒和kiunwaitthread

其实在我的最新版本上的win11上面已经不存在这个函数了,win11上的是kitryunwaitthread

如果kiinsertqueueapc决定唤醒线程,他会通过调用kiunwaitthread函数来完成,这个向我们展示了一个有趣的信息

DDK文档表明当一个等待线程被派遣,来处理一个内核APC的时候,处理完成后他会重新进入等待状态,也就是说这个线程不会在处理完apc之后从kewaitfor...函数中返回,从另一方面来讲,ddk也说明了一个等待线程被派遣用于处理一个用户模式的apc的时候将会从wait函数中返回,返回值是status_user_apc

这个在微软文档中确实可以看到

image-20251107151829186

status_kernel_apc定义为0x100

kiunwaitthread的edx决定了目标线程在处理完apc之后是否应该从wait函数中返回,如果是status_user_apc,那么等待函数就会返回,如果是status_kernel_apc,那么在处理完apc之后会重新恢复为等待状态,即调用者永远都不会看到这个返回值,这也是为什么status_kernel_apc并未被documented

image-20251107152609660

kiunwaitthread的另一个传入参数是优先级增量,这个参数来自于kiinsertqueueapc的caller给他传进来的,有趣的是,如果kiinsertqueueapc必须要自旋来等待同步锁的时候,这个值会会在每次迭代的时候增加1,意味着优先级会一直上升,来补偿在自旋中损失的时间

唤醒目标线程之后,kiinsertqueueapc会释放自旋锁并退出

针对gatewait状态下的thread

gatewait状态的值是8,是另一种类型的等待状态

对于这种线程,kiinsertqueueapc首先会同步threadlock字段

nt!_KTHREAD
   +0x040 ThreadLock : Uint8B

这个锁的用法跟自旋锁很像,只不过不会真的调用正常的自旋锁函数,处理器会在一个循环中自旋直到成功修改他的值

在拥有锁之后,kiinsertqueueapc会检查线程状态是否仍然为gatewait,如果改变了,就释放所有锁并退出,这是正常的,因为对于kiinsertqueueapc来说,除了running、wait和gatewait状态,kiinsertqueueapc什么都不会做,只会设置一个kernelapcpending标志

这意味着threadlock保护了线程状态,函数改变状态,只会在获取到锁之后

之后,kiinsertqueueapc会进行常规检查,和waiting状态的函数一样,waitirql必须是pasive(0),specialapcdisable必须为0,以及kernelapcdisable和kerneapcinprogress

如果这些检查都通过了,kiinsertqueueapc会执行unwait线程的操作

然后将这个线程从一个链表中解脱出来,这个链表被连接在waitblock[0]字段中,然后将其状态修改改为deferredready状态,就像kiunwaitthread对等待线程所作的那样

0: kd> dt _kthread waitblock
nt!_KTHREAD
   +0x140 WaitBlock : [4] _KWAIT_BLOCK
0: kd> dt _KWAIT_BLOCK
nt!_KWAIT_BLOCK
   +0x000 WaitListEntry    : _LIST_ENTRY
   +0x010 WaitType         : UChar
   +0x011 BlockState       : UChar
   +0x012 WaitKey          : Uint2B
   +0x014 SpareLong        : Int4B
   +0x018 Thread           : Ptr64 _KTHREAD
   +0x018 NotificationQueue : Ptr64 _KQUEUE
   +0x018 Dpc              : Ptr64 _KDPC
   +0x020 Object           : Ptr64 Void
   +0x028 SparePtr         : Ptr64 Void

kiinsertqueueapc也会这是watistatus字段为0x100,

kiinsertqueueapc会将线程链接到一个由deferredreadylisthead指向的链表中,这个东西在kprcb结构体中

nt!_KPRCB
   +0x2d08 DeferredReadyListHead : _SINGLE_LIST_ENTRY

针对kireadythread和kideferredreadythread函数的分析证明了deferredready是一个状态,这个状态的含义是,一个线程准备运行了,在选择在哪个处理器上运行本线程之前,线程将会从deferredready状态切换到ready状态,如果选择的那个处理器正忙于运行其他的线程,或者会切换到standby状态,如果目标处理器可以立刻开始执行本线程

因此kiunwaitthread和kiinsertqueueapc会开启相同的转换,这个转换会导致线程成为running状态并开始处理他的apc

wait VS gatewait

有趣的是比较kiunwaitthread的步骤在wait状态中的线程

和kiinsertqueueapc在gatewait线程中的步骤相比

线程等待队列

对于一个wait线程,kiunwaitthread将其从该线程正在等待的对象中解脱出来,线程被连接到他们通过一个由waitblocklist字段指向的链接,kiunwaitthread遍历这个链表并将其解脱出来

这些waitblocks,在azure cto Mark Russinovich的书《Inside Microsoft Windows 2000; Third Edition;》中有详细的介绍,在第230页Data Structures一节:

kthread中有两个重要的数据结构跟踪了是谁在等待什么
dispatcher headers和wait block
0: kd> dt _kthread
nt!_KTHREAD
   +0x000 Header           : _DISPATCHER_HEADER

0: kd> dt _kthread waitblock
nt!_KTHREAD
   +0x140 WaitBlock : [4] _KWAIT_BLOCK

0: kd> dt _DISPATCHER_HEADER
nt!_DISPATCHER_HEADER
   +0x000 Lock             : Int4B
   +0x000 LockNV           : Int4B
   +0x000 Type             : UChar
   +0x001 Signalling       : UChar
   +0x002 Size             : UChar
   +0x003 Reserved1        : UChar
   +0x000 TimerType        : UChar
   +0x001 TimerControlFlags : UChar
   +0x001 Absolute         : Pos 0, 1 Bit
   +0x001 Wake             : Pos 1, 1 Bit
   +0x001 EncodedTolerableDelay : Pos 2, 6 Bits
   +0x002 Hand             : UChar
   +0x003 TimerMiscFlags   : UChar
   +0x003 Index            : Pos 0, 6 Bits
   +0x003 Inserted         : Pos 6, 1 Bit
   +0x003 Expired          : Pos 7, 1 Bit
   +0x000 Timer2Type       : UChar
   +0x001 Timer2Flags      : UChar
   +0x001 Timer2Inserted   : Pos 0, 1 Bit
   +0x001 Timer2Expiring   : Pos 1, 1 Bit
   +0x001 Timer2CancelPending : Pos 2, 1 Bit
   +0x001 Timer2SetPending : Pos 3, 1 Bit
   +0x001 Timer2Running    : Pos 4, 1 Bit
   +0x001 Timer2Disabled   : Pos 5, 1 Bit
   +0x001 Timer2ReservedFlags : Pos 6, 2 Bits
   +0x002 Timer2ComponentId : UChar
   +0x003 Timer2RelativeId : UChar
   +0x000 QueueType        : UChar
   +0x001 QueueControlFlags : UChar
   +0x001 Abandoned        : Pos 0, 1 Bit
   +0x001 DisableIncrement : Pos 1, 1 Bit
   +0x001 QueueReservedControlFlags : Pos 2, 6 Bits
   +0x002 QueueSize        : UChar
   +0x003 QueueReserved    : UChar
   +0x000 ThreadType       : UChar
   +0x001 ThreadReserved   : UChar
   +0x002 ThreadControlFlags : UChar
   +0x002 CycleProfiling   : Pos 0, 1 Bit
   +0x002 CounterProfiling : Pos 1, 1 Bit
   +0x002 GroupScheduling  : Pos 2, 1 Bit
   +0x002 AffinitySet      : Pos 3, 1 Bit
   +0x002 Tagged           : Pos 4, 1 Bit
   +0x002 EnergyProfiling  : Pos 5, 1 Bit
   +0x002 SchedulerAssist  : Pos 6, 1 Bit
   +0x002 ThreadReservedControlFlags : Pos 7, 1 Bit
   +0x003 DebugActive      : UChar
   +0x003 ActiveDR7        : Pos 0, 1 Bit
   +0x003 Instrumented     : Pos 1, 1 Bit
   +0x003 Minimal          : Pos 2, 1 Bit
   +0x003 Reserved4        : Pos 3, 2 Bits
   +0x003 AltSyscall       : Pos 5, 1 Bit
   +0x003 Emulation        : Pos 6, 1 Bit
   +0x003 Reserved5        : Pos 7, 1 Bit
   +0x000 MutantType       : UChar
   +0x001 MutantSize       : UChar
   +0x002 DpcActive        : UChar
   +0x003 MutantReserved   : UChar
   +0x004 SignalState      : Int4B
   +0x008 WaitListHead     : _LIST_ENTRY

0: kd> dt _KWAIT_BLOCK
nt!_KWAIT_BLOCK
   +0x000 WaitListEntry    : _LIST_ENTRY
   +0x010 WaitType         : UChar
   +0x011 BlockState       : UChar
   +0x012 WaitKey          : Uint2B
   +0x014 SpareLong        : Int4B
   +0x018 Thread           : Ptr64 _KTHREAD
   +0x018 NotificationQueue : Ptr64 _KQUEUE
   +0x018 Dpc              : Ptr64 _KDPC
   +0x020 Object           : Ptr64 Void
   +0x028 SparePtr         : Ptr64 Void

dispatch header包含了对象类型即0x0偏移处的type字段

对象类型dump代码

我当前测试用的win11的dump结果

他这个dispatcher header中的type跟我的有点对不上啊,我等待的明明是一个event啊,可是实际调试器中查看type的值是6

他这个object type跟我理解的object type好像不是一个东西

他是KOBJECTS那个枚举

我也不知道他这个准不准,根据他这个上面的定义,6代表的是THREAD,thread等待thread是什么玩意儿

我先来翻译一下azure cto的书里面的内容

dispatch header包含对象类型、signaled state,和一个线程等待的对象的列表

wait block代表了一个线程正在正待的一个对象

每一个线程处于等待状态的,都拥有一个wait block列表,用于表示线程正在等待的对象

每一个dispatcher对象都有一个列表,这个列表是wait blocks列表,代表线程正在等待的对象

这个列表被保存,因此当一个派遣对象被signaled的时候,内核可以快速决定谁正在等待这个对象

这个wait block有一个指针,指向正在等待的对象,还有一个指向正在等待这个对象的线程的指针,还有一个指向下一个wait block的指针(如果这个线程正在等待多个对象的话)

他同时也记录了等待的类型,以及位置of这个entry在这个handle数组由线程通过waitformultipleobjects函数传入的数组(如果线程只等待一个对象的话,那就是0)

下图展示了dispatch对象和wait blocks和线程之间的关系

image-20251108162825941

从我实际调试的结果来看,无法反映上图中所展示的关系,我的kthread结构体中有一个waitblock数组,长度为4,其中前两个有值,后两个没值

image-20251108163008352

我当前调试显示只有0和1号element的object字段不为0,这个对象一个是event一个是transaction manager Enlistment

0: kd> !object 0xffff9b06178cb660
Object: ffff9b06178cb660  Type: (ffff9b060781de20) Event
    ObjectHeader: ffff9b06178cb630 (new version)
    HandleCount: 1  PointerCount: 32769
0: kd> !object 0xffffc10bc3644a88
Object: ffffc10bc3644a88  Type: (ffff9b06077fa230) TmEn
    ObjectHeader: ffffc10bc3644a58 (new version)
    HandleCount: 18446633048981155296  PointerCount: 18446633049058614408

我们现在只需要关注event对象即可,也就是0号元素,WaitListEntry 字段确实不是空的,使用windbg的!list命令查看可以看到两个节点,根据cto的书的说明,这个listentry可以索引到dispatch object中的wait list head指向的链表

哦我好像误会了他的意思,他说的是dispatch对象,并不是kthread中的disatch header

所有的dispatcher对象都拥有一个dispatch header,所以我们应该去查看event对象

0: kd> dt _kevent ffff9b06178cb660
nt!_KEVENT
   +0x000 Header           : _DISPATCHER_HEADER
0: kd> dt _DISPATCHER_HEADER ffff9b06178cb660 WaitListHead  
nt!_DISPATCHER_HEADER
   +0x008 WaitListHead : _LIST_ENTRY [ 0xffff9b06`102e71c0 - 0xffff9b06`102e71c0 ]
0: kd> dt _kthread waitblock
nt!_KTHREAD
   +0x140 WaitBlock : [4] _KWAIT_BLOCK
0: kd> dqs ffff9b06102e7080+140 ; ffff9b06102e7080我们的测试kthread
ffff9b06`102e71c0  ffff9b06`178cb668
ffff9b06`102e71c8  ffff9b06`178cb668

可以看到kevent对象的waitlisthead确实指向了kthread的waitblocks数组的0号元素的listentry所在的链表

好了,关于等待对象我们就看到这里就够了,吼吼吼

回到apc那本书,waitblocks用来连接一个线程到dispatcher对象中(evetn、mutexes等),它允许一个事实,那就是一个线程可以等待多个对象,且每一个对象都可以由多个线程在等待

为了实现这个,关系,每一个线程都有一个waitblocks数组,这里每一个节点代表一个对象这个线程正在等待的

这个结论和我前面看cto的书得到的结论是一致的,每一个节点都是一个_kwait_block结构体

nt!_KWAIT_BLOCK
   +0x000 WaitListEntry    : _LIST_ENTRY
   +0x010 WaitType         : UChar
   +0x011 BlockState       : UChar
   +0x012 WaitKey          : Uint2B
   +0x014 SpareLong        : Int4B
   +0x018 Thread           : Ptr64 _KTHREAD
   +0x018 NotificationQueue : Ptr64 _KQUEUE
   +0x018 Dpc              : Ptr64 _KDPC
   +0x020 Object           : Ptr64 Void
   +0x028 SparePtr         : Ptr64 Void

以前的vista的这个结构体中有一个next字段用于指向当前线程等待的下一个对象,但是我这个win11没有了,那么我想知道,如果当前线程等待了多个对象的时候,你怎么跟踪这个状态呢,让我来写个代码测试一下

桥豆麻袋,不会是这个notificationqueue字段在负责这个吧

我先看一下我等待一个event的时候这个字段是什么状态

可以看到现在这个队列是空的,里面都是无意义的数值

0: kd> Dt _KQUEUE 0xffff9b06`102e7080
nt!_KQUEUE
   +0x000 Header           : _DISPATCHER_HEADER
   +0x018 EntryListHead    : _LIST_ENTRY [ 0x00000000`00000000 - 0x00000000`08229211 ]
   +0x028 CurrentCount     : 0xc3645c70
   +0x02c MaximumCount     : 0xffffc10b
   +0x030 ThreadListHead   : _LIST_ENTRY [ 0xffffc10b`c3640000 - 0xffffc10b`c3646000 ]

ok,我去网上搜了一下,弄明白了

关键就是WaitBlockList字段,这个字段是一个指针,里面存了一个数组的地址,这个数组的元素是_KWAIT_BLOCK,至于他具体有几个元素,是在哪里指定的我没弄清楚,但是每隔0x30字节就是一个_KWAIT_BLOCK结构体,和!process kthread 2的输出是完全吻合的

用于等待多个event来进行测试的代码

kiunwaitthread会遍历这个数组来将entry从event对象链表中解脱出来

而kiinsertqueueapc做的事情对于gatewait状态的线程有点不一样

他会使用kthread的gateobject字段,该字段指向kgate结构体,kgate只有一个字段,就是dispatchheader

靠,我的win11根本就没有他说的这个字段,算了妈的先打会儿游戏再说