内核APC&用户APC详解
0x01 内核APC
线程切换
SwapContext 判断是否有内核APC
KiSwapThread
KiDeliverApc执行内核APC函数
定位到SwapContext
函数,然后查看KernelApcPending
的值是否为空,不为空则跳转,这里只是进行判断,我们往上跟
然后回到KiSwapContext
再往上走得到KiSwapThread
这里判断后进行跳转
然后调用KiDeliverApc
系统调用、中断或者异常
当要执行用户APC之前,先要执行内核APC,这里找到KiServiceExit
,有一个比较检验UserApcPending
的值是否有APC请求
然后调用KiDeliverApc
内核层APC执行
KiDeliverApc
继续往里面跟,判断内核APC的链表是否为空,若不为空则跳转
NormalRoutine
跳转后判断NormalRoutine
里面存储的是内核APC的地址还是APC的的总入口,然后再跳转
如果为空向下执行则会调用KernelRoutine
对APC进行销毁
跳转过后执行真正的内核APC函数NormalRoutine
内核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
9) 循环
0x02 用户APC
当产生系统调用、中断或者异常,线程在返回用户空间前都会调用KiServiceExit
函数,在KiServiceExit
会判断是否有要执行的用户APC,如果有则调用KiDeliverApc
函数(第一个参数为1)进行处理。
处理用户APC要比内核APC复杂的多,因为,用户APC函数要在用户空间执行的,这里涉及到大量换栈的操作:
当线程从用户层进入内核层时,要保留原来的运行环境,比如各种寄存器,栈的位置等等 (_Trap_Frame
),然后切换成内核的堆栈,如果正常返回,恢复堆栈环境即可。
但如果有用户APC要执行的话,就意味着线程要提前返回到用户空间去执行,而且返回的位置不是线程进入内核时的位置,而是返回到其他的位置,每处理一个用户APC都会涉及到:
内核-->用户空间-->再回到内核空间
KiDeliverApc
1) 判断用户APC链表是否为空
2) 判断第一个参数是为1
3) 判断ApcState.UserApcPending是否为1
4) 将ApcState.UserApcPending设置为0
5) 链表操作 将当前APC从用户队列中拆除
6) 调用函数(KAPC.KernelRoutine)释放KAPC结构体内存空间
7) 调用KiInitializeUserApc函数
线程进0环时,原来的运行环境(寄存器栈顶等)保存到_Trap_Frame
结构体中,如果要提前返回3环去处理用户APC,就必须要修改_Trap_Frame
结构体:
比如:进0环时的位置存储在EIP中,现在要提前返回,而且返回的并不是原来的位置,那就意味着必须要修改EIP为新的返回位置。还有堆栈ESP,也要修改为处理APC需要的堆栈。那原来的值怎么办呢?处理完APC后该如何返回原来的位置呢?
KiInitializeUserApc
要做的第一件事就是备份:
将原来_Trap_Frame
的值备份到一个新的结构体中(CONTEXT
),这个功能由其子函数KeContextFromKframes
来完成,代码如下
首先判断参数是否为1,当参数为1的时候处理用户APC
然后进行一系列的操作
KiInitializeUserApc
接着转到KiInitializeUserApc
函数
将CONTEXT
和TrapFrame
传入KeContextFromKframes
这里接着往下看,这里得到C4
C4对应的Esp
存储的是3环原来的栈顶
然后以4字节对齐将3环堆栈减去0x2DC个字节,这里是因为要将CONTEXT
结构和KAPC的4个参数传给3环
原本三环的ESP如图所示
CONTEXT
结构体的大小为0x2CC,KAPC的4个参数的大小为0x10,所以减去0x2DC
这一部分代码主要是将CONTEXT
结构复制到3环的堆栈
当windows把CONTEXT
结构复制到堆栈之后,准备用户层执行环境,首先修改SS、DS、ES、FS、GS和EFLAGS寄存器
然后修改esp到3环堆栈
KiUserApcDispatcher
然后修改eip,这里永远返回一个固定的位置,但是这个位置在每次系统启动的时候都不相同,存放在3环的ntdll
里的KiUserApcDispatcher
参数里面
然后到ntdll里面定位到KiUserApcDispatcher
,首先得到指向CONTEXT
结构的指针,然后pop eax
得到NormalRoutine
结构,这里当APC是内核APC的时候存储的是真正的APC地址,当APC是用户APC的时候存储的是指向用户APC的总入口
当我们调用QueueUserAPC
,并没有指定NormalRoutine
结构,只指定了NormalContext
和SystemArgument1
,那么这个参数在QueueUserAPC
内部指定,在kernel32.dll
的BaseDispatchAPC
,用来调用真正的用户APC函数
再继续往下跟,调用了ZwContinue
1) 返回内核,如果还有用户APC,重复上面的执行过程。
2) 如果没有需要执行的用户APC,会将CONTEXT赋值给Trap_Frame结构体。就像从来没有修改过一样。ZwContinue后面的代码不会执行,线程从哪里进0环仍然会从哪里回去。
使用0x20
的调用号利用调用门回到0环
用户APC执行流程
总结:
1.内核APC在线程切换时执行,不需要换栈,比较简单,一个循环执行完毕。
2.用户APC在系统调用、中断或异常返回3环前会进行判断,如果有要执行的用户APC,再执行。
3.用户APC执行前会先执行内核APC。
- 本文作者: szbuffer
- 本文来源: 奇安信攻防社区
- 原文链接: https://forum.butian.net/share/1640
- 版权声明: 除特别声明外,本文各项权利归原文作者和发表平台所有。转载请注明出处!