LoadLibrary的那些事儿(一)

很早之前就看到过一篇MDSec的文章 https://www.mdsec.co.uk/2021/06/bypassing-image-load-kernel-callbacks/ 提到了一个很有趣的点,图像加载回调,也就是对应内核的 PsSetLoadImageNotifyRoutine函数。

什么是图像加载回调

PsSetLoadImageNotifyRoutine 是 Windows 内核提供的一个函数,用于注册驱动程序提供的回调函数。当系统加载或映射一个图像(例如 DLL 或 EXE)到内存时,内核会调用该回调函数,通知驱动程序此事件。

该函数的原型如下:

1
2
3
NTSTATUS PsSetLoadImageNotifyRoutine(
PLOAD_IMAGE_NOTIFY_ROUTINE NotifyRoutine
);

其中,NotifyRoutine 是指向驱动程序实现的回调函数的指针。

Demo:

1
2
3
4
5
6
7
8
9
10
11
12
13

void LoadImageNotifyRoutine(PUNICODE_STRING imageName, HANDLE pid, PIMAGE_INFO imageInfo)
{
UNREFERENCED_PARAMETER(imageInfo);
PEPROCESS process = NULL;
PUNICODE_STRING processName = NULL;
PsLookupProcessByProcessId(pid, &process);
SeLocateProcessImageName(process, &processName);

DbgPrint("%wZ (%d) loaded %wZ", processName, pid, imageName);
}

PsSetLoadImageNotifyRoutine(LoadImageNotifyRoutine);

需要注意的是,注册的回调函数数量是有限制的。在 Windows 8.1 之前,最多允许 8 个驱动程序同时注册回调函数。在 Windows 8.1 及之后的版本中,这一限制增加到 64 个。

在卸载驱动程序之前,必须调用 PsRemoveLoadImageNotifyRoutine 函数来注销先前注册的回调函数,以避免潜在的系统不稳定或资源泄漏。

通过使用 PsSetLoadImageNotifyRoutine,驱动程序可以监控系统中加载的图像,从而实现特定的功能,如安全监控、日志记录或其他需要跟踪图像加载事件的操作。

那我们需要去理解推断一下,AV/EDR这些会如何利用这一回调。

  1. 监控进程启动和模块加载

通过 PsSetLoadImageNotifyRoutine 注册的回调函数,EDR 可以在每次进程加载可执行文件或动态链接库(DLL)时接收到通知。这让 EDR 能够在每个模块加载到内存时进行检查,并根据预设规则来判断文件是否可信或存在可疑行为。

  1. 识别恶意模块注入

恶意软件常通过 DLL 注入等技术将自身注入到合法进程中,以隐藏其活动。EDR 利用 PsSetLoadImageNotifyRoutine 能够识别这种模块注入行为,通过分析加载的模块源路径、名称等信息来判断是否属于常见的恶意模块注入特征。

  1. 阻止特定模块的加载

一些 EDR 软件利用 PsSetLoadImageNotifyRoutine 检测到特定模块(如未签名的或来源可疑的模块)加载时,可以采取阻止措施。例如,若 EDR 检测到某些已知恶意模块或不受信任的 DLL 试图加载时,可以直接阻止其加载过程,以避免潜在的攻击。

  1. 行为分析和威胁检测

许多 EDR 具备行为分析能力,会将进程加载的模块信息与恶意软件行为模型对比。如果进程加载的模块符合恶意行为模式,EDR 会记录并标记该进程为潜在威胁,甚至可以进一步追踪该进程的后续行为。

  1. 记录审计日志

EDR 还可以使用 PsSetLoadImageNotifyRoutine 来记录系统中所有进程的模块加载情况,以便后续审计和事件响应分析。这些日志信息对于调查入侵活动具有重要价值,帮助分析人员了解攻击者的持久化方式和活动范围。

  1. 检测进程篡改

一些恶意软件会尝试通过加载恶意模块来修改合法进程的行为,造成持久化攻击。通过监控模块加载活动,EDR 可以检测到这些篡改行为并发出警报,从而增强系统的完整性保护。

那么我们想要规避这个监控可以找到这个数据结构,修改 PsSetLoadImageNotifyRoutine 注册的回调函数指针。或者让PsSetLoadImageNotifyRoutine返回无效名称,然后伪造内核中加载的模块名称并将其伪装成合法模块。亦或者修改内核变量 PspNotifyEnableMask来完成等等,有很多方法这里不一一列举。

小记:

PsSetLoadImageNotifyRoutine 在内核内部维护一个列表(数组),存储着所有注册的图像加载回调函数指针。在正常情况下,当系统加载一个新的映像(如 DLL 或 EXE)时,会遍历这个列表,调用所有注册的回调函数。

通过定位并直接修改这个数据结构,我们可以:

  1. 修改回调函数指针:将原有回调函数替换为自定义的函数,从而在图像加载时执行自定义逻辑。(筛选不希望报告的图像加载)
  2. 清空回调函数数组:将数组内容清空,系统在遍历时找不到任何回调,从而达到关闭所有图像加载回调的效果。

但是我们在初始访问阶段很难做到这一点,所以我尝试跟随MDSec的脚步去探寻。

什么玩意儿触发了回调

我们浅浅的搓两个Demo,一个Dll和一个加载他的EXE。

20241101141742

我们分解LoadLibraryA的调用过程:

调用 LoadLibraryA

  • LoadLibraryA 接收一个 DLL 文件的名称作为参数,并开始查找并加载该 DLL。
  • 如果文件名为空,或无法找到指定的 DLL,LoadLibraryA 会返回错误。

转换为 LoadLibraryExA 调用

  • LoadLibraryA 实际上是调用 LoadLibraryExA 的简化版本。LoadLibraryExA 函数允许通过传递标志来设置加载选项(如加载方式、位置等)。
  • LoadLibraryExA 接收 DLL 名称和加载标志,将其转换为宽字符形式,然后调用对应的宽字符版本 LoadLibraryExW

调用 LoadLibraryExW 进行宽字符处理

  • LoadLibraryExWLoadLibraryExA 的宽字符版本,它最终会调用 LdrLoadDll,这是 ntdll.dll 中负责加载 DLL 的底层函数。
  • 该函数通过 LdrLoadDll 处理具体的 DLL 文件路径,并决定加载的具体位置。

进入 ntdll.dll 的 LdrLoadDll 函数

  • LdrLoadDll 是加载 DLL 的核心,它首先检查指定的 DLL 是否已在内存中被加载过,以避免重复加载。
  • LdrLoadDll 会调用 LdrpLoadDllInternal 来继续加载过程。

使用 LdrpLoadDllInternal 进行内部加载

  • LdrpLoadDllInternal 在确保 DLL 需要加载后,会调用 LdrpProcessWork,该函数处理具体的加载任务。
  • 其中,LdrpProcessWork 会根据传入的 DLL 名称查找文件,处理路径和加载标志等。

调用 LdrpMapDllFullPath 进行 DLL 映射

  • LdrpProcessWork 使用 LdrpMapDllFullPath 函数查找 DLL 并进行文件映射。该函数会尝试将 DLL 的路径转换为 NT 路径格式,供后续调用使用。
  • 如果查找到 DLL 文件,该函数会返回一个 DLL 句柄,准备映射文件。

调用 LdrpMapDllNtFileName 映射 DLL

  • LdrpMapDllNtFileName 将 DLL 文件映射到进程地址空间。它会调用 LdrpMapDllWithSectionHandle 将 DLL 的文件内容映射到内存中,以便后续执行。
  • 在实际映射过程中会调用到 LdrpMinimalMapModule

调用 LdrpMinimalMapModule 处理映像文件

  • LdrpMinimalMapModule 将 DLL 文件的内容映射到内存中,并处理相关的页面映射等。
  • 该函数会调用 LdrpMapViewOfSection,然后进一步调用底层的 NtMapViewOfSection 系统调用,以将 DLL 文件的内容加载到目标地址空间。

调用 NtMapViewOfSection 进行最终内存映射

  • NtMapViewOfSection 是内核态的系统调用函数,通过内存管理服务将 DLL 文件的各个部分映射到合适的内存页。
  • 这一步完成后,DLL 文件的内容将以只读或读写的方式加载到内存中。

返回 DLL 句柄

  • 映射成功后,控制返回到 LdrLoadDll 并最终返回到用户态的 LoadLibraryA。此时 LoadLibraryA 返回一个指向已加载 DLL 的句柄。
  • 应用程序可以通过此句柄调用 DLL 中的函数。

也就是说LoadLibraryA 的调用实际上是通过多层封装与系统调用逐层转换和映射实现的。最终通过 NtMapViewOfSection 完成 DLL 文件的加载和内存映射,从而将 DLL 加载到进程的地址空间中,供应用程序调用。

我们着重分析 LdrpMapDllNtFileName 是因为该函数在模块加载过程中起到关键作用,它负责打开并映射 DLL 文件,将其加载到内存中,而这个过程中会触发 PsSetLoadImageNotifyRoutine 注册的回调函数。

直接丢进IDA

20241101144536

20241101144536

20241101144536

LdrpMapDllNtFileName(__int64 dllBase, UNICODE_STRING *dllName)的第2个参数dllName

顾名思义傻子都知道是啥意思。然后尝试打开,使用NtOpen,如果成功打开后则检查一下,然后丢给NtCreateSection创建内存映射区段。但是这里我的和MDsec的逆向有些出入,但是并不影响大体。

我们可以轻松的追踪到最后我们进入了LdrpMapDllWithSectionHandle方法,通过名字也可以才到他的用途吧。不说废话直接跟进去。

20241101150704

20241101150704

20241101150704

