APC机制初探

时间:2022-06-01

本质

线程是不能被“杀掉”、“挂起”、“恢复”的,线程在执行的时候自己占据着CPU,别人怎么可能控制它呢?

举个极端的例子:如果不调用API,屏蔽中断,并保证代码不出现异常,线程将永久占用CPU,何谈控制呢?所以说线程如果想“死”,一定是自己执行代码把自己杀死,不存在“他杀”这种情况!

那如果想改变一个线程的行为该怎么办呢?

可以给他提供一个函数,让它自己去调用,这个函数就是APC(Asyncroneus Procedure Call),即异步过程调用。

APC队列

kd> dt _KTHREAD
nt!_KTHREAD
 ...
   +0x034 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
       
用户APC:APC函数地址位于用户空间,在用户空间执行
内核APC:APC函数地址位于内核空间,在内核空间执行

NormalRoutine会找到你提供的APC函数,并不完全等于APC函数的地址。

APC函数何时被执行?

KiServiceExit函数:

这个函数是系统调用、异常或中断返回用户空间的必经之路。

KiDeliverApc函数:

负责执行APC函数

逆向TerminateThread/ResumeThread

自己实现APC队列的插入,在3环调用QueueUserAPC

// APC1.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//
#include 
#include 
DWORD WINAPI MyThread(LPVOID)
{
 int i = 0;
 while (true)
 {
  SleepEx(300, TRUE);
  printf("%d", i++);
 }
}
void __stdcall MyApcFunction(LPVOID)
{
 printf("Run APCFuntion");
 printf("APCFunction done");
}
int main(int argc, char* argv[])
{
 HANDLE hThread = CreateThread(0, 0, MyThread, 0, 0, 0);
 Sleep(1000);
 if (!QueueUserAPC((PAPCFUNC)MyApcFunction, hThread, NULL))
 {
  printf("QueueUserAPC error : %d", GetLastError());
 }
 getchar();
 return 0;
}

QueueUserApc

通过3环的QueueUserApc函数可以完成将APC插入到队列的操作,首先调用了ntdll.dllNtQueueApcThread

然后通过0xB4的调用号进入ring0

在windbg里面对应的内核函数为NtQueueApcThread

然后在ntosknl.exe里面定位到NtQueueApcThread

最后是调用KeInitializeApcKeInsertQueueApc这两个函数来实现APC的效果

备用APC

备用APC里面有几个重要的成员

kd> dt _KTHREAD
nt!_KTHREAD
   ...
   +0x034 ApcState         : _KAPC_STATE
   ...
   +0x138 ApcStatePointer  : [2] Ptr32 _KAPC_STATE
  ...
   +0x14c SavedApcState    : _KAPC_STATE
  ...
   +0x165 ApcStateIndex    : UChar
   +0x166 ApcQueueable     : UChar

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队列。

挂靠环境下ApcState的意义

在挂靠的环境下,也是可以向线程APC队列插入APC的,那这种情况下,使用的是哪个APC队列呢?

A进程的T线程挂靠B进程,A是T的所属进程,B是T的挂靠进程

ApcState B进程相关的APC函数
SavedApcState A进程相关的APC函数

在正常情况下,当前进程就是所属进程A,如果是挂靠情况下,当前进程就是挂靠进程B。

ApcStatePointer 

为了操作方便,_KTHREAD结构体中定义了一个指针数组ApcStatePointer ,长度为2。

正常情况下:

ApcStatePointer[0] 指向 ApcState

ApcStatePointer[1] 指向 SavedApcState

挂靠情况下:

ApcStatePointer[0] 指向 SavedApcState

ApcStatePointer[1] 指向 ApcState

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状态

APC挂入

无论是正常状态还是挂靠状态,都有两个APC队列,一个内核队列,一个用户队列。

每当要挂入一个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(APC初始化)

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函数
) 

主要看TargetEnvironment这个参数,对应的是ApcStateIndex,与KTHREAD(+0x165)的属性同名,但含义不一样

ApcStateIndex 有四个值:
0 原始环境 1 挂靠环境 2 当前环境 3 插入APC时的当前环境
正常情况下:
 ApcStatePointer[0]  指向 ApcState    
 ApcStatePointer[1]  指向 SavedApcState
挂靠情况下:
 ApcStatePointer[0]  指向 SavedApcState
 ApcStatePointer[1]  指向 ApcState    
2 初始化的时候,当前进程的ApcState
3 插入的时候,当前进程的ApcState    

当传入值为2时,会直接使用当前进程的ApcState

当传入值为3时,使用的是当前进程的APC,那么这里跟2有什么区别呢?

当初始化的时候可能处于原始环境,也可能处于挂靠环境,在即将插入的那个时候可能环境发生了改变,所以传入值设置为3

伪代码分析

KiInsertQueueApc(插入APC队列)

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

1、Alertable=0 当前插入的APC函数未必有机会执行:UserApcPending = 0

2、Alertable=1 UserApcPending = 1 将目标线程唤醒(从等待链表中摘出来,并挂到调度链表)

KeInsertQueueApc源码

NTKERNELAPI
BOOLEAN
KeInsertQueueApc (
    __inout PRKAPC Apc,
    __in_opt PVOID SystemArgument1,
    __in_opt PVOID SystemArgument2,
    __in KPRIORITY Increment    //优先级,3环添加用户APC时默认为0.
);
/*++
 
Routine Description:
 
    This function inserts an APC object into the APC queue specifed by the
    thread and processor mode fields of the APC object. If the APC object
    is already in an APC queue or APC queuing is disabled, then no operation
    is performed. Otherwise the APC object is inserted in the specified queue
    and appropriate scheduling decisions are made.
 
Arguments:
 
    Apc - Supplies a pointer to a control object of type APC.
 
    SystemArgument1, SystemArgument2 - Supply a set of two arguments that
        contain untyped data provided by the executive.
 
    Increment - Supplies the priority increment that is to be applied if
        queuing the APC causes a thread wait to be satisfied.
 
Return Value:
 
    If the APC object is already in an APC queue or APC queuing is disabled,
    then a value of FALSE is returned. Otherwise a value of TRUE is returned.
 
--*/

首先根据之前传入的Enviroment来判断要取哪个_KPAC_STATE成员

选择完_KPAC_STATE后,判断ApcModeNormalRoutine决定插入到哪个链表中。经分析得知,用户APC回调存在NormalRoutine中,但KernelRoutine会存一个名为PsExitSpecialApc的特殊APC回调(用于释放当前APC内存空间)。

如果当前APC插入到了备用APC队列(SavedApcState)中就返回。如果插入的是ApcState队列中就继续判断这个APC是自身插入还是其他线程插入的

如果是插入到其他线程的APC并且是个用户APC

如果这个APC是内核APC并且是插入到其他线程的

APC的插入位置与传入的Enviroment函数相关。如果是插入到了备用APC队列中则执行返回。若是普通APC队列中则继续进行多个判断。

  • 当这个APC是自身线程插入给自身的,并且是个特殊内核APC,则会立马触发软中断执行。
  • 如果这个APC是当前线程插入给其他线程的,且是个用户APC。当APC所属线程处于等待时,会尝试唤醒线程来执行APC。如果不是等待状态,则UserOrNormalKernel默认为0,插入后不执行APC。
  • 如果这个APC是当前线程插入给其他线程的,且是个内核APC。当APC所属线程处于运行时,会直接触发软中断执行APC或通知其他核触发软中断执行。当APC所属线程处于等待时,会尝试唤醒线程来执行APC。其他状态则不会立马执行APC。

联系老师 微信扫一扫关注我们 15527777548/18696195380 在线咨询
关闭