APC机制详解

僤鯓⒐⒋嵵緔 提交于 2020-02-01 19:12:45

APC的本质

线程是不能被杀死 挂起和恢复的,线程在执行的时候自己占据着CPU,别人怎么可能控制他呢?举个极端的例子,如果不调用API,屏蔽中断,并保证代码不出现异常,线程将永久占据CPU。所以说线程如果想结束,一定是自己执行代码把自己杀死,不存在别人把线程结束的情况。

那如果想改变一个线程的行为该怎么办?可以给他提供一个函数,让他自己去调用,这个函数就是APC,即异步过程调用

APC队列

kd> dt _KTHREAD
ntdll!_KTHREAD
+0x040 ApcState         : _KAPC_STATE
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结构

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相关函数

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

挂入流程

在这里插入图片描述

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

  1. 根据KAPC结构中的ApcStateIndex找到对应的APC队列
  2. 再根据KAPC结构中的ApcMode确定是用户队列还是内核队列
  3. 将KAPC挂到对应的队列中,挂到KAPC的ApcListEntry处
  4. 再根据KAPC结构中的Inserted置1,标识当前的KAPC为已插入状态
  5. 修改KAPC_STATE结构中的KernelApcPending/UserApcPending

内核APC的执行过程

APC函数的插入和执行并不是同一个线程,具体点说:

在A线程中向B线程插入一个APC,插入的动作是在A线程中完成的,但什么时候执行则由B线程决定。所以叫异步过程调用。

内核APC函数与用户APC函数的执行时间和执行方式也有区别。我们先来了解内核APC的执行过程

执行点:线程切换

  1. SwapContext 判断是否有内核APC
  2. KiSwapThread 切换线程
  3. KiDeliverApc 执行内核APC函数

执行点:系统调用 中断或者异常(_KiServiceExit)

当要执行用户APC之前,先要执行内核APC

KiDeliverApc函数的执行流程

  1. 判断第一个链表是否为空
  2. 判断KTHREAD.ApcState.KernelApcInProgress是否为1
  3. 判断是否禁用内核APC(KTHREAD.KernelApcDisable是否为1)
  4. 将当前KAPC结构体从链表中摘除
  5. 执行KAPC.KernelRoutine指定的函数 释放KAPC结构体占用的空间
  6. 将KTHREAD.ApcState.KernelApcInProgress设置为1 标识正在执行内核APC
  7. 执行真正的内核APC函数(KAPC.NormalRoutine)
  8. 执行完毕 将KernelApcInProgress改为0

总结

  1. 内核APC在线程切换的时候就会执行,这也就意味着,只要插入内核APC很快就会被执行
  2. 在执行用户APC之前会先执行内核APC
  3. 内核APC在内核空间执行,不需要换栈,一个循环全部执行完毕

用户APC的执行过程

当产生系统调用 中断或者异常,线程在返回用户空间前都会调用_KiServiceExit函数,在_KiServiceExit函数里会判断是否有要执行的用户APC,如果有则调用KiDeliverApc函数进行处理

执行用户APC时的堆栈操作

处理用户APC要比处理内核APC复杂的多,因为用户APC函数要在用户空间执行,这里涉及到大量的换栈操作:

当线程从用户层进入内核层时,要保留原来的运行环境,比如各种寄存器 栈的位置等等,然后切换成内核的堆栈,如果正常返回,恢复堆栈环境即可

但如果有用户APC要执行的话,就意味着线程要提前返回到用户空间去执行,而且返回的位置不是线程 进入内核时的位置,而是返回到其他的位置,每处理一个用户APC就会涉及到

内核—>用户空间—>再回到内核空间

KiInitializeUserApc函数分析:备份CONTEXT

线程进0环时,原来的运行环境(寄存器栈顶等)保存到_Trap_Frame结构体中,如果要提前返回3环去处理用户APC,就必须修改_Trap_Frame结构体

比如:进0环时的位置存储在EIP中,现在要提前返回,而且返回的并不是原来的位置,那就意味着必须要修改EIP为新的返回位置,还有堆栈ESP也要修改为处理APC需要的堆栈。那原来的值怎么办?处理完APC后该如何返回原来的位置呢?

KiInitializeUserApc要做的第一件事就是备份:

将原来_Trap_Frame的值备份到一个新的结构体中(CONTEXT),这个功能由其子函数KeContextFromKframes来完成

KiInitializeUserApc函数分析:堆栈图

在这里插入图片描述

KiInitializeUserApc函数分析:准备用户层执行环境

在这里插入图片描述

  1. 段寄存器 SS DS FS GS
  2. 修改EFLAGS寄存器
  3. 修改ESP
  4. 修改EIP

总结

  1. 内核APC在线程切换时执行,不需要换栈,比较简单,一个循环执行完毕
  2. 用户APC在系统调用、中断或异常返回3环前会进行判断,如果有要执行的用户APC,再执行。
    数分析:堆栈图

[外链图片转存中…(img-pNVmYHGw-1580550581772)]

KiInitializeUserApc函数分析:准备用户层执行环境

[外链图片转存中…(img-BLCfq8qg-1580550581781)]

  1. 段寄存器 SS DS FS GS
  2. 修改EFLAGS寄存器
  3. 修改ESP
  4. 修改EIP

总结

  1. 内核APC在线程切换时执行,不需要换栈,比较简单,一个循环执行完毕
  2. 用户APC在系统调用、中断或异常返回3环前会进行判断,如果有要执行的用户APC,再执行。
  3. 用户APC执行前会先执行内核APC
标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!