我们顺着LdrpMapDllWithSectionHandle->LdrpMinimalMapModule->LdrpMapViewOfSection->NtMapViewOfSection摸查,截图太累了

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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
__int64 __fastcall LdrpMinimalMapModule(__int64 moduleInfo, __int64 sectionHandle) {
_QWORD *moduleBaseAddress; // DLL 的基地址
__int64 unicodeComparisonResult; // 用于字符串比较结果
__int64 sectionProtectionFlags; // 段保护标志
char isKernel32Dll; // 标识是否为 kernel32.dll
int allocationType; // 分配类型标志
struct _TEB *currentThreadEnvironmentBlock; // 当前线程的 TEB(线程环境块)
int allocationTypeFlags; // 最终分配类型标志
__int64 viewSize; // 视图大小
int resultCode; // 临时变量,用于存储结果代码
__int64 tempValue; // 临时值
int finalResult; // 最终返回结果
__int64 imageBaseAddress; // DLL 的基地址
__int64 tempResult; // 临时结果
__int64 imageBaseParameters[2]; // 图像基址参数
__int64 userModeAddressRange[4]; // 用户模式地址范围
void *originalUserPointer; // 用于恢复用户指针
__int64 viewBaseAddress; // 视图基地址
__int64 prevPrivilegeState; // 用于存储特权状态

viewBaseAddress = sectionHandle;
moduleBaseAddress = *(moduleInfo + 56); // 获取模块基地址

// 日志记录模块加载信息
LdrpLogInternal("minkernel\\ntdll\\ldrmap.c", 700, "LdrpMinimalMapModule", 3, "DLL name: %wZ\n", moduleBaseAddress + 9);

// 检查模块是否为 kernel32.dll
LOBYTE(unicodeComparisonResult) = 1;
if (!RtlEqualUnicodeString(moduleBaseAddress + 11, &LdrpKernel32DllName, unicodeComparisonResult)
|| (isKernel32Dll = 1, (*(LdrpAppHeaders + 22) & 0x20) == 0)) {
isKernel32Dll = 0; // 如果不是 kernel32.dll,设置标志位为 0
}

prevPrivilegeState = 0i64;
allocationType = 0x800000; // 默认分配标志

// 如果不是 kernel32.dll,检查是否需要使用大页(Large Pages)
if (!isKernel32Dll) {
if (LdrpLargePageDllKeyHandle) {
imageBaseAddress = moduleBaseAddress[12];
LODWORD(originalUserPointer) = 0;
RtlQueryImageFileKeyOption(LdrpLargePageDllKeyHandle, imageBaseAddress, 4i64, &originalUserPointer, 4, 0i64);
if (originalUserPointer) {
// 获取锁内存特权以便使用大页
if (RtlAcquirePrivilege(&LdrpLockMemoryPrivilege, 1i64, 0i64, &prevPrivilegeState) >= 0)
allocationType = 0x20000000;
}
}
}

currentThreadEnvironmentBlock = NtCurrentTeb(); // 获取当前线程的 TEB
*(moduleInfo + 168) = 0i64;
originalUserPointer = currentThreadEnvironmentBlock->NtTib.ArbitraryUserPointer; // 保存当前用户指针
currentThreadEnvironmentBlock->NtTib.ArbitraryUserPointer = moduleBaseAddress[10]; // 设置 DLL 的用户指针

allocationTypeFlags = allocationType | 0x40000; // 设置分配标志
viewSize = (*(moduleInfo + 32) & 0x800000) != 0 ? 2 : 128; // 根据加载标志设置视图大小

if ((*(moduleInfo + 32) & 0x800000) == 0) // 如果没有指定特殊加载标志,使用默认分配类型
allocationTypeFlags = allocationType;

// 如果 DLL 设置了特殊用户模式加载标志,执行扩展映射逻辑
if ((*(moduleInfo + 32) & 0x800) != 0) {
userModeAddressRange[1] = LdrpMaximumUserModeAddress;
userModeAddressRange[0] = 0i64;
imageBaseParameters[1] = userModeAddressRange;
userModeAddressRange[2] = 0i64;
imageBaseParameters[0] = 1i64;
resultCode = ZwMapViewOfSectionEx(
viewBaseAddress,
-1i64,
moduleBaseAddress + 6,
0i64,
moduleInfo + 168,
allocationTypeFlags,
viewSize,
imageBaseParameters,
1);
} else {
// 使用常规方法映射 DLL
resultCode = LdrpMapViewOfSection(
viewBaseAddress,
viewSize,
(moduleBaseAddress + 6),
sectionProtectionFlags,
moduleInfo + 168,
allocationTypeFlags,
viewSize,
(moduleBaseAddress + 9));
}

finalResult = resultCode;
currentThreadEnvironmentBlock->NtTib.ArbitraryUserPointer = originalUserPointer; // 恢复用户指针

// 如果启用了大页分配,释放特权
if (allocationTypeFlags == 0x20000000)
RtlReleasePrivilege(prevPrivilegeState);

// 检查结果代码,处理不同加载情况
switch (finalResult) {
case 1073741827: // 内存不足
goto LABEL_21;
case 1073741838: // 机器架构不匹配
finalResult = LdrpProcessMachineMismatch(moduleInfo);
break;
case 1073741878: // 部分失败,执行重试逻辑
LABEL_21:
if (!*(moduleInfo + 176) && LdrpMapAndSnapWork) {
LOBYTE(tempValue) = 1;
if (LdrpCheckForRetryLoading(moduleInfo, tempValue)) {
finalResult = -1073741267; // 加载失败
} else if (isKernel32Dll) {
finalResult = -1073741800; // kernel32.dll 特殊错误码
}
}
break;
}

// 如果 DLL 加载失败,执行解映射
if (moduleBaseAddress[6] && (finalResult < 0 || finalResult == 1073741838)) {
NtUnmapViewOfSection(-1i64); // 解除映射
moduleBaseAddress[6] = 0i64;
}

// 日志记录加载状态
LODWORD(tempResult) = finalResult;
LdrpLogInternal("minkernel\\ntdll\\ldrmap.c", 909, "LdrpMinimalMapModule", 4, "Status: 0x%08lx\n", tempResult);

return finalResult; // 返回最终状态
}

我们直接摸进 LdrpMapViewOfSection

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
__int64 __fastcall LdrpMapViewOfSection(
__int64 lpBaseAddress, // 基址
__int64 hFile, // 文件句柄
__int64 lpFileOffset, // 文件偏移量
__int64 dwNumberOfBytesToMap, // 要映射的字节数
__int64 flMapType, // 映射类型
int dwMapFlags, // 映射标志
int ImageSectionName, // 图像段名称
__int64 AllocationAttributes) // 分配属性
{
__int64 v11[3]; // 临时变量,用于存储映射属性

// 检查分配属性是否选择退出 HPAT 分配优化
if (!LdrpHpatAllocationOptOut(AllocationAttributes))
// 如果未选择退出,直接调用 ZwMapViewOfSection 执行标准映射
return ZwMapViewOfSection(lpBaseAddress, -1i64, lpFileOffset);

// 如果选择退出,设置 HPAT 特定的分配标志和属性
v11[0] = 5i64; // 分配属性,标志位
v11[1] = 0x80i64; // 内存保护标志,指定为可执行

// 使用扩展映射功能调用 ZwMapViewOfSectionEx,传入额外的分配选项
return ZwMapViewOfSectionEx(lpBaseAddress, -1i64, lpFileOffset, 0i64, flMapType, dwMapFlags, ImageSectionName, v11, 1);
}

到这里已经是我们所能触达的尽头。

根据逆向整个加载过程,我们可以发现两个事情发生了一个是回调的触发一个是ETW的日志记录,

LdrpMapViewOfSection 或其他加载函数调用 ZwMapViewOfSectionZwMapViewOfSectionEx 来将文件映射到内存时,Windows 内核会触发 PsSetLoadImageNotifyRoutine 注册的图像加载回调。

怎么绕过?

回到正题,MDSec的研究员产生了灵魂发问,这玩意儿怎么绕过呢?

首先肯定是自己去实现一个完整加载方法去加载,但是这条路比较坎坷。整体实现经历了7个过程:

  • 确保要加载的数据是有效的 PE
  • 将标题和部分复制到内存中,设置正确的内存权限
  • 对图像库进行重新定位
  • 解析两个导入表
  • 执行 TLS 回调
  • 注册异常处理程序
  • 调用 DLL 入口点 ( DllMain)

有一个很知名的开源项目 DarkLoadlibrary:https://github.com/bats3c/DarkLoadLibrary

这里不重复造轮子,Havoc的作者Cracked5pider也实现过一个类似的项目
https://github.com/Cracked5pider/LdrLibraryEx

需要注意的一点是为了避免Nt/ZwCreateSection,我们映射的时候使用NtAllocateVirtualMemory和VirtualAlloc

但是这个功能移植到我们的不管是Loader还是Implant中都过于复杂和臃肿,我们不妨先看一下目前有哪些常见的规则是针对于此的去寻找更好的解决方法。

PS.

关于DarkLoadLibrary我个人是不推荐的,DarkLoadLibrary本身是为了取代sRDI,但是他们都使用了近乎相同的办法来解析PEB,然后将DLL映射到内存并调用导出函数,相比较于DarkLoadLibrary我觉得sRDI更加成熟可靠,并且DarkLoadLibrary会产生大量的RX属性的私有内存这是非常可疑的。虽然他在对抗图像加载上的想法很棒~,但是我并不建议使用它去替代LoadLibrary。并且一个正常的程序如果加载的合法dll过少(在安全产品的眼中),这会显得非常突兀不是么。

破壳

如何评判恶意的图像加载事件

来自Elastic的一个检测规则

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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
[rule]
description = """
Identifies the load of a Windows network module by a process where the creating thread's stack contains frames pointing
outside any known executable image. This may indicate evasion via process injection.
"""
id = "aa265fbd-4c57-46ff-9e89-0635101cc50d"
license = "Elastic License v2"
name = "Network Module Loaded from Suspicious Unbacked Memory"
os_list = ["windows"]
reference = [
"https://www.elastic.co/security-labs/pikabot-i-choose-you",
"https://www.elastic.co/security-labs/spring-cleaning-with-latrodectus",
"https://www.elastic.co/security-labs/upping-the-ante-detecting-in-memory-threats-with-kernel-call-stacks",
]
version = "1.0.41"

