前言

这个系列的目的是希望你能系统的理解APC的内部原理(不再是零散的理解)

我重构了用户模式和内核模式下APC相关的函数,希望更好的理解APC机制,在文章的最后会分享源码

在这个系列中,我将探讨下面的主题
1、用户模式下APC的使用
2、内核模式下APC的使用
3、用户模式下APC内部原理
4、内核模式下APC内部原理
5、“Alerts”的概念以及它和APC的关系
6、APC在Wow64中的应用
7、如何在用户模式和内核模式下干扰微软对APC的ETW监控
8、如何使用APC安全的卸载一个驱动程序
9、像Procmon和Process Hacker这类安全工具如何利用APC
10、CET(用于检测ROP的CPU Shadow Stack)如何影响APC
11、关于APC机制有文档的源代码
12、关于APC的逆向工程练习
13、接下来文章中的惊喜

本文是系列的第一篇文章,讲述用户模式下APC,是这个系列中相对简单的部分,文中我将一边讲解,一边分享代码

APC介绍

APC全称Asynchronous Procedure Call,译为“异步过程调用”,是Windows中使用的一种机制,这种机制的核心是一个队列,通常称为APC队列,每个线程有2个APC队列:一个是用户模式APC队列、一个是内核模式APC队列,可以利用这种机制将函数放到指定线程的队列中,安全人员了解APC主要因为它被用在恶意软件中,用于执行代码或将代码注入到远程进程中,本质上是对APC机制的滥用。

好多初学者对异步很模糊,异步简单来说就是:当前执行的任务A不用等待任务B完成,就可以继续执行,相对应的,同步指的是当前执行的任务A需要任务B完成,才能继续

最好不要在内核模式使用APC,因为内核模式下APC的使用是没有官方文档的,那意味着一点差错都可能导致系统崩溃等问题,但是安全人员往往从内核模式使用APC,用来注入代码到用户模式进程中(比如反病毒软件、Rookits、等),如果你不是完全清楚你在做什么的话(即使你是一个超级专家,认为Windows不会影响你的代码),我不推荐你在内核模式下使用APC以及任何无官方文档的机制,然而如果你知道你在做什么,在某些情况下使用APC是相对安全的,关于内核模式下APC会在后续的文章中讲述

APC的应用例如,你调用一个异步的RPC方法,当RPC方法完成时,你指定的APC例程将被执行,再比如NtWriteFile/NtReadFile、Get/SetThreadContext、SuspendThread、TerminateThread、等等都用到了APC,甚至Windows调度程序也使用了APC,这也是为什么我觉得理解APC对理解Windows内部原理至关重要(尽管微软宣称这是一个无官方文档的功能,人们应该忽视它)

用户模式下APC有2种:

  • 普通用户模式APC:仅当目标线程处于警报状态时,才会执行我们通过APC插入的函数(或者特定情况下,后面会提到)
  • 特殊用户模式APC:在Windows 10 RS5中添加的相对新的API(也是无官方文档的)

用户模式下APC的API如下

1
2
3
4
5
DWORD QueueUserAPC(
PAPCFUNC pfnAPC,
HANDLE hThread,
ULONG_PTR dwData
)

Alertable

Alertable译为警报状态,恶意软件使用用户模式APC的主要问题之一是,目标线程必须处于警报状态,才能执行恶意软件插入到目标线程中的APC函数,一个线程在调用带有”等候”特性的函数时,将进入警报状态,例如WaitForSingleObjectEx、SleepEx、等等,并且bAlertable的值为TRUE,如下

1
2
3
4
5
6
7
8
9
10
11
DWORD WaitForSingleObjectEx(
[in] HANDLE hHandle,
[in] DWORD dwMilliseconds,
[in] BOOL bAlertable
);


DWORD SleepEx(
[in] DWORD dwMilliseconds,
[in] BOOL bAlertable
);

APC具体的执行时机是,当调用SleepEx时,SleepEx执行期间会有一个等待状态,这个等待状态期间,会检查APC队列,如果有函数,就会执行,另外一个能让APC队列中函数执行的方式是NtTestAlert,我们会在后面讲述

微软不想强制用户模式下的线程执行APC,因为那可能会导致微妙的条件竞争和死锁(我猜测的),假设你写的程序中有一个被特定锁保护的列表,你的程序中使用了RPC和APC,当线程获取了锁,想要对列表进行操作,这时一个APC被强制运行在这个线程中,然后APC中的代码尝试获取锁,于是死锁产生了,但如果被插入的APC仅在线程处于警报状态时执行,这会大大改善这种情况发生的概率

