新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面

admin 2025年5月18日22:53:26评论3 views字数 18957阅读63分11秒阅读模式

New Process Injection Class The CONTEXT-Only Attack Surface

作者:Yehuda Smirnov, Hoshea Yarden, Hai Vaknin 和 Noam Pomerantz

概述

大多数进程注入技术都遵循一个熟悉的模式:分配 → 写入 → 执行

在本研究中,我们提出了一个问题:如果我们完全跳过分配和写入会怎样?

通过专注于仅执行原语,我们发现了无需分配/写入内存即可注入代码的独特方法:

  1. 仅使用 CreateRemoteThread 注入 DLL
  2. 使用 SetThreadContext 调用带参数的任意 WinAPI 函数
  3. 利用 NtCreateThread 远程分配、写入并执行 shellcode
  4. 将该技术扩展到 APC 函数,如 QueueUserAPC

请访问 RedirectThread GitHub 仓库 https://github.com/Friends-Security/RedirectThread

引言

现代终端检测与响应 (EDR) 系统通常会监控经典进程注入的三个迹象:

  1. 新内存的分配 (VirtualAlloc[Ex])
  2. 内存的修改 (WriteProcessMemoryVirtualProtect)
  3. 执行操作 (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 注入

下面的概念验证演示了将该想法转化为可工作的进程注入原语所需的最少步骤。

新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面
  1. 定位进程内字符串
    • 在 ntdll.dll 中搜索静态 ASCII 字符串,如 "0" (0x30 0x00)。由于 DLL 在目标进程和我们自己的进程中映射到相同的偏移量,虚拟地址在两者之间应该是有效的。
新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面

ntdll.dll 在进程间映射到相同的地址

  • 准备有效载荷 DLL
    • 使用选择的植入物构建 0.dll
    • 将 DLL 放入通过搜索顺序解析的目录中。

我们不一定局限于经典的搜索顺序劫持。也许可以滥用 DefineDosDeviceW 或 NT 符号链接从任意位置(如 SMB 共享或 WebDAV 挂载)加载有效载荷。

  1. 创建远程线程
  • 注意 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);
  1. 执行
    • LoadLibraryA 会附加“.dll”,解析路径并加载 0.dll 到目标进程中:
新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面

我们得到了一个意外但令人兴奋的结果:

  • 通过跳过内存分配和写入,一个众所周知的注入技术绕过了两个行业领先的 EDR 的检测
    • 我们还测试了常规的 DLL 注入技术(涉及写入内存),它被两个 EDR 都检测到了。
  • 这个小调整揭示了许多技术都依赖于相同的早期行为,以及这种行为可以多么容易被避免

这让我们重新思考注入链:

  • 大多数技术都遵循相同的模式:注入数据 → 触发执行
  • 在众多技术中,执行触发器是共同的部分。

因此我们提出了问题:

  • 我们真的需要注入数据吗?
  • 如果我们只专注于执行触发器会怎样?

我们没有替换链中的每个部分,而是加倍关注触发器,并试图探索仅凭这一点我们能走多远

使用 ETW 调查远程线程创建噪音

在深入之前,我们被一个简单的问题所启发,进行了一次快速探索:

这只是一个检测疏忽吗?为什么安全产品不将 任何 远程线程创建标记为恶意?难道它不够罕见,无法通过简单的白名单捕获吗?

事实证明——完全不是。远程线程创建出奇地常见,被以下用途使用:

  • 应用程序资源监视器
  • 性能分析器
  • 检测和跟踪工具
  • 调试器、兼容性垫片、辅助功能助手
  • 企业代理和终端框架

为了测量基线“噪音”,我们编写了一个基于 ETW 的小型跟踪器,用于捕获和关联创建者 PID ≠ 目标 PID 的线程创建事件。ETW 为我们提供了所需的所有数据。

在干净的 Windows 安装上,查看不到一分钟内捕获的数据,我们已经发现了一些跨不同进程的线程创建:新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面

FindRemoteThreads.ps1

此时,我们已经觉得值得深入研究执行触发器,同时避免分配和修改。