query = '''
sequence by process.entity_id
[process where event.action == "start" and process.parent.executable != null and

not process.Ext.token.integrity_level_name == "low" and

not user.id : ("S-1-5-19", "S-1-5-20") and

not (process.executable : ("?:\\Program Files (x86)\\*", "?:\\Program Files\\*", "?:\\ProgramData\\*", "?:\\Users\\*\\AppData\\*") and
process.code_signature.trusted == true) and

not (process.executable : ("?:\\Program Files (x86)\\*", "?:\\Program Files\\*") and process.Ext.relative_file_creation_time >= 80000) and

not process.executable : ("?:\\Windows\\Microsoft.NET\\Framework*\\NGenTask.exe", "?:\\Windows\\Microsoft.NET\\Framework*\\ngen.exe") and

not (process.executable : "?:\\Windows\\Microsoft.NET\\Framework*\\mscorsvw.exe" and
process.parent.executable : "?:\\Windows\\Microsoft.NET\\Framework*\\ngen.exe") and

not (process.executable : "?:\\WINDOWS\\SysWOW64\\DWRCS.EXE" and
process.parent.executable : "?:\\WINDOWS\\SysWOW64\\DWRCS.EXE" and process.parent.args : "-service") and

not (process.executable : "?:\\Windows\\System32\\LogonUI.exe" and
process.parent.executable : "?:\\Windows\\System32\\winlogon.exe") and

not (process.executable : "?:\\Windows\\SysWOW64\\icacls.exe" and
process.args : "?:\\Program Files\\Tenable\\Nessus Agent\\*" and
process.parent.executable : "?:\\Windows\\SysWOW64\\msiexec.exe") and

not (process.name : "rundll32.exe" and
process.command_line : "*zzzzInvokeManagedCustomActionOutOfProc*" and
process.parent.executable : "?:\\Windows\\sys*\\msiexec.exe") and

not (process.code_signature.subject_name :
("Mozilla Corporation", "Commvault Systems, Inc.", "Google LLC", "YANDEX LLC", "ConnectWise, Inc.",
"Brave Software, Inc.", "Opera Norway AS", "GitHub, Inc.", "Stefan Ries", "JetBrains s.r.o.",
"Intel(R) Rapid Storage Technology", "Waves Inc", "Dell Inc", "Lenovo", "DameWare Development, LLC.",
"Essential Objects, Inc*", "HP Inc.", "Aina Maximit Oy", "Logitech Inc", "N-ABLE TECHNOLOGIES LTD",
"Cognizant TriZetto Software Group, Inc.", "win.acme.simple@gmail.com", "Crownalytics, LLC",
"Kodak Alaris Inc.", "JAM Software GmbH", "UBISOFT ENTERTAINMENT INC.", "DASSAULT SYSTEMES SE",
"Link Data Security A/S", "Western Digital Technologies, Inc.", "Rockstar Games, Inc.",
"SEMPERIS INC.", "Micro-Star International CO., LTD.", "Kaseya Holdings Inc", "KASEYA US LLC",
"Intel(R) Software Development Products", "Commvault Systems, Inc.", "AAC Infotray AG",
"CORE.AI SCIENTIFIC TECHNOLOGIES PRIVATE LIMITED", "ClaimMaster Software LLC", "Cellebrite DI LTD") and
process.code_signature.trusted == true) and

not (process.pe.original_file_name : ("msedge.exe", "msedgewebview2.exe") and
process.code_signature.subject_name : "Microsoft *" and process.code_signature.trusted == true) and

not (process.executable : "?:\\Windows\\System32\\Essentials\\SharedServiceHost.exe" and
process.parent.executable : "?:\\Windows\\System32\\services.exe") and

not (process.name : "powershell.exe" and
process.parent.executable : "?:\\Program Files (x86)\\Lenovo\\VantageService\\*\\LenovoVantage-(LenovoSystemUpdateAddin).exe") and

not process.hash.sha256 :
("35542bc04fbfa2e3ef68837640e0459c6f99729c0c73578c08ab351cdf030696",
"1005dcfddfbde91cc967ecc6d778c81cb4f7bede03121a3e6e0d9bae12a033e0",
"53cec44e4fc9a3477d264c5edc9e376af8fcca20853faa289387f5bd7eaae05f",
"49113f4cd7bbf3343a43b13edd745008c4f41da1d80c9f89dc90a4b0bb39b8f8",
"0ab8a14e7fd42818608cc0916fc26a12b2ae6b976c97310dc86011713e455d2d",
"afa3dc1ecd4e15a869dc57a280aee930fc0bab1cd49e17afd3944ae4ad1fc91f",
"18b177280b0e0e05aa0e26807f34634c3384ae4f5901f41ff5bb0a720ea5c106",
"4f1528318085f6117734a27f34a317a83b096d55532000f81ed67b1bb10632fa",
"35f210c1f941f917b81c2516bee9de8f0495baca42095685e9b0b76f67250dc9",
"edef301528767963f460bf7fe497ca3b5c648627a2d13abe74d5544578b56bd4",
"f856a3c582ca689bdea3e784ef6a0db37011bd5ebb31d7c79d0328ebfcf6d8a4",
"fe62ba0a61191a9b54aab7ba3221479c451b042fa30a08957cacff84ddfe094b",
"183cd12fbdd93aa785d3793a8872c4a9730dd154f6482c39f94552e556a7b4e9",
"ed6c844c72fccd7e7799d448f5e74df368cafd2631858573e6e29110c213ac79",
"43a84e01b5ddecf0b6297277d53c72025a73b00b9f0073933a700695a240b5e8",
"f4cb2a001dcee0577046c56df5adfe989bc875c29ecfe27c9569849a9a6f0671",
"ed3730a3436454022e8cf1a27569babef8c9c348ea875f1df80cba9b743365c9",
"51346e95656164783ae4de9d6b202f28be87358eb0e056d2d7cdd12b502d30e7",
"889cb8827a400984bea2e0561a1efbf9d18044e879974da8baa750a0c63748c6",
"96dcdb449ab48b21b6efd33afc59ae163dea5fc597ffaf5dd7030b20d2624467",
"43714f0fc5cea25a6ee936fd36a83f86a45f61447c16c8e9255ef317089ce39c",
"52a5036d1578a6b899fb5d14fd3ab12af463e94ac791d1dddb22b7e8cc1f4bd7",
"f5f15ab19171dbcb58e757cd6446be41e04adf00797d712b07e544066ecd3c67",
"49b95a804337ee7e12092cc7a13c2eb6c3acb33f9bbaac8e820b9184063642f2",
"4c974212f5fc3720d58a8df6c2b9587b85949edc676f9f82921c04f067c261f1",
"ed3730a3436454022e8cf1a27569babef8c9c348ea875f1df80cba9b743365c9",
"121a4e78abe13c92a7ff91d2e91bc98173724072cc891ecbbc10765e8d5bd024",
"54e55d6da825d709cf495eb18be10d8cbb92ac1904c1359999d53d3fa42161e2",
"07e79814fde31ff5968d5c0448014d931cd3a2e59b2ac841bc53a155c333a1b9",
"afcee50eace500b9b2c3dc4faab371fd040d769ba3a5197ef3a8762fe5457337",
"e7883c7d57f5cfe7d1649ab138f62f5042f7acda8ed3c8664c9335c3ddba85c4",
"8982729121fa3b3e6c283437f5832916a5a3611374ac848368c6edaae5086257",
"63f2a4be0dfbaf2b9740aa5c2320d0290451d6d5581cc6f8e183ced9ea796d95") and

not (process.executable : "?:\\Windows\\SysWOW64\\dllhost.exe" and process.parent.name : "svchost.exe" and
process.Ext.effective_parent.executable : "?:\\Program Files (x86)\\Skillbrains\\lightshot\\*\\Lightshot.exe") and

not (process.executable : ("?:\\Windows\\SysWOW64\\DWRCST.EXE", "?:\\Windows\\SysWOW64\\DWRCS.EXE") and
process.parent.executable : ("?:\\WINDOWS\\SysWOW64\\runonce.exe", "?:\\Windows\\SysWOW64\\DWRCST.EXE", "?:\\Windows\\SysWOW64\\DWRCS.EXE")) and

not (process.executable : "?:\\Windows\\system32\\winsat.exe" and
process.parent.executable : "?:\\Windows\\system32\\rundll32.exe" and
process.parent.args : "sysmain.dll,PfSvWsSwapAssessmentTask") and

not (process.executable : "?:\\Windows\\System32\\inetsrv\\w3wp.exe" and
process.parent.executable : ("?:\\Windows\\System32\\inetsrv\\w3wp.exe", "?:\\Windows\\System32\\svchost.exe")) and

not (process.name : "rundll32.exe" and process.args : "--no-sandbox" and
_arraysearch(process.parent.thread.Ext.call_stack, $entry,
$entry.symbol_info : "?:\\Windows\\assembly\\NativeImages_*\\EO.Base\\*\\EO.Base.ni.dll*")) and

not (user.id : "S-1-5-18" and
process.parent.executable : ("?:\\Program Files (x86)\\Advanced Monitoring Agent\\featureres\\PMESetup.exe",
"?:\\PROGRA~2\\ADVANC~1\\featureres\\PMESetup.exe",
"?:\\PROGRA~2\\ADVANC~1\\downloads\\NetworkManagementInstall.exe",
"?:\\PROGRA~2\\ADVANC~1\\staging\\_new_setup.exe",
"?:\\PROGRA~2\\ADVANC~1\\downloads\\MAV-Installer.exe"))
]
[library where
dll.name : ("ws2_32.dll", "wininet.dll", "winhttp.dll") and
process.thread.Ext.call_stack_contains_unbacked == true and
(
process.thread.Ext.call_stack_summary :
("ntdll.dll|wow64.dll|wow64cpu.dll|wow64.dll|ntdll.dll|kernelbase.dll|Unbacked",
"ntdll.dll|wow64.dll|wow64cpu.dll|wow64.dll|ntdll.dll|kernelbase.dll|Unbacked|kernel32.dll|ntdll.dll",
"ntdll.dll|kernelbase.dll|Unbacked",
"ntdll.dll|iphlpapi.dll|Unbacked",
"ntdll.dll|winhttp.dll|Unbacked",
"ntdll.dll|kernelbase.dll|wininet.dll|Unbacked",
"ntdll.dll|kernelbase.dll|Unbacked|kernel32.dll|ntdll.dll",
"ntdll.dll|wow64.dll|wow64cpu.dll|wow64.dll|ntdll.dll|Unbacked",
"ntdll.dll|wow64.dll|wow64cpu.dll|wow64.dll|ntdll.dll|wininet.dll|Unbacked|ntdll.dll",
"ntdll.dll|wow64.dll|wow64cpu.dll|wow64.dll|ntdll.dll|Unbacked|kernel32.dll|ntdll.dll",
"ntdll.dll|kernelbase.dll|Unbacked|kernelbase.dll|ntdll.dll|kernel32.dll|ntdll.dll") or

startswith~(process.thread.Ext.call_stack_summary, concat(concat("ntdll.dll|wow64.dll|wow64cpu.dll|wow64.dll|ntdll.dll|kernelbase.dll|Unbacked|", process.name), "|kernel32.dll|ntdll.dll"))
) and
/* DynTrace, HP Sure Click Hook Dll */
not _arraysearch(process.thread.Ext.call_stack, $entry,
$entry.callsite_leading_bytes :
("*6764a118008b40243b835b080000753033c089835b0800008d835f080000508d8353000000506a006a00ff936b080000*",
"45fc33c08945f88bf28dbd82fcffffb9dc000000f3a566a568008000006a0052ff55b86803800000ff55bc8bf080bd82fcffff0074188d8592fdffff50ff55c4",
"*d74533db4c8b4d504c894d284c8d0d0f0000004c894d404c8b4d6041c6410c00ffd0*",
"83ec28488b4c2430ff15b40f00004883c428c3cccccccccccccc4c894424188954241048894c24084883ec38837c244801755f488d0d701f0000ff15720f0000",
"55c0488d55c0488b4d10e8886dadff488bc8488975b8488b55b0488b5220488b02488bd64533db4c8b45b04c8945804c8d050a0000004c894598c6470c00ffd0",
"488bcd48894da8488d8d78ffffff48894b10488975c0488b4db8488b4920488b01488bcf488bd64c8b45b84c8945884c8d050a0000004c8945a0c6430c00ffd0",
"80000000c7858c00000001000000488b4d50488b4920488b01488b8d800000004533db488b555048895528488d150e00000048895540488b5560c6420c00ffd0")) and
/* Managed Code, Cynet MemScanner, xSecuritas */
not _arraysearch(process.thread.Ext.call_stack, $entry,
$entry.callsite_trailing_bytes :
("*8945b4488bcce82c000000908b45b4488b55a8c6420c01488b55a8488b*",
"c6430c01833d*5f007406ff1*",
"48898424e80300004883bc24e803000000750eff94247e030000898424f0030000eb30488d8c2430010000ff94248e03000048898424e80300004883bc24e803",
"8bd885db751eff55c88945f8eb168d8592fdffff50ff55d08bd885db7506ff55c88945f856ff55bc85db0f847601000080bd82fcffff0074508b45fc05a00000",
"488b55a8c6420c01833d8bbeb25f007406ff1593c7b25f8945b4488bcce82c000000908b45b4488b55a8c6420c01488b55a8488b8d70ffffff48894a10488d65",
"**45a848894590eb00488b4590488b5588c6420c01488b5588",
"*488b5560c6420c01488b5560488b4d2048894a",
"c6470c01833d6cf1b45f007406ff15f40cb45f8bf0e8b5e76f5f85f6400f95c6400fb6f64883bdc0000000007423488b8dc0000000e825ebe25e448bc0488b95",
"488b5560c6420c01833db339f55f007406ff153b43f55f898584000000488b4d00e845000000908b8584000000488b5560c6420c01488b5560488b4d2048894a",
"898383080000c7837f080000010000006a0056ff9397080000ff938b*",
"41c64?0c01833d*",
"c6430c0148ba*f87f0000833a00740c*",
"c6470c01833d*",
"a3b8eb*",
"a314a2e40585c*",
"488b9570ffffffc6420*",
"49bb60c0*",
"488b5590c6420c01833d*",
"*ba0c0000004c8d4c2450448d423441ffd3*",
"*ca85c0b8000000000fb6d60f95c0898424f40200000ac22b742458d0d80fb6c0*",
"*898424300300008d14922b9c24d0010000f7d28bc3034424600fbbc20facc2183b44240892",
"41c6460c0148ba*7f0000833a00740c*",
"8be55d558bec83ec08c745f800000000*",
"48898424b80000004883bc24b8000000000f848d000000488b442440480510130000488bd0488b8c24b8000000ff94249000000048898424d80000004883bc24",
"898383080000c7837f080000010000006a0056ff9397080000ff938b08000050ff9393080000cc8b1c24c3ce0000087c00960046003a005c00500072006f0067",
"488944242048837c24200075*",
"c6430c01833d*",
"*0000ba0c0000004c8d4c2450448d423441ffd3*",
"41c6470c0148ba*7f0000833a00740c48b9*",
"41c6470c0148ba*4c8b6d8849897508498bc5488b9548ffffff498957104881",
"c6460c01833db6e5e75f007406ff15eae8e65f488bd8e8aa38b25f488bc3488b4d8048894e104883c4785b5e5f415c415d415e415f5dc367611910090010e20c",
"49bb60c05eb7ff7f000048b9*",
"488945984885c07507bac4020000eb2d6685db750*",
"*488b5588498956104883c4785b5e5f415c415d415e",
"c6460c01833d*",
"898383080000c7837f080000010000006a0056ff9397080000*",
"8be55d558bec83ec08c745f800000000c745fc02000000*",
"898383080000c7837f080000010000006a0056ff*",
"49bbb014*f0000ba0c0000004c8d4c2450448d423441ffd349bb403*",
"*00ba0c0000004c8d4c2450448d423441ffd349bb*",
"*48bf7063107ef67f0000488b064889074883c6084883c708488b06488907415f415e415d415c41*",
"84c07507e8b8836effeb498b45f88b55c089108b45e48945f4c70518c*",
"488b8d70ffffffc6410c01833df07bca5e007406ff15a47fc95e488b8d70ffffff488b9528ffffff48895110488945b0ff15804ce6fe8945c4488bcce8780000",
"498bd7488bcf4c8be8e819bbffff4533ff4d85ed0f84520200004c8b9760020000488d95e0000000440fb74424744533c9498bcd41ffd24c8be04885c00f8420",
"488943384885c07512ff15035608004c8d054ce80800e93fffffff4c3973407438663b6b1c7511488b5330488bcfff15d65a080085c07421488b4b40ff157857",
"89434885c07507f0ff054b360c000f57c04489bb2c0200004c89bb400200004c89bb480200004489bb500200000f1183580200000f1183680200000f11837802",
"488944242048837c242000750e488d0d7e0f0000e8a1ffffffeb37488d15980f0000488b4c2420ff153d0f0000488944242848837c242800750e488d0d910f00",
"c6430c0148ba446a76e3fd7f0000833a00740c48b9988375e3fd7f0000ff11488b5580488953104883c4785b5e5f415c415d415e415f5dc30000001910090010",
"f7d20fca85c08af3a37002f103b8000000000fca0f95c01af08984243803000033b424e001000003c60fc8e9a50100004a8bc6c1ea0b8d94248c01000003023b",
"488945984885c07507bac4020000eb2d6685db7505bbffff00000fb7d3488bc8448bca89542420448bc2488d058dc00000ff140785c0752cbad0020000488b8d",
"807c240c00894604740d85c0740583f8ff7504c64608008bc65ec20800568bf18b4e0433c085c9741083f9ff740bff74240851ff1598412f03807c240c007407",
"488906488bcb41ff542420488b064903e6eb17488bcb41ff5424384883f8087e06488b46f8eb034833c04883c420415e415c5f5e5bc300000000000000000000",
"48898424f00300004883bc24f003000000750eff94248e030000898424f80300008b8c24fc030000ff942476030000eb30488d8c2440010000ff94249e030000",
"8945f8837df80074658b45fc05601a0000508b4df8518b55fc8b8250170000ffd08945f4837df4007426b9010000006bd1008b45fc0fbe8c10641b000085c974",
"c6430c0148ba446a8e00f87f0000833a00740c48b998838d00f87f0000ff11488b5580488953104883c4785b5e5f415c415d415e415f5dc30000001910090010",
"48890550cf4700483bc30f849e010000488d15f0ab3800488bc8ff15a7173600488b0d30cf4700488d15c1ab380048890532cf4700ff158c173600488b0d15cf",
"4889842470020000488d0d1e5e1100ff15c0fc10004889442458488d0dfc5d1100ff15aefc100048898424a0020000488d15cf5d1100488b4c2458ff159cfc10",
"488944242048837c242000750e488d0d7e0f0000e8a1ffffffeb37488d15980f0000488b4c2420ff153d0f0000488944242848837c242800750e488d0d910f00",
"4885c0750bb8030000004883c4205bc3488d9318020000488bc8ff53084885c0750bb8040000004883c4205bc3488b4b10ffd033c04883c4205bc3cccccccccc",
"488944242048837c242000750e488d0d7e0f0000e8a1ffffffeb37488d15980f0000488b4c2420ff153d0f0000488944242848837c242800750e488d0d910f00",
"488b8d70ffffffc6410c01833d1479e65d007406ff1524cde55d488b8d70ffffff488b9528ffffff48895110488945b0ff1558ca1cfe8945c4488bcce8780000",
"4c8bd04889442458488d05345d00004803c6488d4c2458ffd084c07407bac3020000eb2d6685db7505bb102700000fb7d3488d053fbd0000448bca8954242044",
"86f285c00f94c28b9424c0020000a3302858048bc2b8000000000f90c00fa3d00f95c03ae084f48984245c0300000f94c4c0dc022b9c24bc0100008d049b0fa4",
"86f285c00f94c28b9424c0020000a33028*",
"488944242848837c242800750bff9424be03000089442430eb27488d8c2470010000ff9424ce030000488944242848837c242800750bff9424be030000894424",
"4c8bf04885c00f84cc00000083fb027c22488d4c2458c74424585b57485dc744245c2047504166c74424600a00ff9570010000488d542468498bceff55384c8b",
"488bc84885c075098d41034883c4205bc3488b4308488d9318020000ffd04885c0750bb8040000004883c4205bc3488b4b10ffd033c04883c4205bc3cccc488b"))
]
until [process where event.action:"end"]
'''

