APC的本质

c++

Posted by YiMiTuMi on August 12, 2021

APC的本质

线程是不能被“杀掉”、“挂起”、“恢复”的,线程在执行的时候自己占据着CPU,别人是不可能控制它的。

例如:如果线程不调用API,屏蔽中断,并保证代码不会出现异常,线程将永久占用CPU,所以说线程如果想停止,一定时自己执行代码把自己干掉,不存在被别的线程干掉的情况。

如果想改变一个线程的行为,操作系统给线程提供了一个函数,让线程自己去调用,这个函数就是APC(Asyncroneus Procedure Call),即异步过程调用

异步过程调用:

APC函数的执行与插入并不是同一个线程,即在A线程中向B线程插入一个APC,插入的动作是在A线程完成的,但什么时候执行则是由B线程决定的,所以叫“一部过程调用”。

APC分为:

用户APC:APC函数地址位于用户空间,在用户空间执行。
内核APC:APC函数地址位于内核空间,在内核空间执行。

APC的执行函数KiDeliverApc,在KiServiceExit中会调用,而KiServiceExit系统调用、异常或中断返回用户空间的必经之路,所以在系统调用、异常或中断返回时会调用处理APC函数。

APC队列

kd> dt _KTHREAD
ntdll!_KTHREAD
   +0x000 Header           : _DISPATCHER_HEADER
   ...
   +0x040 ApcState         : _KAPC_STATE
   +0x040 ApcStateFill     : [23] UChar


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

+0x000 ApcListHead : [2] _LIST_ENTRY:

两个双向链表,这两个保存当前线程所有执行操作的函数。如当想让当前线程执行某种操作,则将函数添加到这两个双向链表中,线程会在某一时刻去检查这个链表执行里面的操作。

ApcListHead[0]:内核APC队列 = _KAPC_STATE + 0
ApcListHead[1]:用户APC队列 = _KAPC_STATE + 8

ApcListHead中保存的两个链表分别用于保存内核空间和用户空间的函数地址。所以APC队列分为两类,一个是用户APC队列,一个是内核APC队列。(函数地址小于0x80000000就是用户空间,否则就是内核空间)

当APC结构体_KAPC在APC链表中时,链表的地址指向的是_KAPC.ApcListEntry的位置,所以ApcListHead链表中的地址减去_KAPC.ApcListEntry的偏移(XP = 0CH)才是_KAPC结构体开始的位置。

+0x010 Process : Ptr32 _KPROCESS:

提供CR3的进程的KPROCESS。

+0x014 KernelApcInProgress : UChar:

当前内核APC的程序是否正在执行, 0就是没执行。

+0x015 KernelApcPending : UChar:

APC队列中是否存在未执行的内核函数,为1则存在。

+0x016 UserApcPending : UChar:

APC队列中是否存在未执行的用户函数,为1则存在。

当要执行APC队列中的函数时,则会先判断UserApcPending和KernelApcPending的值是否为1。

执行APC函数

KiServiceExit函数:系统调用、异常或中断返回用户空间的必经之路。

mov     ebx, ds:0FFDFF124h
mov     byte ptr [ebx+2Eh], 0
cmp     byte ptr [ebx+4Ah], 0
jz      short loc_407864
mov     ebx, ebp
mov     [ebx+44h], eax
mov     dword ptr [ebx+50h], 3Bh ; ';'
mov     dword ptr [ebx+38h], 23h ; '#'
mov     dword ptr [ebx+34h], 23h ; '#'
mov     dword ptr [ebx+30h], 0
mov     ecx, 1          ; NewIrql
call    ds:__imp_@KfRaiseIrql@4 ; KfRaiseIrql(x)
push    eax
sti
push    ebx
push    0
push    1
call    _KiDeliverApc@12 ; KiDeliverApc(x,x,x)  //负责执行APC的函数
pop     ecx             ; NewIrql
call    ds:__imp_@KfLowerIrql@4 ; KfLowerIrql(x)
mov     eax, [ebx+44h]
cli
jmp     short loc_40780D

KiDeliverApc函数:负责执行APC的函数。

备用APC队列

kd> dt _KTHREAD
	ntdll!_KTHREAD
	   +0x000 Header           : _DISPATCHER_HEADER
	   ...
	   +0x040 ApcState         : _KAPC_STATE
	   +0x040 ApcStateFill     : [23] UChar
	   
	   +0x170 SavedApcState    : _KAPC_STATE   //备用队列

SavedApcState的意义:

线程APC队列中的APC函数都是与进程相关联的(APC队列虽然每个线程一个,但是这些APC函数所读取的内存是进程的,即读取的的内存都是进程提供的CR3中的),具体点说:A进程的T线程中的所有APC函数,要访问的内存地址都是A进程的。

