新的进程注入类:仅上下文攻击面

admin 2025年5月18日20:30:02评论1 views字数 17226阅读57分25秒阅读模式
新的进程注入类:仅上下文攻击面

TL;DR

大多数进程注入技术都遵循一个熟悉的模式:

分配→写入→执行。

在这项研究中,我们问:如果我们完全跳过分配和写入会怎样?

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

  1. 仅 使用 注入 DLL CreateRemoteThread。

  2. 使用 调用带有参数的任意 WinAPI 函数SetThreadContext。

  3. 利用NtCreateThread远程分配、编写和执行shellcode。

  4. 将该技术扩展到 APC 功能,例如QueueUserAPC。

在这里找到RedirectThread Github 仓库

https://github.com/Friends-Security/RedirectThread

介绍

现代端点检测和响应 (EDR)堆栈通常会监视经典进程注入的三种迹象:

  1. 分配新内存(  VirtualAlloc[Ex] )

  2. 修改该记忆(  WriteProcessMemory ,VirtualProtect)

  3. 执行它(  CreateRemoteThread、APC 等)。

我们在这项研究中的目标是测试下限:我们是否可以只触发执行原语,跳过分配和写入原语,但仍然将恶意代码放入目标中?

这个想法源于一个谣言:大内存页面总是被分配和映射为读写执行(RWX),即使没有特别要求这些。

后期研究说明:当我们测试这一点时,情况并非如此。

这就引出了一个自然的问题:

安全工具是否会将其与典型的 RWX 分配区别对待?

如果是这样,跳过链中的分配部分可以绕过部分常规检测逻辑。如果可以的话,那么跳过数据注入步骤怎么样?如果我们需要的字节已经存在于目标内部,我们可能实际上根本不需要写入任何新内容。

这引发了一个想法:

如果我们已经拥有目标进程内有效的可寻址数据,我们是否可以采用经典的 DLL 注入 - LoadLibrary 方法,并将其指向目标进程内现有的数据,然后让 Windows 完成剩下的工作?

为什么要使用 LoadLibrary?

LoadLibraryA/W自动将“ .dll ”附加到它接收到的任何字符串指针,然后解析通常的 DLL 搜索顺序 ( stackoverflow.com )。通过这种行为,我们可以找到一个现有的进程内字符串(例如“ 0”),并将名为(例如)的文件放在0.dll搜索路径中较靠前的某个位置。

这将启动一个远程线程,其启动例程为LoadLibraryA,其参数设置为字符指针(例如“ 0”),最终导致 DLL 被加载到目标进程中。

但是我们如何找到这个现有的进程内字符串呢?

共享内存101

当 Windows 将文件支持部分(例如 ntdll.dll)映射到多个用户模式进程中时,这些部分由相同的物理内存支持;每个进程仅接收其自己的虚拟地址视图。

写时复制 (Copy-On-Write)保护共享内存区域,直到某个进程尝试修改它们。此时,内核会创建页面的私有副本,以确保该进程不会更改系统中所有进程共享的内存

自 Windows Vista 起,ASLR 会在每次启动时随机化基址。为了优化重定位性能,系统 DLL会在所有进程中加载到一致的基址。

因此,类似 的偏移量在所有进程ntdll + 0x4中都应该指向相同的字节。

然后我们可以在本地定位指向字符的指针,并LoadLibraryA在目标进程中调用时重用该地址。

所有进程都会加载许多便捷的字符串文字ntdll.dll。我们选择使用“ 0”作为示例。

仅指针的 LoadLibrary 注入

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

新的进程注入类:仅上下文攻击面

1.找到正在处理的字符串

搜索ntdll.dll静态 ASCII 字符串,例如“ 0”(0x30 0x00)。由于DLL 在目标进程和我们自己的进程中映射到相同的偏移量,因此虚拟地址在两个进程中都应该有效。

新的进程注入类:仅上下文攻击面

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

  • 准备有效载荷DLL

  • 使用所选的植入物进行构建0.dll。

  • 将 DLL 放入通过搜索顺序解析的目录中。

我们并非局限于经典的搜索顺序劫持。或许可以滥用DefineDosDeviceWNT符号链接,从任意位置(例如 SMB 共享或 WebDAV 挂载)加载有效载荷。

2.创建远程线程

  • 注意,的地址LoadLibraryA是从我们自己的进程中获取的,类似于步骤1中的方法。