min_endpoint_version = "8.8.0"
[[actions]]
action = "kill_process"
field = "process.entity_id"
state = 1

[[optional_actions]]
action = "rollback"
field = "process.entity_id"
state = 0

[[threat]]
framework = "MITRE ATT&CK"
[[threat.technique]]
id = "T1055"
name = "Process Injection"
reference = "https://attack.mitre.org/techniques/T1055/"


[threat.tactic]
id = "TA0005"
name = "Defense Evasion"
reference = "https://attack.mitre.org/tactics/TA0005/"

[internal]
min_endpoint_version = "8.8.0"

我们可以观察到Elastic密切监控了”ws2_32.dll”, “wininet.dll”, “winhttp.dll”这三个dll,这三个dll和我们的C2通讯密切相关,确切的说想要通讯我们就离不开这三个dll。

有人会说我可以使用其他库比如curl但是curl的底层通讯还是依赖ws2_32。因为 ws2_32.dll 提供了 cURL 进行网络连接的基本 API,比如创建套接字、连接服务器、发送和接收数据等功能。

Elastic列出了很多个可疑的堆栈状态,我们只需要关注一点,那就是Dll的加载是从非备份内存发生的。这在大多数的C2和RAT上都会产生(当然只要他们使用Shellcode这种内存加载的方式)。