如果 EDR 不单独基于执行步骤标记恶意活动,即使在使用臭名昭著的 CreateRemoteThread 时,那么问题就变成了:这个原语有多强大,我们接下来能把它带到哪里?

CreateRemoteThread + SetThreadContext 注入

我们进一步利用 CreateRemoteThread 的第一个想法是避免将 DLL 写入磁盘,并尝试实现仅内存注入。我们的目标是实现与经典的远程 Allocate → Modify → Execute 链类似的能力。

显而易见的问题是: 为什么不直接在远程进程中调用这些函数?

如果我们在目标进程中创建的新线程被设计为在其自身进程内分配和修改内存,那么我们可以简单地将现有的远程内存分配/修改技术改编为 “自我”分配/自我修改 方法。

更好的是,我们可以使用目标进程中任何本地功能作为我们的原语——而不仅仅局限于支持远程操作的 WinAPI 或系统调用。

毕竟,进程读取或写入其自身内存没有任何可疑之处。

我们基本上将这样:新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面

改为这样:新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面

CreateRemoteThread 的限制

我们在使用 CreateRemoteThread 时遇到的第一个问题是它的参数限制。该 API 只接受指向启动例程(要执行的代码)的指针和指向其第一个参数的单个指针。

虽然一个参数对于像 LoadLibrary 这样的简单 API 已经足够,但调用更有用的函数就变得困难了。例如,VirtualAlloc 和 WriteProcessMemory 都需要四个参数

另一个问题是,任何额外的参数(除了第一个)不保证为 NULL——它们通常只是未定义的垃圾数据。例如,如果你调用 MessageBox,你  在远程进程中看到一个消息框,但标题(第二个参数)文本(第三个参数)很可能是垃圾。

对于我们的目标,我们需要完全控制所有四个参数。幸运的是,有一个直接的方法可以实现这一点,它也可以作为执行触发器:劫持线程上下文

通过使用 SetThreadContext 等 API,我们可以配置远程进程中的目标线程以运行 VirtualAlloc 和 WriteProcessMemory,并控制最多四个参数

绕过这个限制后,我们现在可以自由调用运行时函数,如 mallocmemsetmemcpy,以及系统原生函数,如 RtlFillMemoryHeapAlloc 等。

在我们继续之前,让我们快速回顾一下 x64 调用约定 以及它与我们广泛使用的 CONTEXT 结构的关系。

Windows x64 调用约定 101

x64 调用约定 通过寄存器(RCXRDXR8R9)传递函数的前四个参数,其余参数通过栈传递。新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面

摘自 MSDN

实际上:如果我们能控制线程的 CONTEXT,我们就可以将 RIP(指令指针)设置为我们想要执行的任何函数,并设置寄存器以向该函数传递适当的参数。

对于我们的第二个注入概念验证,我们旨在改编经典的线程劫持技术:SuspendThread → SetThreadContext → ResumeThread —— 但将其应用于新创建的线程

我们的预期流程如下:CreateRemoteThread(挂起)→ SetThreadContext → ResumeThread

这种方法被证明是具有挑战性的。然而,我们成功地解决了这些问题,同时坚持我们的规则:绝不使用标准的远程内存分配或修改

故障排除与研究

本节介绍了我们遇到的问题以及我们如何克服这些问题,内容有点技术性,可能有些复杂,但我们想分享我们的方法论。

问题 – 初始空栈

设置

首先,我们尝试创建一个挂起状态的新线程。然后我们覆盖它的 CONTEXT 结构,以便当我们恢复线程时,它会直接跳转到 VirtualAlloc

RIP  = &pVirtualAllocRCX = param1RDX = param2R8  = param3R9  = param4

结果

我们在从 VirtualAlloc 返回时遇到了内存访问违规崩溃

根本原因

  • 线程启动时具有空栈
  • VirtualAlloc 正常执行并返回其调用者,但调用者的返回地址为零。这是因为返回地址通常由线程启动存根(thread-startup stub)推入栈中,而这种情况并未发生
  • 解引用空返回地址触发了内存访问违规

