内核-⑥APC机制
1. APC本质
- 终止线程,是我们提供一个函数给线程,线程在某个时刻会调用这个函数,才会终止,这个函数就是APC(异步过程调用)。并不是说我们可以直接控制这个线程。
- 线程是不能被“杀掉”、“挂起”、“恢复”的,线程在执行的时候自己占据着CPU,别人怎么可能控制它呢?
- 举个极端的例子:如果不调用API,屏蔽中断,并保证代码不出现异常,线程将永久占用CPU,何谈控制呢?所以说线程如果想“死”,一定是自己执行代码把自己杀死,不存在“他杀”这种情况!
- 那如果想改变一个线程的行为该怎么办呢?
- 可以给他提供一个函数,让它自己去调用,这个函数就是APC(Asyncroneus Procedure Call),即异步过程调用。
1.1 APC队列
- _KAPC_STATE结构体
- 一个线程有一份APC队列
1.1.1 ApcState结结构
- ApcListHead:两个双向链表,16字节。里面存储的就是APC结构体的ApcListEntry,通过结构体可以找到APC函数
- 线程执行到某一个时刻的时候,会检查APC表,发现有APC函数的时候,就会调用这个函数
- 两个链表:第一个链表存储0环函数(函数地址高2GB),第二个链表存储3环函数(函数地址低2GB)
- Process:进程结构体
- 情况①,没有挂靠进程:指向该线程的父进程
- 情况②,已经挂靠进程:指向K_THREAD的进程结构体,即:所挂靠的进程结构体
- KernelApcInProgress:当前内核APC的程序是否正在执行,1:正在执行
- KernelApcPending:当前APC是否存在内核APC函数,存在为1,不存在为0
- UserApcPending:当前APC是否有3环APC函数,存在为1,不存在为0
1.2 APC结构体
1.3 APC函数调用时机
- 1.3.1 IDA分析KiServiceExit函数
- 判断是否存在3环的APC,存在的话会继续往下走,调用_KiDeliverApc处理APC队列里面的函数
- 0环的APC函数KiDeliverApc是一定会处理的,不需要判断。
- _KiDeliverApc是专门处理APC函数的函数,参数1的值为0就只处理内核APC,值为1内核和用户APC都会处理。
1.4 总结
- 如果想要控制某一个线程,我们不能直接控制这个线程,而是要给这个线程发送一个APC函数,插入到APC队列中。这个线程触发中断、系统调用、异常的其中一种情况的时候就会调用我们发送过去的APC函数,从而达到间接控制这个线程的目的,APC函数处理完成之后,将会从APC队列中移除这个APC函数。
- APC队列存储在ApcState结构体中的ApcListHead
2. 备用APC队列
- SavedApcState的意义
- 线程APC队列中的APC函数都是与进程相关联的,具体点说:A进程的T线程中的所有APC函数,要访问的内存地址都是A进程的。
- 但线程是可以挂靠到其他的进程:比如A进程的线程T,通过修改Cr3(改为B进程的页目录基址),就可以访问B进程地址空间,即所谓“进程挂靠”。
- 当T线程挂靠B进程后,APC队列中存储的却仍然是原来的APC!具体点说,比如某个APC函数要读取一个地址为0x12345678的数据,如果此时进行读取,读到的将是B进程的地址空间,这样逻辑就错误了!
- 为了避免混乱,在T线程挂靠B进程时,会将ApcState中的值暂时存储到SavedApcState中,等回到原进程A时,再将APC队列恢复。
- 所以,SavedApcState又称为备用APC队列。
- 总结:当线程挂靠之后,线性地址已经不是当前进程的线性地址了,但APC中的地址又是当前线程的地址,所以做一个备份。当取消挂靠时,将备份的内容复原。
- 挂靠环境下ApcState的意义
- 在挂靠的环境下,也是可以向线程APC队列插入APC的,那这种情况下,使用的是哪个APC队列呢?
- A进程的T线程挂靠B进程,A是T的所属进程,B是T的挂靠进程
- ApcState B进程相关的APC函数
- SavedApcState A进程相关的APC函数
- 在未挂靠情况下,当前进程(ApcState.Process)就是所属进程A,如果是挂靠情况下,当前进程(ApcState.Process)就是挂靠进程B(可回顾1.1.1)。
- ApcSatePointer
- 为了操作方便,_KTHREAD结构体中定义了一个指针数组ApcStatePointer ,长度为2。
- 未挂靠情况下:
- ApcStatePointer[0] 指向 ApcState //指向父进程
- ApcStatePointer[1] 指向 SavedApcState //指向挂靠进程
- 挂靠情况下:
- ApcStatePointer[0] 指向 SavedApcState //还是指向父进程(备份后SavedApcState指向父进程)
- ApcStatePointer[1] 指向 ApcState //指向挂靠进程(备份后ApcState指向挂靠进程)
- ApcStateIndex
- ApcStateIndex用来标识当前线程处于什么状态:
- 0 正常状态 1 挂靠状态
- 通过ApcStatePointer和ApcIndex组合寻址
- ApcStatePointer[ApcIndex]
- 未挂靠情况下,向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
- ApcQueueable用于表示是否可以向线程的APC队列中插入APC。
- 当线程正在执行退出的代码时,会将这个值设置为0 ,如果此时执行插入APC的代码(KeInsertQueueApc后面会说到),在插入函数中会判断这个值的状态,如果为0,则插入失败。
总结
- 每个线程都有一个APC队列和一个备用APC队列
- 线程未挂靠进程时,ApcState里面的APC队列的地址使用的进程空间就是父进程的
- 线程已挂靠进程时,ApcState里面的APC队列的地址使用的进程空间是挂靠进程的
- 线程的ApcStatePointer是一个数组,存储了2个ApcState结构,一个指向ApcState,一个指向SavedApcState
- 未挂靠进程时:ApcStatePointer[0]指向ApcState,ApcStatePointer[1]指向SavedApcState
- 已挂靠进程时,ApcStatePointer[0]指向SavedApcState,ApcStatePointer[1]指向ApcState
- 不管什么情况下,ApcStatePointer[0]都是指向父进程的进程空间
- ApcStateIndex标志着当前是否处于挂靠状态,1则表示挂靠状态,0表示正常状态
- ApcStatePointer[ApcStateIndex]则永远指向ApcStater
- ApcQueueable为0说明此时不能向该线程插入APC队列
3. APC挂入流程
- 无论是正常状态还是挂靠状态,都有两个APC队列,一个内核队列,一个用户队列。
- 每当要挂入一个APC函数时,不管是内核APC还是用户APC,内核都要准备一个KAPC的数据结构,并且将这个KAPC结构挂到相应的APC队列中。
3.1 APC结构体
3.2 挂入流程
- KeInitializeApc函数
VOID KeInitializeApc ( IN PKAPC Apc,//KAPC指针 IN PKTHREAD Thread,//目标线程,会挂到KAPC.Thread IN KAPC_ENVIRONMENT TargetEnvironment,//0 1 2 3四种状态(对应KAPC.ApcStateIndex) IN PKKERNEL_ROUTINE KernelRoutine,//销毁KAPC的函数地址 IN PKRUNDOWN_ROUTINE RundownRoutine OPTIONAL, IN PKNORMAL_ROUTINE NormalRoutine,//用户APC总入口或者内核apc函数 IN KPROCESSOR_MODE Mode,//要插入用户apc队列还是内核apc队列(KAPC.ApcMode) IN PVOID Context//情况①内核APC:NULL 情况②用户APC:真正的APC函数 ) 参考:KeInitializeApc函数(内核函数)
- TargetEnvironment(对应KAPC.ApcStateIndex)
- 与KTHREAD(+0x165)的属性同名,但含义不一样:
- ApcStateIndex 有四个值:
- 0 :原始环境 ,1:挂靠环境 ,2: 当前环境 ,3:APC时的当前环境
- 2与3的区别:真正插入的时候,APC.StateIndex的值可能与初始化的时候不一样,如:初始化时是挂靠环境,但是插入时是非挂靠环境,但是我只想将APC插入到ApcStateIndex的环境下(当前环境,可能是挂靠也可能是非挂靠),此时使用3就可以解决这个问题
- 值为0和1:
- 未挂靠情况下:
- ApcStatePointer[0] 指向 ApcState //父进程内存的APC
- ApcStatePointer[1] 指向 SavedApcState //挂靠进程内存的APC
- 挂靠情况下:
- ApcStatePointer[0] 指向 SavedApcState //父进程内存的APC
- ApcStatePointer[1] 指向 ApcState //挂靠进程内存的APC
- 值为2 :
- 初始化时,写入当前进程的ApcState :若挂靠了,那就是挂靠进程内存的APC,没挂靠,就是父进程内存的APC
- 初始化的时候获取KTHREAD.ApcStateIndex的值,插入的时候插入到ApcStatePointer[ApcStateIndex]
- 值为 3:
- 插入时,写入当前进程的ApcState
- 真正要插入APC的时候,再获取KTHREAD.ApcStateIndex的值,插入到ApcStatePointer[ApcStateIndex]
- KiInsertQueueApc函数说明
- 1) 根据KAPC结构中的ApcStateIndex找到对应的APC队列
- 2) 再根据KAPC结构中的ApcMode确定是用户队列还是内核队列
- 3) 将KAPC挂到对应的队列中(挂到KAPC的ApcListEntry处)
- 4) 再根据KAPC结构中的Inserted置1,标识当前的KAPC为已插入状态
- 5) 修改KAPC_STATE结构中的KernelApcPending/UserApcPending
- 插入成功后,修改UserApcPending时有可能失败,要想成功需要满足以下条件
- ①被插入APC的那个线程需要处于等待状态
- ②等待状态需要是由用户主动导致的,如sleep,WaitForSingleObject等
- ③被插入APC的线程支持被APC唤醒,即:KTHREAD.Alertable的值为真
- 若UserApcPending的值没有成功修改,则有可能不会执行APC函数,除非该线程又被插入新的APC,UserApcPending的值被修改为1了
- KTHREAD.Alertable属性
IDA分析KiInsertQueueApc函数
- 小结:
- 1、Alertable=0 当前插入的APC函数未必有机会执行:UserApcPending = 0
- 2、Alertable=1
- UserApcPending = 1
- 将目标线程唤醒(从等待链表中摘出来,并挂到调度链表)
3.3 总结
- 3环用户挂入APC流程比较繁琐,需要从kernel32.dll一层一层调用到内核函数初始化KAPC结构体,再执行插入函数0环挂入APC就可以直接调用内核函数初始化KAPC结构体,执行插入函数。
- 初始化KAPC结构体:KeInitializeApc函数
- 插入KAPC结构体:KiInsertQueueApc函数
- ①根据KAPC结构体中的ApcStateIndex找到需要插入的ApcState或SavedApcState中的KAPC双向链表
- ②根据KAPC结构体中的ApcMode,决定插入到用户队列还是内核队列
- ③将KAPC挂到对应的KPAC.ApcListEntry中(K_THREAD的KAPC_STATE ApcState的ApcListHead就是APC队列头)
- ④将该APC的Inserted置为1,表示本APC已经挂入APC队列
- ⑤修改K_THREAD的KAPC_STATE ApcState中的KernelApcPending和UserApcPending,表示当前线程存在APC函数
- 修改UserApcPending可能不成功,需要满足3个条件,若UserApcPending修改不成功,那么APC函数不一定会执行
4. 内核APC执行过程
- APC函数的执行与插入并不是同一个线程,具体点说:在A线程中向B线程插入一个APC,插入的动作是在A线程中完成的,但什么时候执行则由B线程决定!所以叫“异步过程调用”。
4.1 执行点1
- 4.1.1 分析内核模块的SwapContex函数
4.2 执行点2
- 当产生系统调用、中断或者异常,线程在返回用户空间前都会调用_KiServiceExit函数,在_KiServiceExit会判断是否有要执行的用户APC,如果有则调用KiDeliverApc函数(第一个参数为1)进行处理,没有则直接return
- 4.2.1 分析内核模块的SwapContex函数
- 当UserApcPending为0时,没有用户APC,那么内核APC也不会执行了
4.3 内核APC调用
- 4.3.1 IDA分析内核模块,KiDeliverApc函数执行流程
- 1) 判断第一个链表是否为空
- 2) 判断KTHREAD.ApcState.KernelApcInProgress是否为1(当前APC是否正在执行)
- 3) 判断是否禁用内核APC(KTHREAD.KernelApcDisable是否为1)
- 4) 将当前链表的KAPC结构体从链表中摘除(我们是通过链表找到这个KAPC的)
- 5) 执行KAPC.KernelRoutine指定的函数 释放KAPC结构体占用的空间
- 6) 将KTHREAD.ApcState.KernelApcInProgress设置为1 标识正在执行内核APC
- 7) 执行真正的内核APC函数(KAPC.NormalRoutine)
- 8) 执行完毕 将KernelApcInProgress改为0
- 9) 循环第1)步
4.4 总结
- 1) 内核APC在线程切换的时候就会执行,这也就意味着,只要插入内核APC很快就会执行。
- 2) 在执行用户APC之前会先执行内核APC。
- 3) 内核APC在内核空间执行,不需要换栈,一个循环全部执行完毕。
- 内核APC函数调用的2种情况:
- ①线程切换
- ②用户APC调用了(执行用户APC函数前会先执行内核APC函数)
- KiDeliverApc函数负责执行APC函数,它的第一个参数为0时,仅处理内核APC函数,为1时,处理内核和用户APC函数
- KiDeliverApc函数处理步骤:
- 1) 判断第一个链表是否为空
- 2) 判断当前APC是否正在执行
- 3) 判断是否禁用内核APC
- 4) 将当前链表的KAPC结构体从链表中摘除
- 5) 释放KAPC结构体占用的空间
- 6) 标识正在执行内核APC
- 7) 执行真正的内核APC函数
- 8) 将KernelApcInProgress改为0
- 9) 循环第1)步
5. 用户APC执行过程
5.1 用户APC的堆栈操作
- 用户APC函数要在用户空间执行的,这里涉及到大量换栈的操作,比内核APC复杂的多:
- 当线程从用户层进入内核层时,要保留原来的运行环境,比如各种寄存器,栈的位置等等 (_Trap_Frame),然后切换成内核的堆栈,如果正常返回,恢复堆栈环境即可。
- 但如果有用户APC要执行的话,就意味着线程要提前返回到用户空间去执行,而且返回的位置不是线程进入内核时的位置,而是返回到3环执行APC的位置,每处理一个用户APC都会涉及到:
- 内核-->用户空间(处理APC的位置)-->再回到内核空间
5.2 执行点
- 当产生系统调用、中断或者异常,线程在返回用户空间前都会调用_KiServiceExit函数,在_KiServiceExit会判断是否有要执行的用户APC,如果有则调用KiDeliverApc函数(第一个参数为1)进行处理,没有则直接return
- 1) 判断用户APC链表是否为空
- 2) 判断第一个参数是为1
- 3) 判断ApcState.UserApcPending是否为1
- 4) 将ApcState.UserApcPending设置为0
- 5) 链表操作 将当前APC从用户队列中拆除
- 6) 调用函数(KAPC.KernelRoutine)释放KAPC结构体内存空间
- 7) 调用KiInitializeUserApc函数
- KiInitializeUserApc函数
5.3 总结
- 内核APC
- 1.内核APC函数在线程切换的时候就执行了,不需要换栈,比较简单,一个循环就可以执行完毕
- 2.用户APC函数在系统调用,中断,异常返回3环的时候,会判断是否有需要执行的用户APC函数,如果有再执行APC函数
- 3.每个用户APC函数执行前都会先执行内核APC函数
- 用户APC
- 1.判断用户APC链是否为空
- 2.判断第一个参数是否为1,为1时才会处理用户APC
- 3.判断_KTHREAD Thread->_KAPC.STATE ApcState -> UChar UserApcPending是否为1,为1则当前APC有3环APC函数
- 4.将_KTHREAD Thread->_KAPC.STATE ApcState -> UChar UserApcPending设置为0
- 5.链表操作,将当前APC从用户APC队列中摘除
- 6.调用KiInitializeUserApc函数,初始化用户APC的执行环境
- ①申请堆栈空间0x2DC字节(Context大小+APC相关16字节),备份当前TrapFrame到堆栈中的ConText
- ②给APC相关的16字节赋值(APC总入口或内核函数、用户APC函数、参数1,参数2)
- ③修改当前TrapFrame,将TrapFrame修改成3环堆栈,EIP修改成KeUserDispatcher函数
- ④执行用户APC总入口,它会调用用户APC函数
- ⑤返回内核,循环操作

















浙公网安备 33010602011771号