不用WriteProcessMemory的进程注入:借控制台命名管道把载荷写进远程进程

admin 2026-09-29 05:48:33 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文介绍一种新型进程注入技术,通过控制台命名管道将载荷写入远程进程,全程不依赖WriteProcessMemory和VirtualAllocEx,规避EDR监控。技术利用控制台子进程的hStdInput管道写入数据,需避开0x0D、0x0A、0x1A三个坏字符。防御方应监控VirtualProtectEx使用和管道读写操作。 综合评分: 90 文章分类: 红队,渗透测试,免杀,安全工具


不用 WriteProcessMemory 的进程注入:借控制台命名管道把载荷写进远程进程

TwoSevenOneT TwoSevenOneT

赛博生存指南

2026年9月28日 20:36 浙江

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

原文:https://www.zerosalarium.com/2026/09/edr-evasion-process-injection-without-WriteProcessMemory.html 作者:Two Seven One Three(@TwoSevenOneT) 发布时间:2026-09-26 翻译模型:GLM-5.3

编者按:zerosalarium(@TwoSevenOneT)的新作,本仓库第二次译介这位作者,上一篇是 2026-06 的《EDRChoker:通过限速遥测流量绕过防御》。远程进程注入的标配四件套是 OpenProcess → VirtualAllocEx → WriteProcessMemory → CreateRemoteThread,中间两步是 EDR 用户态 hook 与内核回调盯得最紧的地方。本文的替代思路:把载荷写进一个交互式控制台子进程(netsh.exe / nslookup.exe)的 hStdInput。那本来就是一根命名管道,父进程对它调 WriteFile 再正常不过,写进去的数据会原样落进子进程内存。之后用 VirtualProtectEx 补上执行权限、劫持线程改 RIP,整条链路一次都不碰 VirtualAllocEx 和 WriteProcessMemory。载荷只需避开三个坏字符,也不需要以挂起状态创建子进程。作者同步开源了 PoC 仓库 InjectSetConsole,文末「译者补充」逐段过了这份源码——哪里写得讲究、哪里是概念验证的通病,一并说清。

I. 引子

在针对目标的红队项目或渗透测试中,你大概率会用到远程进程注入来执行载荷。正因为这套手法出现得太频繁,EDR(Endpoint Detection and Response)对相关 API 盯得非常紧。

本文介绍一种向远程进程注入代码的新做法,全程不依赖大名鼎鼎的 WriteProcessMemory 和 VirtualAllocEx。

在我跟一堆 EDR 许可证搏斗的那段时间,我发现 SensePost 的两位实力派研究员 Max Hirschberger 和 Ogulcan Ugur 已经独立想到了类似的点子并发了文章;顺着他们的工作,我又找到了 modexp 探索过一条相近路线的研究。说实话,这几位做得都比我好。

不过,我对两点始终不满意:一是必须在初始化阶段把进程挂起,二是 lpCommandLine 和 lpEnvironment 得改成奇奇怪怪的格式。于是我放下这些路线,转而研究出一套新的注入技术,也就是下面要讲的这套。