我们使用经典的C2 CobaltStrike来进行演示,

20241101155602

可以看到我们加载wininet.dll的堆栈过程中,LoadLibraryA是从非备份内存发起的。

那么安全产品就可以追踪到这块内存进行告警和内存扫描。

如何避免被评判

我们把思路转换一下,从如何避免产生转换为如何避免被评判为非法的,这样会让我们变的更加轻松愉快。

我们可以注意到,再逆向ntdll的过程中我们发现了两个可以使EDR知道我们加载了这个DLL的方案,一个是回调通知一个是ETW。但是归根结底,目前通用的检测方案是对于加载过程的堆栈进行评判,所以我们需要一个干净的堆栈。

什么是堆栈

灵魂发问:什么是堆栈?

确切的说我们这里是要讨论栈(stack),堆是(Heap)我们今天只讨论Stack。为了口语上的舒适我习惯称为堆栈。这里我白嫖一下BRC4作者的一些书面。

堆栈(Stack)是计算机科学中用于临时存储数据的一种数据结构,通常用于存放局部变量和函数参数。可以将堆栈想象成一叠书,最后放上去的书会最先被拿走,这就是“后进先出”(Last In, First Out, LIFO)的原则。

在程序执行时,每当一个新线程被创建时,系统会为这个线程分配一个新的堆栈空间。这个堆栈的初始大小通常为1MB,除非开发者在创建线程时明确指定其他大小。堆栈从底部向顶部增长,像一个叠书一样。在这个过程中,当前的堆栈指针由寄存器(在x64中是RSP,在x86中是ESP)来存储,指示着当前堆栈的顶部位置。

堆栈的特点是,它的管理是自动的。编译器会在编译过程中自动计算出每个函数所需的堆栈空间并生成相应的汇编指令。当函数被调用时,编译器会为该函数分配所需的堆栈空间,例如:

1
2
3
void samplefunction() {
char test[8192]; // 分配8192字节的局部变量
}

在这个例子中,编译器会将这个函数转换为类似下面的汇编代码:

1
sub rsp, 0x2000 // 从堆栈中减去8192字节

这表示函数在运行时会从堆栈中保留8192字节用于变量存储。当函数执行完毕后,它会将之前分配的空间释放掉:

1
2
add rsp, 0x2000 // 将8192字节加回堆栈
ret // 返回到调用该函数的位置

每个函数的堆栈帧(stack frame)包含了函数的返回地址、局部变量以及用于返回的必要信息。当一个函数调用另一个函数时,当前函数的地址会被压入堆栈,以便在被调用的函数执行完毕后能正确返回到原来的位置。

当我们调用多个函数时,堆栈会逐层形成堆栈帧。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void func3() {
char test[2048]; // func3 的局部变量
return;
}

void func2() {
char test[4096]; // func2 的局部变量
func3(); // 调用 func3
}

void func1() {
char test[8192]; // func1 的局部变量
func2(); // 调用 func2
}

这段代码在汇编层面上大致会变成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func3:
sub rsp, 0x800 // 分配2048字节
; 执行某些操作
add rsp, 0x800 // 释放2048字节
ret // 返回

func2:
sub rsp, 0x1000 // 分配4096字节
call func3 // 调用 func3
add rsp, 0x1000 // 释放4096字节
ret // 返回

func1:
sub rsp, 0x2000 // 分配8192字节
call func2 // 调用 func2
add rsp, 0x2000 // 释放8192字节
ret // 返回

每个函数的堆栈帧记录了局部变量的大小、返回地址和其他必要的信息。这样,程序在执行时能清楚地知道当前的执行上下文是什么,能有效管理内存。

为了让五岁的孩子也可以理解,我尝试解释的更清楚一些(lol)

  • 想象你有一个叠好的盘子,每次你想放一个新盘子时,你会把它放到最上面;每次你需要一个盘子时,你也会先拿走最上面的那个。这就是堆栈的工作原理:后放进去的东西先拿出来。

我们记住两个重要的知识点:

  • 后进先出(LIFO):最后放入堆栈的东西,最先被取出。

  • 自动管理:程序运行时,堆栈的内存分配和释放是自动的,程序员不需要手动去管理。

在程序中,堆栈主要用于存放:

  • 局部变量:函数内部定义的变量。
  • 函数调用的信息:比如当你在一个函数中调用另一个函数时,程序需要记住返回到哪个地方去执行接下来的代码。

比如这个很基础的Demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void funcA() {
int x = 10; // funcA 的局部变量
}

void funcB() {
int y = 20; // funcB 的局部变量
funcA(); // 调用 funcA
}

void funcC() {
int z = 30; // funcC 的局部变量
funcB(); // 调用 funcB
}

在这段代码中,函数 funcC 调用了 funcB,而 funcB 又调用了 funcA。当 funcC 被调用时,会发生以下事情:

  1. 分配空间:
    • funcC 分配堆栈空间,存储局部变量 z
  2. 调用 funcB
    • funcC 中调用 funcB,这时 funcC 的返回地址(即执行完 funcC 后要返回的地方)会被保存到堆栈中。
    • funcB 分配堆栈空间,存储局部变量 y
  3. 调用 funcA
    • funcB 中调用 funcAfuncB 的返回地址也会被保存到堆栈中。
    • funcA 分配堆栈空间,存储局部变量 x

返回的过程

funcA 执行完毕时,它会返回到 funcB,并释放 funcA 的堆栈空间。接着,funcB 也执行完毕,返回到 funcC,并释放 funcB 的堆栈空间。最后,funcC 完成后,释放它的堆栈空间。

我来给各位描绘一下:

当程序开始时,堆栈是空的。

1
2
堆栈:
(空)
  1. 当调用 funcC 时,堆栈中会添加 funcC 的信息,包括返回地址和局部变量 z
1
2
3
4
5
6
堆栈:
+--------------------+
| 返回地址 (funcC后) |
+--------------------+
| z = 30 |
+--------------------+
  1. funcC 中调用 funcB,堆栈会添加 funcB 的信息,包括返回地址和局部变量 y
1
2
3
4
5
6
7
8
9
10
堆栈:
+--------------------+
| 返回地址 (funcB后) |
+--------------------+
| y = 20 |
+--------------------+
| 返回地址 (funcC后) |
+--------------------+
| z = 30 |
+--------------------+
  1. funcB 中调用 funcA,堆栈会添加 funcA 的信息,包括返回地址和局部变量 x
1
2
3
4
5
6
7
8
9
10
11
12
13
14
堆栈:
+--------------------+
| 返回地址 (funcA后) |
+--------------------+
| x = 10 |
+--------------------+
| 返回地址 (funcB后) |
+--------------------+
| y = 20 |
+--------------------+
| 返回地址 (funcC后) |
+--------------------+
| z = 30 |
+--------------------+
  1. funcA 执行完毕时,它会返回到 funcB,堆栈将释放 funcA 的空间。
1
2
3
4
5
6
7
8
9
10
堆栈:
+--------------------+
| 返回地址 (funcB后) |
+--------------------+
| y = 20 |
+--------------------+
| 返回地址 (funcC后) |
+--------------------+
| z = 30 |
+--------------------+
  1. funcB 执行完毕时,它会返回到 funcC,堆栈将释放 funcB 的空间。
1
2
3
4
5
6
堆栈:
+--------------------+
| 返回地址 (funcC后) |
+--------------------+
| z = 30 |
+--------------------+
  1. funcC 执行完毕时,程序将返回到主程序,堆栈会清空。
1
2
堆栈:
(空)

这要是还不能理解,请不要看下去了,我真的会谢~🥲

思考破局之法

不管杀软是通过Userland下的Hook还是ETWTi亦或者内核上的一些处理,都可以捕获到我们的堆栈遥测。那么有人会说我们不用shellcode不就行了,我们在合法的模块不就行了…

先把那个说不用shellcode的同学叉出去。

我们先来思考怎么才能在合法的模块中呢?

1、欺骗过程中的返回地址

2、模块践踏

3、使用虚假的堆栈

PS.SentinelOne检测特定模块的堆栈的返回地址

20241102025101

在我的小玩具XSafe中正是实现了Userland的Hook,对调用地址进行了(可能较为充分)检查。

在过去,我关注到的一下Proxy dll load技术,虽然可能稍微有些过时,但是还可以拿出来讲一讲。他们来自于Nighthawk 以及 bruteratelc4。

代理模块加载

在我们红队操作过程中,许多时候都会直接或者间接的利用到LoadLibrary函数,用来解析并且继续执行我们所需的 API 函数。最常见的场景就是动态加载动态调用(简直太常见了)。很多的安全产品已经根据这一点进行了针对性检查。安全产品可以通过堆栈遍历LoadLibrary来检查其调用的来源,并验证合法性。

堆栈遍历是安全供应商用来分析程序调用堆栈的一种技术,它追溯函数调用的顺序,以确定 WinAPI 调用的来源和上下文。通过了解执行了哪些函数、它们的顺序以及调用它们的模块,来抓住隐藏在内存中的威胁。

在堆栈分析期间,安全工具会寻找常见异常,例如调用是否源自私有可执行内存(当payload驻留在VirtualAlloc/分配的内存区域中时,这是一种常见指标VirtualAllocEx)或者是否尝试加载常被滥用的库(如winhttp.dllwininet.dll用于 HTTP/HTTPS 的通信)。

当 shellcode 在私有虚拟内存(即由VirtualAlloc/分配的内存VirtualAllocEx)中执行时,从该区域进行的每个函数调用都将具有指向私有虚拟内存的返回地址。

举个例子:20241014070016

我们这里借用一个经典的方案RtlQueueWorkItem,通过这个API我们可以排队到一个工作线程,拥有一个干净的堆栈。

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
#include <Windows.h>
#include <winternl.h>
#include <stdio.h>

// 宏定义:获取当前进程句柄
#define NtCurrentProcess() ( ( HANDLE ) ( LONG_PTR ) -1 )

