New Process Injection Class The CONTEXT-Only Attack Surface
作者:Yehuda Smirnov, Hoshea Yarden, Hai Vaknin 和 Noam Pomerantz
概述
大多数进程注入技术都遵循一个熟悉的模式:分配 → 写入 → 执行。
在本研究中,我们提出了一个问题:如果我们完全跳过分配和写入会怎样?
通过专注于仅执行原语,我们发现了无需分配/写入内存即可注入代码的独特方法:
-
仅使用 CreateRemoteThread
注入 DLL -
使用 SetThreadContext
调用带参数的任意 WinAPI 函数 -
利用 NtCreateThread
远程分配、写入并执行 shellcode -
将该技术扩展到 APC 函数,如 QueueUserAPC
请访问 RedirectThread GitHub 仓库 https://github.com/Friends-Security/RedirectThread
引言
现代终端检测与响应 (EDR) 系统通常会监控经典进程注入的三个迹象:
-
新内存的分配 ( VirtualAlloc[Ex]
) -
内存的修改 ( WriteProcessMemory
,VirtualProtect
) -
执行操作 ( CreateRemoteThread
, APC 等)
本研究的目标是测试下限:我们能否仅触发执行原语,跳过分配和写入原语,同时仍能在目标进程中植入恶意代码?
这个想法源于一个传闻:大内存页 总是被分配和映射为读写执行 (RWX),即使没有特别请求。
研究后记:当我们测试时,情况并非如此。
这引发了一个自然的问题:安全工具会以不同于典型 RWX 分配的方式处理这种情况吗?
如果是这样,跳过链中的分配部分可能会绕过部分常规检测逻辑。如果这是可能的,那么跳过数据注入步骤又如何?如果我们需要的数据已经存在于目标进程中,我们可能根本不需要写入任何新内容。
这激发了一个想法:
如果我们已经在目标进程中拥有有效的、可寻址的数据,我们能否采用经典的 DLL 注入—— LoadLibrary 方法,简单地将其指向目标进程中的现有数据,然后让 Windows 完成剩下的工作?
为什么选择 LoadLibrary?
LoadLibraryA/W
会自动将 ".dll" 附加到它接收到的任何字符串指针,然后解析常规的 DLL 搜索顺序 (stackoverflow.com)。利用这种行为,我们可以找到一个进程内现有字符串(例如 "0
"),并将一个名为(例如)0.dll
的文件放在搜索路径中较早的目录中。
这将启动一个远程线程,其启动例程为 LoadLibraryA
,参数设置为字符指针(例如 "0
"),最终导致 DLL 被加载到目标进程中。
但我们如何找到这个进程内现有字符串呢?
共享内存基础
当 Windows 将文件支持的部分(例如 ntdll.dll
)映射到多个用户模式进程时,这些部分由相同的物理内存支持;每个进程仅获得自己的虚拟地址视图。
写时复制 保护共享内存区域,直到进程尝试修改它们。此时,内核会创建页面的私有副本,以确保进程 不会修改 系统中 所有进程共享 的内存。
自 Windows Vista 以来,ASLR 随机化每次启动时的基址。系统 DLL 在所有进程中加载到一致的基址以优化重定位性能。 因此,像 ntdll + 0x4
这样的偏移量应该指向 所有进程 中的相同字节。
然后我们可以在本地定位字符指针,并在目标进程中调用 LoadLibraryA
时重用该地址。
许多方便的字符串字面量存在于 ntdll.dll
中,它被所有进程加载。我们选择使用 "0
" 作为我们的案例。
仅指针的 LoadLibrary 注入
下面的概念验证演示了将该想法转化为可工作的进程注入原语所需的最少步骤。
-
定位进程内字符串 -
在 ntdll.dll
中搜索静态 ASCII 字符串,如 "0
" (0x30 0x00
)。由于 DLL 在目标进程和我们自己的进程中映射到相同的偏移量,虚拟地址在两者之间应该是有效的。
ntdll.dll 在进程间映射到相同的地址
-
准备有效载荷 DLL -
使用选择的植入物构建 0.dll
。 -
将 DLL 放入通过搜索顺序解析的目录中。
我们不一定局限于经典的搜索顺序劫持。也许可以滥用
DefineDosDeviceW
或 NT 符号链接从任意位置(如 SMB 共享或 WebDAV 挂载)加载有效载荷。
-
创建远程线程
-
注意 LoadLibraryA
的地址是从我们自己的进程中获取的,类似于步骤 1 中的方法。
HANDLE hThread = CreateRemoteThread(
hProcess,
NULL, // default security
0, // default stack size
(LPTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandleA("kernel32"), "LoadLibraryA"),
(LPVOID)0x7FFE0300, // pointer to shared NUL byte inside ntdll
0, // run immediately
NULL);
-
执行 -
LoadLibraryA
会附加“.dll”,解析路径并加载0.dll
到目标进程中:
我们得到了一个意外但令人兴奋的结果:
-
通过跳过内存分配和写入,一个众所周知的注入技术绕过了两个行业领先的 EDR 的检测。 -
我们还测试了常规的 DLL 注入技术(涉及写入内存),它被两个 EDR 都检测到了。 -
这个小调整揭示了许多技术都依赖于相同的早期行为,以及这种行为可以多么容易被避免。
这让我们重新思考注入链:
-
大多数技术都遵循相同的模式:注入数据 → 触发执行。 -
在众多技术中,执行触发器是共同的部分。
因此我们提出了问题:
-
我们真的需要注入数据吗? -
如果我们只专注于执行触发器会怎样?
我们没有替换链中的每个部分,而是加倍关注触发器,并试图探索仅凭这一点我们能走多远。
使用 ETW 调查远程线程创建噪音
在深入之前,我们被一个简单的问题所启发,进行了一次快速探索:
这只是一个检测疏忽吗?为什么安全产品不将 任何 远程线程创建标记为恶意?难道它不够罕见,无法通过简单的白名单捕获吗?
事实证明——完全不是。远程线程创建出奇地常见,被以下用途使用:
-
应用程序资源监视器 -
性能分析器 -
检测和跟踪工具 -
调试器、兼容性垫片、辅助功能助手 -
企业代理和终端框架
为了测量基线“噪音”,我们编写了一个基于 ETW 的小型跟踪器,用于捕获和关联创建者 PID ≠ 目标 PID 的线程创建事件。ETW 为我们提供了所需的所有数据。
在干净的 Windows 安装上,查看不到一分钟内捕获的数据,我们已经发现了一些跨不同进程的线程创建:
FindRemoteThreads.ps1
此时,我们已经觉得值得深入研究执行触发器,同时避免分配和修改。
如果 EDR 不单独基于执行步骤标记恶意活动,即使在使用臭名昭著的 CreateRemoteThread
时,那么问题就变成了:这个原语有多强大,我们接下来能把它带到哪里?
CreateRemoteThread + SetThreadContext 注入
我们进一步利用 CreateRemoteThread
的第一个想法是避免将 DLL 写入磁盘,并尝试实现仅内存注入。我们的目标是实现与经典的远程 Allocate
→ Modify
→ Execute
链类似的能力。
显而易见的问题是: 为什么不直接在远程进程中调用这些函数?
如果我们在目标进程中创建的新线程被设计为在其自身进程内分配和修改内存,那么我们可以简单地将现有的远程内存分配/修改技术改编为 “自我”分配/自我修改 方法。
更好的是,我们可以使用目标进程中任何本地功能作为我们的原语——而不仅仅局限于支持远程操作的 WinAPI 或系统调用。
毕竟,进程读取或写入其自身内存没有任何可疑之处。
我们基本上将这样:
改为这样:
CreateRemoteThread 的限制
我们在使用 CreateRemoteThread
时遇到的第一个问题是它的参数限制。该 API 只接受指向启动例程(要执行的代码)的指针和指向其第一个参数的单个指针。
虽然一个参数对于像 LoadLibrary
这样的简单 API 已经足够,但调用更有用的函数就变得困难了。例如,VirtualAlloc
和 WriteProcessMemory
都需要四个参数。
另一个问题是,任何额外的参数(除了第一个)不保证为 NULL——它们通常只是未定义的垃圾数据。例如,如果你调用 MessageBox
,你 会 在远程进程中看到一个消息框,但标题(第二个参数)和文本(第三个参数)很可能是垃圾。
对于我们的目标,我们需要完全控制所有四个参数。幸运的是,有一个直接的方法可以实现这一点,它也可以作为执行触发器:劫持线程上下文。
通过使用 SetThreadContext
等 API,我们可以配置远程进程中的目标线程以运行 VirtualAlloc
和 WriteProcessMemory
,并控制最多四个参数。
绕过这个限制后,我们现在可以自由调用运行时函数,如 malloc
、memset
、memcpy
,以及系统原生函数,如 RtlFillMemory
、HeapAlloc
等。
在我们继续之前,让我们快速回顾一下 x64 调用约定 以及它与我们广泛使用的 CONTEXT
结构的关系。
Windows x64 调用约定 101
x64 调用约定 通过寄存器(RCX
、RDX
、R8
、R9
)传递函数的前四个参数,其余参数通过栈传递。
摘自 MSDN
实际上:如果我们能控制线程的 CONTEXT
,我们就可以将 RIP
(指令指针)设置为我们想要执行的任何函数,并设置寄存器以向该函数传递适当的参数。
对于我们的第二个注入概念验证,我们旨在改编经典的线程劫持技术:SuspendThread
→ SetThreadContext
→ ResumeThread
—— 但将其应用于新创建的线程。
我们的预期流程如下:CreateRemoteThread
(挂起)→ SetThreadContext
→ ResumeThread
这种方法被证明是具有挑战性的。然而,我们成功地解决了这些问题,同时坚持我们的规则:绝不使用标准的远程内存分配或修改。
故障排除与研究
本节介绍了我们遇到的问题以及我们如何克服这些问题,内容有点技术性,可能有些复杂,但我们想分享我们的方法论。
问题 – 初始空栈
设置
首先,我们尝试创建一个挂起状态的新线程。然后我们覆盖它的 CONTEXT
结构,以便当我们恢复线程时,它会直接跳转到 VirtualAlloc
。
RIP = &pVirtualAlloc
RCX = param1
RDX = param2
R8 = param3
R9 = param4
结果
我们在从 VirtualAlloc
返回时遇到了内存访问违规崩溃。
根本原因
-
线程启动时具有空栈 -
VirtualAlloc
正常执行并返回其调用者,但调用者的返回地址为零。这是因为返回地址通常由线程启动存根(thread-startup stub)推入栈中,而这种情况并未发生 -
解引用空返回地址触发了内存访问违规
为什么 CreateRemoteThread
在单参数情况下不会崩溃
-
当让 Windows 正常启动线程时,执行从原生启动存根开始( RtlUserThreadStart
→BaseThreadInitThunk
) -
该存根设置正确的栈帧,调用目标例程,然后以 ExitThread
结束 -
然而,当强制将 RIP 设置为 VirtualAlloc
等函数时,线程从函数内部开始,似乎跳过了原生启动存根。这意味着线程完成后无法进入ExitThread
尝试 2 - 从其他线程窃取有效栈
思路
重用使用 SleepEx
(NtDelayExecution
)永久休眠的"牺牲"线程的栈。希望牺牲睡眠线程的调用栈是干净且安全的返回路径。 如果线程在调用过程中被暂停,例如在栈设置期间(在序言之后但在尾声之前),我们可能需要手动重新对齐栈,例如通过调整 RSP += 16
来补偿。
计划
-
生成牺牲线程 -
CreateRemoteThread( hProc, NULL, 0, Sleep, (LPVOID)INFINITE, 0, NULL);
-
等待直到线程进入 NtDelayExecution
-
获取其栈指针 -
CONTEXT ctx;
-
GetThreadContext(hSleep, &ctx);
-
uintptr_t sleepRsp = ctx.Rsp;
-
创建第二个线程(挂起状态)并覆盖其 CONTEXT
:
RIP = &VirtualAlloc
RSP = sleepRsp // borrow stack from the sleeping thread
RCX, RDX, R8, R9 = args // first four params
-
恢复第二个线程的执行
结果
-
在返回到原生启动存根时发生崩溃 -
原因:虽然借用的栈是有效的,但新线程的 TEB(Thread Environment Block)为空 -
BaseThreadInitThunk
期望已初始化的字段(SEH 列表、TLS 等) -
解引用 TEB → NtTib.ExceptionList
(仍为 NULL)触发了内存访问违规
经验教训
仅有一个良好的栈是不够的。每个线程都会获得一个全新的线程环境块(TEB),内核会将 GS
寄存器指向它。返回到假设这些 TEB 字段已初始化的代码将会失败。
我们的下一个想法是直接劫持休眠线程本身,而不是重新利用它的栈。
尝试 3 - 劫持牺牲的休眠线程
目标在线程进入 SleepEx
后,劫持一个具有完全初始化 TEB 和栈的线程的执行。
预期的调用栈
RtlUserThreadStart
↳ BaseThreadInitThunk
↳ Sleep → SleepEx → NtDelayExecution
↳ (context switch) → VirtualAlloc
步骤
-
创建休眠线程,设置休眠时间为 1 秒: -
HANDLE hT = CreateRemoteThread( hProc, NULL, 0, Sleep, (LPVOID)1000, 0, NULL);
-
等待直到 RIP
指针进入NtDelayExecution
函数 -
劫持上下文,在线程仍处于休眠状态时: -
RIP = &VirtualAlloc
-
RCX,RDX,R8,R9 = 所需参数
-
RSP保持不变
-
让计时器到期;线程应直接恢复执行 VirtualAlloc
结果
-
RIP
如预期改变——执行流成功到达VirtualAlloc
-
所有其他寄存器均为无效值; VirtualAlloc
执行失败,进程在返回时崩溃
经验总结:在定时休眠期间,只有 RIP
能够被可靠地写入。内核的等待 - 恢复路径似乎会覆盖或忽略提供的其他上下文信息
下一步方向
更理想的目标是寻找一个正在运行但处于空闲状态的线程——即未在内核等待中被阻塞,且已完成初始化的线程。这一洞察引导我们进入尝试 4,即让线程在一个极简的无限循环 gadget 中启动,从而更容易被劫持
尝试 4 - 休眠替代方案:循环 Gadget 与 CFG(控制流防护)
核心思想让线程在一个 busy-wait 循环(JMP -2
)中启动,该循环不会影响任何寄存器,然后劫持其上下文
为何选择EB FE
gadget?
-
这是一个两字节的 " jmp -2
" 指令——构成无限循环 -
易于在任何可执行模块中定位;我们只需扫描自己的 ntdll
即可找到它,并在目标进程中重用相同地址 -
除了 RIP 外,不会影响任何寄存器,因此不会造成附带损害
预期的调用栈
RtlUserThreadStart
↳ BaseThreadInitThunk
↳ [loop gadget: EB FE]
↳ (context hijack) → VirtualAlloc
操作步骤
-
创建一个远程线程,其起始地址设置为循环 gadget -
等待直到线程进入循环状态 -
设置线程上下文 -
RIP = &VirtualAlloc
-
RCX,RDX,R8,R9 = 参数
-
RSP 保持不变
-
线程执行我们的函数(无需挂起/恢复操作,线程已在运行状态)
结果
-
立即触发控制流防护 (Control-Flow Guard, CFG) 违规 → 进程崩溃 -
根本原因: BaseThreadInitThunk
通过受 CFG 检测的间接跳转分发到起始地址 我们的循环 gadget 不在模块的有效调用目标位图中,因此 CFG 在线程开始循环之前就终止了它
尝试 5 - 双重劫持:循环 Gadget 枢纽
核心思想
与之前类似,从正常的线程启动进入 Sleep
(以满足 CFG 要求)开始。但这次我们进行双重劫持:
-
首先劫持到循环 gadget,以获得稳定的执行控制 -
然后劫持到目标函数(例如 VirtualAlloc
),并完全控制参数
之前,我们尝试直接用 gadget 替换 sleep 函数——但这跳过了线程的自然启动逻辑。我们仍然希望原生线程初始化首先发生,然后安静地进入休眠状态,直到劫持时机。
突破点在于结合这两个想法:
-
让线程正常启动并进入 Sleep
-
当它到达 NtDelayExecution
时,将其劫持到循环 gadget -
从循环中再次劫持,这次完全设置上下文( RIP
+RCX
,RDX
,R8
,R9
)
调用流程
RtlUserThreadStart
↳ BaseThreadInitThunk
↳ ...
↳ NtDelayExecution (from Sleep)
↳ Context Hijack → Loop Gadget
↳ Context Hijack → VirtualAlloc (with params)
执行流程
-
使用 CreateRemoteThread
创建远程线程,起始地址设置为Sleep
-
短暂等待线程初始化并进入睡眠状态 -
第一次劫持: 将 RIP
设置为循环 gadget。保持其他寄存器不变。无需挂起/恢复操作 -
第二次劫持: 将 RIP
设置为VirtualAlloc
,并在RCX
、RDX
、R8
和R9
中设置正确的参数值。同样无需挂起/恢复操作
结果
成功。 我们现在有了一个干净的概念验证(PoC),可以在远程线程中调用任何本地函数并传递最多 4 个参数,而无需使用任何标准的远程分配或修改技术。
这种方法甚至能与 APC 投递 很好地配合,因为线程正常返回,而不会被牺牲。唯一的缺点?每次调用需要两次等待,让线程先进入睡眠状态,然后再进入循环 gadget。
也许如果我们能找到初始化 + 睡眠阶段的替代方案,可以稍微优化一下。
尝试 6 - 使用 ROP 修复栈
核心思想
最后,我们想到了一个更简单的解决方案:使用目标进程中的 ROP gadget 来设置包含两个返回地址的栈——一个用于线程退出(RtlExitThread
),另一个用于我们的目标函数。这个单一的 gadget 就足以满足我们的栈构建需求。
回到尝试 1,我们遇到了一个问题:目标函数执行完毕后,线程试图返回到空地址——这是从空栈开始的结果。我们想要更优雅的解决方案。
等待线程初始化、睡眠并进行两次上下文劫持(如尝试 5)是可行的——但感觉有点过于繁琐。
ROP Gadget
通过使用目标进程中已有的 ROP gadget,我们可以仅用一步就构建一个有效的栈。
ROP gadget 如下所示:
push reg1
push reg2
ret
其中上述两个寄存器彼此不同,可以是以下任意一个:RAX/RBX/RBP/RDI/RSI/R10-15
。
通过这个简单的 gadget,我们可以为任何 API 提供以下 Context 结构:
-
RIP
→ Gadget 地址 -
RCX, RDX, R8, R9
→ 4 个参数 -
Gadget 寄存器 1 → RtlExitThread
-
Gadget 寄存器 2 → 函数指针(例如, VirtualAlloc
)
在 Notepad.exe 中发现 ROP gadgets
通过这种方式,我们成功跳过了初始化 + 睡眠阶段,并创建了一个干净的栈,当目标函数完成工作后,通过RtlExitThread
优雅退出。
调用流程
ROP Gadget
↳ VirtualAlloc
↳ RtlExitThread
-
我们扫描远程进程的内存以寻找所需的 ROP gadget。 -
我们通过 CreateRemoteThread 创建一个新线程,并将其置于挂起状态。线程的起始地址无关紧要。 -
我们准备一个新的上下文: -
VirtualAlloc
(函数调用目标) -
RtlExitThread
(调用后返回目标) -
将 RIP
设置为 ROP gadget -
将 gadget 的输入寄存器设置为: -
用四个 VirtualAlloc
参数填充RCX
、RDX
、R8
和R9
-
我们恢复线程并让其运行
结果
线程成功执行 VirtualAlloc
,然后通过返回到 RtlExitThread
干净退出——没有崩溃,无需清理。
概念验证
概念验证执行以下操作:
-
在目标进程内存中搜索 ROP gadget( push reg1; push reg2; ret
) -
使用此 gadget 调用 VirtualAlloc
、RtlFillMemory
并执行 shellcode -
RIP
指向 ROP gadget -
RCX
–R9
作为函数参数 -
将 ExitThread
和目标函数的栈值分别赋给reg1
和reg2
-
调用 ResumeThread
运行线程 -
每个线程先压入 ExitThread
的地址,然后压入目标函数的地址,最后执行ret
跳转到目标函数 -
使用 CreateRemoteThread
创建一个挂起线程 -
使用 SetThreadContext
设置线程的CONTEXT
,分配:
NtCreateThread 上下文注入
虽然CreateRemoteThread
被广泛用于线程注入,但它只接受:
-
线程的起始地址 -
第一个参数的指针
这让我们不禁思考为什么它如此受限,以及如果我们研究底层 API(NtCreateThread
)会有什么发现,该 API 提供了对线程创建的更多控制。
研究后记:这里需要说明的是——我们原以为
CreateRemoteThread
调用NtCreateThread
,而CreateRemoteThreadEx
调用NtCreateThreadEx
。事实并非如此。在现代 Windows 中,这两个 API 都调用
NtCreateThreadEx
,而它 不接受上下文结构作为参数。使用NtCreateThreadEx
时,内核会在远程进程中执行栈分配。使用
NtCreateThread
,我们可以传递一个CONTEXT
结构,从而控制线程的寄存器、栈和返回地址。
这激发了一个想法:如果我们通过向 NtCreateThread
提供 CONTEXT
结构来避免使用 SetContext API 会怎样?
来自 ntinternals 的 NtCreateThread 原型
故障排除与研究
起初,这看起来似乎很简单,但最终却花费了几个晚上的调试时间。本节内容也涉及较多调试细节,但我们还是想分享方法论和结论。您可以跳过这部分。
NTSTATUS 0xC0000022 – 访问被拒绝
我们的第一个 PoC 很简单(或者说我们以为很简单):
-
获取目标进程的 PROCESS_ALL_ACCESS
句柄 -
从我们的进程中获取 Sleep()
函数地址 -
使用 VirtualAllocEx
在远程进程中分配一个干净的栈(我们可能可以通过一些创意结合之前的想法来避免这一步) -
在我们的进程中准备 CONTEXT
结构和TEB
结构 -
调用 NtCreateThread
然而,即使我们:
-
使用了具有管理员令牌并启用了 SeDebugPrivilege
的高完整性注入进程 -
注入到中等完整性进程—— notepad.exe
、calc.exe
、msedge.exe
和我们创建的虚拟进程
我们还是从系统调用中收到了"访问被拒绝"的 NTSTATUS
。
在网上查找时,我们没有找到太多关于此问题的信息,但 ChatGPT 很乐意告诉我们,NtCreateThread
系统调用是一个遗留系统调用,在 Windows Vista 之后跨进程使用时将无法工作:
启用了搜索功能的 ChatGPT o3(openrce 参考)(securityxploded 参考)
这显然像是幻觉,特别是当 GPT 指向的源并没有明确包含这种措辞时,所以我们继续进行了一些内核调试。
在内核中跟踪 NtCreateThread
NtCreateThread
系统调用的一般流程是:
nt!NtCreateThread 执行的检查
在内核到达 nt!PspCreateThread
之前,nt!NtCreateThread
包装器会执行一些基本的准备工作,包括:
-
验证所有传入的指针(句柄、 CLIENT_ID
、CONTEXT
、INITIAL_TEB
)是否对齐且可读/可写 -
清理 CONTEXT
记录,去除特权标志和非法寄存器值 -
检查 INITIAL_TEB
中的栈信息,确保分配了真实的栈且没有设置"前一个栈"字段 -
将输出句柄清零并保持栈 16 字节对齐,以便任何早期错误都能干净地回滚
这些主要是安全检查,与我们的故障排除无关,但我们认为有必要提及。
nt!PspCreateThread 执行的检查
PspCreateThread
是实际创建线程对象(ETHREAD
)并将其链接到目标进程的地方。它执行自己的一系列检查,其中一些更侧重于安全性。
PspCreateThead
缺乏文档,所以我们尽最大努力进行了逆向工程。其原型如下(当从 NtCreateThread
调用时):
NTSTATUS __fastcall PspCreateThread(
PHANDLE RemoteThreadHandle,
ACCESS_MASK DesiredAccess,
POBJECT_ATTRIBUTES ObjectAttributes,
HANDLE RemoteProcessHandle,
_EPROCESS *TargetProcObject,
__int64 __zero,
PCLIENT_ID ClientID,
PCONTEXT InitialContext,
PINITIAL_TEB InitialTeb,
ULONG CreationFlagsRaw,
ULONG_PTR IsSecureProcess,
ULONG_PTR Spare,
PVOID InternalFlags)
首先,我们进行了一些参数初始化和缓存:
callerThread = KeGetCurrentThread();
RemoteProcessHandle2 = RemoteProcessHandle;
RemoteThreadHandle2 = RemoteThreadHandle;
__zero_3 = __zero;
ClientID2 = ClientID;
InitialTeb2 = InitialTeb;
DesiredAccess2 = DesiredAccess;
callerProcess = (_EPROCESS *)callerThread->ApcState.Process;
InternalFlags2 = InternalFlags;
ObjectAttributes2 = ObjectAttributes;
CallerProcess2 = callerProcess;
跳过一些步骤,首先,内核会解析目标进程:
if ( RemoteProcessHandle )
{
LOBYTE(RemoteProcessHandle) = PreviousMode;
result = ObpReferenceObjectByHandleWithTag(
RemoteProcessHandle2,
2LL,
PsProcessType,
RemoteProcessHandle,
0x72437350,
&TargetProcessObject,
0LL,
0LL);
TargetProcessObject2 = (_EPROCESS *)TargetProcessObject;
goto LABEL_5;
}
随后,内核会验证目标进程解析是否成功,并跳转执行 PspIsProcessReadyForRemoteThread
检查:
CallerProcess3 = CallerProcess2;
if ( TargetProcessObject2 != CallerProcess2 )
{
if ( !PspIsProcessReadyForRemoteThread(TargetProcessObject2) )
return 0xC0000001; // STATUS_UNSUCCESSFUL
CallerProcess3 = CallerProcess2;
}
现在情况开始变得有些奇怪。 这里有一个检查,用于验证目标进程是否受到虚拟化保护并运行在 VTL-1(安全内核)中,但由于 NtCreateThread
总是以 IsSecureProcess = 0
调用 PspCreateThread
,因此该检查被跳过,并作为我们下面可以看到的其他条件的一部分被评估为 True
。
我们到达了导致 Access Denied 错误的关键检查:
if ( !__zero_3 // Always true from NtCreateThread
&& !IsSecureProcess // Always true
&& ((TargetProcessObject2->MitigationFlags & 1) != 0
|| (CallerProcess3->MitigationFlags & 1) != 0
|| (TargetProcessObject2->MitigationFlags2 & 0x4000) != 0
|| (CallerProcess3->MitigationFlags2 & 0x4000) != 0) )
{
return 0xC0000022;
}
前两个条件在从 NtCreateThread
调用时评估为 true。接下来的两个检查 (MitigationFlags & 1
) 用于确定 Control Flow Guard (CFG) 是否在我们的进程或目标进程中启用。最后两个检查 (MitigationFlags2 & 0x4000
) 验证 Import Address Filter (IAF) mitigation 是否在任一进程中启用。
稍后还有一些其他有趣的检查可能会返回 0x22:
if ( (TargetProcessObject2->Flags3 & 1) != 0 // Minimal Process bit
&& !TargetProcessObject2->PicoContext // Not a Pico Process
&& InitialContext )
{
return 0xC0000022;
}
第一个条件检查目标进程是否为 Minimal Process(最小化进程),并且不是 Pico Process(Pico 进程),同时提供了InitialContext
(来自NtCreateThread
的初始上下文)。
总结来说,如果目标进程满足以下任一条件,NtCreateThread
注入将会失败:
-
Control Flow Guard (CFG,控制流防护)已启用 -
Import Address Filtering (EAF,导入地址过滤)已启用 -
进程是 Minimal Process 且不是 Pico Process
因此,NtCreateThread
系统调用很可能适用于大多数第三方程序,这些程序通常没有编译启用 CFG 或 IAF,也通常不是 Minimal Process。相比之下,大多数 Microsoft 二进制文件通常都编译启用了 Control Flow Guard (CFG)。
概念验证
该概念验证执行以下操作:
-
在目标进程内存中搜索 ROP gadget( push reg1; push reg2; ret
) -
使用该 gadget 调用 VirtualAlloc
、RtlFillMemory
并执行 shellcode -
RIP
指向 ROP gadget -
RCX
–R9
作为函数参数 -
将 ExitThread
和目标函数的栈值分配给reg1
和reg2
-
每个线程先推送 ExitThread
的地址,然后是目标函数的地址,并通过ret
跳转到目标函数 -
使用 VirtualAllocEx
为目标进程中的新线程分配空栈(我们可能可以省略此步骤) -
初始化 CONTEXT
结构体,分配: -
使用 NtCreateThread
创建线程并立即执行给定的CONTEXT
由于新线程的上下文已经预先提供,因此它不需要进行上下文劫持,因为它已经属于目标进程。
RedirectThread 工具
请访问 RedirectThread Github 仓库
为了以实用且可重复的方式演示本文中的技术,我们构建了一个命令行工具来实现所讨论的注入方法。RedirectThread
支持仅使用上下文的进程注入,包括:
-
仅指针的 DLL 注入 -
各种 APC 注入 -
CreateRemoteThread
+SetThreadContext
注入 -
NtCreateThread
用法:
Usage: C:RedirectThread.exe [options]
Required Options:
--pid <pid> Target process ID to inject into
--inject-dll Perform DLL injection (hardcoded to "0.dll")
--inject-shellcode <file> Perform shellcode injection from file
--inject-shellcode-bytes <hex> Perform shellcode injection from hex string (e.g. 9090c3)
Delivery Method Options:
--method <method> Specify code execution method
CreateRemoteThread Default, creates a remote thread
NtCreateThread Uses NtCreateThread (less traceable)
QueueUserAPC Uses QueueUserAPC (requires --tid)
QueueUserAPC2 Uses QueueUserAPC2 (requires --tid)
NtQueueApcThread Uses NtQueueApcThread (requires --tid)
NtQueueApcThreadEx Uses NtQueueApcThreadEx (requires --tid)
NtQueueApcThreadEx2 Uses NtQueueApcThreadEx2 (requires --tid)
Context Method Options:
--context-method <method> Specify context manipulation method
rop-gadget Default, uses ROP gadget technique
two-step Uses a two-step thread hijacking approach
Additional Options:
--tid <tid> Target thread ID (required for APC methods)
--alloc-size <size> Memory allocation size in bytes (default: 4096)
--alloc-perm <hex> Memory protection flags in hex (default: 0x40)
--alloc-address <hex> Specify base address for allocation (hex, optional)
--use-suspend Use thread suspension for increased reliability
--verbose Enable verbose output
--enter-debug Pause execution at key points for debugger attachment
Example:
C:RedirectThread.exe --pid 1234 --inject-dll mydll.dll
C:RedirectThread.exe --pid 1234 --inject-shellcode payload.bin --verbose
C:RedirectThread.exe --pid 1234 --inject-shellcode payload.bin --method NtCreateThread
C:RedirectThread.exe --pid 1234 --inject-shellcode-bytes 9090c3 --method QueueUserAPC --tid 5678
C:RedirectThread.exe --pid 1234 --inject-shellcode-bytes $bytes --context-method two-step --method NtQueueUserApcThreadEx2 --tid 5678
注入检测逻辑理论
本节讨论 EDR 常用的检测逻辑模型,以及仅执行技术如何挑战其核心假设。
1. "线程上下文劫持"本身并非恶意
-
单独的执行触发器。例如:挂起线程、调整其 CONTEXT
并恢复,不会触及内存,因此看起来是良性的。 -
现代 EDR 通常在同一受害进程中两个或更多以下活动相关联时标记为进程注入:
|
|
|
---|---|---|
远程分配 | VirtualAllocEx
MapViewOfFile3 等 |
|
远程修改 | WriteProcessMemory
VirtualProtectEx |
|
远程执行触发器 | CreateRemoteThread
|
|
EDR 通常将这些建模为有序链 (1→2→3) 或任何 {2 of 3} 组合。
这种检查通常作为与新线程或 APC 的起始地址的关联检查,或在涉及该活动的任何 API 调用事件时进行。
2. 为什么仅执行攻击难以检测
防御端点需要:
-
发现触发器#1(远程线程创建) - 容易。 -
发现触发器#2(上下文劫持) - 也容易。 -
将该线程内所有后续的本地内存访问提升为"远程"。 -
需要追踪所有系统调用和用户模式助手 ( memset
等)。 -
需要数据流 ("污点") 分析来证明写入源自外部上下文。 -
在大规模下不切实际;实时下几乎不可能。
结果:跳过活动 1&2,仅依赖创造性触发器的攻击者可能绕过检测。
3. 为什么攻击者更常交换触发器而非分配器
-
"远程分配/写入"API 池很小且被严密监控。 -
执行面巨大:线程、APC、定时器、UI 回调、UMS、DCOM 编组等。这是变种的沃土。 -
随着 EDR 在内存操作方面加强,仅选择奇特的触发器不再保证隐蔽性,但它仍然是更简单和更便宜的选择。
4. "2 of 3"理念的弱点
该模型默认假设"远程≠本地"。一旦攻击者迫使受害者执行自己的写入,该假设就会失败:
-
本地写入发生 ( memset
在受害者内部)。 -
写入实际上由外部上下文劫持驱动 = 逻辑上远程。 -
当前遥测无法证明这种因果关系,导致没有警报。
除非防御者能够将触发器遥测与深度线程内污点跟踪结合,否则注入将隐藏在众目睽睽之下。
5. 防御者的启示
根据我们在博客中讨论的内容:
-
监控短时间内快速创建线程的事件可能有用。 -
监控线程创建后大量使用 SetThreadContext
(及类似 API)。
但上述检测是 API 特定的,要深入了解,我们需要关联谁请求了触发器,而不仅仅是什么 API 被调用。
研究笔记
本节记录我们未探索的内容、未解答的问题和其他细节。
我们是否限于 4 个参数?
不。Windows x64 调用约定为我们提供了四个寄存器 (RCX
, RDX
, R8
, R9
) 用于参数,但如果需要,如果我们找到更好的 ROP 链,可以手动将额外参数放在堆栈上。
我们能避免创建数百个线程吗?
可能可以。我们探索了通过多次重新劫持其上下文来重用同一线程。 与其推送RtlExitThread
,不如推送一个循环 gadget(如此处故障排除部分讨论的)并回收线程。 这可以在需要随时间执行多个操作的情况下减少线程创建,特别是对于 shellcode 交付。
我们可能劫持现有的等待运行线程,在 DeferredReady/Ready 等正确状态下,稍后恢复其状态。这也可以通过推送先前地址而非退出来使用 gadget 完成。这将实现与早期 APC 两步方法类似的结果,但具有后期方法的效率。
我们能避免使用ReadProcessMemory
来查找 ROP gadget 吗?
可以!虽然我们的方法是直接在内存中搜索,但这肯定不是唯一可行的方法。有几种潜在的替代方案可以工作:
-
选项 1:加载目标进程使用的相同 DLL,并在本地扫描以查找特定模块中的 ROP gadget。 -
选项 2:从磁盘解析 PE 文件,定位 gadget,然后使用 基地址+偏移量
计算其内存地址。
每种方法在精度、隐蔽性和复杂性方面都有权衡。我们选择内存扫描主要是为了简单。
我们能使用其他 WinAPI/NT 函数来控制 Context 吗?
答案是肯定的!虽然并非所有 API 都适合跨进程注入,但有些可能提供更隐蔽的方式来实现相同的结果。
以下是我们发现的接受CONTEXT
结构的 API 的非详尽列表。然而,并非所有都经过测试,因为列表很快变得太大而无法完全探索。
-
WinAPI:
-
SetThreadContext
-
SetThreadInformation
-
Wow64SetThreadContext
-
RtlWow64SetThreadContext
-
RtlRestoreContext
-
RtlRegisterFeatureConfigurationChangeNotification
-
RtlRegisterWait
-
SetUmsThreadInformation
-
EtwRegister
-
UpdateProcThreadAttribute
-
UmsThreadUserContext
-
NT Functions:
-
ThreadWow64Context
-
ThreadCreateStateChange
-
ThreadApplyStateChange
-
NtSetContextThread
-
NtCreateThread
-
NtSetInformationThread
-
NtContinue
-
NtContinueEx
-
ALLOCATE_VIRTUAL_MEMORY_EX_CALLBACK
-
EtwEventRegister
优化建议?
当然!我们可以进行许多优化:
我们可以通过以下方式改进 gadget 发现逻辑:
-
容忍无干扰的中间指令(例如,像 push rax; push rbx; mov r10, r11; ret
这样的 gadget,其中mov r10, r11
没有副作用) -
寻找其他指令。例如 push rax; call rbx;
-
使用链式而非单一 gadget。使用一个 gadget 来准备 rsp,第二个来保存寄存器,第三个来分发 ROP
我们可以通过以下方式提高两步法的效率:
-
同时使用多个线程,并自动化线程选择。写入/复制操作可以并行处理每个字节 -
缩短休眠时间,寻找更好的方法来检测线程何时准备好进行下一步 -
使用 Nt*
版本进行写入操作。由于它们支持 3 个参数(完美匹配RtlFillMemory
),我们可以跳过大部分操作的两步劫持
未探索的想法
想法 | 结论 |
---|---|
在线程返回前捕获它,挂起并重用 |
|
寻找接受回调的原生函数(例如重定向到ExitThread ) |
|
推送已存在于其他线程堆栈上的退出函数 |
ExitThread ,然后重用堆栈指针。但这是一个狭窄且不稳定的时间窗口,可能很危险 |
寻找内存中已存在的退出函数地址 |
ExitThread 、RtlExitUserThread ),但没有找到可用的结果。这个子想法仍未解决 |
通过调试 API 捕获错误返回时的异常 |
|
注入自定义 SEH(结构化异常处理程序) |
|
关于 "GhostWriting" 及相关工作的说明
当我们启动这个项目时,我们找不到任何公开的技术文章能够完全跳过VirtualAlloc[Ex]
/WriteProcessMemory
同时还能交付 x64 代码。直到研究接近尾声时,我们才偶然发现了 GhostWriting 研究[1] [2] [3],这令人惊叹。
GhostWriting 提出了窃取现有线程并操作其CONTEXT
以运行 shellcode 而无需远程分配内存的想法。我们的研究从相同的直觉出发,但在三个方面有所不同:
-
仅指针的 LoadLibrary
注入。我们展示了通过向LoadLibraryA
提供指向 进程内 ASCII 字面量(例如"0"
)的指针加上名为0.dll
的磁盘文件,可以实现零远程写入的 DLL 加载。GhostWriting 仅关注原始 shellcode -
CreateRemoteThread ➜ SetThreadContext ROP 枢纽。我们没有劫持实时 GUI 线程,而是创建新的远程线程,使用 push reg; push reg; ret
gadget 修复其空堆栈,并链接最多四个参数的任意 WinAPI。据我们所知,这种工作流程没有出现在之前的论文中(如有错误欢迎指正) -
完整的 x64 NtCreateThread
PoC。我们的演示直接向NtCreateThread
提供了精心设计的CONTEXT
和INITIAL_TEB
,并进行了 CFG/IAF 门的内核调试探索。早期的 GhostWriting PoC 仅限于 x86 和用户模式
因此,虽然"仅执行"注入有其历史,但我们相信这里提出的变体、缓解措施和见解将这个想法推向了新的领域
结论
如果你读到这里,你已经赢得了调试器咖啡断点,希望它值得占用这些堆栈空间。
原文始发于微信公众号(securitainment):新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论