但线程是可以挂靠到其他的进程:比如A进程的线程T,通过修改CR3(改为B进程的页目录基址),就可以访问B进程地址空间,即所谓的“进程挂靠”。

当T线程挂靠B进程后,APC队列中存储的却仍是原来的APC!具体点说,比如某个APC函数要读取一个地址为0x12345678的数据,如果此时进行读取,读到的将是B进程的地址空间,这样逻辑就错误了!

为了避免混乱,在T线程挂靠B进程时,会将ApcState中的值暂时存储到SavedApcState中,等回到原进程A时,再将APC队列恢复。所以,SavedApcState又被称为备用APC队列。

挂靠环境下ApcState的意义

在挂靠环境下,也是可以向线程的APC队列中插入APC的,此时APC是针对的是挂靠进程的,即插入的是挂靠进程的APC队列的。

A进程的T线程挂靠B进程,A是T的所属进程,B是T的挂靠进程

ApcState        B进程相关的APC函数

SavedApcState   A进程相关的APC函数

获取当前进程时,在正常情况下,当前进程就是所属进程A,如果是挂靠情况下,当前进程就是挂靠进程B。

_KTHREAD.ApcStatePointer

kd> dt _KTHREAD
		ntdll!_KTHREAD
		   +0x000 Header           : _DISPATCHER_HEADER
		   ...
		   +0x040 ApcState         : _KAPC_STATE
		   +0x040 ApcStateFill     : [23] UChar
		   ...
		   +0x168 ApcStatePointer  : [2] Ptr32 _KAPC_STATE
		   +0x170 SavedApcState    : _KAPC_STATE   //备用队列

为了操作方便,_KTHREAD 结构体中定义了一个指针数组 ApcStatePointer,长度为2。

正常情况下:

ApcStatePointer[0] 指向 ApcState

ApcStatePointer[1] 指向 SavedApcState (此时 ApcState == SavedApcState)

挂靠情况下:

ApcStatePointer[0] 指向 SavedApcState

ApcStatePointer[1] 指向 ApcState     (此时的ApcState是挂靠的进程的APC队列)

_KTHREAD.ApcStateIndex

kd> dt _KTHREAD
		ntdll!_KTHREAD
		   +0x000 Header           : _DISPATCHER_HEADER
		   ...
		   +0x040 ApcState         : _KAPC_STATE
		   +0x040 ApcStateFill     : [23] UChar
           
		   +0x134 ApcStateIndex    : UChar	
		   +0x168 ApcStatePointer  : [2] Ptr32 _KAPC_STATE				   
		   +0x170 SavedApcState    : _KAPC_STATE   //备用队列

ApcStateIndex用来标识当前线程处于什么状态:

0 正常状态

1 挂靠状态

_KTHREAD.ApcStatePointer 与 _KTHREAD.ApcStateIndex组合寻址

正常情况下,向ApcState队列中插入APC时:

ApcStatePointer[0] 指向 ApcState 此时 ApcStateIndex的值为 0

ApcStatePointer[ApcStateIndex] 指向 ApcState

挂靠情况下,向ApcState队列中插入APC时:

ApcStatePointer[1] 指向 ApcState 此时 ApcStateIndex的值为 1

ApcStatePointer[ApcStateIndex] 指向 ApcState

即:

无论什么环境下,ApcStatePointer[ApcStateIndex] 指向的都是 ApcState,ApcState则总是表示线程当前使用的apc状态。

KTHREAD.ApcQueueable

kd> dt _KTHREAD
		ntdll!_KTHREAD
		   +0x000 Header           : _DISPATCHER_HEADER
		   ...
		   +0x0b8 ApcQueueable : Pos 5, 1 Bit

ApcQueueable 用于表示是否可以向线程的APC队列中插入APC。

当线程正在执行退出的代码时,会将这个值设置为0,如果此时执行插入APC的代码(KeInsertQueueApc),在插入函数中会判断这个值的状态,如果为0,则插入失败。

APC挂入过程

无论是正常状态还是挂靠状态,都有两个APC队列,一个内核队列,一个用户队列。

每当要挂入一个APC函数时,不管是内核APC还是用户APC,内核都要准备一个KAPC的数据结构,并且将这个KAPC结构挂到相应的APC队列中。

KAPC结构

