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函数进行分析:
总结
1)内核APC在线程切换时执行,不需要切换栈,一个循环执行完毕。
2)用户APC在系统调用、中断或异常返回3环前会进行判断,如果有要执行的用户APC,再执行。
3)用户APC执行前会执行内核APC。