欢迎在 X 上找我,看我最近在琢磨的渗透测试与红队技巧:Two Seven One Three (@TwoSevenOneT)[https://x.com/TwoSevenOneT]

II. 主体

1. 远程进程注入技术概览

进程注入是一种重要的规避与持久化技术:攻击者强迫一个合法、受信的 Windows 进程替自己运行任意代码。

在经典的远程线程注入或 PE 注入流程里,注入方进程要先通过 OpenProcess 拿到目标应用(比如 explorer.exe 或 svchost.exe)的句柄,再用 VirtualAllocEx 在目标的虚拟地址空间里划一块专属缓冲区。

内存备好后,攻击者调用 WriteProcessMemory 把恶意载荷拷进远程进程的内存空间。

接着用 CreateRemoteThread 或其他办法创建一个线程,让它的 RIP 指向刚写入 shellcode 的那块内存。这种跨进程动作天然绕过常规边界防御,还继承了宿主进程的访问权限,所以 EDR 解决方案会通过用户态 hook 和内核回调对 WriteProcessMemory 严加监视和审查。各家 EDR 平台都把跨进程内存修改视为高严重级遥测事件,逼得现代威胁行为者不断寻找能绕开传统内存操作签名的替代方案。

大多数远程注入技术都遵循这个通用公式:

[OpenProcess/CreateProcess] + [VirtualAllocEx] + [WriteProcessMemory] + [某种把线程 RIP 重定向到新写入 shellcode 的方法]

2. 用 Windows 命名管道向远程进程写任意载荷

打开一个交互式控制台程序,它带一个名为 conhost.exe 的子进程;你在里面敲命令时,这些命令的内容存在哪儿?

答案:存在这个程序的内存里。

为了演示,我写一个小程序:用 CreateProcess 创建子进程,然后往子进程的 hStdInput 写数据。

我拿控制台程序 nslookup.exe 举例:

当我对子进程的 hStdInput 调用 WriteFile 时,被写入的数据就存进了子进程的内存。

那么思路就来了:用 hStdInput 这根命名管道把载荷写进另一个进程,而不是对子进程调 WriteProcessMemory。

但有个问题:Windows 控制台界面只能显示有限的可读字符,而写命名管道本质上就是调 WriteFile。理论上,我们应该能往 hStdInput 写入控制台显示不了的字节。实践检验一下,我有下面这个二进制数组:

写入命名管道之后:

这证明:几乎任何我们想要的文件,都能写进一个控制台进程的内存里。

3. 经控制台命名管道的远程进程注入

基于上述信息,要不用 VirtualAllocEx 和 WriteProcessMemory 把载荷注入远程进程,主要步骤如下:

  • 选一个交互式控制台程序。我找到两个合适的:netsh.exe 和 nslookup.exe。
  • 调用 CreateProcess,拿到子进程 hStdInput 的句柄。
  • 对 hStdInput 调 WriteFile,把载荷写进子进程。
  • 在子进程内存里定位新写入的载荷。
  • 用 VirtualProtectEx 给定位到的内存区域加上执行权限。
  • 劫持一个线程,把它的 RIP 重定向到上述地址。

用 WriteFile 写 hStdInput 时,载荷必须避开几个在 Windows 控制台里有特殊含义的坏字符:

  • 0x0D:回车符(CR),ASCII 与 Unicode 里的控制字符。
  • 0x0A:换行符(LF),控制字符。
  • 0x1A:SUB(替换字符),按 Ctrl+Z 产生,历史上用作 EOF(End-of-File)标记。

生成载荷时必须避开这些字符,否则子进程会把载荷当成命令、按普通命令去执行,结果是「Command not found」之类的输出,原始载荷也就不再留在进程内存里了。

要在子进程内存里定位新写入的载荷,我在载荷开头放一段特征字符序列,称之为 marker(标记)。搜索这个 marker,就能确定载荷的位置。重定向 RIP 时,要在 marker 所在地址上加 marker 的大小:

RIP = marker_addr + sizeof(marker)

我写了一个 PoC,完整跑通上述六步,完成 shellcode 的远程注入与执行,效果如下:

此前已有研究者拿这套技术实测过多款 EDR,所以这里不再放我自己的测试结果。

演示视频:https://youtu.be/DCUnbj_usPM

4. 防御

这套控制台命名管道注入技术彻底甩开了 VirtualAllocEx 和 WriteProcessMemory,因此监控重心应放在:对远程进程使用 VirtualProtectEx 的行为,以及命名管道上的读写操作。

III. 结语

红队项目或渗透测试里用到的多数远程注入技术都依赖 VirtualAllocEx + WriteProcessMemory 这对 API 组合,EDR 对它们的监视自然非常严密。

与传统路线不同,控制台命名管道注入不用 VirtualAllocEx 和 WriteProcessMemory,而是利用命名管道上的读写操作,以及控制台程序把交互命令存进内存的方式。除此之外,这套技术还有几个额外优点:

  • 不需要 CreateProcess 以挂起状态启动进程。
  • 不会让子进程的 lpCommandLine 或 lpEnvironment 出现异常格式。

载荷或 shellcode 能用的字符几乎不受限制,需要避开的坏字符很少。

因此,传统的监控手段无法可靠地检测和阻止这套技术。防御方应该把注意力放在控制台程序存放命令的那块内存区域上,监控 VirtualProtectEx 的使用,并跟踪命名管道上的读写操作。

本文作者:Two Seven One Three

参考资源

  • 原文(Zero Salarium 博客):https://www.zerosalarium.com/2026/09/edr-evasion-process-injection-without-WriteProcessMemory.html
  • PoC 源码仓库(InjectSetConsole):https://github.com/TwoSevenOneT/InjectSetConsole
  • PoC 演示视频:https://youtu.be/DCUnbj_usPM
  • 作者 X 主页:https://x.com/TwoSevenOneT

译者解读

这篇文章的巧妙之处,在于把「写远程内存」这个高危动作换了一件完全合法的外衣。控制台子进程的 hStdInput 本质是一根命名管道,父进程往子进程的标准输入里写数据,是操作系统设计里最天经地义的行为之一,shell 和无数父进程每秒都在做。EDR 可以给 WriteProcessMemory 挂 hook、给跨进程内存写挂内核回调,却很难对「父进程写子进程 stdin」这种动作做什么文章,因为误报面大到不可用。

这套思路也不是孤例。本仓库之前译介过 Module Stomping——把 shellcode 踩进 file-backed DLL 的 .text 段,《DEF CON 34 从加载器到内核》工坊篇里三款 EDR 对其集体沉默。命名管道注入与它是一个哲学:不对抗检测,让写入动作本身获得合法语义。一个藏在合法模块的代码段里,一个借道子进程的 console 输入缓冲。检测方要回答的问题从「谁写了远程内存」变成「这次写是否合法」,后者在用户态几乎没法回答。

链路后半段并没有消失,只是换了地方。VirtualProtectEx 对远程进程加可执行权限仍是必经步骤,Elastic 的 suspicious_memory_protection_change 一类规则、调用栈检测(本仓库 DEF CON 34 篇有展开)直接适用,这也是原文 Defense 小节点名的重点。管道读写的监控没有现成的公开 ETW provider,现实落点是 Sysmon 的管道事件(Event 17/18)与对象句柄审计——配套 PoC 用的是 CreatePipe 匿名管道(底层实现就是随机命名的管道实例),监控语义与显式命名的管道不完全一样,这点在文末的源码评估里有展开。载荷本身则躺在 console 输入缓冲区里,一块本不该有可执行内容、内容也不是合法命令历史的区域,Moneta 这类属性驱动的内存扫描器(见本仓库《Moneta 内存隐蔽与误报》篇)原则上能把它捞出来。

只避开 0x0D / 0x0A / 0x1A 三个字节,对现代 shellcode 几乎不构成负担。位置无关的 shellcode 里这三个字节本来就少见,真碰上用编码器过一遍就行。对比走 ROP 链或自定义协议解析时的字符约束,这差不多是白送的条件。

和前作比一下:SensePost 与 modexp 的同类思路研究,要么要在初始化时挂起进程,要么让 lpCommandLine / lpEnvironment 变形。挂起创建的进程、异常的命令行格式,都是 Sysmon EID 1 的现成狩猎字段。本文用「正常创建 + 写 stdin」绕开了这两个副作用,OPSEC 上确实更干净。

至于这套技术对 2026 年的商业 EDR 还剩多少实效,原文没有给出自己的测试数据(作者只说前人测过),要验证只能自己动手复现。

译者补充:PoC 源码评估

作者同步开源了 PoC 仓库 InjectSetConsole(C++,约 330 行有效代码)。上面解读里说的检测面,这份源码里能看到具体长什么样,逐段过一遍。

先核实对应关系。README 链回本文和同一个演示视频;rawData 数组开头 25 字节是 0x61×3, 0x62×3, 0x63×3, 0x64×3, 0x65×3, 0x66×4, 0x67×3, 0x6A×3,长度 25 = 0x19,与 README「替换 shellcode 从偏移 0x19 开始」、代码里劫持 RIP 的 (*result) + 0x19 三方吻合。载荷数组开头的注释把作者意图写得很直白:

//msfvenom -p windows/x64/exec CMD="notepad.exe" -e x64/zutto_dekiru -b '\x0a\x0d\x1a' -f hex
// bad chars are 0x0a, 0x0d, 0x1a
//Execute code at offset 0x19
//Need marker to search from remote memory
unsignedchar rawData[368] = {
0x61, 0x61, 0x61, 0x62, 0x62, 0x62, 0x63, 0x63, 0x63, 0x64, 0x64, 0x64,
0x65, 0x65, 0x65, 0x66, 0x66, 0x66, 0x66, 0x67, 0x67, 0x67, 0x6A, 0x6A,
0x6A, 0x48, 0xB8, 0x30, 0xAC, 0x10, 0x28, 0x03, 0xD6, 0x03, 0x02, 0xDB,
    ... // shellcode from offset 0x19
};

仓库分三个文件:InjectSetConsole.cpp 是主流程(管道创建、CreateProcess、写载荷、找 marker、改保护、劫持线程,六步在 main() 里顺序排开);PipeExchange.h 是管道读写辅助,ReadFromPipe 用 PeekNamedPipe 先探可用字节再 ReadFile,非阻塞;InjectHelp.h 装着三个核心函数——远程特征扫描、主线程定位、RIP 改写。管道部分是 MSDN 标准重定向模式:CreatePipe 两根管道,SetHandleInformation 关掉父进程侧两端的继承标志,STARTUPINFO 挂 STARTF_USESTDHANDLES,CreateProcess 以 CREATE_NEW_CONSOLE + bInheritHandles = TRUE 拉起子进程。

写得讲究的三处

FindPatternInRemoteProcess 的扫描过滤规矩:从 lpMinimumApplicationAddress 扫到 lpMaximumApplicationAddress,VirtualQueryEx 逐区域推进,只碰 MEM_PRIVATE + MEM_COMMIT + 可读 + 非 PAGE_GUARD 的区域,整个 region 一次 ReadProcessMemory 读回再 memcmp 滑窗。镜像和映射文件都被排除,只搜私有提交内存——载荷落点(console 输入缓冲)必然在其中,扫描量也小。

// 3. Filter: only private, committed, readable (not guarded) memory.
constbool isPrivate   = (mbi.Type == MEM_PRIVATE);
constbool isCommitted = (mbi.State == MEM_COMMIT);
constbool isReadable  = (mbi.Protect & (PAGE_READONLY | PAGE_READWRITE |
                          PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE |
                          PAGE_WRITECOPY | PAGE_EXECUTE_WRITECOPY)) != 0;
constbool isGuarded   = (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0;

GetMainThreadId 是全仓库最细的一处。文章只说「劫持一个线程」,怎么选线程没讲,代码给出了答案:用 TH32CS_SNAPMODULE 取主模块范围,再遍历线程快照,对每个线程查 Win32 起始地址,落在主模块内的才是主线程——避开 CRT 工作线程,找准真正在 console 输入循环里等命令的那个。

void* startAddr = nullptr;
NtQueryInformationThread(hT, ThreadQuerySetWin32StartAddress,
                         &startAddr, sizeof(startAddr), nullptr);

if (startAddr && imageBase && imageEnd &&
    (uintptr_t)startAddr >= imageBase &&
&nbsp; &nbsp; (uintptr_t)startAddr < imageEnd) {
&nbsp; &nbsp; best = te.th32ThreadID; &nbsp;&nbsp;// Win32 start address inside the main module
break;
}

HijackThreadRip 用 NtSetContextThread 而不是 SetThreadContext 改 RIP,少了 kernelbase 一层;失败路径上都记得 ResumeThread 收尾。SuspendThread → 改 RIP → ResumeThread 三拍子走完,netsh 主线程从内核读调用返回用户态的那一刻就落进 shellcode。

alignas(16) CONTEXT ctx {};
ctx.ContextFlags = CONTEXT_CONTROL; &nbsp; &nbsp; &nbsp;// RIP + RSP + RBP + flags + segs
GetThreadContext(hT, &ctx);
ctx.Rip = newRip;
NTSTATUS st =&nbsp;NtSetContextThread(hT, &ctx);
if&nbsp;(resumeAfter) {&nbsp;ResumeThread(hT); }

毛病与局限

按影响从大到小。

时序靠赌。Sleep(200) 等子进程初始化,Sleep(2000) 等数据落内存,pattern 找不到再 Sleep(2000) 盲重试一次,没有任何事件同步。机器慢一点扫描就扑空,这是工程上第一个该改的地方——轮询直到 pattern 出现,比固定等待可靠得多:

WriteToPipeBin(hChildStd_IN_Wr, rawData,&nbsp;sizeof(rawData));
Sleep(2000); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// wait for data to land
auto&nbsp;result =&nbsp;FindPatternInRemoteProcess(pi.hProcess, rawDataPattern);
if&nbsp;(!result) {
Sleep(2000); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// one blind retry, then give up
&nbsp; &nbsp; result =&nbsp;FindPatternInRemoteProcess(pi.hProcess, rawDataPattern);
}

RWX 一步到位。VirtualProtectEx 直接给 368 字节整块设 PAGE_EXECUTE_READWRITE,而 console 输入缓冲本来就可写,缺的只是执行位,设 PAGE_EXECUTE_READ 就够。RWX 内存是 EDR 扫描的头号特征之一,文章 Defense 小节让监控盯 VirtualProtectEx,这份代码把被盯的权限位设成了最扎眼的那种:

SIZE_T size = (SIZE_T)sizeof(rawData); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 368 bytes, marker included
DWORD &nbsp;newProtect = (DWORD)PAGE_EXECUTE_READWRITE;
VirtualProtectEx(pi.hProcess, (LPVOID)*result, size, newProtect, &oldProt);
HijackThreadRip(mainTid, ((*result)+0x19),&nbsp;/*resumeAfter=*/true);

默认 marker 是送上门的签名。25 字节的 61 61 61 62 62 62... 明晃晃躺在子进程内存里,一条 YARA 规则就能覆盖,而且执行完不清理。README 自己也说「可以改搜索模式来提升规避」,等于承认默认值只是演示用的。

注入方自己的扫描动作暴露面不小。FindPatternInRemoteProcess 会把子进程的全部私有提交内存读一遍,成规模的 ReadProcessMemory 序列在 ETW-TI 内核遥测下可见,文章的 Defense 小节没提这层。就算不盯写入,盯「谁在成片读别人内存」也能撞见这个注入器。

主线程是被牺牲的。netsh 主线程正在 console 输入循环里阻塞,RIP 被拨走后原上下文全丢,shellcode 跑完宿主进程大概率异常退出。演示截图里 netsh 还活着,是因为 payload 是 exec 弹 notepad、新进程另起。作为「注入并执行」成立,作为「宿主存活」不合格——文章也只承诺了前者。

还有一处语义细节:代码用的是 CreatePipe(匿名管道),不是 CreateNamedPipe。匿名管道底层实现确实是 \Device\NamedPipe 下的随机命名实例,文章把 hStdInput 称作命名管道没错;但 Sysmon Event 17/18 这类管道事件对匿名管道是否触发、以什么名字触发,和显式命名的管道不是一回事,要单独验证才能下结论。

小问题一堆:WriteToPipeBin 返回值没检查;VirtualQueryEx 失败标记 continuing anyway 继续走;x64 only(CONTEXT.Rip 直接用,无 WoW64 分支)且 README 未注明;CreateProcess 第一个参数传 NULL、路径放 lpCommandLine,路径带空格有经典参数注入风险;载荷硬编码在源码里,换 shellcode 要重编译;大载荷未验证——console 输入缓冲有容量上限,368 字节没问题,几十 KB 的 stager 能不能完整落进去是另一回事。

要往实战改,就是把这些毛病反过来

轮询替代固定 Sleep;PAGE_EXECUTE_READ 替代 RWX(或分两段改保护);marker 随机化并让 shellcode 起手第一件事自擦除;控制扫描范围,别全地址空间翻;宿主存活方案换 APC 或新线程——不过后两者就回到被重点监控的老路了,这个取舍正是这套技术的核心矛盾。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。

本文转载自:赛博生存指南 TwoSevenOneT TwoSevenOneT《不用 WriteProcessMemory 的进程注入:借控制台命名管道把载荷写进远程进程》

评论:0   参与:  0