kd> dt _KAPC
ntdll!_KAPC
   +0x000 Type             : UChar       //类型 APC类型为 0x12
   +0x001 SpareByte0       : UChar
   +0x002 Size             : UChar       //本结构体的大小 0x30
   +0x003 SpareByte1       : UChar
   +0x004 SpareLong0       : Uint4B
   +0x008 Thread           : Ptr32 _KTHREAD  //目标线程
   +0x00c ApcListEntry     : _LIST_ENTRY     //APC队列挂的位置
   +0x014 KernelRoutine    : Ptr32 void      //指向一个函数(调用ExFreePoolWithTag 释放 APC),用来释放APC函数
   +0x018 RundownRoutine   : Ptr32 void 
   +0x01c NormalRoutine    : Ptr32 void      //用户APC总入口 或者 真正的内核APC函数
   +0x020 NormalContext    : Ptr32 Void		 //内核APC: NULL, 用户APC:真正的APC函数
   +0x024 SystemArgument1  : Ptr32 Void      //APC函数的参数
   +0x028 SystemArgument2  : Ptr32 Void      //APC函数的参数
   +0x02c ApcStateIndex    : Char            //挂在哪个队列,有4个值:0 1 2 3
   +0x02d ApcMode          : Char            //内核APC还是用户APC 内核:0,用户:1
   +0x02e Inserted         : UChar           //表示当前APC是否已经挂入队列。挂入前:0,挂入后 1

+0x01c NormalRoutine : Ptr32 void:

用于找到所要执行的函数地址。

+0x00c ApcListEntry : _LIST_ENTRY:

当APC结构体_KAPC在APC链表中时,链表的地址指向的是_KAPC.ApcListEntry的位置。

+0x02d ApcMode : Char:

内核APC还是用户APC,0为内核APC,1为用户APC

+0x00c ApcListEntry : _LIST_ENTRY:

保存_KAPC在链表中的位置

+0x02c ApcStateIndex : Char:

与KTHREAD(+0x165)的属性同名,但含义不一样:

ApcStateIndex 有四个值(影响写入那个环境的APC队列):

0 原始环境

ApcStatePointer[0] 指向 ApcState
ApcStatePointer[1] 指向 SaveApcState

1 挂靠环境

ApcStatePointer[0] 指向 SaveApcState
ApcStatePointer[1] 指向 ApcState

2 当前环境

 初始化的时候,当前进程的ApcState

3 插入APC时的当前环境

插入的时候,当前进程ApcState。 中途ApcState的值可能会发生变化

挂入流程

QueueUserAPC(Kernel32.dll)     ↓
↓							   → 用户层调用
NtQueueApcThread(ntosker.exe)  ↑
			↓
			KeInitializeApc(分配空间,初始化KAPC结构体) ↓
			↓                                         ↓
			KeInsertQueueApc                          →   很多内核函数调用
			  ↓									      ↑
			  KiInsertQueueApc(将KAPC插入指定APC队列)  ↑

大多数内核函数会直接调用KeInitializeApc和KiInsertQueueApc。

KiInsertQueueApc:

void KiInsertQueueApc
(
	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函数
)

KTHREAD.Alertable

kd> dt _KTHREAD
			ntdll!_KTHREAD
			   +0x000 Header           : _DISPATCHER_HEADER
			   ...
			   +0x0b8 Alertable : UChar

+0x0b8 Alertable : UChar:

Alertable为真则可以被APC唤醒,如果为假则不会被APC唤醒。

DWORD SleepEx(DWORD dwMilliseconde, BOOL bAlertable);

DWORD WaitForSingleObjectEx(HANDLE hHandle, DWORD dwMillIseconds, BOOL bAlertable);

以上的函数让线程进入等待状态会修改Alertable这个值,普通的Sleep可能不会。

Alertable用法:

当线程处于等待状态时:

	1.Alertable = 0 当前插入的APC函数未必有机会执行:UserApcPending = 0

	2 Alertable = 1 , UserApcPending = 1 将唤醒进程,即将线程结构体从等待链表添加到调度队列。

APC函数执行过程

第一个APC函数执行点,线程切换(这里只会执行内核APC):

SwapContext    判断是否有内核APC

	↓

KiSwapThread

	↓

KiDeliverApc    执行内核APC函数

第二个APC函数执行点,系统调用、中断或者异常(这里会执行内核APC和用户APC函数):

注:当前的线程在返回3环时,一定会执行一个函数:KiServiceExit。

KiServiceExit
	
	 ↓

KiDeliverApc    执行内核和用户APC函数

插入、调用APC函数详解

对插入、调用APC函数进行分析:

插入、调用APC函数详解

总结

1)内核APC在线程切换时执行,不需要切换栈,一个循环执行完毕。

2)用户APC在系统调用、中断或异常返回3环前会进行判断,如果有要执行的用户APC,再执行。

3)用户APC执行前会执行内核APC。

红豆 – 相思