HANDLE hThread = CreateRemoteThread(    hProcess,NULL// default security0// default stack size    (LPTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandleA("kernel32"), "LoadLibraryA"),    (LPVOID)0x7FFE0300// pointer to shared NUL byte inside ntdll0// run immediatelyNULL);

3.执行

  • LoadLibraryA附加“.dll”,解析路径,并加载0.dll到目标进程中:

新的进程注入类:仅上下文攻击面

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

  • 通过跳过内存分配和写入,一种著名的注入技术绕过了两个行业领先的 EDR 的检测。

我们还测试了常规的 DLL 注入技术(涉及写入内存),并且两个 EDR 都检测到了它。

  • 这个小小的调整表明,许多技术依赖于相同的早期行为,以及这种行为是多么容易被避免。

这促使我们重新思考注入链:

  • 大多数技术遵循相同的模式:注入数据→触发执行。

  • 在其中许多中,执行触发器是共同的部分。

因此我们问道:

  • 我们真的需要注入数据吗?

  • 如果我们只关注执行触发器会怎么样?

我们没有更换链条的每个部分,而是加倍扣动扳机,看看仅凭这一点我们能走多远。

使用 ETW 调查远程线程创建

在深入探讨之前,我们先来快速回答一个简单问题:

这仅仅是检测疏忽吗?

为什么安全产品没有将任何远程线程创建标记为恶意?这种攻击难道还不够罕见,用一个简单的白名单就能发现吗?

事实证明并非如此。远程线程创建非常常见,并且通常用于:

  • 应用程序资源监视器

  • 性能分析器

  • 检测和跟踪工具

  • 调试器、兼容性垫片、可访问性助手

  • 企业代理和端点框架

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

查看在全新 Windows 安装上捕获的不到一分钟 的数据,我们已经发现跨不同进程创建了一些线程:

新的进程注入类:仅上下文攻击面

查找远程线程.ps1

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

如果 EDR 不能仅根据执行步骤标记恶意活动,即使使用臭名昭著的CreateRemoteThread,那么问题就变成了:这个原语有多强大,我们下一步可以把它带到哪里?

CreateRemoteThread + SetThreadContext 注入

我们进一步利用漏洞的首要想法CreateRemoteThread是避免将 DLL 释放到磁盘,而是尝试仅利用它实现内存注入。我们的目标是实现与经典远程Allocate→ Modify→Execute链类似的功能。

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

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

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

毕竟,进程读取或写入自己的内存并没有什么可疑之处。

我们基本上会改变这一点:

新的进程注入类:仅上下文攻击面

有了这个:

新的进程注入类:仅上下文攻击面

CreateRemoteThread 限制

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

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

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

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

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

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

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

Windows x64 调用约定 101

x64 调用约定通过寄存器(、、、)RCX将前四个参数传递给函数,其余参数则在堆栈上传递。RDXR8R9

新的进程注入类:仅上下文攻击面

摘自MSDN

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

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

我们预期的流程如下所示:(

CreateRemoteThread暂停)SetThreadContext→→ResumeThread

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

故障排除和研究

本节介绍我们遇到的问题以及我们如何克服这些问题,它有点技术性,可能有点沉重,但我们想分享我们的方法。

问题——清空初始堆栈

设置

首先,我们尝试创建一个处于挂起状态的新线程。然后,我们覆盖它的CONTEXT结构体,这样当我们恢复线程时,它就能直接跳转到VirtualAlloc。

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

结果

从 返回时,我们遇到了内存访问冲突崩溃VirtualAlloc。

根本原因

  • 线程以空栈启动。

  • VirtualAlloc执行正常并返回给调用者,但调用者的返回地址为零。这是因为返回地址通常会被线程启动存根推送到堆栈,但实际上并没有发生这种情况。

  • 取消引用空返回地址会触发内存访问冲突。

为什么CreateRemoteThread在 1 个参数的情况下不会崩溃

  • 当您让 Windows 正常启动线程时,执行将从

  • 本机启动存根( RtlUserThreadStart→ BaseThreadInitThunk) 开始。

  • 存根设置适当的堆栈框架,调用目标例程,然后以结束ExitThread。

  • 但是,当你强制将 RIP 设置为某个函数(例如VirtualAlloc)时,线程会在函数内部启动,并且似乎跳过了本机启动存根。这意味着线程ExitThread一旦完成就无法继续执行。

试验2——从另一个线程窃取有效堆栈

主意

SleepEx使用( )重用永久休眠的“牺牲”线程的堆栈NtDelayExecution。 希望牺牲休眠线程的调用堆栈 干净且可以通过()安全返回。如果线程在调用过程中

暂停,例如在堆栈设置期间(在序言之后但在尾声之前),我们可能需要手动重新调整堆栈,例如进行调整以进行补偿。RSP += 16

计划

1.产生牺牲线程

CreateRemoteThread( hProc, NULL0, Sleep, (LPVOID)INFINITE, 0NULL);

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

5.恢复第二个线程。

结果

  • 返回本机启动存根时崩溃。

  • 原因:借用的堆栈有效,但新线程的TEB为空。

  • BaseThreadInitThunk需要初始化字段(SEH 列表、TLS 等)。

  • 取消引用TEB → NtTib.ExceptionList(仍然为 NULL)会触发内存访问冲突。

仅有一个好的堆栈是不够的。每个线程都会获得一个新的线程环境块 (TEB),内核会将GS寄存器指向它。返回到假设这些 TEB 字段已初始化的代码将会失败。

我们的下一个想法是劫持休眠线程本身,而不是重新利用其堆栈。

试验 3 – 劫持牺牲睡眠线程

目标:在线程进入后,

劫持具有完全初始化的 TEB 和堆栈的线程的执行。SleepEx

预期调用堆栈

RtlUserThreadStart ↳ BaseThreadInitThunk     ↳ Sleep → SleepEx → NtDelayExecution                           ↳ (context switch) → VirtualAlloc

步骤

1.启动休眠线程 1 秒:

HANDLE hT = CreateRemoteThread( hProc, NULL0, Sleep, (LPVOID)10000NULL);

2.等到坐RIP进去NtDelayExecution。

3.当线程仍在休眠时 劫持上下文:

RIP = &VirtualAllocRCX,RDX,R8,R9 = desired parametersRSP left unchanged

4.让计时器到期;线程应该直接在 中恢复VirtualAlloc。

结果

  • RIP 按预期更改— 执行已达成VirtualAlloc。

  • 所有其他寄存器都是垃圾;VirtualAlloc失败并且进程在返回时崩溃。

要点:在定时睡眠期间,只有RIP是可靠写入的。内核的等待恢复路径似乎会覆盖或忽略所提供的其余上下文。

我们下一步的重点

更清晰的目标是正在运行但空闲的线程——没有被内核等待阻塞,而是处于初始化后状态。

这一发现引出了试验 4,我们利用一个极简的无限循环小工具来启动该线程,这个小工具很容易被劫持。

试验 4 – Sleep 替代方案、Loop Gadget 和 CFG

想法:在忙等待循环( )

中启动线程,该循环不接触任何寄存器,然后劫持其上下文。JMP -2

为什么是这个EB FE小工具?

  • 这是一个双字节“ jmp ‑2”指令——一个无限循环。

  • 很容易在任何可执行模块中找到;我们只需扫描我们自己的ntdll模块并在目标进程中重用相同的地址。

  • 除RIP之外的所有寄存器均不受影响,因此不会造成附带损害。

预期调用堆栈

RtlUserThreadStart ↳ BaseThreadInitThunk     ↳ [loop gadget: EB FE]         ↳ (context hijack) → VirtualAlloc

程序

1.创建一个远程线程,其起始地址为循环小工具。

2.等待它在环内旋转。

3.设置线程上下文

RIP = &VirtualAllocRCX,RDX,R8,R9 = paramsRSP unchanged

4.线程执行我们的功能(不需要暂停/恢复;线程已经在运行)。

结果

  • 立即违反控制流保护 (CFG) → 进程崩溃。

  • 根本原因:通过CFG 检测的间接跳转BaseThreadInitThunk调度到起始地址。我们的循环小工具不在模块的有效调用目标位图中,因此 CFG 在循环开始之前就终止了线程。

试炼 5 – 双重劫持:循环装置枢轴

像之前一样,先正常启动线程Sleep(以满足 CFG 规则)。但这次,我们劫持了两次:

  1. 首先进入一个循环小工具,以获得稳定的执行控制。

  2. 然后进入具有完全参数控制的目标函数(例如)。VirtualAlloc

之前,我们尝试直接用小工具替换睡眠函数——但这会跳过线程的自然启动逻辑。我们仍然希望原生线程先初始化,然后悄悄进入休眠状态,直到劫持时间到来。

突破在于将两个想法结合起来:

  • 让线程正常启动Sleep。

  • 当它击中时NtDelayExecution,将其劫持到循环装置中。

  • 从循环中再次劫持,这次完全设置上下文(RIP+ RCX,RDX,R8,R9)。

呼叫流程

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

执行流程

  1. CreateRemoteThread起始地址设置为Sleep。

  2. 短暂等待线程初始化并进入睡眠状态。

  3. 劫持 #1:设置RIP为循环小工具。其他操作保持不变。无需暂停/恢复。

  4. 劫持 #2:设置RIP为VirtualAlloc,并在RCX、RDX、R8和 中采用适当的值R9。同样,无需暂停/恢复。

结果

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

这种方法甚至可以与APC 传递完美兼容,因为线程可以正常返回,而不会被牺牲。唯一的缺点是什么?每次调用都需要两次等待才能让线程进入睡眠状态,然后还要调用循环小工具。

如果我们能够找到初始化 + 睡眠阶段的替代方案,也许我们可以对其进行一些优化。

试验 6 – 使用 ROP 修复堆栈

最后,我们想到了一个更简单的解决方案:在目标进程中使用 ROP 小工具,在堆栈中设置两个返回地址——一个用于线程退出(RtlExitThread),一个用于目标函数。仅此一个工具就足以满足我们构建堆栈的目的。

在试验 1中,我们遇到了一个问题:目标函数执行完毕后,线程试图返回空地址——这是由于栈从空开始导致的。我们希望找到更优雅的方法来解决这个问题。

等待线程初始化、休眠以及执行两次上下文劫持(如在试验 5 中)是有效的 - 但感觉有点过于严厉。

ROP 小工具

通过使用目标进程中已经存在的ROP 小工具,我们只需一步即可构建有效的堆栈。

ROP 小工具如下所示:

push reg1push reg2ret

其中,上述两个寄存器彼此唯一,并且是以下任一项:RAX/RBX/RBP/RDI/RSI/R10-15。

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

  • RIP→ 小工具地址

  • RCX, RDX, R8, R9→ 4 个参数

  • 小工具寄存器 1 →RtlExitThread

  • 小工具寄存器 2 → 指向函数的指针(例如,VirtualAlloc)

新的进程注入类:仅上下文攻击面

在 Notepad.exe 中发现 ROP 小工具

这样,我们就成功地跳过了初始化 + 睡眠并创建了一个干净的堆栈 ,一旦目标函数完成其工作,该堆栈就会退出。RtlExitThread

呼叫流程

ROP Gadget    ↳ VirtualAlloc        ↳ RtlExitThread

1.我们扫描远程进程的内存以查找所需的 ROP 小工具。

2.我们通过CreateRemoteThread创建一个处于挂起状态的新线程。线程的起始地址无关紧要。

3.我们准备一个新的上下文:

  • 设置RIP为ROP 小工具。

  • 将小工具的输入寄存器设置为:

  • VirtualAlloc(函数调用目标)

  • RtlExitThread(通话后回报目标)。

  • 用四个参数填充RCX、、和。RDXR8R9VirtualAlloc

4.我们恢复线程并让其运行。

结果

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

概念验证

概念验证执行以下操作:

1.在目标进程内存中搜索ROP 小工具(push reg1; push reg2; ret)

2.使用此小工具调用VirtualAlloc、RtlFillMemory并执行shellcode

  • 使用以下方式创建挂起线程CreateRemoteThread

  • 使用 设置线程的,CONTEXT并SetThreadContext分配:

  • RIP 到 ROP 小工具

  • RCX–R9函数参数

  • 堆栈值ExitThread和目标函数到reg1和reg2

  • 调用ResumeThread运行线程。

  • 每个线程先推送 的地址ExitThread,然后推送目标函数,并执行ret以跳转到目标函数。

NtCreateThread上下文注入

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

  1. 线程的起始地址

  2. 指向第一个参数的指针。

这让我们想知道为什么它如此有限,以及如果我们研究NtCreateThread提供对线程创建的更多控制的底层 API(),会发生什么。

后研究说明:这是一个很好的时机来表达——我们认为在呼叫时CreateRemoteThread呼叫。NtCreateThreadCreateRemoteThreadExNtCreateThreadEx

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

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

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

新的进程注入类:仅上下文攻击面

来自 ntinternals 的 NtCreateThread 原型

故障排除和研究

起初,这看似轻而易举的事,却变成了耗费数个晚上调试的工作。本节也涉及一些繁重的调试工作,但我们再次想分享我们的方法论和结论。欢迎跳过此部分。

NTSTATUS 0xC0000022 –访问被拒绝

我们的第一个 PoC 很简单(或者我们认为如此):

  1. 获取PROCESS_ALL_ACCESS目标进程的句柄。

  2. 从我们的进程中获取Sleep()函数地址。

  3. VirtualAllocEx为远程进程分配一个干净的堆栈(我们可以 通过发挥一点创造力并结合以前的想法来避免这一步)

  4. 在我们的流程中准备CONTEXT结构和结构。TEB

  5. 称呼NtCreateThread

然而,我们从 Syscall 收到了“访问被拒绝” NTSTATUS,尽管我们:

  • 使用具有管理员令牌的高完整性注入过程并SeDebugPrivilege启用。

  • 注入到中等完整性进程 – notepad.exe、calc.exe和msedge.exe我们创建的虚拟进程。

在网上查找后,我们没有找到太多关于这种情况发生的原因的信息,但 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__fastcallPspCreateThread(        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) )return0xC0000001// STATUS_UNSUCCESSFUL   CallerProcess3 = CallerProcess2; }