在APC内部获取锁不是一个好想法,但在内核态,微软解决了一部分问题,你可以阻止一个线程的APC获取某个锁通过将IRQL提升到APC_LEVEL,或者通过KeEnterCriticalRegion/KeEnterGuardedRegion关闭APC,这在某些情况下是需要的,例如调用ExAcquireResourceSharedLite时,我将在内核模式APC中详细讨论

此外,微软对用户模式APC有一些建议:

  • 不要在APC内部进入警报状态
  • 不要在远程进程的APC队列中添加函数,因为不同进程中的函数地址、重定位表均不同,还涉及Wow64
    我既不同意也不反对这些建议,但是想要用好APC机制,就需要深刻理解它并知道如何使用它

APC用于注入

如果你想在用户模式下使用APC进行注入,你必须找到一个可警报的线程或者希望线程自己进入警报状态,但是在Windows 10 RS5中,微软实现了一个有趣的机制:特殊用户APC,这种机制允许目标线程强制执行我们在APC队列中插入的函数(即使目标线程不是警报状态),原理是通过内核模式APC队列发出信号,触发用户模式下线程的APC函数执行

我们从API视角探讨一下这些APC的不同,假如有2个进程,合法的和恶意的,恶意进程想通过APC将代码注入到合法进程,合法进程只有一个线程,代码如下

1
2
3
4
5
6
7
int main() {
while (1) {
Sleep(500);
}

return 0;
}

如果注入器使用普通用户APC函数插入函数到目标线程,插入的代码永远不会执行,无需惊讶,因为线程不会进入警报状态,插入的代码位于目标线程的APC队列,但不会执行,直到线程终止,APC队列被释放

特殊用户APC这个机制在Windows 10 RS5中被添加(Native API是NtQueueApcThreadEx,位于ntdll.dll中,后面改为NtQueueApcThreadEx2,这是一个新的syscall),如果这类APC被调用,则在线程执行过程中对其发出信号,让它执行特殊用户APC

这对攻击者是很有吸引力的,但实际上用起来很危险,假设一个线程调用LoadLibrary期间,攻击者调用特殊用户APC想要将自己的代码加入到目标线程APC队列中,已知LoadLibrary会修改PEB中加载器的结构以及获取一些锁,攻击者自己的代码也是LoadLibrary,这会造成问题,因为目标线程中已经有了LoadLibrary(这也是为什么微软不想你在线程没有处于警报状态时运行APC队列中的函数),这种情况下线程会卡住或者PEB中加载器的数据结构被异常修改,这个问题听起来很少见,但实际上很危险,因为不只是LoadLibrary使用锁,其他好多函数都会用到锁,不过,特殊用户APC对于攻击者还是很有用的

通常,想要正确使用APC需要你对目标线程有一定了解

探索API

让我们从底层开始,内核提供了3个和APC相关的API:NtQueueApcThread、NtQueueApcThreadEx、NtQueueApcThreadEx2,其中QueueUserAPC是NtQueueApcThread的上层函数,QueueUserAPC2是NtQueueApcThreadEx和NtQueueApcThreadEx2的上层函数,均位于KernelBase.dll中,我们看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ThreadHandle - 线程句柄,必须拥有THREAD_SET_CONTEXT权限,可以是不同进程下线程的句柄(尽管微软不推荐使用不同进程下线程句柄)
//
// ApcRoutine - 要执行的函数
//
// SystemArgument1-3 - ApcRoutine的前三个参数
//
//
NTSTATUS
NtQueueApcThread(
IN HANDLE ThreadHandle,
IN PPS_APC_ROUTINE ApcRoutine,
IN PVOID SystemArgument1 OPTIONAL,
IN PVOID SystemArgument2 OPTIONAL,
IN PVOID SystemArgument3 OPTIONAL
);


typedef VOID (*PPS_APC_ROUTINE)(
PVOID SystemArgument1,
PVOID SystemArgument2,
PVOID SystemArgument3,
PCONTEXT ContextRecord
);

这个API的用法很简单,我们看一下使用示例(出于简化考虑,移除了错误处理)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
VOID QueueLoadLibrary(ULONG ProcessId, PSTR LibraryName) {
PVOID RemoteLibraryAddress;
HANDLE ProcessHandle;
HANDLE ThreadHandle;
NTSTATUS Status;

//
// 使用需要的权限打开进程,用来分配和写入库名
//
ProcessHandle = OpenProcess(
PROCESS_QUERY_INFORMATION |
PROCESS_VM_OPERATION |
PROCESS_VM_WRITE,
FALSE,
ProcessId
);

RemoteLibraryAddress = WriteLibraryNameToRemote(ProcessHandle, LibraryName);

//
// 在进程中获取第一个线程的句柄
//
NtGetNextThread(
ProcessHandle,
NULL,
THREAD_SET_CONTEXT,
0,
0,
&ThreadHandle
);

NtQueueApcThread(
ThreadHandle,
GetProcAddress(GetModuleHandle("kernel32"), "LoadLibraryA"),
RemoteLibraryAddress,
NULL,
NULL
);
}

