avatar

Catalog
用户APC执行过程

前言

在前一篇分析了内核APC执行过程,本篇开始分析用户APC执行过程,处理用户APC要比内核APC复杂的多,因为,用户APC函数要在用户空间执行,涉及到大量换栈的操作

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

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

这一过程非常复杂,需要了解堆栈的操作细节,这里,我们先从KiDeliverApc开始,一步步分析。

KiDeliverApc

回到上次KiDeliverApc的位置,橙色框住的部分,会判断内核APC队列是否存在需要处理的APC;接着下方就是判断用户APC队列是否存在需要处理的APC,判断方法与内核APC一样,若队列不为空,则会跳转处理用户APC。根据代码的流程也可以看出,系统在处理用户APC之前,一定会先处理内核APC

跳转进来,首先,橙色框住的部分,是一些先行校验(处理内核APC部分也有,但校验的参数不同),若校验不通过,则无法继续执行,将会离开KiDeliverApc函数。其中一个校验的是KiDeliverApc的参数PreviousMode,判断该值是否为1。若不为1,则无法继续执行。先前的文章有分析过,线程发生切换时会调用KiDeliverApc,此时PreviousMode的值必然为0,所以线程切换时用户APC没法得到执行

然后来看绿色框住的地方,这部分就比较熟悉了,和处理内核APC过程类似。先将处理APC要用到的参数存储到局部变量,接着摘除APC块,释放Kapc结构,然后校验参数NormalRoutine。

接着就是与内核APC处理不同的地方了,处理内核APC会直接调用NormalRoutine;而处理用户APC会依次将需要用到的4个参数以及KiDeliverApc函数还未使用到的2个参数全部压入堆栈,并调用KiInitializeUserApc函数。这个函数是处理用户APC的关键,它用来初始化处理用户APC的环境。具体看接下来的分析。

KiInitializeUserApc

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

原先进0环时的位置存储在_Trap_Frame的EIP中,现在要提前返回,而且返回的并不是原来的位置(原来的位置并不能执行用户APC函数),那就意味着必须要修改EIP为新的返回位置。ESP也要修改为处理APC所需要的堆栈。那么原来的值怎么办呢?处理完APC后该如何返回原来的位置呢?

所以KiInitializeUserApc要做的第一件事就是备份:将原来_Trap_Frame的值备份到一个新的结构体中(Context),这个功能由其子函数KeContextFromKframes来完成。下面来看KiInitializeUserApc如何实现这一过程:

先看这一部分,关键的部分都框出来了,下面一个个来看。

  • 橙色方框:KiDeliverApc有3个参数,其中之一就是_Trap_Frame,并作为KiInitializeUserApc的参数,传进来。

  • 红色方框1&2:这部分将_Trap_Frame的内容备份到Context,Context结构和TrapFrame类似。 需要注意的是,这部分将TrapFrame的值备份到了Ring0堆栈中的Context

  • 绿色方框:这部分是开辟Ring3的栈空间,首先可以根据_Trap_Frame获取到原先Ring3环境下的ESP,但是此时的ESP无法用来执行用户APC,所以需要开辟新的大小为0x2DC的内存空间。这个0x2DC,能刚好容纳下Context结构体(大小0x2CC)+ 4个执行用户APC需要用到的参数(大小0x10)

  • 红色方框3:这部分,将前面在Ring0堆栈中的Context结构体的内容复制到Ring3堆栈中(刚刚开辟的内存空间)

  • 紫色方框:当原先TrapFrame的值已经备份到Context中以后,就可以修改_Trap_Frame的值了,这部分主要是修改段寄存器的值。

接着来看这一部分:

  • 紫色方框1:紧接着上面那部分,这里是修改_Trap_Frame中Eflags的值。
  • 紫色方框2:这里修改_Trap_Frame中的ESP,EIP以及ErrCode。其中
    • ESP:建立在原先ESP基础上有新增的0x2DC大小的空间,并且此时新增区域已经赋上了Context结构体,还余有0x10大小的空间。
    • EIP:EIP是返回3环后开始执行的位置,前言中提到,返回3环的位置不是线程进入内核时的位置,而是返回到其它的位置。说的就是这里,这里将EIP的值修改为KeUserApcDispatcher函数的地址,该函数是属于ntdll.dll的一个3环函数,也是所有用户APC执行时返回到3环的位置。这样就可以保证了用户APC返回3环后都会从这里开始执行。
  • 红色方框:先前开辟内存空间时,开辟了0x2DC个字节,用来存放Context结构体和4个执行APC时需要用到的参数。方才上一部分分析中已经将Context结构体复制到3环的这一块区域中,这里则是将4个参数复制进来。由于Ring.ESP的值已经确定,并且此时执行时仍位于0环堆栈,只是在0环堆栈中操作3环堆栈的内存空间,因此不能才用push,而是用上述这种方法复制值。

上述操作完后,Ring3的堆栈分配大致如下

然后,根据_Trap_Frame内的值,就可以返回到3环,从KiUserApcDispatcher函数开始执行。

KiUserApcDispatcher

这部分就不那么困难了,只有几行代码。先看橙色方框,这里是调用堆栈栈顶的函数,需要注意一点,由于KiUserApcDispatcher是3环函数,在执行此函数时,环境应是3环堆栈,因此,可以根据上方的3环堆栈图可以得知此时调用的函数是NormalRoutine。

先前在介绍Kapc结构时提到过,如果是内核APC,NormalRoutine就是内核APC函数;如果是用户APC,NormalRoutine表示的是用户APC的总入口。那么这个入口是什么呢?当用户在3环调用QueueUserApc函数来插入APC时,不需要提供NormalRoutine,这个参数是在QueueUserApc内部指定的BaseDispatchApc。通过这个入口,内部会调用真正的用户APC函数执行。至此,用户APC函数已成功执行。

再接下来,前言提到过,用户APC是在3环执行的,因此还需要返回内核,观察红色方框,ZwContinue做的就是这件事,用来返回内核,此处就不再继续分析该函数。返回内核后,如果还有用户APC,则重复之前的执行过程。如果没有需要执行的用户APC,会将Context赋值给_Trap_Frame结构体。就像从来没有修改过一样。ZwContinue后面的代码不会执行,线程从哪里进的3环,就回到哪里去。至此,整个用户APC执行过程结束。

小技巧

这里有个小技巧,由于ZwContinue执行时会将Context的值赋值给_Trap_Frame结构体,因此只要修改Context结构体存的EIP的值就可以控制内核在处理完内核APC和用户APC之后返回3环的位置了。返回到自己写的代码段,当然执行起来并不容易,但也算是一种不错的思路。

总结

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

参考资料

课程:

  1. 滴水海哥中级预习班

链接:

  1. https://blog.csdn.net/weixin_42052102/article/details/83348780 (CSDN-My classmates笔记)
  2. https://www.cnblogs.com/DeeLMind/p/6855085.html (博客园-Context结构体)
  3. https://blog.csdn.net/jn1158359135/article/details/7761011 (CSDN-Eflags寄存器)
Author: cataLoc
Link: http://cataloc.gitee.io/blog/2020/08/10/%E7%94%A8%E6%88%B7APC%E6%89%A7%E8%A1%8C%E8%BF%87%E7%A8%8B/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
    微信
  • 支付寶
    支付寶