为什么 CreateRemoteThread 在单参数情况下不会崩溃

  • 当让 Windows 正常启动线程时,执行从原生启动存根开始(RtlUserThreadStart → BaseThreadInitThunk
  • 该存根设置正确的栈帧,调用目标例程,然后以 ExitThread 结束
  • 然而,当强制将 RIP 设置为 VirtualAlloc 等函数时,线程从函数内部开始,似乎跳过了原生启动存根。这意味着线程完成后无法进入 ExitThread

尝试 2 - 从其他线程窃取有效栈

思路

重用使用 SleepExNtDelayExecution)永久休眠的"牺牲"线程的栈。希望牺牲睡眠线程的调用栈是干净且安全的返回路径。 如果线程在调用过程中被暂停,例如在栈设置期间(在序言之后但在尾声之前),我们可能需要手动重新对齐栈,例如通过调整 RSP += 16 来补偿。

计划

  1. 生成牺牲线程
    • CreateRemoteThread( hProc, NULL, 0, Sleep, (LPVOID)INFINITE, 0, NULL);
  2. 等待直到线程进入 NtDelayExecution
  3. 获取其栈指针
    • CONTEXT ctx;
    • GetThreadContext(hSleep, &ctx);
    • uintptr_t sleepRsp = ctx.Rsp;
  4. 创建第二个线程(挂起状态)并覆盖其 CONTEXT:
RIP = &VirtualAllocRSP = sleepRsp          // borrow stack from the sleeping threadRCX, RDX, R8, R9 = args // first four params
  1. 恢复第二个线程的执行

结果

  • 在返回到原生启动存根时发生崩溃
  • 原因:虽然借用的栈是有效的,但新线程的 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. 创建休眠线程,设置休眠时间为 1 秒:
    • HANDLE hT = CreateRemoteThread( hProc, NULL, 0, Sleep, (LPVOID)1000, 0, NULL);
  2. 等待直到RIP指针进入NtDelayExecution函数
  3. 劫持上下文,在线程仍处于休眠状态时:
    • RIP = &VirtualAlloc
    • RCX,RDX,R8,R9 = 所需参数
    • RSP保持不变
  4. 让计时器到期;线程应直接恢复执行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

操作步骤

  1. 创建一个远程线程,其起始地址设置为循环 gadget
  2. 等待直到线程进入循环状态
  3. 设置线程上下文
    • RIP = &VirtualAlloc
    • RCX,RDX,R8,R9 = 参数
    • RSP 保持不变
  4. 线程执行我们的函数(无需挂起/恢复操作,线程已在运行状态)

结果

  • 立即触发控制流防护 (Control-Flow Guard, CFG) 违规 → 进程崩溃
  • 根本原因:BaseThreadInitThunk 通过受 CFG 检测的间接跳转分发到起始地址 我们的循环 gadget 不在模块的有效调用目标位图中,因此 CFG 在线程开始循环之前就终止了它

尝试 5 - 双重劫持:循环 Gadget 枢纽

核心思想

与之前类似,从正常的线程启动进入 Sleep以满足 CFG 要求)开始。但这次我们进行双重劫持

  1. 首先劫持到循环 gadget,以获得稳定的执行控制
  2. 然后劫持到目标函数(例如 VirtualAlloc),并完全控制参数

之前,我们尝试直接用 gadget 替换 sleep 函数——但这跳过了线程的自然启动逻辑。我们仍然希望原生线程初始化首先发生,然后安静地进入休眠状态,直到劫持时机。