用法很简单,查看注释就能理解,可以看到LoadLibraryA的函数原型不是PPS_APC_ROUTINE,但在x64系统中没关系

QueueUserAPC:KernelBase.dll层

微软喜欢为系统调用创建“包装器”,这个“包装器”就是指上层函数,以至于他们可以改变内部实现而不影响上层函数,微软也喜欢COM和DLL的载入和重定向机制,上述的结合造就了QueueUserAPC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
//
// This is the wrapper function implemented in kernelbase.dll to queue APCs. This function is documented.
// This function has 3 arguments:
//
// pfnAPC - the pointer to the apc routine in the target process context.
// Note that the signature of this function is different from the signature in NtQueueApcThread.
//
// hThread - the handle to the target thread. Requires THREAD_SET_CONTEXT.
//
// dwData - the context argument passed to pfnAPC - This is the only argument passed to pfnAPC.
//
DWORD
QueueUserAPC(
PAPCFUNC pfnAPC,
HANDLE hThread,
ULONG_PTR dwData
);

//
// This is the signature of the APC Routine if QueueUserAPC is used.
// The only parameter here is the dwData argument from QueueUserAPC.
// You may ask why the signature is different than the signature of PPS_APC_ROUTINE, we'll see below why.
//
typedef
VOID
(NTAPI *PAPCFUNC)(
IN ULONG_PTR Parameter
);


//
// This is the reverse engineered implementation of QueueUserAPC in windows 10
// (In was changed a bit in the latest insider, you'll see below)
//
// This function captures the activation context of the current thread
// and saves it, so it can be inherited by the APC routine.
//
// Activation Contexts are data structures that save configuration for DLL redirection, SxS and COM.
// To read more about activation context: https://docs.microsoft.com/en-us/windows/win32/sbscs/activation-contexts.
//
DWORD
QueueUserAPC(
PAPCFUNC pfnAPC,
HANDLE hThread,
ULONG_PTR dwData
)
{
ACTIVATION_CONTEXT_BASIC_INFORMATION Info;
NTSTATUS Status;

//
// 捕获激活上下文,激活上下文指DLL重定向、SxS、COM等配置,并传递给APC队列中的函数
//
Status = RtlQueryInformationActivationContext(
1,
NULL,
NULL,
ActivationContextBasicInformation,
&Info,
sizeof(Info),
NULL
);

if (!NT_SUCCESS(Status)) {
DbgPrint("SXS: %s failing because RtlQueryInformationActivationContext() returned status %08lx",
"QueueUserAPC", Status);
BaseSetLastNTError(Status);
return 0;
}

//
// Forward the call to the actual system call.
// The ApcRoutine that is used is actually a wrapper function in ntdll, called "RtlDispatchApc"
// The purpose of this wrapper function is to use the activation context passed as a parameter.
//
Status = NtQueueApcThread(
hThread, // ThreadHandle
RtlDispatchAPC, // ApcRoutine
(PPS_APC_ROUTINE)pfnAPC, // SystemArgument1
(PVOID)dwData, // SystemArgument2
Info.hActCtx // SystemArgument3
);

if (!NT_SUCCESS(Status)) {
BaseSetLastNTError(Status);
return 0;
}

return 1;
}

//
// This is used as SystemArgument3 if QueueUserAPC
// was used to queue the APC.
//
typedef union _APC_ACTIVATION_CTX {
ULONG_PTR Value;
HANDLE hActCtx;
} APC_ACTIVATION_CTX;


