文章目录
- APC的本质
- APC队列
- APC结构
- APC相关函数
- KiServiceExit
- KiDeliveApc
- 备用APC队列
- ApcState的含义
- 挂靠环境下的ApcState的含义
- 其他APC相关成员
- ApcStatePointer
- ApcStateIndex
- ApcStatePointer与ApcStateIndex组合寻址
- ApcQueueable
- APC挂入过程
- KAPC结构
- 挂入流程
- KeInitializeApc
- ApcStateIndex
- KiInsertQueueApc
- 内核APC的执行过程
- 执行点:线程切换
- 执行点:系统调用 中断或者异常(_KiServiceExit)
- KiDeliverApc函数分析
- 总结
- 用户APC的执行过程
- 执行用户APC时的堆栈操作
- KiDeliverApc函数分析
- KiInitializeUserApc函数分析:备份CONTEXT
- KiInitializeUserApc函数分析:堆栈图
- KiInitializeUserApc函数分析:准备用户层执行环境
- ntdll.KiUserApcDispatcher函数分析
- 总结
APC的本质
线程是不能被杀死 挂起和恢复的,线程在执行的时候自己占据着CPU,别人怎么可能控制他呢?举个极端的例子,如果不调用API,屏蔽中断,并保证代码不出现异常,线程将永久占据CPU。所以说线程如果想结束,一定是自己执行代码把自己杀死,不存在别人把线程结束的情况。
那如果想改变一个线程的行为该怎么办?可以给他提供一个函数,让他自己去调用,这个函数就是APC,即异步过程调用
APC队列
我们现在需要讨论是的,如果我给某一个线程提供一个函数,那么这个函数挂在哪里?答案是APC队列,先来看一下当前线程的结构体
kd> dt _KTHREAD
ntdll!_KTHREAD
+0x040 ApcState : _KAPC_STATE
线程结构体KTHREAD
0x40的位置的成员是一个子结构体ApcState,也就是APC队列
kd> dt _KAPC_STATE
nt!_KAPC_STATE+0x000 ApcListHead //2个APC队列 用户APC和内核APC +0x010 Process //线程所属进程或者所挂靠的进程+0x014 KernelApcInProgress //内核APC是否正在执行+0x015 KernelApcPending //是否有正在等待执行的内核APC+0x016 UserApcPending //是否有正在等待执行的用户APC
_KAPC_STATE
的第一个成员是两个APC队列,每一个成员都是一个双向链表,这个双向链表就是APC队列。
APC一共有两个,一个是用户态APC队列,一个是内核态的APC队列,里面存储的都是APC函数。
你想让线程执行某些操作的时候,就可以提供一个函数,挂到这个链表里,在某一个时刻,当前线程会检查当前的函数列表,当里面有函数的时候,就会去调用。这样就相当于改变了线程的行为。
现在我们知道了如果想改变线程的行为,需要提供一个函数挂到线程的APC队列里,准确的说是提供一个APC,接下里需要了解APC的结构。
APC结构
kd> dt _KAPC
ntdll!_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
其中最重要的是+0x01c NormalRoutine
的这个成员,通过这个成员可以找到你提供的APC函数。
现在我们知道了提供APC需要遵循的格式,以及存到线程的位置,但是还有另外的问题,当前的线程什么时候会执行所提供的APC函数
如果想要解决这个问题,需要知道一个内核函数:KiServiceExit
APC相关函数
KiServiceExit
这个函数是系统调用 异常或中断返回用户空间的必经之路
KiDeliveApc
负责执行APC函数
备用APC队列
kd> dt _KTHREAD
ntdll!_KTHREAD
+0x040 ApcState : _KAPC_STATE
+0x170 SavedApcState : _KAPC_STATE
在线程结构体0x40的位置是APC队列,在0x170的位置也有一个APC队列,这两个成员的结构是完全一样的
ApcState的含义
线程队列中的APC函数都是与进程相关联的,具体点说:A进程的T线程中所有的APC函数,要访问的内存地址都是A进程的。
但线程是可以挂靠到其他的进程:比如A进程的线程T,通过修改CR3,就可以访问B进程的地址空间,即所谓的进程挂靠。
当T线程挂靠B进程后,APC队列中存储的仍然是原来的APC。具体点说,比如某个APC函数要读取地址为0x12345678的数据,如果此时进行读取,读到的将是B进程的地址空间,这样逻辑就错误了。
为了避免混乱,在T线程挂靠B进程时,会将ApcState中的值暂时存储到SavedApcState中,等回到原进程A时,再将APC队列恢复
所以,SavedApcState又称为备用APC队列
挂靠环境下的ApcState的含义
在挂靠环境下,也是可以将线程APC队列插入APC的,那这种情况下,使用的是哪个APC队列呢?
A进程的T线程挂靠B进程,A是T的所属进程,B是T的挂靠进程
- ApcState:B进程相关的APC函数
- SavedApcState:A进程相关的APC函数
在正常情况下,当前进程就是所属进程A,如果是挂靠情况下,当前进程就是挂靠进程B
其他APC相关成员
ApcStatePointer
+0x168 ApcStatePointer : [2] Ptr32 _KAPC_STATE
在KTHREAD结构体的0x168的位置的成员是一个指针数组,有两个指针,每一个指针都指向一个ApcState
为了操作方便,KTHREAD结构体中定义了一个指针数组ApcStatePointer,长度为2。
正常情况下:
ApcStatePointer[0]指向ApcState
ApcStatePointer[1]指向SavedApcState
挂靠情况下:
ApcStatePointer[0]指向SavedApcState
ApcStatePointer[1]指向ApcState
ApcStateIndex
+0x134 ApcStateIndex : UChar
ApcStateIndex用来标识当前线程处于什么状态:0正常状态 1挂靠状态
ApcStatePointer与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状态
ApcQueueable
+0x0b8 ApcQueueable : Pos 5, 1 Bit
ApcQueueable用于表示是否可以向线程的APC队列中插入APC。
当线程正在执行退出的代码时,会将这个值设置为0,如果此时执行插入APC的代码,在插入函数中会判断这个值的状态,如果为0,则插入失败。
APC挂入过程
无论是正常状态还是挂靠状态,都要有两个APC队列,一个内核队列,一个用户队列。每当要挂入一个APC函数时,不管是用户队列还是内核队列,内核都要准备一个KAPC的数据结构,并且将这个KAPC结构挂到相应的APC队列中。
KAPC结构
kd> dt _KAPC
nt!_KAPC+0x000 Type //类型 APC类型为0x12+0x002 Size //本结构体的大小 0x30+0x004 Spare0 //未使用 +0x008 Thread //目标线程 +0x00c ApcListEntry //APC队列挂的位置+0x014 KernelRoutine //指向一个函数(调用ExFreePoolWithTag 释放APC)+0x018 RundownRoutine//略 +0x01c NormalRoutine //用户APC总入口 或者 真正的内核apc函数+0x020 NormalContext //内核APC:NULL 用户APC:真正的APC函数+0x024 SystemArgument1//APC函数的参数 +0x028 SystemArgument2//APC函数的参数+0x02c ApcStateIndex //挂哪个队列,有四个值:0 1 2 3+0x02d ApcMode //内核APC 用户APC+0x02e Inserted //表示本apc是否已挂入队列 挂入前:0 挂入后 1
- Type :类型。在Windows里,任何一种内核对象都有一个编号,这个编号用来标识你是属于哪一种类型,APC本身也是一种内核对象,它也有一个编号,是0x12
- Size:这个成员指的是当前的KAPC的结构体的大小
- Thread:每一个线程都有自己的APC队列,这个成员指定了APC属于哪一个线程
- ApcListEntry:APC队列挂的位置,是一个双向链表,通过这个双向链表可以找到下一个APC
- KernelRoutine:指向一个函数(调用ExFreePoolWithTag 释放APC)。当我们的APC执行完毕以后,当前的KAPC本身的这块内存,会由KernelRoutine指定的函数来释放
- NormalRoutine:如果当前是内核APC,通过这个值找到的就是真正的内核APC函数;如果当前的APC是用户APC,那么这个位置指向的是用户APC总入口,通过这个总入口可以找到所有用户提供的APC函数
- NormalContext:如果当前是内核APC,通过这个值为空;如果当前的APC是用户APC,那么这个值指向的是真正的用户APC函数
- SystemArgument1 SystemArgument2 APC函数的参数
- ApcStateIndex:当前的APC要挂到哪个队列
- ApcMode:当前的APC是用户APC还是内核APC
- Inserted:当前的APC结构体是否已经插入到APC队列
挂入流程
KeInitializeApc
VOID KeInitializeApc
(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函数
)
KeInitializeApc函数的作用就是给当前的KAPC结构体赋值
ApcStateIndex
与KTHREAD(+0x134)的属性同名,但含义不一样:
ApcStateIndex有四个值:
- 0 原始环境->插入到当前线程的所属进程APC队列,不管是否挂靠都插入到当前线程的所属进程。
- 1 挂靠环境
- 2 当前环境->插入到当前进程的APC队列,如果没有挂靠,当前进程则是父进程,如果挂靠了,当前进程就是挂靠进程
- 3 插入APC时的当前环境->线程随时处于切换状态 当值为3时,在插入APC之前会判断当前线程是否处于挂靠状态 再进行APC插入
KiInsertQueueApc
- 根据KAPC结构中的ApcStateIndex找到对应的APC队列
- 再根据KAPC结构中的ApcMode确定是用户队列还是内核队列
- 将KAPC挂到对应的队列中,挂到KAPC的ApcListEntry处
- 再根据KAPC结构中的Inserted置1,标识当前的KAPC为已插入状态
- 修改KAPC_STATE结构中的KernelApcPending/UserApcPending
内核APC的执行过程
APC函数的插入和执行并不是同一个线程,具体点说:
在A线程中向B线程插入一个APC,插入的动作是在A线程中完成的,但什么时候执行则由B线程决定。所以叫异步过程调用。
内核APC函数与用户APC函数的执行时间和执行方式也有区别。我们先来了解内核APC的执行过程
执行点:线程切换
IDA打开ntkrnlpa,找到SwapContext函数
在这个函数即将执行完成的时候,会判断当前是否有要执行的内核APC,接着将判断的结果存到eax,然后返回
接着找到上一层函数KiSwapContext函数继续跟进
这个函数也没有对APC进行处理,而是继续返回,继续跟进父函数
返回到这里,会判断KiSwapContext的返回值,也就是判断当前是否有要处理的内核APC,如果有,则调用KiDeliverApc进行处理。
这个函数有三个参数,第一个参数如果是0,就意味着KiDeliverApc在执行的时候只会处理内核APC,第一个参数如果是1,KiDeliverApc除了处理内核APC以外,还会处理用户APC
流程总结:
- SwapContext 判断是否有内核APC
- KiSwapThread 切换线程
- KiDeliverApc
执行点:系统调用 中断或者异常(_KiServiceExit)
找到_KiServiceExit函数,这里会判断是否有要执行的用户APC,如果有的话则会调用KiDeliverApc函数进行处理,此时KiDeliverApc第一个参数为1,代表执行用户APC和内核APC。
当要执行用户APC之前,先要执行内核APC
KiDeliverApc函数分析
无论是执行内核APC还是执行用户APC都会调用KiDeliverApc函数,接下来分析KiDeliverApc函数主要做了什么事情
首先这里会取出内核APC列表,然后执行跳转
接着判断第一个链表是否为空(内核APC队列),如果不为空则跳转
跳转以后,首先得到KACP的首地址,然后取出KACP结构体的各个参数,放到局部变量里
在这里,因为我们要处理的是内核APC,所以NormalRoutine代表是内核APC函数地址,这里会判断内核APC函数地址是否为空,不为空的话则进行跳转
跳转以后,先判断是否有正在执行内核APC,然后判断是否禁用内核APC,接着将APC从内核队列中摘除。
接着先调用KAPC.KernelRoutine指定的函数 释放KAPC结构体占用的空间
然后将ApcState.KernelApcInProgress 设置为1 标识正在执行内核APC。
接着将三个参数压入栈里,开始执行真正的内核APC函数
执行完毕以后,将ApcState.KernelApcInProgress 置0,接着再次判断内核APC队列,开始下一轮循环
内核APC执行流程总结:
- 判断第一个链表(内核APC队列)是否为空
- 判断KTHREAD.ApcState.KernelApcInProgress(是否正在执行内核APC)是否为1
- 判断是否禁用内核APC(KTHREAD.KernelApcDisable是否为1)
- 将当前KAPC结构体从链表中摘除
- 执行KAPC.KernelRoutine指定的函数 释放KAPC结构体占用的空间
- 将KTHREAD.ApcState.KernelApcInProgress设置为1 标识正在执行内核APC
- 执行真正的内核APC函数(KAPC.NormalRoutine)
- 执行完毕 将KernelApcInProgress改为0
总结
- 内核APC在线程切换的时候就会执行,这也就意味着,只要插入内核APC很快就会被执行
- 在执行用户APC之前会先执行内核APC
- 内核APC在内核空间执行,不需要换栈,一个循环全部执行完毕
用户APC的执行过程
当产生系统调用 中断或者异常,线程在返回用户空间前都会调用_KiServiceExit
函数,在_KiServiceExit
函数里会判断是否有要执行的用户APC,如果有则调用KiDeliverApc函数进行处理
执行用户APC时的堆栈操作
处理用户APC要比处理内核APC复杂的多,因为用户APC函数要在用户空间执行,这里涉及到大量的换栈操作:
当线程从用户层进入内核层时,要保留原来的运行环境,比如各种寄存器 栈的位置等等,然后切换成内核的堆栈,如果正常返回,恢复堆栈环境即可
但如果有用户APC要执行的话,就意味着线程要提前返回到用户空间去执行,而且返回的位置不是线程进入内核时的位置,而是返回到真正执行APC的位置
每处理一个用户APC就会涉及到:内核—>用户空间—>再回到内核空间
执行用户APC最为关键的就是理解堆栈操作的细节
KiDeliverApc函数分析
KiDeliverApc函数会push三个参数,第一个参数如果为0,代表只处理内核APC,如果为1,代表处理用户APC和内核APC。
也就是说内核APC是无论如何都会执行的。
取出内核APC队列之后会再次取出用户APC队列,并判断用户APC队列是否为空
.text:00426063 cmp [ebp+arg_0], 1
接着判断KiDeliverApc第一个参数是否为1 如果不是1 说明不处理用户APC,直接返回
.text:00426069 cmp byte ptr [esi+4Ah], 0 ;
+0x4A=UserApcPending 表示是否正在执行用户APC,为0说明正在执行的用户APC,继续往下走
.text:0042606F mov byte ptr [esi+4Ah], 0
先将UserApcPending置0,表示当前正在执行用户APC
.text:00426073 lea edi, [eax-0Ch]
-0xC 得到KPCR首地址
.text:00426076 mov ecx, [edi+1Ch] ; +0x1C=NormalRoutine 用户APC总入口
.text:00426079 mov ebx, [edi+14h] ; +0x14=KernelRoutine 释放APC的函数
.text:0042607C mov [ebp+var_4], ecx
.text:0042607F mov ecx, [edi+20h] ; +0x20 NormalContext 用户APC:真正的APC函数
.text:00426082 mov [ebp+var_10], ecx
.text:00426085 mov ecx, [edi+24h] ; +0x24 SystemArgument1
.text:00426088 mov [ebp+var_C], ecx
.text:0042608B mov ecx, [edi+28h] ; SystemArgument2
接着取出KAPC结构体的成员,放到局部变量里保存
.text:00426091 mov ecx, [eax] ; --------------------------
.text:00426093 mov eax, [eax+4]
.text:00426096 mov [eax], ecx ; 链表操作 将用户APC从链表中移除
.text:00426098 mov [ecx+4], eax ; --------------------------
然后将当前的用户APC从链表中摘除
.text:004260B7 push eax
.text:004260B8 push edi
.text:004260B9 call ebx ; 调用KAPC.KernelRoutine 释放KAPC结构体内存
接着调用调用KAPC.KernelRoutine指定的函数, 释放KAPC结构体内存
到这里为止,用户APC和内核APC的处理方式就发生了变化。
如果是内核APC这里会直接调用APC入口函数,执行内核APC,但是用户APC执行的方式不一样。当前的堆栈处于0环,而用户APC需要在三环执行。
.text:004260CA push [ebp+var_8]
.text:004260CD push [ebp+var_C]
.text:004260D0 push [ebp+var_10]
.text:004260D3 push [ebp+var_4]
.text:004260D6 push [ebp+arg_8]
.text:004260D9 push [ebp+arg_4]
.text:004260DC call _KiInitializeUserApc
接着这里调用了KiInitializeUserApc函数,接下来就要研究一下这个函数是如何实现的
用户APC执行流程总结:
- 判断用户APC链表是否为空
- 判断第一个参数是为1,为1说明处理用户APC和内核APC
- 判断ApcState.UserApcPending(是否正在执行用户APC)是否为1
- 将ApcState.UserApcPending设置为0,表示正在处理用户APC
- 链表操作 将当前APC从用户队列中拆除
- 调用函数(KAPC.KernelRoutine)释放KAPC结构体内存空间
- 调用KiInitializeUserApc函数
KiInitializeUserApc函数分析:备份CONTEXT
线程进0环时,原来的运行环境(寄存器栈顶等)保存到_Trap_Frame
结构体中,如果要提前返回3环去处理用户APC,就必须修改_Trap_Frame
结构体,因为此时Trap_Frame
中存储的EIP是从三环进零环时保存的EIP,而不是用户APC函数的地址
比如:进0环时的位置存储在EIP中,现在要提前返回,而且返回的并不是原来的位置,那就意味着必须要修改EIP为新的返回位置,还有堆栈ESP也要修改为处理APC需要的堆栈。那原来的值怎么办?处理完APC后该如何返回原来的位置呢?
KiInitializeUserApc要做的第一件事就是备份:
将原来_Trap_Frame
的值备份到一个新的结构体中(CONTEXT),这个功能由其子函数KeContextFromKframes来完成
找到KiInitializeUserApc函数,首先调用了KeContextFromKframes,将Trap_Frame备份到Context
第一个参数ebx是Trap_Frame结构体首地址,第三个参数ecx是CONTEXT结构体首地址
那么问题在于CONTEXT结构体存到哪?肯定不能存到当前函数的局部变量里。Windows想了一个办法,把这个结构体和APC需要的参数,直接存到三环的堆栈里
KiInitializeUserApc函数分析:堆栈图
.text:00429EFC mov esi, [ebp+var_224] ; 2E8-224=C4 刚好是CONTEXT结构体ESP的偏移
.text:00429F02 and esi, 0FFFFFFFCh ; 进行4字节对齐
.text:00429F05 sub esi, eax ; 在0环直接修改3环的栈 将用户3环的栈减0x2DC个字节
首先esi是CONTEXT结构体里ESP的偏移,也就是三环的堆栈,然后进行4字节对齐。
接着将用户3环的栈减0x2DC个字节,此时三环的堆栈被拉伸,为什么是2DC个字节呢?
因为CONTEXT结构体的大小加上用户APC所需要的4个参数正好是2DC个字节,如下图:
.text:00429F16 lea edi, [esi+10h]
此时的esi指向的是-2DC的位置,也就是上图的NormalRoutine,+10降低堆栈,将指针指向SystemArgument2
.text:00429F19 mov ecx, 0B3h
.text:00429F1E lea esi, [ebp+var_2E8]
.text:00429F24 rep movsd
这几行代码将CONTEXT复制到了三环的堆栈
.text:00429FAC push 4
.text:00429FAE pop ecx
.text:00429FAF add eax, ecx
.text:00429FB1 mov [ebp+var_2EC], eax
.text:00429FB7 mov edx, [ebp+arg_C]
.text:00429FBA mov [eax], edx
.text:00429FBC add eax, ecx
.text:00429FBE mov [ebp+var_2EC], eax
.text:00429FC4 mov edx, [ebp+arg_10]
.text:00429FC7 mov [eax], edx
.text:00429FC9 add eax, ecx
.text:00429FCB mov [ebp+var_2EC], eax
.text:00429FD1 mov edx, [ebp+arg_14]
.text:00429FD4 mov [eax], edx
.text:00429FD6 add eax, ecx
.text:00429FD8 mov [ebp+var_2EC], eax ; 修正3环堆栈栈顶
接着这几行代码就是将APC函数执行时需要的4个值压入到3环的堆栈
KiInitializeUserApc函数分析:准备用户层执行环境
当KiInitializeUserApc将CONTEXT和执行用户APC所需要的4个值备份到3环的堆栈时,就开始准备用户层的执行环境了
.text:00429F2D push 23h
.text:00429F2F pop eax ; eax=0x23
.text:00429F30 mov [ebx+78h], eax ; 修改Trap_Frame中的SS
.text:00429F33 mov [ebx+38h], eax ; 修改Trap_Frame中的DS
.text:00429F36 mov [ebx+34h], eax ; 修改Trap_Frame中的ES
.text:00429F39 mov dword ptr [ebx+50h], 3Bh ; 修改Trap_Frame中的FS
.text:00429F40 and dword ptr [ebx+30h], 0 ; 修改Trap_Frame中的GS
首先修改段寄存器 SS DS FS GS
.text:00429F78 mov [ebx+70h], eax ; 修改Trap_Frame中的EFLAGS
接着修改EFLAGS寄存器
.text:00429F97 mov [ebx+74h], eax ; 修改Trap_Frame中的ESP
.text:00429F9A mov ecx, _KeUserApcDispatcher
.text:00429FA0 mov [ebx+68h], ecx ; 修改Trap_Frame中的EIP
然后修改ESP和EIP。这个EIP就是执行用户APC时返回到3环的位置。
这个位置是固定的,是一个全局变量:KeUserApcDispatcher。这个值在系统启动的时候已经赋值好了,是3环的一个函数:ntdll.KiUserApcDispatcher
然后回到3环,由KiUserApcDispatcher执行用户APC
总结:
- 段寄存器 SS DS FS GS
- 修改EFLAGS寄存器
- 修改ESP
- 修改EIP->ntdll.KiUserApcDispatcher
ntdll.KiUserApcDispatcher函数分析
找到KiUserApcDispatcher函数,结合上面的堆栈图我们可以得知,esp+0x10的位置就是CONTEXT指针
此时的ESP指向的是NormalRoutine,pop eax
将NormalRoutine赋值给了eax,然后call eax
开始处理用户APC的总入口
处理完用户的APC函数之后,会调用ZwContinue,这个函数的意义在于:
- 返回内核,如果还有用户APC,重复上面的执行过程
- 如果没有需要执行的用户APC,会将CONTEXT赋值给Trap_Frame结构体,回到0环
总结
- 内核APC在线程切换时执行,不需要换栈,比较简单,一个循环执行完毕
- 用户APC在系统调用、中断或异常返回3环前会进行判断,如果有要执行的用户APC,再执行。
- 用户APC执行前会先执行内核APC