突破点在于结合这两个想法:

  • 让线程正常启动并进入 Sleep
  • 当它到达 NtDelayExecution 时,将其劫持到循环 gadget
  • 从循环中再次劫持,这次完全设置上下文(RIP + RCXRDXR8R9

调用流程

RtlUserThreadStart    ↳ BaseThreadInitThunk        ↳ ...            ↳ NtDelayExecution (from Sleep)                ↳ Context Hijack → Loop Gadget                    ↳ Context Hijack → VirtualAlloc (with params)

执行流程

  1. 使用 CreateRemoteThread 创建远程线程,起始地址设置为 Sleep
  2. 短暂等待线程初始化并进入睡眠状态
  3. 第一次劫持: 将 RIP 设置为循环 gadget。保持其他寄存器不变。无需挂起/恢复操作
  4. 第二次劫持: 将 RIP 设置为 VirtualAlloc,并在 RCXRDXR8 和 R9 中设置正确的参数值。同样无需挂起/恢复操作

结果

成功。 我们现在有了一个干净的概念验证(PoC),可以在远程线程中调用任何本地函数并传递最多 4 个参数,而无需使用任何标准的远程分配或修改技术。

这种方法甚至能与 APC 投递 很好地配合,因为线程正常返回,而不会被牺牲。唯一的缺点?每次调用需要两次等待,让线程先进入睡眠状态,然后再进入循环 gadget。

也许如果我们能找到初始化 + 睡眠阶段的替代方案,可以稍微优化一下。

尝试 6 - 使用 ROP 修复栈

核心思想

最后,我们想到了一个更简单的解决方案:使用目标进程中的 ROP gadget 来设置包含两个返回地址的栈——一个用于线程退出(RtlExitThread),另一个用于我们的目标函数。这个单一的 gadget 就足以满足我们的栈构建需求。

回到尝试 1,我们遇到了一个问题:目标函数执行完毕后,线程试图返回到空地址——这是从空栈开始的结果。我们想要更优雅的解决方案。

等待线程初始化、睡眠并进行两次上下文劫持(如尝试 5)是可行的——但感觉有点过于繁琐。

ROP Gadget

通过使用目标进程中已有的 ROP gadget,我们可以仅用一步就构建一个有效的栈

ROP gadget 如下所示:

push reg1push reg2ret

其中上述两个寄存器彼此不同,可以是以下任意一个:RAX/RBX/RBP/RDI/RSI/R10-15

通过这个简单的 gadget,我们可以为任何 API 提供以下 Context 结构:

  • RIP → Gadget 地址
  • RCX, RDX, R8, R9 → 4 个参数
  • Gadget 寄存器 1 → RtlExitThread
  • Gadget 寄存器 2 → 函数指针(例如,VirtualAlloc
新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面

在 Notepad.exe 中发现 ROP gadgets

通过这种方式,我们成功跳过了初始化 + 睡眠阶段,并创建了一个干净的栈,当目标函数完成工作后,通过RtlExitThread优雅退出。

调用流程

ROP Gadget  ↳ VirtualAlloc      ↳ RtlExitThread
  1. 我们扫描远程进程的内存以寻找所需的 ROP gadget。
  2. 我们通过 CreateRemoteThread 创建一个新线程,并将其置于挂起状态。线程的起始地址无关紧要。
  3. 我们准备一个新的上下文
    • VirtualAlloc(函数调用目标)
    • RtlExitThread(调用后返回目标)
    • 将 RIP 设置为 ROP gadget
    • 将 gadget 的输入寄存器设置为:
    • 四个 VirtualAlloc 参数填充 RCXRDXR8R9
  4. 我们恢复线程并让其运行

结果

线程成功执行 VirtualAlloc,然后通过返回到 RtlExitThread干净退出——没有崩溃,无需清理。

概念验证

概念验证执行以下操作:

  1. 在目标进程内存中搜索 ROP gadgetpush reg1; push reg2; ret
  2. 使用此 gadget 调用VirtualAllocRtlFillMemory并执行 shellcode
    • RIP指向 ROP gadget
    • RCXR9 作为函数参数
    • 将 ExitThread 和目标函数的栈值分别赋给 reg1 和 reg2
    • 调用 ResumeThread 运行线程
    • 每个线程先压入 ExitThread 的地址,然后压入目标函数的地址,最后执行 ret 跳转到目标函数
    • 使用 CreateRemoteThread 创建一个挂起线程
    • 使用 SetThreadContext 设置线程的 CONTEXT,分配:

NtCreateThread 上下文注入

虽然CreateRemoteThread被广泛用于线程注入,但它只接受:

  1. 线程的起始地址
  2. 第一个参数的指针

这让我们不禁思考为什么它如此受限,以及如果我们研究底层 API(NtCreateThread)会有什么发现,该 API 提供了对线程创建的更多控制。

研究后记:这里需要说明的是——我们原以为CreateRemoteThread调用NtCreateThread,而CreateRemoteThreadEx调用NtCreateThreadEx

事实并非如此。在现代 Windows 中,这两个 API 都调用 NtCreateThreadEx,而它 不接受上下文结构作为参数。使用 NtCreateThreadEx 时,内核会在远程进程中执行栈分配。

使用 NtCreateThread,我们可以传递一个 CONTEXT 结构,从而控制线程的寄存器、栈和返回地址

这激发了一个想法:如果我们通过向 NtCreateThread 提供 CONTEXT 结构来避免使用 SetContext API 会怎样?

新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面

来自 ntinternals 的 NtCreateThread 原型

故障排除与研究

起初,这看起来似乎很简单,但最终却花费了几个晚上的调试时间。本节内容也涉及较多调试细节,但我们还是想分享方法论和结论。您可以跳过这部分。

NTSTATUS 0xC0000022 – 访问被拒绝

我们的第一个 PoC 很简单(或者说我们以为很简单):

  1. 获取目标进程的 PROCESS_ALL_ACCESS 句柄
  2. 从我们的进程中获取 Sleep() 函数地址
  3. 使用 VirtualAllocEx 在远程进程中分配一个干净的栈(我们可能可以通过一些创意结合之前的想法来避免这一步
  4. 在我们的进程中准备 CONTEXT 结构和 TEB 结构
  5. 调用 NtCreateThread

然而,即使我们:

  • 使用了具有管理员令牌并启用了 SeDebugPrivilege 的高完整性注入进程
  • 注入到中等完整性进程——notepad.execalc.exemsedge.exe 和我们创建的虚拟进程

我们还是从系统调用中收到了"访问被拒绝"的 NTSTATUS

在网上查找时,我们没有找到太多关于此问题的信息,但 ChatGPT 很乐意告诉我们,NtCreateThread 系统调用是一个遗留系统调用,在 Windows Vista 之后跨进程使用时将无法工作新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面

启用了搜索功能的 ChatGPT o3(openrce 参考)(securityxploded 参考)

这显然像是幻觉,特别是当 GPT 指向的源并没有明确包含这种措辞时,所以我们继续进行了一些内核调试。

在内核中跟踪 NtCreateThread

NtCreateThread系统调用的一般流程是:

新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面

nt!NtCreateThread 执行的检查

在内核到达 nt!PspCreateThread 之前,nt!NtCreateThread 包装器会执行一些基本的准备工作,包括:

  • 验证所有传入的指针(句柄、CLIENT_IDCONTEXTINITIAL_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)

概念验证

该概念验证执行以下操作:

  1. 在目标进程内存中搜索 ROP gadgetpush reg1; push reg2; ret
  2. 使用该 gadget 调用VirtualAllocRtlFillMemory并执行 shellcode
    • RIP指向 ROP gadget
    • RCXR9作为函数参数
    • ExitThread和目标函数的栈值分配给reg1reg2
    • 每个线程先推送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 approachAdditional 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 attachmentExample:  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 通常在同一受害进程中两个或更多以下活动相关联时标记为进程注入
活动
典型 API 证据
实际含义
远程分配 VirtualAllocEx

MapViewOfFile3
在另一个进程内创建新页面
远程修改 WriteProcessMemory

VirtualProtectEx
更改这些页面中的字节或权限
远程执行触发器 CreateRemoteThread

, APC 排队,上下文劫持,UI 回调注册
强制目标跳转到攻击者控制的代码

EDR 通常将这些建模为有序链 (1→2→3) 或任何 {2 of 3} 组合。

这种检查通常作为与新线程或 APC 的起始地址的关联检查,或在涉及该活动的任何 API 调用事件时进行。

2. 为什么仅执行攻击难以检测

防御端点需要:

  1. 发现触发器#1(远程线程创建) - 容易。
  2. 发现触发器#2(上下文劫持) - 也容易。
  3. 将该线程内所有后续的本地内存访问提升为"远程"
    • 需要追踪所有系统调用和用户模式助手 (memset等)。
    • 需要数据流 ("污点") 分析来证明写入源自外部上下文。
    • 在大规模下不切实际;实时下几乎不可能。

结果:跳过活动 1&2,仅依赖创造性触发器的攻击者可能绕过检测。

3. 为什么攻击者更常交换触发器而非分配器

  • "远程分配/写入"API 池很小且被严密监控。
  • 执行面巨大:线程、APC、定时器、UI 回调、UMS、DCOM 编组等。这是变种的沃土。
  • 随着 EDR 在内存操作方面加强,仅选择奇特的触发器不再保证隐蔽性,但它仍然是更简单和更便宜的选择。

4. "2 of 3"理念的弱点

该模型默认假设"远程≠本地"。一旦攻击者迫使受害者执行自己的写入,该假设就会失败:

  1. 本地写入发生 (memset在受害者内部)。
  2. 写入实际上由外部上下文劫持驱动 = 逻辑上远程
  3. 当前遥测无法证明这种因果关系,导致没有警报。

除非防御者能够将触发器遥测与深度线程内污点跟踪结合,否则注入将隐藏在众目睽睽之下。

5. 防御者的启示

根据我们在博客中讨论的内容:

  • 监控短时间内快速创建线程的事件可能有用。
  • 监控线程创建后大量使用SetThreadContext(及类似 API)。

但上述检测是 API 特定的,要深入了解,我们需要关联请求了触发器,而不仅仅是什么 API 被调用。

研究笔记

本节记录我们未探索的内容、未解答的问题和其他细节。

我们是否限于 4 个参数?

不。Windows x64 调用约定为我们提供了四个寄存器 (RCXRDXR8R9) 用于参数,但如果需要,如果我们找到更好的 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
我们构思了一个跳板方法来重定向调用链到退出,但在 NTDLL/Kernel32/KernelBase 中找不到合适的原生候选
推送已存在于其他线程堆栈上的退出函数
未探索。理论上,等待线程调用ExitThread,然后重用堆栈指针。但这是一个狭窄且不稳定的时间窗口,可能很危险
寻找内存中已存在的退出函数地址
我们扫描了常见函数指针(如ExitThreadRtlExitUserThread),但没有找到可用的结果。这个子想法仍未解决
通过调试 API 捕获错误返回时的异常
未探索。可能违反本研究中"无额外进程钩子"的原则。在单独的工作中可能很有趣
注入自定义 SEH(结构化异常处理程序)
理论上很有趣。我们想知道仅执行设置是否可以在不修改内存的情况下安装处理程序。未测试

关于 "GhostWriting" 及相关工作的说明

当我们启动这个项目时,我们找不到任何公开的技术文章能够完全跳过VirtualAlloc[Ex]/WriteProcessMemory同时还能交付 x64 代码。直到研究接近尾声时,我们才偶然发现了 GhostWriting 研究[1] [2] [3],这令人惊叹。

GhostWriting 提出了窃取现有线程并操作其CONTEXT以运行 shellcode 而无需远程分配内存的想法。我们的研究从相同的直觉出发,但在三个方面有所不同:

  1. 仅指针的LoadLibrary注入。我们展示了通过向LoadLibraryA提供指向 进程内 ASCII 字面量(例如"0")的指针加上名为0.dll的磁盘文件,可以实现远程写入的 DLL 加载。GhostWriting 仅关注原始 shellcode
  2. CreateRemoteThread ➜ SetThreadContext ROP 枢纽。我们没有劫持实时 GUI 线程,而是创建新的远程线程,使用push reg; push reg; retgadget 修复其空堆栈,并链接最多四个参数的任意 WinAPI。据我们所知,这种工作流程没有出现在之前的论文中(如有错误欢迎指正)
  3. 完整的 x64 NtCreateThread PoC。我们的演示直接向NtCreateThread提供了精心设计的CONTEXTINITIAL_TEB,并进行了 CFG/IAF 门的内核调试探索。早期的 GhostWriting PoC 仅限于 x86 和用户模式

因此,虽然"仅执行"注入有其历史,但我们相信这里提出的变体、缓解措施和见解将这个想法推向了新的领域

结论

如果你读到这里,你已经赢得了调试器咖啡断点,希望它值得占用这些堆栈空间。

原文始发于微信公众号(securitainment):新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年5月18日22:53:26
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   新型进程注入技术: 仅使用 CONTEXT 的无痕攻击面http://cn-sec.com/archives/4075187.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息