现在事情开始变得有点奇怪了。有一个检查,用于检查目标进程是否受虚拟化保护,并且是否位于 VTL-1(安全内核)中,但由于NtCreateThreadalways 调用PspCreateThread了IsSecureProcess = 0,因此该检查被跳过,并被评估为True其他条件的一部分,我们将在下面看到。

我们到达了导致“访问被拒绝”错误的密钥检查:

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) ){return0xC0000022;}

前两个条件在从 调用时评估为trueNtCreateThread。接下来的两个检查(MitigationFlags & 1)确定我们的进程或目标进程中是否启用了控制流保护 (CFG)MitigationFlags2 & 0x4000 。最后两个检查( )验证任一进程中是否启用了导入地址过滤器 (IAF) 缓解措施。

稍后,还有一些其他有趣的检查,可能会返回0x22:

if ( (TargetProcessObject2->Flags3 & 1) != 0// Minimal Process bit      && !TargetProcessObject2->PicoContext // Not a Pico Process      && InitialContext ){return0xC0000022;}

第一个条件检查目标进程是否为最小进程,并且 不是Pico 进程,并且InitialContext提供了(它来自NtCreateThread)。

综上所述,如果目标进程存在以下情况,NtCreateThread注入就会失败:

  • 控制流防护已启用

  • 导入地址过滤(EAF)已启用

  • 该过程是一个最小过程,而不是微微过程。

因此,该NtCreateThread系统调用很可能在大多数第三方程序上都能正常工作,这些程序通常不使用 CFG 或 IAF 进行编译,并且通常不是最小进程。相比之下,大多数微软二进制文件通常使用控制流保护 (CFG)进行编译。

概念验证

概念验证执行以下操作:

1.在目标进程内存中搜索ROP 小工具(push reg1; push reg2; ret)

2.使用此小工具调用VirtualAlloc、RtlFillMemory并执行shellcode

  • 在目标进程中为新线程分配一个空堆栈VirtualAllocEx(我们可以避免这一步)

  • 初始化CONTEXT结构,分配:

  • RIP 到 ROP 小工具

  • RCX–R9函数参数

  • 堆栈值ExitThread和目标函数到reg1和reg2

  • 每个线程先推送 的地址ExitThread,然后推送目标函数,并执行ret以跳转到目标函数。

  • NtCreateThread使用给定的方法创建一个CONTEXT立即执行的线程。

新线程在属于目标进程时不需要上下文劫持,因为其上下文已经预先提供。

RedirectThread 工具

在这里找到RedirectThread Github 仓库

https://github.com/Friends-Security/RedirectThread

为了以实用且可重复的方式演示本博客中的技术,我们构建了一个命令行工具来实现所讨论的注入方法。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 通常会标记进程注入:

新的进程注入类:仅上下文攻击面

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/3”哲学的弱点

该模型默认假设“远程≠本地”。一旦攻击者强迫受害者执行其自己的写入操作,该假设就会失效:

  1. 发生本地写入memset(在受害者内部)。

  2. 写入实际上是由外部上下文劫持=逻辑上远程驱动的。

  3. 当前的遥测无法证明因果关系,因此无法发出警报。

除非防御者能够将触发遥测与深度线程内污点跟踪结合起来,否则注入就会显而易见。

5. 防守方的要点

对于我们在博客中讨论的内容:

  • 在短时间内监控快速的线程创建事件集可能会很有用。

  • 监控随后的大量线程创建SetThreadContext(和类似的API)。

但上述检测是特定于 API 的,为了弄清事情的真相,我们需要关联谁 请求了触发器,而不仅仅是调用了哪个API。

研究笔记

这是我们没有追究的事情、未解答的问题和其他细节的部分。

我们是否仅限于 4 个论点?

不可以。Windows x64 调用约定为我们提供了四个寄存器(,,,RCX)用于存储参数,但如果需要,如果我们找到更好的 ROP 链,则可以手动将其他参数放置在堆栈上。RDXR8R9

我们能避免创建数百个线程吗?

可能是的。我们尝试过多次劫持同一个线程的上下文来重用它。

与其推送,不如推送一个循环小工具(正如这里https://blog.fndsec.net/2025/05/16/the-context-only-attack-surface/#trial-5-double-hijack-loop-gadget-pivoRtlExitThread故障排除部分所述),然后循环利用该线程。这可以减少线程的创建,尤其是在需要长时间执行多个操作的情况下,尤其是在 Shellcode 投递时。

我们有可能劫持处于正确状态的现有等待运行线程(例如等待运行的 DeferredReady/Ready 线程),并在稍后恢复其状态。这也可以通过使用小工具来实现,即推送先前的地址而不是退出。这将实现与之前使用 APC 的两步方法类似的结果,但具有后者的效率。

我们能避免ReadProcessMemory找到 ROP 小工具吗?

是的!虽然我们的方法是直接在内存中搜索,但这肯定不是唯一可行的方法。还有几种可行的替代方案:

  • 选项 1:加载目标进程使用的相同 DLL 并在本地扫描它们以查找特定模块内的 ROP 小工具。

  • 选项 2:从磁盘解析 PE 文件,找到小工具,然后使用计算其内存地址base address + offset。

每种方法在精度、隐蔽性和复杂性方面都有所取舍。我们选择内存扫描主要是因为它的简单性。

我们可以使用其他 WinAPI / NT 函数来控制上下文吗?

答案是肯定的!虽然并非所有 API 都适合跨进程注入,但有些 API 或许能提供一些“更隐蔽”的方式来实现相同的结果。

以下是我们发现的接受结构体的 API 的非最终列表CONTEXT。然而,由于列表过长,我们无法完全探索,因此并非所有 API 都经过了测试。

WinAPI:

  • SetThreadContext

  • SetThreadInformation

  • Wow64SetThreadContext

  • RtlWow64SetThreadContext

  • RtlRestoreContext

  • RtlRegisterFeatureConfigurationChangeNotification

  • RtlRegisterWait

  • SetUmsThreadInformation

  • EtwRegister

  • UpdateProcThreadAttribute

  • UmsThreadUserContext

NT功能:

  • NtSetContextThread

  • NtCreateThread

  • NtSetInformationThread

  • ThreadWow64Context

  • ThreadCreateStateChange

  • ThreadApplyStateChange

  • NtContinue

  • NtContinueEx

  • ALLOCATE_VIRTUAL_MEMORY_EX_CALLBACK

  • EtwEventRegister

优化?

当然!还有很多优化可以做:

我们可以通过以下方式改进小工具发现逻辑:

  • 容忍非破坏性的中间指令(例如,像 这样的小工具push rax; push rbx; mov r10, r11; ret,mov r10, r11没有副作用)。

  • 寻找其他说明。例如push rax; call rbx;。

  • 使用链式处理,而非 整体式处理。使用一个小工具准备 rsp,使用第二个小工具保存寄存器,使用第三个小工具进行调度。ROP。

我们可以通过以下方式提高两步法的效率:

  • 一次使用多个线程,并自动选择线程。可以并行执行每个字节的写入/复制操作。

  • 缩短睡眠时间,寻找更好的方法来检测线程何时准备好进行下一步。

  • 使用Nt*版本进行写入操作。由于它们支持 3 个参数(完美契合RtlFillMemory),我们可以跳过大部分操作的两步劫持。

未探索的想法

新的进程注入类:仅上下文攻击面

原文始发于微信公众号(Ots安全):新的进程注入类:仅上下文攻击面

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

发表评论

匿名网友 填写信息