目录
APC简介
APC即异步过程调用(Asyncroneous Procedure Call),是Windows系统中一种基于线程的机制,用于将要在特定线程环境下进行的操作排队,可以实现各种异步操作和多任务处理。
APC是基于线程的异步调用,所以可以用来执行处于某特定线程环境才能做的操作。
例如,文件IO操作,某个线程正在读取一个文件,此时可以使用APC向特定线程插入一个中断APC,IRQL=1,在这个APC中可以完成已经读写成功的操作。
APC与DPC的区别
相关结构与成员
APC既然是基于线程的,那么在线程结构ETHREAD
上就会有与其相关的成员。
_KAPC_STATE(ApcState)
表示APC状态。
位置:
nt!_KTHREAD
...
+0x098 ApcState : _KAPC_STATE
....
结构
nt!_KAPC_STATE
+0x000 ApcListHead : [2] _LIST_ENTRY //核APC在索引为0的链表里,用户APC在索引为1的链表里。
+0x020 Process : Ptr64 _KPROCESS //当前进程。
+0x028 InProgressFlags : UChar //APC是否正在执行,派遣APC时将此位置1,防止重入。
+0x028 KernelApcInProgress : Pos 0, 1 Bit
+0x028 SpecialApcInProgress : Pos 1, 1 Bit
+0x029 KernelApcPending : UChar //是否有正在等待执行的内核APC。
+0x02a UserApcPendingAll : UChar //是否有正在等待执行的用户APC。
+0x02a SpecialUserApcPending : Pos 0, 1 Bit //特殊用户APC无论是否可以唤醒(alterable是否为1),都能打断 (XP,Win7,Win8没有此成员,从Win10_1809开始增加的)
+0x02a UserApcPending : Pos 1, 1 Bit
如果想让线程执行某些操作,可以将例程挂到ApcListHead
链表里。
_KAPC_STATE(SaveApcState)
备用ApcState,在_KTHREAD + 0x258
处。
nt!_KTHREAD
...
+0x258 SavedApcState : _KAPC_STATE
....
当线程挂靠时会用到此成员。
举个栗子,P1进程的T1线程,想要访问P2进程的内存,可以使用挂靠来进行切换T1线程所属进程为P2,当T1挂靠到P2后,ApcState.ApcListHead
中存储的仍然是原来的APC,为了避免混淆,当T1挂靠P2后,会将ApcState中的值存储到SaveApcState中,等回到原进程P1时,再将ApcState恢复。
_KTHREAD的ApcStateIndex成员
用来标识当前线程处于什么状态:0:正常状态,1:挂靠状态。
_KAPC
APC结构,_KAPC_STATE.ApcListHead
链表就是把一个个此结构穿起来。
当要挂入一个APC函数时,内核需要准备一个_KAPC结构,并将其挂入到相应的链表中。
nt!_KAPC
+0x000 Type : UChar //不同版本可能不同
+0x001 AllFlags : UChar
+0x001 CallbackDataContext : Pos 0, 1 Bit
+0x001 Unused : Pos 1, 7 Bits
+0x002 Size : UChar //不同版本可能不同
+0x003 SpareByte1 : UChar
+0x004 SpareLong0 : Uint4B
+0x008 Thread : Ptr64 _KTHREAD
+0x010 ApcListEntry : _LIST_ENTRY //_KAPC_STATE.ApcListHead链表挂入的地方。
+0x020 KernelRoutine : Ptr64 void //内核APC
+0x028 RundownRoutine : Ptr64 void //APC执行完之后执行此例程,一般用来做收尾工作
+0x030 NormalRoutine : Ptr64 void //正常执行例程,并不完全等于APC函数地址。一般情况下,内核态下如果没有那么就是特殊内核APC(中断APC),用户态必须要有。
+0x020 Reserved : [3] Ptr64 Void
+0x038 NormalContext : Ptr64 Void
+0x040 SystemArgument1 : Ptr64 Void
+0x048 SystemArgument2 : Ptr64 Void
+0x050 ApcStateIndex : Char // 0:原始环境;1:挂靠环境;2:当前环境,初始化APC时,判断KTHREAD.ApcStateIndex,如果为0,插入原始环境,如果为1,插入挂靠环境;3:插入APC时的当前环境,判断方法与2一样。
+0x051 ApcMode : Char
+0x052 Inserted : UChar //当前APC是否已经插入到APC队列中
_KTHREAD的Alerted与Alertable成员
位置
nt!_KTHREAD
...
+0x072 Alerted : [2] UChar
...
+0x074 Alertable : Pos 4, 1 Bit
...
_KTHREAD.Alerted
表示状态,即如果在等待时被唤醒了,那么就把此位置1。这个位影响了APC是否可以在线程等待的时候执行,如果使用WaitForSingleObjectEx,最后一个参数就是用来填写是否可以被唤醒。
_KTHREAD.Alertable
表示是否能够唤醒,是否有唤醒的能力,在WaitForSingleObject中,是否可以被APC打断。
APC控制位
APC初始化分析
内核中使用KeInitializeApc初始化,
KeInitializeApc分析
函数声明:
VOID
KeInitializeApc (
IN PRKAPC Apc, //_KPAC结构指针
IN PRKTHREAD Thread, //目标线程
IN KAPC_ENVIRONMENT Environment, //0、1、2、3四种状态,根据此值初始化_KAPC.ApcStateIndex
IN PKKERNEL_ROUTINE KernelRoutine, //内核APC函数,初始化_KAPC.KernelRoutine
IN PKRUNDOWN_ROUTINE RundownRoutine OPTIONAL, // 初始化_KAPC.RundownRoutine
IN PKNORMAL_ROUTINE NormalRoutine OPTIONAL, // 初始化_KAPC.NormalRoutine
IN KPROCESSOR_MODE ApcMode OPTIONAL, //要插入内核链表还是用户链表
IN PVOID NormalContext OPTIONAL //内核APC:NULL,用户APC函数
);
顾名思义,就是初始化_KAPC
结构。
_KAPC.Type
与_KAPC.Size
是操作系统规定好的,不同的版本可能不同。
如果参数Environment为2,则让Environment等于_KTHREAD.ApcStateIndex。根据_KTHREAD.ApcStateIndex
的值去判断插入原始环境还是挂靠环境(0:原始环境,1:挂靠环境)。
如果参数NormalRoutine为0,那么_KAPC.ApcMode
为0,如果参数NormalRoutine不为0的话,则_KAPC.ApcMode
等于参数_ApcMode。
如果参数NormalRoutine为0,那么_KAPC.NormalContext
就为0。
如果为内核模式,那么_KAPC.NormalContext
为0。(这句话是从别的资料上看到的,但根据分析并不准确。)
将_KAPC.Inserted
设置为0,表示未插入状态。
将_KAPC.AllFlags
设置为0。
伪代码如下:
下面给出更详细的汇编分析。
APC插入分析
将APC挂入,通常调用KeInsertQueueApc。
KeInsertQueueApc
BOOLEAN
KeInsertQueueApc (
__inout PRKAPC Apc,
__in_opt PVOID SystemArgument1,
__in_opt PVOID SystemArgument2,
__in KPRIORITY Increment
);
此函数主要做了:
加锁(好像是个自旋锁)。伪代码如下:
随后,如果_KTHREAD.MiscFlags&0x4000(ApcQueueable)
不为0或参数Apc是未插入状态,
则将参数Apc设置为插入状态,填充Apc的两个参数指针,调用KiInsertQueueApc
(插入APC的核心函数),调用KiSignalThreadForApc
(根据APC的类型,检查是否应该向目标线程发送信号以及如何发送信号)。
伪代码如下:
之后,解锁,调用KiExitDispatcher(主要降低IRQL,也可以将线程上下文切换到其它线程,甚至可能是我们插入APC的线程,必须进行此调用,因为调度程序必须评估应该运行哪个线程,现在我们将一个APC排队给可能具有更高优先级的线程)。
随后,如果满足下面四个条件,则执行ETW日志记录,记录APC插入。
1.能够开启ETW。
2.APC插入线程的当前进程不是原始进程。
3._KAPC.ApcMode
为用户模式或_KAPC.KernelRoutine
==KeSpecialUserApcKernelRoutine
。
4._KTHREAD.u.ApcQueueable!=0
(能够插入APC)或参数Apc是未插入状态。
之后,函数返回。
伪代码如下:
KiInsertQueueApc
此函数主要功能:将APC插入到_KTHREAD._KAPC_STATE
。
根据_KAPC
的ApcStateIndex
和KTHREAD
的ApcStateIndex
判断插入到ApcState
还是SaveApcState
。
例如,如果_KAPC.ApcStateIndex
为0(插入到原始环境),KTHREAD.ApcStateIndex
为0(正常状态),则插入偏移为0x98,即KTHREAD.AcpState
。
如果_KAPC.NormalRoutine
不为0。
如果为普通用户APC、ApcMode不为0、_KAPC.KernelRoutine
为KiSchedulerApcTerminate
。
设置ApcState的UserApcPending位为1,即设置有普通用户APC在等待执行。
这里ApcMode为1,将APC设置插入用户链表_KAPC_STATE.ApcListHead[1]
。
随后有个判断,如果头节点的下一个节点的上一个不是头节点,则跳转到LABEL_12处,调用 __fastfail
快速失败机制退出。
头插法插入结点,然后函数返回。(这种APC与退出有关,所以挂入到头部。)
如果ApcMode=0或者为普通用户APC且_KAPC.KernelRoutine
不为KeSpecialUserApcKernelRoutine
(普通内核APC || 特殊用户APC || (普通用户APC && KernelRoutine != KeSpecialUserApcKernelRoutine) )。
ApcMode为0,APC插入_KAPC_STATE.ApcListHead[0]
内核链表;ApcMode为1,则APC插入_KAPC_STATE.ApcListHead[1]
用户链表。
这里也有个判断,跟上面那个差不多(如果头节点的上一个节点的下一个不是头节点,则跳转到LABEL_12处)。
尾插法插入结点,然后函数返回。
这里需要注意,用户特殊APC的ApcMode=0,插入的是内核链表。
到这里没有插入的话,遍历对应_APC_STATE.ApcListHead
,将KTHREAD.ApcState的SpecialUserApcPending位设置为1,即有特殊用户APC在等待执行。
如果_KAPC.NormalRoutine
不存在的话,遍历对应_APC_STATE.ApcListHead
。
然后,还会进行判断,跟上面的差不多(如果头节点的下一个节点的上一个不是当前节点,则跳转到LABEL_12处)。
再进行插入操作,头插法插入链表。(主要内核特殊APC)
APC执行分析
通过函数KiDeliverApc
执行APC。
KiDeliverApc
void __fastcall KiDeliverApc(char _PreviousMode, _KEXCEPTION_FRAME *_ExceptionFrame, _KTRAP_FRAME *_TrapFrame)
看一下此函数的交叉引用。
调用此函数挺频繁的。
如果参数_KTRAP_FRAME
不为0的话,调用KiCheckForSListAddress
(检查_KTRAP_FRAME的RIP是否在一定范围,保护栈的安全。这里不作详细解释,想了解具体可以去逆向,还是挺有意思的)。
KTHREAD
的SpecialApcDisable
位(+0x1E6处)为APC执行的开关。如果其为0,可以执行APC;如果其不为0,则不能执行APC。当然,KTHREAD.SpecialApcDisable==0
时是主要分析的。
将当前线程KTHREAD.TrapFrame
设置为第三个参数的值。
将当前线程KTHREAD.ApcState.KernelApcPending
设置为0,即没有内核APC在等待执行。
下面主要分析KTHREAD.SpecialApcDisable==0
时(不为0时,做个检查就返回了。)。
有个while(1)
循环来执行内核APC。
判断当前线程APC链表头的下一个结点是否为链表头(当前线程是否有需要执行的APC),如果有,继续往下执行;如果没有,则跳转至LABEL_15处(主要执行用户APC,后面会讲解)。
随后,提升当前IRQL到2,加锁,防止重入。
之后,再判断当前线程是否有需要执行的内核APC,如果没有的话,跳出while(1)
循环;有的话,继续执行。
将当前线程KTHREAD.ApcState.KernelApcPending
设置为0,即没有内核APC在等待执行(下面即将执行内核APC)。
调用_m_prefetchw预取至cache。
_KAPC.NormalRoutine
不为0,是普通内核APC。 下面这段主要分析_KAPC.NormalRoutine
不为0时。
如果当前线程有APC在执行或当前线程没有执行内核APC的能力,释放锁,降低IRQL至1,跳转到LABEL_16处。
如果当前线程没有APC在执行并且当前线程有执行内核APC的能力,继续执行。
判断链表连接是否错误,错误的话跳转至LABEL_95处,没错的话继续执行。
移除选中的APC,将APC设置为未插入状态,因为要执行这个APC了。
之后,释放锁,将IRQL设置为1,将当前线程设置为正在执行APC,然后执行KernelRoutine
。
随后呢,这个地方IDA翻译的伪代码应该有点儿问题,将IRQL设置为0,执行NormalRoutine
,执行完之后再将IRQL设置为1。
将当前线程设置为APC没有在执行状态。
如果_KAPC.NormalRoutine
为0,代表是特殊内核apc。
判断链表连接是否错误,如果错误,跳转到LABEL_95处;如果没错,继续执行。
从链表上移除选中的APC,将APC的插入状态设置为0,释放线程锁。
将IRQL设置为1。
将当前线程设置为特殊内核APC正在执行的状态。
执行KernelRoutine
,执行完之后,将当前线程设置为特殊内核APC不在执行的状态。
while(1)
循环体至此,不断循环,直到执行完需要执行的内核APC。
将IRQL设置为1,对两个局部变量赋值后来到LABEL_15处。
如果上下文环境是用户态并且用户APC链表不为空,代表可以去执行用户APC;如果不满足前面两个条件,则会执行到LABEL_16处。下面会主要分析满足条件时的情况。
将IRQL设置为2,加线程锁,防止重入。
将当前线程设置为没有等待执行的普通用户APC(因为下面有while
循环去执行普通用户APC,暂且认为普通用户APC要没有了)。
判断用户APC链表是否为空,如果不为空,进入while(1)
循环。
调用_m_prefetchw
预取至cache。
判断是否是特殊用户APC,如果是,则跳出循环;如果不是,继续执行。
如果有普通用户APC在等待执行,并且不是特殊用户APC,跳转至LABEL_44处执行,如果是特殊用户APC则跳出循环。
如果没有普通用户APC在等待执行,获取下一个链表结点。如果结点是链表头,这时候说明没有用户APC可以执行了,跳到LABEL_49处,如果结点不是链表头,则继续在while(1)
循环执行。
LABEL_44处,判断链表连接是否错误,如果错误就蓝屏(LABEL_95处);如果没错,就继续执行。
移除链表结点,将APC设置为未插入状态。
判断当前线程有没有特殊用户APC在等待执行。
- 如果当前线程有特殊用户APC在等待执行,将当前线程设置为没有特殊用户APC在等待执行。判断当前线程用户APC链表是否为空,如果为空,执行到LABEL_49处;如果不为空,循环遍历用户APC链表,如果有特殊用户APC(
KernelRoutine==KeSpecialUserApcKernelRoutine
),则跳出循环并将当前线程设置为有特殊用户APC在等待执行,然后执行到LABEL_49处。 - 如果当前线程没有特殊用户APC在等待执行,跳到LABEL_49处。
(因为写流程分析经常遇到“如果…如果…”,搞得分句分段什么的有点儿难受,感觉上面那种格式写流程判断还是挺不错的~)
LABEL_49处,释放线程锁,将IRQL设置为1。
判断是否有需要执行的用户APC,如果有(如果没有的话,就执行到LABEL_16处),执行KernelRoutine
。
如果有正在等待执行的普通用户APC,但是NormalRoutine==0
,就会警醒该线程,随后跳转到LABEL_16处。
随后,调用KiInitializeUserApc
(后面会讲解)。有无正在等待执行的普通用户APC会影响此函数的最后一个参数。
LABEL_16处,判断当前线程刚进此函数时的线程是否与当前线程相同,如果相同,设置当前线程的TrapFrame
后函数返回;如果不同,蓝屏(蓝屏信息详见:https://learn.microsoft.com/zh-cn/windows-hardware/drivers/debugger/bug-check-0x5–invalid-process-attach-attempt)。
KiInitializeUserApc
unsigned __int64 __fastcall KiInitializeUserApc(
_KEXCEPTION_FRAME *_ExceptionFrame_a,
_KTRAP_FRAME *_TrapFrame_a,
PVOID _NormalRoutine_a,
PVOID _NormalContext_a,
PVOID _arg_1,
PVOID _arg_2,
unsigned int a7)
此函数只看重要的部分。
线程进入内核态后,将大部分用户态的环境保存到_KTRAP_FRAME
中,等要回到用户态时恢复,如果要返回用户态去处理用户APC,就要修改_KTRAP_FRAME
结构体,并将原来的值备份下来。
通过KeContextFromKframes
将_KTRAP_FRAME
和_KEXCEPTION_FRAME
的值复制到_CONTEXT
中,以便恢复。
将_CONTEXT.P1Home
设置为NormalContext
;将_CONTEXT.P2Home
设置为_SystemArgument1
;将_CONTEXT.P3Home
设置为_SystemArgument2
;将_CONTEXT.P4Home
设置为NormalRoutine
;将_CONTEXT.P5Home
设置为a7(最后一个参数);
(当是普通用户APC时,a7=0;是特殊用户APC时,a7=1)
修改_TRAP_FRAME
的RSP指向_CONTEXT
结构的顶部;修改_TRAP_FRAME
的RIP指向ntdll!KiUserApcDispatcher
,修改_TRAP_FRAME
的CS为0x33。
进入用户态时的栈为:
随后我们来看ntdll中的KiUserApcDispatcher
。
ntdll!KiUserApcDispatcher
通过对NormalRoutine解密判断是64位还是32位,如果是64位,调用KiUserCallForwarder
;如果是32位调用Wow64ApcRoutine
。
在KiUserCallForwarder
里,调用CFG(__guard_check_icall_fptr)以确保APC目标是CFG的有效函数。
随后调用NormalRoutine
。
之后,调用ZwContinueEx
,第一个参数是_CONTEXT
结构,第二个参数是“a7”。
ZwContinueEx
通过syscall进内核后会调用KiContinueEx
。下面简要分析KiContinueEx
。
KiContinueEx
NTSTATUS
KiContinueEx(
IN PCONTEXT ContextRecord,
IN BOOLEAN TestAlert,
IN PKEXCEPTION_FRAME ExceptionFrame,
IN PKTRAP_FRAME TrapFrame
)
调用此函数是为了将指定的_CONTEXT
复制到指定的_KEXCEPTION_FRAME
和_KTRAP_FRAME
,用于继续系统服务。
如果先前模式为用户模式,线程是可警醒的,用户APC可执行,指定的上下文记录先前由用户APC初始化例程放在堆栈上,然后绕过上下文记录复制到内核帧并再次返回。
如果当前线程可以被警醒并且有用户APC在等待执行,将上下文记录和异常帧地址保存在陷阱帧中,随后调用KiDeliverApc
再次执行APC。(就不将_CONTEXT
复制到_KTRAP_FRAME
和_KEXCEPTION_FRAME
中了。)
否则,根据提供的_CONTEXT
还原_KTRAP_FRAME
和_KEXCEPTION_FRAME
。
(其实这块代码可以结合WRK分析)
这样就可以遍历完所有的用户apc,形成一个闭环。