//
// This is the actual APC routine.
// It enables the activation context, calls the user provided routine, and deactivates the context.
//
VOID
RtlDispatchAPC( // ntdll
PAPCFUNC pfnAPC,
ULONG_PTR dwData,
APC_ACTIVATION_CTX ApcActivationContext
)
{
RTL_CALLER_ALLOCATED_ACTIVATION_CONTEXT_STACK_FRAME_EXTENDED StackFrame;

//
// Initialize the StackFrame data structure.
//
StackFrame.Size = sizeof(RTL_CALLER_ALLOCATED_ACTIVATION_CONTEXT_STACK_FRAME_EXTENDED);
StackFrame.Format = 1;
StackFrame.Extra1 = 0;
StackFrame.Extra2 = 0;
StackFrame.Extra3 = 0;
StackFrame.Extra4 = 0;

if (ApcActivationContext.Value == -1) {
pfnAPC(dwData);
return;
}

//
// Use the activation context of the queuing thread.
//
RtlActivateActivationContextUnsafeFast(&StackFrame, ApcActivationContext.hActCtx);

//
// Call the user provided routine.
//
pfnAPC(dwData);

//
// Pop the activation context from the "activation context stack"
//
RtlDeactivateActivationContextUnsafeFast(&StackFrame);

//
// Free the handle to the activation context.
//
RtlReleaseActivationContext(ApcActivationContext.hActCtx);

正如代码和注释中展示的,攻击者插入的APC函数位于SystemArgument1中,这对于反病毒开发中想要hook的人来说,需要注意

NtQueueApcThreadEx:内核内存的重复使用

每次NtQueueApcThread被调用,内核模式中会有一个新的KAPC对象被创建(从内核池中)来存储关于APC对象的数据,如果有一个组件它的APC队列中有很多APC函数,这会影响性能,因为大量非分页内存被使用,并且内存分配也需要时间

在Windows 7中,微软在内核模式添加了一个非常简单的对象,叫做内存保留对象,它允许在内核模式为特定对象保留内存,在释放对象时,用相同区域存储另一个对象,这样大大减少了ExAllocatePool/ExFreePoll的调用,NtQueueApcThreadEx就是接收这样一个对象的句柄,因此允许调用者重复使用相同的内存

下述代码用于创建“保留内存”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//
// 内存保留对象当前支持分配2种类型的对象
// - User APC
// - Io Completion
//
typedef enum _MEMORY_RESERVE_OBJECT_TYPE {
MemoryReserveObjectTypeUserApc,
MemoryReserveObjectTypeIoCompletion
} MEMORY_RESERVE_OBJECT_TYPE, *PMEMORY_RESERVE_OBJECT_TYPE;

//
// 这个系统调用分配一个内存保留对象
//
NTSTATUS
NtAllocateReserveObject(
__out PHANDLE MemoryReserveHandle,
__in_opt POBJECT_ATTRIBUTES ObjectAttributes,
__in MEMORY_RESERVE_OBJECT_TYPE ObjectType
);

//
// 这是Windows 7中新添加的系统调用
// 这个系统调用功能和NtQueueApcThread类似,但是允许指定一个MemoryReserveHandle
// 使用NtAllocateReserveObject创建的对象的句柄
//
// 如果内存在使用中(例如,APC对象没有被释放),你可以重新使用内存
//
NTSTATUS
NtQueueApcThreadEx(
IN HANDLE ThreadHandle,
IN HANDLE MemoryReserveHandle,
IN PPS_APC_ROUTINE ApcRoutine,
IN PVOID SystemArgument1 OPTIONAL,
IN PVOID SystemArgument2 OPTIONAL,
IN PVOID SystemArgument3 OPTIONAL
);

使用这个新对象,你可以节省KAPC对象分配的开销,示例代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
int main(int argc, const char** argv)
{
NTSTATUS Status;
HANDLE MemoryReserveHandle;

Status = NtAllocateReserveObject(&MemoryReserveHandle, NULL, MemoryReserveObjectTypeUserApc);

if (!NT_SUCCESS(Status)) {
printf("NtAllocateReserveObject Failed! 0x%08X\n", Status);
return -1;
}

while (TRUE) {
Status = NtQueueApcThreadEx(
GetCurrentThread(),
MemoryReserveHandle,
ExampleApcRoutine,
NULL,
NULL,
NULL
);

if (!NT_SUCCESS(Status)) {
printf("NtQueueApcThreadEx Failed! 0x%08X\n", Status);
return -1;
}

// 警报状态的
SleepEx(0, TRUE);
}

return 0;
}

VOID ExampleApcRoutine(PVOID SystemArgument1, PVOID SystemArgument2, PVOID SystemArgument3) {
// 非警报状态的
Sleep(500);

printf("This is the weird loop!\n");
}

此机制被RPC服务器用来重复使用完成例程的APC对象,如果你对此感兴趣,可以进一步查看rpcrt4!CALL::QueueAPC

NtQueueApcThreadEx:特殊用户APC

Windows 10 RS5开始,加入了特殊用户APC功能,正如我上面提到的,特殊用户APC可以强制一个线程执行APC队列中的函数,无需线程处于警报状态

Windows 10 RS5中,微软不想添加新的系统调用,所以修改了NtQueueApcThreadEx来支持特殊用户APC,通过调整MemoryReserveHandle为一个联合体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 定义一个枚举类型,后面只会用到QueueUserApcFlagsSpecialUserApc
typedef enum _QUEUE_USER_APC_FLAGS {
QueueUserApcFlagsNone,
QueueUserApcFlagsSpecialUserApc,
QueueUserApcFlagsMaxValue
} QUEUE_USER_APC_FLAGS;


// 之前的MemoryReserveHandle被替换为联合体USER_APC_OPTION,出于兼容性考虑,包含了之前的MemoryReserveHandle
typedef union _USER_APC_OPTION {
ULONG_PTR UserApcFlags;
HANDLE MemoryReserveHandle;
} USER_APC_OPTION, *PUSER_APC_OPTION;


// 除了MemoryReserveHandle被替换为UserApcOption,其他和上面一样,这允许调用者使用UserApcOption中的MemoryReserveHandle或UserApcFlags
NTSTATUS
NtQueueApcThreadEx(
IN HANDLE ThreadHandle,
IN USER_APC_OPTION UserApcOption,
IN PPS_APC_ROUTINE ApcRoutine,
IN PVOID SystemArgument1 OPTIONAL,
IN PVOID SystemArgument2 OPTIONAL,
IN PVOID SystemArgument3 OPTIONAL
);

下面是特殊用户APC的示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
int main(
int argc,
const char** argv
)
{
PNT_QUEUE_APC_THREAD_EX NtQueueApcThreadEx;
USER_APC_OPTION UserApcOption;
NTSTATUS Status;

NtQueueApcThreadEx = (PNT_QUEUE_APC_THREAD_EX)(GetProcAddress(GetModuleHandle("ntdll.dll"), "NtQueueApcThreadEx"));

if (!NtQueueApcThreadEx) {
printf("wtf, before win7\n");
return -1;
}

//
// This is a special flag that tells NtQueueApcThreadEx this APC is a special user APC.
//
UserApcOption.UserApcFlags = QueueUserApcFlagsSpecialUserApc;

while (TRUE) {
//
// This will force the current thread to execute the special user APC,
// Although the current thread does not enter alertable state.
// The APC will execute before the thread returns from kernel mode.
//
Status = NtQueueApcThreadEx(
GetCurrentThread(),
UserApcOption,
ApcRoutine,
NULL,
NULL,
NULL
);

if (!NT_SUCCESS(Status)) {
printf("NtQueueApcThreadEx Failed! 0x%08X\n", Status);
return -1;
}

//
// This sleep does not enter alertable state.
//
Sleep(500);
}

return 0;
}

VOID
ApcRoutine(
PVOID SystemArgument1,
PVOID SystemArgument2,
PVOID SystemArgument3
)
{
printf("yo wtf?? I was not alertable!\n");
}

需要注意:特殊用户APC可以打断一个不同线程的执行,在之后的文章会提到

NtQueueApcThreadEx2:一些新东西

大约在Windows 10 19603,微软新添加了2个函数

  • NtQueueApcThreadEx2:这是一个新的系统调用,允许同时传递UserApcFlags和MemoryReserveHandle(由于冲突检查,这并不会生效)
  • QueueUserAPC2:这是位于kernelbase.dll中的新的Win32 API,允许用户访问特殊用户APC

这个Win32 API表明微软希望用户使用这个API,这可以被用来在线程执行期间对其发送信号,这会很有用,类似于Linux中的信号机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
NTSTATUS
NtQueueApcThreadEx2(
IN HANDLE ThreadHandle,
IN HANDLE UserApcReserveHandle,
IN QUEUE_USER_APC_FLAGS QueueUserApcFlags,
IN PPS_APC_ROUTINE ApcRoutine,
IN PVOID SystemArgument1 OPTIONAL,
IN PVOID SystemArgument2 OPTIONAL,
IN PVOID SystemArgument3 OPTIONAL
);


DWORD
QueueUserApc2(
PAPCFUNC pfnAPC,
HANDLE hThread,
ULONG_PTR dwData,
QUEUE_USER_APC_FLAGS Flags
);

NtTestAlert

NtTestAlert是Windows中警报机制相关的系统调用,它会让线程APC队列中函数执行,即使这个线程本身是不能警报的,我们会在后面探索这个机制的内部原理,通过调用NtTestAlert可以执行任何待处理的APC函数

总结

在下一篇文章中,我将继续探索APC内部原理,这个是存储APC相关代码的地址

参考

https://repnz.github.io/posts/apc/user-apc/