内核-⑥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函数
      • ⑤返回内核,循环操作










posted @ 2021-01-01 10:38  三一米田  阅读(1258)  评论(0)    收藏  举报