Windows XP APC(二)
1 APC函数执行
上一篇文章《Windows XP APC(一)》已经将APC内核对象插入到目标线程的APC队列讲解完成了,接下来讲解APC函数在目标线程中是如何执行的。
一个线程如果要执行挂在APC队列的APC函数,最先会调用函数KiDeliverApc去处理后续的流程。
1.1 APC函数执行的条件
哪些情况下线程会执行APC函数:
- 线程切换。线程切换后(
KiSwapThread
),新线程执行时会调用KiDeliverApc
。 - 系统调用、中断、异常。会执行函数
_KiServiceExit
,然后调用KiDeliverApc
。
总结一句话:凡是会调用KiDeliverApc
函数的点,都有机会执行APC函数。
函数_KiServiceExit
是CPU从系统调用、中断或异常处理返回用户空间时的必经之路。
在函数KiDeliverApc
按一下X
查看一下交叉引用(ntkrpamp.exe
2-9-9-12分页多核):
实际上调用KiDeliverApc
的函数有以上那些。
然后看一下《Windows内核原理与实现5.2 P335》中,在WRK版本里除了上面提到的线程切换、系统调用、中断、异常,还有一种情况是:
当内核代码离开一个临界区或者守护区(调用
KeLeaveGuardedRegion
或KeLeaveCriticalRegion
)时,通过KiCheckForKernelApcDelivery
函数直接调用KiDeliverApc
,或者调用KiRequestSoftwareInterrupt
函数请求一个APC_LEVEL
的软件中断。这是因为,当线程进入临界区或守护区时,普通内核模式APC或特殊内核模式APC被禁止了,所以,当离开时,KiCheckForKernelApcDelivery
函数被调用,以便及时地交付内核模式APC。
1.2 内核APC执行过程
跟着海哥追了一下SwapContext
最后的代码,如果切换后的线程NextThread的KernelApcPending != 0
,则会返回eax = 1
,然后一直返回到函数KiSwapThread
中,紧接着该函数就调用函数KiDeliverApc
去线程的APC。
KiDeliverApc
函数执行内核APC流程:
1)判断第一个链表是否为空
2)判断KTHREAD.ApcState.KernelApcInProgress是否为1
3)判断是否禁用内核APC(KTHREAD.KernelApcDisable是否为1)
4)将当前KAPC结构体从链表中摘除
5)执行KAPC.KernelRoutine指定的函数 释放KAPC结构体占用的空间
6)将KTHREAD.ApcState.KernelApcInProgress设置为1 标识正在执行内核APC
7)执行真正的内核APC函数(KAPC.NormalRoutine)
8)执行完毕 将KernelApcInProgress改为0
9)循环
实际执行过程会在后面的KiDeliverApc
函数逆向分析时候具体解释。
总结:
1)内核APC在线程切换的时候就会执行,这也就意味着,只要插入内核APC很快就会执行。
2)在执行用户APC之前会先执行内核APC。
3)内核APC在内核空间执行,不需要换栈,一个循环全部执行完毕。
1.3 用户APC执行过程
处理用户APC要比内核APC复杂的多,因为用户APC函数要在用户空间执行的,这里涉及到大量换栈的操作:
当线程从用户层进入内核层时,要保留原来的运行环境,比如各种寄存器,栈的位置等等 (
_Trap_Frame
),然后切换成内核的堆栈,如果正常返回,恢复堆栈环境即可。但如果有用户APC要执行的话,就意味着线程要提前返回到用户空间去执行,而且返回的位置不是线程进入内核时的位置,而是返回到其他的位置,每处理一个用户APC都会涉及到:内核 –> 用户空间 –> 再回到内核空间。
KiDeliverApc
函数分析:
1)判断用户APC链表是否为空
2)判断第一个参数是为1
3)判断ApcState.UserApcPending是否为1
4)将ApcState.UserApcPending设置为0
5)链表操作 将当前APC从用户队列中拆除
6)调用函数(KAPC.KernelRoutine)释放KAPC结构体内存空间
7)调用KiInitializeUserApc
函数
KiInitializeUserApc
函数分析:备份CONTEXT
。线程进0环时,原来3环的运行环境(寄存器栈顶等)保存到0环的一块内存
_Trap_Frame
结构体中,如果要提前返回3环去处理用户APC,就必须要修改_Trap_Frame
结构体,但是又要保证不影响正常调用时线程返回到原来3环的地方,所以需要对_Trap_Frame
的值进行备份:- 比如:进0环时的位置存储在EIP中,现在要提前返回,而且返回的并不是原来的位置,那就意味着必须要修改EIP为新的返回位置。还有堆栈ESP也要修改为处理APC需要的堆栈。那原来的值怎么办呢?处理完APC后该如何返回原来的位置呢?
- 函数
KiInitializeUserApc
要做的第一件事就是备份:将原来_Trap_Frame的值备份到一个新的结构体中(CONTEXT),这个功能由其子函数KeContextFromKframes
来完成。
KiInitializeUserApc
函数分析:线程0环与3环堆栈切换、准备用户层执行环境。堆栈示意图如下
准备用户层执行环境
ntdll.KiUserApcDispatcher
函数分析:1、当用户在3环调用
QueueUserAPC
函数来插入APC时,不需要提供NormalRoutine
,这个参数是在QueueUserAPC
内部指定的:BaseDispatchAPC
。
2、ZwContinue
函数的意义:- 返回内核,如果还有用户APC,重复上面的执行过程。
- 如果没有需要执行的用户APC,会将CONTEXT赋值给Trap_Frame结构体。就像从来没有修改过一样。ZwContinue后面的代码不会执行,线程从哪里进0环仍然会从哪里回去。
2 KiDeliverApc
KiDeliverApc
:This function is called from the APC interrupt code and when one or more of the APC pending flags are set at system exit and the previous IRQL is zero. All special kernel APC’s are delivered first, followed by normal kernel APC’s if one is not already in progress, and finally if the user APC queue is not empty, the user APC pending flag is set, and the previous mode is user, then a user APC is delivered. On entry to this routine IRQL is set to APC_LEVEL.
1 | VOID __stdcall KiDeliverApc ( |
函数逆向分析如下:
1 | VOID __stdcall KiDeliverApc(PreviousMode, ExceptionFrame, TrapFrame) |
函数KiDeliverApc
主要功能如下:
- 先将当前线程的
TrapFrame
和所属进程进行备份,然后使用参数中的TrapFrame
给当前线程的TrapFrame
赋值; KernelApcPending = FALSE
提前声明没有待执行的内核APC函数,因为马上会循环全部执行完;- 循环检查内核APC队列,不为空的话,再判断内核APC函数是普通APC还是特殊APC:
- 普通APC:
- 如果
KernelApcInProgress ==0 && CurrentThread.KernelApcDisable == 0
,将当前APC对象从内核APC队列里摘除。如果前面的条件不满足就去BugCheckAndReturn
检查是否需要调用函数KeBugCheckEx
去蓝屏; - 执行函数
Apc->KernelRoutine
; - 如果
Apc->NormalRoutine != 0
的话(KernelRoutine
的执行有可能改变这个指针的值)就先将KernelApcInProgress = TRUE
,并将IRQL降为PASSIVE_LEVEL(0)
; - 执行函数
Apc->NormalRoutine
; - 将IRQL升为
APC_LEVEL(1)
。 - 设置
KernelApcInProgress = FALSE
。
- 如果
- 特殊APC:
- 将当前APC对象从内核APC队列里摘除;
- 执行函数
Apc->KernelRoutine
;
- 普通APC:
- 如果用户APC队列不为空,并且
UserApcPending == 1
,就会试图执行用户APC。UserApcPending = FALSE
,提前声明用户APC队列没有正在等待执行的用户APC,因为一次只会执行一次(没有循环);- 将当前APC对象从用户APC队列里摘除;
- 执行函数
Apc->KernelRoutine
; - 判断
Apc->NormalRoutine
是否为0
(KernelRoutine
的执行有可能改变这个指针的值):Apc->NormalRoutine != 0
,调用KiInitializeUserApc
函数准备回3环调用Apc->NormalContext
;Apc->NormalRoutine == 0
,调用KeTestAlertThread
函数(没有用户层的回调函数,那么自己alert,执行后面的APC)检查该线程是否可以交付另一个用户模式的APC。
- 恢复TrapFrame。用之前备份的
TrapFrame
给当前线程的TrapFrame
赋值。
需要注意一下几点:
- 执行内核
NormalRoutine
时才会KernelApcInProgress = 1
,执行KernelRoutine
时KernelApcInProgress
不会置1。 - 执行内核
NormalRoutine
IRQL是0
。 - 用函数
KiInitializeUserApc
去准备回3环调用Apc->NormalContext
时并不会将IRQL降为0
。 - 每次进入函数
KiDeliverApc
,会将当前线程的所有内核APC函数全部循环执行了,但是只会执行挂在用户APC队列的第一个用户APC函数,处理用户APC函数并没有循环。《Windows内核情景分析5.8 P373》
APC整个执行过程在《Windows内核情景分析5.8》都有函数分析。
源代码如下:
1 | VOID KiDeliverApc ( |
3 KeTestAlertThread
通过对函数KiDeliverApc
交付(Deliver)用户APC函数的过程,在交付用户过程中会先执行KernelRoutine
函数(参数包含NormalRoutine
),函数执行过程中有可能会修改NormalRoutine
,所以需要对NormalRoutine
进行检查是否为0
。
为什么需要判断NormalRoutine
是否为NULL
,是因为多核情况下其他CPU在某个过程中已经执行了这个用户APC么?
我认为,并不是因为多核的原因。因为在交付用户APC函数之前,调用了函数KeReleaseInStackQueuedSpinLock
上了自旋锁。同时参考《Windows情景分析P371》,KernelRoutine
执行的时候有可能改变指针NormalRoutine
的值,所以需要测试一下。
函数KeTestAlertThread
:This function tests to determine if the alerted variable for the specified processor mode has a value of TRUE or whether a user mode APC should be delivered to the current thread.(processor mode:UserMode/KernelMode处理器模式,variable-变量,specified-指定的)
该函数检查当前线程是否可以交付第一个用户模式的APC
如果吵醒模式AlertMode == 1,并且用户APC队列不为空,那就设置 Thread->ApcState.UserApcPending = TRUE 然后交付下一个用户APC。
1 | BOOLEAN __stdcall KeTestAlertThread ( |
Alerted:用来说明线程在指定模式下是否为”已经被吵醒” 。具体在哪进行修改,目前就知道在函数KeAlertThread
中,当吵醒条件不满足时候就会Thread->Alerted[AlertMode] = TRUE;
来说明已经调用过该函数一次了,只是吵醒条件不满足,如果可以吵醒是不会设置的为TRUE
的。所以这里的Thread->Alerted[AlertMode] = FALSE;
我理解就是一个额外的修正操作。**但是在APC的交付过程中,一般使用到该函数的else if
处的功能,即Thread->ApcState.UserApcPending = TRUE
**。
在APC注入过程中,经常使用该函数,因为函数KiInsertQueueApc
在KAPC插入过后判断条件满足:
1 | (Thread.State == Waiting && Thread.WaitMode == 1 && (Thread.Alertable == 1 || Thread.ApcState.UserApcPending == 1)) |
就会设置Thread.ApcState.UserApcPending = 1
然后调用KiUnwaitThread
让线程开始转为就绪,线程切换后就有机会执行APC函数。上面可以看到Alertable == 1
与UserApcPending == 1
满足其一即可,所以一般调用函数KeTestAlertThread
就可进行注入,可以参考:
4 _CONTEXT
每个线程都会维护一个_CONTEXT
结构,里面保存着线程运行在3环时的寄存器的值,里面保存了线程运行的状态,使得CPU可以记得上次运行该线程运行到哪里了,该从哪里开始运行。在线程的挂起、恢复线程状态切换时经常用到。
_CONTEXT
结构与_Trap_Frame
结构很相似,都是保存一堆寄存器的值,但是在用途上却不一样:
_CONTEXT
:线程发生状态切换时,将当前寄存器的值保存下来,方便恢复线程运行的时候回到原来的地方继续运行(一个CPU就一套寄存器,线程切换后其他线程要使用寄存器,自己的寄存器的值要先保存起来)。_Trap_Frame
:线程从3环进入0环时,将3环的寄存器值保存到该结构中,线程从0环正常返回到3环后从原来的地方继续运行。
1 | kd> dt _CONTEXT -v |
源代码定义如下:
1 | // Context Frame |
该结构的ContextFlags
成员很重要,当我们要查询CONTEXT
中某些寄存器的值的时候,需要先指定ContextFlags
的值,由它来指定有哪些权限,能查询到哪些寄存器的值。
寄存器组 | ContextFlags | 包含的寄存器 | 对应值 |
---|---|---|---|
调试寄存器组 | CONTEXT_DEBUG_REGISTERS | Dr0-7 | 0x00010000 | 0x00000010 == 0x00010010 |
浮点寄存器组 | CONTEXT_FLOATING_POINT | 387 state | 0x00010000 | 0x00000008 == 0x00010008 |
段寄存器组 | CONTEXT_SEGMENTS | DS, ES, FS, GS | 0x00010000 | 0x00000004 == 0x00010004 |
通用数据寄存器 | CONTEXT_INTEGER | AX, BX, CX, DX, SI, DI | 0x00010000 | 0x00000002 == 0x00010002 |
控制寄存器组 | CONTEXT_CONTROL | SS:SP, CS:IP, FLAGS, BP | 0x00010000 | 0x00000001 == 0x00010001 |
拓展寄存器组 | CONTEXT_EXTENDED_REGISTERS | cpu specific extensions | 0x00010000 | 0x00000020 == 0x00010020 |
FULL字段 | CONTEXT_FULL | 控制、通用、段寄存器 | CONTEXT_CONTROL |CONTEXT_INTEGER | CONTEXT_SEGMENTS 0x00010000 | 0x00000004 | 0x00000002 | 0x00000001 == 0x00010007 |
有这样的定义:CONTEXT_i386 = 0x00010000
。
还可以参考:[API档案] CONTEXT 结构、Windows线程的上下文结构体(线程本质。
5 KiInitializeUserApc
函数KiInitializeUserApc
:该函数给用户模式的APC初始化一个CONTEXT
结构。
1 | VOID __stdcall KiInitializeUserApc ( |
执行环境条件:Environment:Kernel mode only, IRQL APC_LEVEL.
KiInitializeUserApc
函数:
1 | VOID KiInitializeUserApc ( |
逆向分析KiInitializeUserApc
:
1 | .text:0042D2C0 // =============== S U B R O U T I N E ======================================= |
6 KeContextFromKframes
KeContextFromKframes
函数:
1 | VOID KeContextFromKframes ( |
KiUserApcDispatcher
在ntdll.ll
中:
1 | .text:7C92E430 // =============== S U B R O U T I N E ======================================= |
R3 ZwContinue
在ntdll.ll
中:
1 | .text:7C92D040 // =============== S U B R O U T I N E ======================================= |
R0 NtContinue
1 | .text:0046DE6C // =============== S U B R O U T I N E ======================================= |
KiSwapThread
A线程调用SwapContext函数切换到线程B后,B原路返回执行么?分析一下KiSwapThread,直接将当前线程的context复制给下一个线程?
1 | LONG_PTR FASTCALL KiSwapThread(VOID); |
在整个线程切换的过程中,两个线程的堆栈是怎么变化的?trapframe、context又是怎样变化的?
线程切换后,EIP没有切换。就只是切换了esp?是的,将当前的esp修改为新线程的InitStack后,就已经切换到新线程了。然后按照新线程堆栈压入的返回地址进行返回,注意这里的堆栈里的返回地址不是trapFrame里面的esp,不要搞混了。
学完APC就去学异常,应该是分发那里不太一样。调试就是异常处理,异常处理只不过是我们提供了一个函数来处理这个异常,调试器不过就是把这个异常传给另外一个进程,让调试器去处理。
调试器就是想方设法让对方程序抛异常,程序一抛异常了就会把异常传回来,如果此时有调试器,先传给调试器,如果没有调试器就把异常传给异常处理程序。
异常和APC几乎一摸一样,异常也分两类,即0环和3环的异常。
两种被动切换:
正在执行的线程如何知道时间片已经用完?
正在执行的线程如何被其他线程抢占执行的?
难道被动切换是:一个正在执行的线程被下了一个BreakPoint?然后当前线程去处理这个BreakPoint?
可以看一下《Windows内核原理与实现》P166
《Windows内核情景分析》P382
1 | //精况一:线程等待状态 alertble=0 APC不执行 |
KiFastCallEntry
系统调用:
1 | .text:0046A520 // =============== S U B R O U T I N E ======================================= |
7 总结
7.1 关于用户APC的执行
在函数KiInsertQueueApc
,最后处理用户APC时有如下代码:
1 | if(Thread.State == Waiting && Thread.WaitMode == UserMode && (Thread.Alertable == 1 || |
需要说明是,如果要让一个刚才插入的用户APC的UserApcPending = 1
,最重要的两件事:
在APC插入前:目标线程的
Thread.Alertable == 1 || Thread.ApcState[UserMode].UserApcPending == 1
,在APC对象插入目标线程前,才会设置Thread.ApcState[UserMode].UserApcPending = 1
。在APC插入后:除了上述的那两种情况可以使刚插入的用户APC获得执行机会之外,还有一种可以让用户APC可执行的方法。在函数
KiDeliverApc
中对于非空的用户APC队列有如下的判断,如果符合如下条件就会执行用户APC函数:1
PreviousMode == 1 && CurrentThread->ApcState->UserApcPending == 1
所以当一个用户APC对象插入APC队列之后,在目标线程3环调用函数
NtTestAlert
也是可以的,因为:R3 NtTestAlert:
1
2
3
4
5
6
7
8
9
10//R3 NtTestAlert
.text:7C92DE70 ; _DWORD __stdcall NtTestAlert()
.text:7C92DE70 public _NtTestAlert@0
.text:7C92DE70 _NtTestAlert@0 proc near // CODE XREF: _LdrpInitialize(x,x,x):loc_7C93AFF7↓p
.text:7C92DE70 // DATA XREF: .text:off_7C923428↑o
.text:7C92DE70 mov eax, 103h // NtTestAlert
.text:7C92DE75 mov edx, 7FFE0300h
.text:7C92DE7A call dword ptr [edx]
.text:7C92DE7C retn
.text:7C92DE7C _NtTestAlert@0 endpR0 NtTestAlert:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26//R0 NtTestAlert
PAGE:004FDBFE ; =============== S U B R O U T I N E =======================================
PAGE:004FDBFE
PAGE:004FDBFE
PAGE:004FDBFE ; NTSTATUS __stdcall NtTestAlert()
PAGE:004FDBFE _NtTestAlert@0 proc near // DATA XREF: .text:0042D85C↑o
PAGE:004FDBFE mov eax, large fs:124h
PAGE:004FDC04 movsx eax, [eax+_KTHREAD.PreviousMode]
PAGE:004FDC0B push eax
PAGE:004FDC0C call _KeTestAlertThread@4 // ---
PAGE:004FDC0C // BOOLEAN __stdcall KeTestAlertThread (
PAGE:004FDC0C // IN KPROCESSOR_MODE AlertMode
PAGE:004FDC0C // )
PAGE:004FDC0C //
PAGE:004FDC0C // 如果吵醒模式AlertMode == 1,并且用户APC队列不为空,
PAGE:004FDC0C // 那就设置 Thread->ApcState.UserApcPending = TRUE 然后交付
//下一个用户APC
PAGE:004FDC0C // 【检查该线程是否可以交付另一个用户模式的APC】
PAGE:004FDC0C // ---
PAGE:004FDC11 neg al
PAGE:004FDC13 sbb eax, eax
PAGE:004FDC15 and eax, 101h
PAGE:004FDC1A retn
PAGE:004FDC1A _NtTestAlert@0 endp
PAGE:004FDC1A
PAGE:004FDC1A ; ---------------------------------------------------------------------------
7.2 多个用户APC在R0-R3来回循环执行过程
假设第一个用户APC是在线程正常从R0返回R3时触发的,因为KiFastCallEntry
函数在call ebx
调用完成之后(ebx
是Nt*
函数),会继续往下走直接调用函数KiServiceExit
。
在KiServiceExit
函数开头判断KTRAP_FRAME.SegCs
最低位为1
,这里实际上就是类似验证PreviousMode
是来自3环,就会调用函数KiDeliverApc
。
这个时候才会开始进行APC的交付调度。
在函数
KiDeliverApc
中对用户APC作如下判断,如果条件符合就开始交付:1
2if(&CurrentThread->ApcState->ApcListHead[1] != NextEntry && PreviousMode == 1 &&
CurrentThread->ApcState[UserMode]->UserApcPending == 1)首先会设置
Thread->ApcState[UserMode].UserApcPending = FALSE
,然后往下判断Apc->NormalRoutine != 0
就会调用KiInitializeUserApc
去将“从3环经过快速调用进0环后”在函数KiFastCallEntry
中的TrapFrame
拷贝到Conntext
后对TrapFrame
相关成员进行修改,使其从KiServiceExit
返回到ntdll.dll!KiUserApcDispatcher
函数中去。(我将这里的TrapFrame
叫做TrapFrame_Initial
)在函数
KiUserApcDispatcher
中做了以下这些事:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18//R3 ntdll.dll KiUserApcDispatcher
.text:7C92E430 // =============== S U B R O U T I N E =======================================
.text:7C92E430
.text:7C92E430
.text:7C92E430 // __stdcall KiUserApcDispatcher(x, x, x, x, x)
.text:7C92E430 public _KiUserApcDispatcher@20
.text:7C92E430 _KiUserApcDispatcher@20 proc near // DATA XREF: .text:off_7C923428↑o
.text:7C92E430
.text:7C92E430 pR3_Stack_Context = byte ptr 10h
.text:7C92E430
.text:7C92E430 lea edi, [esp+pR3_Stack_Context]
.text:7C92E434 pop eax // NormalRoutine
.text:7C92E435 call eax
.text:7C92E437 push 1 // TestAlert
.text:7C92E439 push edi // 在3环函数中保存的 CONTEXT 的首地址
.text:7C92E43A call _ZwContinue@8 // __stdcall NtContinue(PCONTEXT Context, BOOLEAN TestAlert)
.text:7C92E43F nop
.text:7C92E43F _KiUserApcDispatcher@20 endp // sp-analysis failed然后在3环的
ZwContinue
函数如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16//R3 ZwContinue
.text:7C92D040 // =============== S U B R O U T I N E =======================================
.text:7C92D040
.text:7C92D040 // NTSTATUS __stdcall NtContinue(PCONTEXT Context, BOOLEAN TestAlert)
.text:7C92D040
.text:7C92D040 // __stdcall ZwContinue(x, x)
.text:7C92D040 public _ZwContinue@8
.text:7C92D040 _ZwContinue@8 proc near // CODE XREF: KiUserApcDispatcher(x,x,x,x,x)+A↓p
.text:7C92D040 // KiUserExceptionDispatcher(x,x)+17↓p ...
.text:7C92D040 mov eax, 20h // NtContinue
.text:7C92D045 mov edx, 7FFE0300h
.text:7C92D04A call dword ptr [edx]
.text:7C92D04C retn 8
.text:7C92D04C _ZwContinue@8 endp
.text:7C92D04C
.text:7C92D04C // ---------------------------------------------------------------------------可以看到,通过
_ZwContinue
函数走的是快速调用KiFastSystemCall
进的0环。进入0环后的函数是KiFastCallEntry
,然后将其分发到函数NtContinue
中。0环
NtContinue
函数调用KiContinue
将保存在3环的Context
拷贝到TrapFrame_Initial
使其被更新,然后调用KeTestAlertThread
去设置Thread.ApcState[UserMode].UserApcPending = 1
。然后
NtContinue
接着调用函数KiServiceExit2
继续去判断是否还有用户APC待执行,这样就会构成一个循环。让用户APC全部能够得到执行。
以上可以看到,步骤2每当在KiDeliverApc
中准备去执行一个用户APC时会设置Thread->ApcState[UserMode].UserApcPending = FALSE
,而步骤5当这一个用户APC在3环执行完毕并返回0环后,会调用KeTestAlertThread
去设置Thread.ApcState[UserMode].UserApcPending = 1
。这样在NtContinue
又会接着调用函数KiServiceExit2
继续去判断是否还有用户APC待执行,这样就会构成一个循环。让用户APC全部能够得到执行。
7.3 3环的APC注入