// 宏定义:动态导入指定 DLL 中的函数
#define IMPORTAPI( DLLFILE, FUNCNAME, RETTYPE, ...) \
typedef RETTYPE( WINAPI* type##FUNCNAME )( __VA_ARGS__ ); \
type##FUNCNAME FUNCNAME = (type##FUNCNAME)GetProcAddress((LoadLibraryW(DLLFILE), GetModuleHandleW(DLLFILE)), #FUNCNAME);

// 获取指定库的模块句柄
HMODULE getModuleHandle(LPCWSTR libraryName)
{
// 获取当前线程的环境块,访问模块列表
const LIST_ENTRY* head = &NtCurrentTeb()->ProcessEnvironmentBlock->Ldr->InMemoryOrderModuleList;
LIST_ENTRY* next = head->Flink;

// 遍历模块列表
while (next != head)
{
// 获取当前模块的条目
LDR_DATA_TABLE_ENTRY* entry =
CONTAINING_RECORD(next, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
// 获取模块的完整名称
const UNICODE_STRING* basename = (UNICODE_STRING*)((BYTE*)&entry->FullDllName
+ sizeof(UNICODE_STRING));

// 比较库名称
if (_wcsicmp(libraryName, basename->Buffer) == 0)
{
// 返回库的基址
return entry->DllBase;
}

next = next->Flink; // 移动到下一个条目
}
return NULL; // 未找到指定的库
}

// 队列加载库的函数
HMODULE queueLoadLibrary(WCHAR* libraryName, BOOL swtch)
{
// 导入 NtWaitForSingleObject 函数
IMPORTAPI(L"NTDLL.dll", NtWaitForSingleObject, NTSTATUS, HANDLE, BOOLEAN, PLARGE_INTEGER);

// 根据 swtch 的值决定加载方式
if (swtch)
{
// 导入 RtlQueueWorkItem 函数
IMPORTAPI(L"NTDLL.dll", RtlQueueWorkItem, NTSTATUS, PVOID, PVOID, ULONG);

// 异步加载库
if (NT_SUCCESS(RtlQueueWorkItem(&LoadLibraryW, (PVOID)libraryName, WT_EXECUTEDEFAULT)))
{
LARGE_INTEGER timeout;
timeout.QuadPart = -500000; // 设置超时时间
// 等待当前进程完成
NtWaitForSingleObject(NtCurrentProcess(), FALSE, &timeout);
}
}
else
{
// 导入 RtlRegisterWait 函数
IMPORTAPI(L"NTDLL.dll", RtlRegisterWait, NTSTATUS, PHANDLE, HANDLE, WAITORTIMERCALLBACKFUNC, PVOID, ULONG, ULONG);
HANDLE newWaitObject; // 新的等待对象
HANDLE eventObject = CreateEventW(NULL, FALSE, FALSE, NULL); // 创建事件对象

// 注册等待对象
if (NT_SUCCESS(RtlRegisterWait(&newWaitObject, eventObject, LoadLibraryW, (PVOID)libraryName, 0, WT_EXECUTEDEFAULT)))
{
// 等待事件对象的信号,最多等待 500 毫秒
WaitForSingleObject(eventObject, 500);
}
}

// 获取并返回库的模块句柄
return getModuleHandle(libraryName);
}

// 主函数
int main()
{
WCHAR libraryName[] = L"wininet.dll"; // 要加载的库名
// 加载库并获取其句柄
HMODULE moduleHandle = queueLoadLibrary(libraryName, TRUE);

// 打印模块句柄
printf("0x%p", moduleHandle);
}

我们把他丢进Shellcode框架中(后面再谈),或者偷懒的可以直接PE2shellcode。

20241102031041

可以看到LoadLibrary的发起者已经变成的RtlQueueWorkItem。但是这样会有三个缺陷我们先说一个另一个后面再说,最后一个我就不说(哎,气死你)

安全产品只需要对RtlQueueWorkItem进行密切监控就可以解决问题,同样的类似的Api还有多个这里不一一描述。

我们先从爬开始学,先来了解一下这段代码做了什么:

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
主函数 (main)

├── 定义 DLL 名称: "Wininet.dll"

├── 调用 queueLoadLibrary 函数
│ ├── 传入参数: DLL 名称, swtch (TRUE)
│ │
│ ├── 进入 queueLoadLibrary
│ │ ├── 导入 API
│ │ │ ├── NtWaitForSingleObject
│ │ │ └── RtlQueueWorkItem
│ │ │
│ │ └── 判断 swtch
│ │ ├── TRUE
│ │ │ ├── 调用 RtlQueueWorkItem
│ │ │ │ ├── 封装 LoadLibraryW 调用
│ │ │ │ └── 传入 DLL 名称
│ │ │ │
│ │ │ └── 调用 NtWaitForSingleObject
│ │ │ └── 等待一定时间
│ │ │
│ │ └── FALSE (未执行)
│ │
│ ├── 调用 getModuleHandle 函数
│ │ ├── 遍历当前进程模块列表
│ │ │ └── 查找 DLL 基地址
│ │ │
│ │ └── 返回模块句柄或 NULL
│ │
│ └── 返回到主函数

└── 打印模块句柄

RtlQueueWorkItem 之所以能帮助规避检测,主要是因为它允许在一个干净的线程上下文中异步执行代码。这意味着,当我们通过 RtlQueueWorkItem 请求加载一个 DLL 时,它实际上是在一个新的线程中运行,而不是在原始 shellcode 的线程中直接调用 LoadLibrary,当然我们先忽略别的监测点。

我们用文字来抽象的描绘一下:

当然,可以通过文字描述来解释代理加载的流程、线程及堆栈的变化。下面是一个简化的描述,模拟这个过程:

  1. 初始状态

    • 当进程启动时,系统为该进程分配一个默认的堆栈,此时的堆栈指针为 RSP
    • 线程创建时,堆栈中可以保存函数参数和局部变量。
  2. 调用 shellcode

    • 当 shellcode 开始执行时,RSP 指向当前堆栈的顶部,堆栈中可能保存了一些初始的局部变量。
    • 在 shellcode 中有一个调用,触发 LoadLibrary 以加载一个 DLL。
  3. 加载 DLL 的直接调用

    • 如果直接调用 LoadLibrary,那么当前线程的堆栈会被修改。堆栈框架中将存储调用 LoadLibrary 的返回地址、参数等信息。
    • 检测系统可以通过堆栈跟踪发现这个调用链。
  4. 使用 RtlQueueWorkItem 进行异步加载

    • 通过 RtlQueueWorkItem 提交一个异步任务,这个任务将在一个新的线程中执行。
    • 在新的线程中,系统为其分配一个新的堆栈,这个堆栈与原始线程的堆栈是独立的。
  5. 新线程的堆栈

    • 新线程的堆栈是干净的,初始状态下没有任何来自原始 shellcode 的调用信息。
    • 在这个新堆栈中执行 LoadLibrary,参数等信息在新堆栈中被处理。
  6. 返回到主线程

    • 当 DLL 加载完成后,新线程的执行结束,返回地址将被处理,原始线程的堆栈保持不变。
    • 原始线程继续执行后续的代码,但没有直接调用 LoadLibrary 的痕迹。

原始线程堆栈(未使用代理加载)

1
2
3
4
5
6
7
|-----------Top Of The Stack-----------|
| 局部变量和参数 |
|--------------------------------------|
|------Stack Frame of LoadLibrary------| <-- 调用 LoadLibrary
| 返回地址:shellcode 的地址 |
| |
|-----------Bottom Of The Stack--------|

新线程堆栈(使用 RtlQueueWorkItem)

1
2
3
4
5
6
7
|-----------Top Of The Stack-----------|
| 局部变量和参数 (LoadLibrary) |
|--------------------------------------|
|------Stack Frame of LoadLibrary------| <-- 调用 LoadLibrary
| 返回地址:新线程的返回地址 |
| |
|-----------Bottom Of The Stack--------|

通过使用 RtlQueueWorkItem,加载 DLL 的过程被分离到一个新线程中,堆栈中的调用信息和返回地址与原始 shellcode 无关。

返回地址欺骗

我们现在来看另一种方案,返回地址欺骗。说实话这是一个古老的技术,最早用于外挂技术。

注意:我们需要一个找到一个基于非易失性寄存器跳转(jmp rbx)

Demo代码如下:

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
.code
SpoofStub PROC
pop r11 ;保存SpoofStub返回地址
add rsp, 8
mov rax, [rsp + 24] ;rax = &param

mov r10, [rax] ;
mov [rsp], r10 ;被hook函数的返回地址为param.trampoline 也就是jmp [rbx]所在

mov r10, [rax + 8] ;r10 = param.function 用于跳转到被hook的函数
mov [rax + 8], r11 ;param.function = SpoofStub返回地址

mov [rax + 16], rbx ;param.rbx = rbx
lea rbx, fixup
mov [rax], rbx ;param.trampoline = fixup
mov rbx, rax ;rbx = &param.trampoline --> 被hook地址返回为fixup

jmp r10 ;跳转到被hook函数的地址

fixup:
sub rsp, 16
mov rcx, rbx
mov rbx, [rcx + 16] ;还原rbx
jmp QWORD PTR [rcx + 8] ;跳出SpoofStub

SpoofStub ENDP

end
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
#pragma once
#include <Windows.h>
#include "Spoofer.h"

typedef struct _PRM {
const void* trampoline;
void* function;
void* rbx;
} PRM, * PPRM;

typedef BOOL(WINAPI* fnCheckGadget) (PBYTE);

extern "C" PVOID SpoofStub(PVOID, PVOID, PVOID, PVOID, PPRM, PVOID, PVOID, PVOID, PVOID, PVOID);


PVOID FindGadget(PVOID pModule, fnCheckGadget CallbackCheck)
{
for (int i = 0;; i++)
{
if (CallbackCheck((PBYTE)pModule + i))
return (PBYTE)pModule + i;
}
}

BOOL fnGadgetJmpRbx(PBYTE pAddr)
{
if (
((PBYTE)pAddr)[0] == 0xFF &&
((PBYTE)pAddr)[1] == 0x23
)
return TRUE;
else
return FALSE;
}

PVOID Spoofer(LPCSTR ModuleName, PVOID pFunction, PVOID pArg1, PVOID pArg2, PVOID pArg3, PVOID pArg4, PVOID pArg5, PVOID pArg6, PVOID pArg7, PVOID pArg8)
{
PVOID pGadgetAddr = NULL;
PVOID pK32 = GetModuleHandleA(ModuleName);
pGadgetAddr = FindGadget(pK32, fnGadgetJmpRbx);
PRM param = { pGadgetAddr, pFunction };

PVOID pRet = SpoofStub(pArg1, pArg2, pArg3, pArg4, &param, NULL, pArg5, pArg6, pArg7, pArg8);
return pRet;
}

我们的工作流程如下:

保存返回地址

  • pop r11 从栈中弹出返回地址(即调用 SpoofStub 的返回地址)并保存到 r11 中。

设置栈指针

  • add rsp, 8 跳过调用约定中的参数区域,为后续操作准备栈空间。

获取参数

  • mov rax, [rsp + 24]param 的地址存储在 rax 中。

设置返回地址

  • mov r10, [rax] 获取 param.trampoline(即查找到的 jmp rbx 指令地址)。
  • mov [rsp], r10 将这个地址存储为新的返回地址,确保在执行完被钩取函数后能跳转到正确的位置。

修改钩取函数信息

  • mov r10, [rax + 8] 获取被钩取函数的地址,并将原返回地址(r11)存储到 param.function 中,以便后续恢复。
  • mov [rax + 16], rbx 保存当前 rbx 寄存器的值到 param.rbx

设置钩取的 trampoline 地址

  • 使用 lea rbx, fixup 将控制流转向 fixup 标签,随后将其地址存储到 param.trampoline

跳转到被钩取的函数

  • jmp r10 跳转到被钩取的函数地址,执行实际的功能。

恢复状态

  • fixup 标签处,首先恢复栈指针,并还原 rbx 的值。
  • 最后,通过 jmp QWORD PTR [rcx + 8] 跳转回原始的返回地址,确保控制流回到 SpoofStub 的调用处。

通过操作栈和寄存器,创建了一个伪装的返回地址。它将原本的返回地址替换为一个新的地址,控制函数执行的返回流。

我们用这种方法来加载Wininet看一下有什么区别

1
Spoofer(pKernel32, pLoadLibraryA, PVOID("wininet.dll"), 0, 0, 0, 0, 0, 0, 0);

20241102041035

可以观察到我们和LoadLibrary之间隔了一个ReleasePackageVirtualizationContext,这是啥呢,这就是我们前面找到的jmp。也就是说我们的LoadLibrary的返回地址来自ReleasePackageVirtualizationContext偏移0x1fa的地方。这样安全产品再分析的时候可能到这里就结束了。

这是一个比较敷衍的办法,但是我觉得这是一个很好的学习教材,正如我所提倡的思维不定式,我希望更多人可以从中的到启发,而不是一味的迷信固定式解决,返回地址欺骗同样有他的价值,以及它可以衍生出其他的对抗方式,这里不做详细解释~

代理加载(New)

BRC4作者提出他在ntdll中逆向出了至少27个可用的回调来代理执行Windows Api,这听起来很酷,可惜我是个懒B~

介绍一下

Windows 回调函数是指由应用程序或操作系统在特定事件发生时调用的函数。回调机制通常用于事件驱动编程,它允许程序在发生特定条件时执行自定义代码。这些函数可以在多种场景中使用,例如消息处理、定时器、异步操作等。

回调函数的特点

  1. 异步执行:回调函数通常在事件发生后被调用,而不是在主程序的控制流中被直接调用(Oops),这使得程序能够处理多个任务而不阻塞。
  2. 灵活性:通过使用回调函数,程序可以在运行时动态决定要执行的操作,从而提高了灵活性和可重用性。
  3. 参数传递:回调函数可以接受参数,这使得它们能够处理特定的数据或上下文信息。

常见用途

  • 消息循环:在Windows程序中,消息循环使用回调函数来处理来自系统的消息。
  • 定时器:可以设置定时器,当定时器到期时调用指定的回调函数。
  • 异步I/O:在异步操作完成时,操作系统会调用相应的回调函数以处理结果。

说个题外的基础:如何在Windows API中注册回调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <windows.h>

// 回调函数定义
void CALLBACK TimerProc(HWND hwnd, UINT uMsg, UINT_PTR idEvent, DWORD dwTime) {
MessageBox(NULL, "Timer triggered!", "Info", MB_OK);
}

int main() {
// 设置一个定时器
SetTimer(NULL, 0, 1000, (TIMERPROC)TimerProc);

// 进入消息循环
MSG msg;
while (GetMessage(&msg, NULL, 0, 0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}

return 0;
}

当定时器到期时,TimerProc 回调函数会被调用,显示一个消息框。

Microsoft 贴心的为软件开发人员提供了大量回调,以便通过其他函数执行代码。有一位热心的Hxd已经替我们整理好了很多可以用来恶意利用的 https://github.com/aahmad097/AlternativeShellcodeExec 相信很多人都熟悉这个。

正如Paranoid Ninja所言所有这些回调都存在一个主要问题。执行回调时,回调与调用者线程位于同一线程中。

也就是我们上面返回地址欺骗中所提到的尴尬局面:

LoadLibrary returns to -> Callback Function returns to -> RX region

为了获得干净的堆栈,我们需要确保我们的 LoadLibrary 在独立于我们的 RX 区域的单独线程中执行,如果我们使用回调,我们需要回调能够将适当的参数传递给LoadLibraryA。Windows 中的大多数回调要么没有参数,要么不将参数“按原样”转发给我们的目标函数“LoadLibrary”。

Paranoid Ninja说的很好(不插嘴了)

我们可以据此有一个大概的判断,线程池 API 可能是不错的选择。在目标线程中排队一个异步过程调用(APC)或许也可以。总之我们要找到合适的Windows Api。

开始摸索

Paranoid Ninja给出的Demo中选择了使用TpAllocWork TpPostWork TpReleaseWork这三个Windows Api去完成这一操作。

TpAllocWorkTpPostWorkTpReleaseWork 是 Windows 线程池 API 中的一组函数,用于创建和管理线程池中的工作项。它们的功能如下:

  1. TpAllocWork
    • 用于分配一个工作项。
    • 可以指定一个回调函数,这个函数将在后台线程中执行。
    • 允许传递可选参数(虽然在某些情况下参数可能无法按预期传递)。
  2. TpPostWork
    • 将之前通过 TpAllocWork 分配的工作项排入线程池,以便在可用线程上异步执行。
    • 一旦工作项被排入,它将在线程池中执行,而不会阻塞调用线程。
  3. TpReleaseWork
    • 用于释放与工作项相关的资源。
    • 在工作项完成执行后调用,以确保没有内存泄漏或资源浪费。

虽然我对挖掘更多方式感兴趣,但是我太懒了一点也不想动,所以我仅仅给我我的推测和观点:

寻找特定的 API, “callback”、”thread”、”work” 等关键字。然后分析函数的参数,看是否可以

通过指针、结构体等传递参数。然后检查 API 是否支持异步或后台线程执行,并且观察一下调用的堆栈状态。

https://processhacker.sourceforge.io/doc/nttp_8h.html)

TpAllocWork 的原型如下:

1
2
3
4
5
6
NTSTATUS NTAPI TpAllocWork(
PTP_WORK* ptpWrk,
PTP_WORK_CALLBACK pfnwkCallback,
PVOID OptionalArg,
PTP_CALLBACK_ENVIRON CallbackEnvironment
);

其中 pfnwkCallback 是回调函数指针,OptionalArg 是一个可选参数,可以传递给回调函数。

LoadLibraryA 是一个直接调用 Windows API 加载 DLL 的函数。为了在 TpAllocWork 中使用它,需要一个符合回调函数签名的函数。

TpAllocWork 期望的回调函数类型是 PTP_WORK_CALLBACK,但 LoadLibraryA 不符合这个签名,因此必须间接使用。

由于 LoadLibraryA 直接调用不符合 TP_WORK_CALLBACK 的签名,因此需要用一个包装函数(如前面提到的 WorkCallback)来接收 libName 参数,并调用 LoadLibraryA。在这种情况下,libName 作为 OptionalArg 被传递:

  • WorkCallback 可以接收 Context 参数,并将其传递给 LoadLibraryA

回调函数签名什么意思呢?

回调函数签名是指函数的定义,包括返回类型、函数名称和参数类型。它描述了如何正确调用该函数,以及可以接受哪些类型的参数。签名通常用于指明函数的接口,使得其他代码能够正确地调用该函数。

以 Windows API 中的 TP_WORK_CALLBACK 类型为例,其签名如下:

1
2
3
4
5
VOID CALLBACK WorkCallback(
PTP_CALLBACK_INSTANCE Instance,
PVOID Context,
PTP_WORK Work
);

这个签名的组成部分是:

  1. 返回类型VOID 表示该函数不返回任何值。
  2. 调用约定CALLBACK 指明了调用约定,表示该函数的参数如何在调用时通过栈传递(例如谁负责清理栈)。
  3. 函数名称WorkCallback 是函数的名称,调用时将使用这个名称。
  4. 参数列表
    • PTP_CALLBACK_INSTANCE Instance:第一个参数是指向回调实例的指针,提供上下文信息。
    • PVOID Context:第二个参数是一个指向用户定义数据的指针,可以传递给回调的额外信息。
    • PTP_WORK Work:第三个参数是指向工作对象的指针,代表与当前工作相关的上下文。
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
#include <windows.h>
#include <stdio.h>

#pragma comment(lib, "ntdll.lib")

VOID CALLBACK WorkCallback(
_Inout_ PTP_CALLBACK_INSTANCE Instance,
_Inout_opt_ PVOID Context,
_Inout_ PTP_WORK Work
) {
LoadLibraryA(Context);
}

int main() {
CHAR* libName = "wininet.dll";

PTP_WORK WorkReturn = NULL;
TpAllocWork(&WorkReturn, WorkCallback, libName, NULL);
TpPostWork(WorkReturn);
TpReleaseWork(WorkReturn);

WaitForSingleObject((HANDLE)-1, 1000);
printf("hWininet: %p\n", GetModuleHandleA(libName));

return 0;
}

20241102054028

我们的堆栈现在好像变得清澈无比(陷入沉思),但是!Paranoid Ninja想要追求更完美的堆栈,那么哪里不完美呢。

具体来说,调用 LoadLibraryA 后,返回地址的顺序变成了:

  1. LoadLibraryA 的返回地址(指向回调函数)。
  2. 回调函数的返回地址(指向 RtlUserThreadStart)。
  3. 最后是 TpPostWork 的返回地址。

根据我的观察:

当调用 LoadLibraryA 时,栈的状态可以描述如下:

  1. LoadLibraryA 的返回地址:这个地址指向 WorkCallback 函数结束后程序应该跳转的位置,即 LoadLibraryA 完成后会返回到这个地址。

  2. WorkCallback 的返回地址:在 LoadLibraryA 被调用时,程序的控制权会传递给 WorkCallback,这个地址是 WorkCallback 函数结束后应该返回的位置,通常是指向 RtlUserThreadStart 的地址。

  3. RtlUserThreadStart 的返回地址:这是系统级的调用,指向线程的起始点,通常是由 TpPostWork 调用的返回地址。

因此,这个栈的结构可以被表示为:

1
2
3
4
5
6
7
|------------------------|  <- 栈顶
|RtlUserThreadStart返回地址|
|------------------------|
| WorkCallback返回地址 | <- 返回到 RtlUserThreadStart
|------------------------|
| LoadLibraryA返回地址 | <- 返回到 WorkCallback
|------------------------| <- 栈底

20241102065125

20241102065125

1
2
3
4
5
6
>	KernelBase.dll!LoadLibraryA()	未知
ntdll.dll!TppWorkpExecuteCallback() 未知
ntdll.dll!TppWorkerThread() 未知
kernel32.dll!BaseThreadInitThunk() 未知
ntdll.dll!RtlUserThreadStart() 未知

我可怜的ProcessHacker卡住了用文字描述吧。

不能理解的多看几遍自行理解,当 LoadLibraryA 被调用时,返回地址(Callback in RX Region)也会被压入栈。这意味着返回过程会变得更加复杂,最终的栈帧包含了许多额外的层级。我们现在试图直接从调用TpPostWorkLoadLibrary省略中间的一层弯弯绕绕导致不必要的EDR跟随。

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
#include <windows.h>
#include <stdio.h>

typedef NTSTATUS(NTAPI* TPALLOCWORK)(PTP_WORK* ptpWrk, PTP_WORK_CALLBACK pfnwkCallback, PVOID OptionalArg, PTP_CALLBACK_ENVIRON CallbackEnvironment);
typedef VOID(NTAPI* TPPOSTWORK)(PTP_WORK);
typedef VOID(NTAPI* TPRELEASEWORK)(PTP_WORK);

FARPROC pLoadLibraryA;

UINT_PTR getLoadLibraryA() {
return (UINT_PTR)pLoadLibraryA;
}

extern VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work);

int main() {
pLoadLibraryA = GetProcAddress(GetModuleHandleA("kernel32"), "LoadLibraryA");
FARPROC pTpAllocWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpAllocWork");
FARPROC pTpPostWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpPostWork");
FARPROC pTpReleaseWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpReleaseWork");

CHAR* libName = "wininet.dll";
PTP_WORK WorkReturn = NULL;
((TPALLOCWORK)pTpAllocWork)(&WorkReturn, (PTP_WORK_CALLBACK)WorkCallback, libName, NULL);
((TPPOSTWORK)pTpPostWork)(WorkReturn);
((TPRELEASEWORK)pTpReleaseWork)(WorkReturn);

WaitForSingleObject((HANDLE)-1, 0x1000);
printf("hWininet: %p\n", GetModuleHandleA(libName));

return 0;
}

MASM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
; 声明外部函数
extern getLoadLibraryA:near

.code

; 回调函数
WorkCallback PROC
; 将第二个参数 Context 移动到 rcx
mov rcx, rdx
; 清零 rdx,作为 LoadLibraryA 的第二个参数
xor rdx, rdx
; 调用 getLoadLibraryA
call getLoadLibraryA
; 跳转到 LoadLibraryA 的返回地址
jmp rax
WorkCallback ENDP

END

为什么要这么做呢,我们直接看重点WorkCallback

根据前面Stack的介绍我们知道,函数调用时需要保存调用者的上下文,所以我们直接调用 LoadLibraryA,而不通过 WorkCallback 的返回地址,用一些汇编技巧,这样看起来就像是我们实现了一个LoadLibraryA的Stub。:通过这种方法,调用 LoadLibraryA 时不会将 WorkCallback 的返回地址推入栈中,堆栈结构保持简单,没有多余的层级。

  • mov rcx, rdx 将传入的库名(存储在 RDX 中)移动到 RCX 中,这是 LoadLibraryA 的第一个参数。

  • xor rdx, rdx 将 RDX 清零,作为 LoadLibraryA 的第二个参数。

  • call getLoadLibraryA 调用一个函数以获取 LoadLibraryA 的地址。

  • jmp rax 跳转到 LoadLibraryA 的地址,直接执行而不推送返回地址到栈中。

现在再回头看我们的堆栈,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
猜测:
+-----------------------------+
| 返回地址 (TpPostWork) | <- 栈顶
+-----------------------------+
| WorkCallback 上下文 |
+-----------------------------+
| libName (参数) |
+-----------------------------+
| ... |
+-----------------------------+
| (空或其他局部变量) |
+-----------------------------+
| LoadLibraryA 返回地址 | <- 被清空,不在栈中
+-----------------------------+

1
2
3
4
5
6
>	kernel32.dll!LoadLibraryAStub()	未知
ntdll.dll!TppWorkpExecuteCallback() 未知
ntdll.dll!TppWorkerThread() 未知
kernel32.dll!BaseThreadInitThunk() 未知
ntdll.dll!RtlUserThreadStart() 未知

20241102072352

绕过重获新生

Emmm真不错我们成功的学会了这个对抗技术,但是很快就被推出了新的检测规则,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sequence by process.entity_id 
[process where event.action == "start"]
[library where
(
((dll.Ext.relative_file_creation_time <= 300 or
(dll.Ext.device.product_id : ("Virtual DVD-ROM", "Virtual Disk") and not dll.path : "C:\\*")) and
(dll.code_signature.trusted == false or dll.code_signature.exists == false)) or

dll.name : ("vaultcli.dll", "wmiutils.dll", "taskschd.dll", "dnsapi.dll", "dsquery.dll",
"mstask.dll", "mstscax.dll", "sqlite3.dll", "clr.dll", "coreclr.dll", "ws2_32.dll",
"wininet.dll", "dnsapi.dll", "winhttp.dll", "psapi.dll", "bitsproxy.dll", "softokn3.dll",
"System.Management.Automation.dll", "Wldap32.dll")
) and

process.thread.Ext.call_stack_summary :
("ntdll.dll|kernelbase.dll|ntdll.dll|kernel32.dll|ntdll.dll",
"ntdll.dll|wow64.dll|wow64cpu.dll|wow64.dll|ntdll.dll|kernelbase.dll|ntdll.dll|kernel32.dll|ntdll.dll")]
until [process where event.action == "end"]

简单理解一下

  • 首先按进程 ID 进行排序**:

    • 获取所有 “start” 操作的进程。
  • 库文件条件

    • 要求以下情况之一成立:
      • DLL 文件的相对创建时间小于等于 300 秒,或者设备产品 ID 为虚拟 DVD-ROM 或虚拟磁盘且不在 “C:*” 路径下,同时文件的代码签名不受信任或不存在;
      • 或者 DLL 的名称为特定列表中的某一个,例如 “vaultcli.dll”、”wmiutils.dll” 等等。
  • 线程调用栈条件

    • 仅选择具有特定调用栈摘要的进程。这些摘要包括 “ntdll.dll|kernelbase.dll|ntdll.dll|kernel32.dll|ntdll.dll” 或类似结构的组合。
  • 结束条件

    • 直到找到对应的 “end” 操作的进程。

也就是说我们可以观察到的组合有两种

  1. 调用栈组合 1:
    • ntdll.dll -> kernelbase.dll -> ntdll.dll -> kernel32.dll -> ntdll.dll
  2. 调用栈组合 2:
    • ntdll.dll -> wow64.dll -> wow64cpu.dll -> wow64.dll -> ntdll.dll -> kernelbase.dll -> ntdll.dll -> kernel32.dll -> ntdll.dll

我们看一下代理调用的堆栈:

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
C:\Windows\System32\ntdll.dll!ZwMapViewOfSection+0x14

C:\Windows\System32\ntdll.dll!LdrControlFlowGuardEnforced+0x69c

C:\Windows\System32\ntdll.dll!LdrControlFlowGuardEnforced+0x29a

C:\Windows\System32\ntdll.dll!LdrControlFlowGuardEnforced+0x424

C:\Windows\System32\ntdll.dll!RtlQueryPerformanceCounter+0x24f

C:\Windows\System32\ntdll.dll!RtlQueryPerformanceCounter+0x708

C:\Windows\System32\ntdll.dll!RtlQueryPerformanceCounter+0xad0

C:\Windows\System32\ntdll.dll!RtlImageRvaToSection+0x1e4

C:\Windows\System32\ntdll.dll!RtlUnicodeToCustomCPN+0x3fc

C:\Windows\System32\ntdll.dll!LdrLoadDll+0xfa

C:\Windows\System32\KernelBase.dll!LoadLibraryExW+0x172

C:\Windows\System32\KernelBase.dll!LoadLibraryExA+0x31

C:\Windows\System32\KernelBase.dll!LoadLibraryA+0x3f

C:\Windows\System32\ntdll.dll!TpReleaseWork+0x262

C:\Windows\System32\ntdll.dll!RtlClearThreadWorkOnBehalfTicket+0x79c

C:\Windows\System32\kernel32.dll!BaseThreadInitThunk+0x1d

C:\Windows\System32\ntdll.dll!RtlUserThreadStart+0x28

很显然命中了:ntdll.dll|kernelbase.dll|ntdll.dll|kernel32.dll|ntdll.dll

那么我们只要把这个过程改变为使用LdrLoadDll直接越过了中间的kernelbase.dll
堆栈的调用过程变成了 ntdll.dll|kernel32.dll|ntdll.dll

最合理的调用过程应该是 ntdll.dll|kernelbase.dll|kernel32.dll|ntdll.dll

写的有些疲惫了,如有写错的地方请联系我及时更正,感激不尽!


LoadLibrary的那些事儿(一)
https://kyxiaxiang.github.io/2024/11/01/AboutLoadLibrary-01/
作者
keyixiaxiang
发布于
2024年11月1日
许可协议