零内存写入注入杀器!WhisperHijack:线程名偷渡载荷,等待线程劫持执行"

admin 2026-11-01 01:36:25 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

零内存写入注入杀器!WhisperHijack:线程名偷渡载荷,等待线程劫持执行

网安之家-CyberHomestead

2026年10月30日 00:13
湖北

去阅读

零内存写入注入杀器!WhisperHijack:线程名偷渡载荷,等待线程劫持执行

编译说明(编辑添加,非原文内容):本文为 Maldev-Academy/WhisperHijack[1] 项目 README 的全文中文编译,严格忠实原文、未做任何增删改;两项核心技术的原始研究均出自 Check Point Research(hasherezade)。编译内容仅用于安全研究、攻防对抗学习与授权测试场景。


WhisperHijack

一段将线程名调用(Thread Name-Calling)用于载荷投递、并以等待线程劫持(Waiting Thread Hijacking)驱动执行的隐蔽进程注入 PoC——全程零跨进程写入。

项目信息:仓库 Maldev-Academy/WhisperHijack | 语言 C(98.5%)| 许可证 Apache-2.0 | 作者 NUL0x4C

快速链接

•Maldev Academy 主页•Maldev 数据库•恶意软件开发课程大纲•攻击性钓鱼行动课程大纲•勒索软件内部机制、模拟与检测课程大纲

它做了什么

向一个正在运行的 64 位进程注入 x64 cmd.exe shellcode,并让其在一条已有的等待线程上执行,全程不使用:

•针对载荷的 NtWriteVirtualMemory / WriteProcessMemory•针对目标线程的 NtSetContextThread / SetThreadContext•NtSuspendThread / NtResumeThread•NtCreateThreadEx / CreateRemoteThread•目标进程句柄上的 PROCESS_VM_WRITE 权限

目标进程保持稳定状态。被劫持的线程执行完载荷后,恢复其原调用者。

致谢

本项目所用两项核心技术由 hasherezade(Check Point Research)提出:

•线程名调用(TNC)(2024 年 7 月):https://research.checkpoint.com/2024/thread-name-calling-using-thread-name-for-offense/[2]•等待线程劫持(WTH)(2025 年 4 月):https://research.checkpoint.com/2025/waiting-thread-hijacking/[3]

本项目是对这两项技术的独立实现,修改之处均在源码内联标注。原始研究的全部荣誉归于这两篇博客的作者。

架构

注入过程横跨三个协作进程,以拆散行为特征:

1. 祖父进程(GrandparentMain)

无参数启动。通过 NtCreateUserProcess 派生 Writer 子进程(如启用则附带 MITIGATION_POLICY_HARDENED),随后退出。

2. Writer(WriterChildMain)

•通过 NtQuerySystemInformation(SystemProcessInformation) 枚举系统,在非 WoW64 的 64 位进程中寻找一条可警报的等待线程(ThreadState == Waiting && WaitReason == WrQueue)。•以最小权限打开目标:PROCESS_VM_OPERATION | PROCESS_VM_READ | PROCESS_QUERY_INFORMATION | PROCESS_DUP_HANDLE。不含 PROCESS_VM_WRITE。•组装包裹式载荷:[WTH stub(0x45 字节)][主 shellcode][尾部 8 字节劫持指针]。•在目标内分配一块分配粒度大小的区域,初始保护属性 PAGE_READWRITE(经由 AllocateVirtualMemory 与 FindSuitableAddress)。•在投递前,把劫持指针(= pAllocatedAddress + WTH_STUB_CODE_OFFSET)植入包裹载荷的尾部。•经由线程名调用(WriteMemoryViaThreadNameApc)将完整的包裹载荷投递进目标。•派生 Flipper 子进程,目标进程与线程的句柄随之继承。

3. Flipper(FlipperChildMain)

•从命令行接收 hProcess、hThread、pAllocatedAddress 与 cbSize。•经由 NtGetContextThread(CONTEXT_CONTROL) 取得线程的 Rsp;RSP 是唯一需要的寄存器字段。•向目标内排队一个 RtlMoveMemory APC,把 [Rsp] 处保存的 RET 拷贝到 stub 的数据槽 pAllocatedAddress[0..7]。•经由 NtProtectVirtualMemory 将目标区域从 PAGE_READWRITE 翻转为 PAGE_EXECUTE_READWRITE。必须用 RWX,因为 shellcode 是多态、自解码的。•再向目标内排队一个 RtlMoveMemory APC,把预先植入的劫持指针从尾部拷贝进 [Rsp]。•调用 NtAlertThread 强制排空 APC。FIFO 顺序保证数据槽先被填充,[Rsp] 才被改写。

基于分配粒度的内存分配

分配粒度(allocation granularity)是 Windows 摆放一块保留虚拟内存区域的最小对齐边界。在 x86/x64 上恒为 64 KB(0x10000)。VirtualAlloc / VirtualAllocEx 会把任何基址静默地向下取整到最近的 64 KB 边界——也就是说,无论区域多小,两块保留区域之间的距离都不可能小于 64 KB。这与页大小(4 KB,即提交与保护属性的最小单位)是两个概念。

举例:对 0x00401001 处的 1 字节调用 VirtualAllocEx,Windows 会向下取整到 0x00400000,并把 0x00400000 至 0x0040FFFF 的整个 64 KB 块保留下来。在这整块区域被释放之前,块内剩余空间无法被任何其他分配使用。

本项目刻意在粒度边界上做文章。载荷区域被安置在一个或多个地址可预测的 64 KB 块中,每一块都按页逐页构建,而不是通过一次性的 MEM_RESERVE | MEM_COMMIT 组合调用完成。下面两个小节分别说明基址如何挑选、逐页提交如何塑形。

地址选择(FindSuitableAddress)

目标基址从一份固定的候选地址清单(0xDDDD0000、0x10000000、0x21000000、……、0xCCCC0000)中挑选。第一个满足\"所需跨度的每一块粒度大小区块均为 MEM_FREE\"(用 NtQueryVirtualMemory(MemoryBasicInformation) 探测)的候选地址胜出。

分配形态(AllocateVirtualMemory)

对每块粒度大小区域(x64 上为 64 KB),载荷需要:

•NtAllocateVirtualMemory(MEM_RESERVE, PAGE_NOACCESS):将整个 64 KB 保留为不可访问。•对区域内的每个 4 KB 页,用 NtAllocateVirtualMemory(MEM_COMMIT, <请求的保护属性>) 逐一提交。

线程名调用(TNC)

基于:[线程名调用博客](Check Point,2024)。

TNC 滥用线程描述 API(ETHREAD->ThreadName)把载荷偷渡进目标,其后备缓冲区分配在内核非分页池中,池标记为 'ThNm'。

分块链

每个分块(上限 MAX_THREAD_NAME_LEN = 0xFFFE 字节)按以下流程投递:

1.             NtSetInformationThread(ThreadName)       : 将分块暂存进 ETHREAD->ThreadName2. (APC #1)    [RtlFillMemory(scratch, 8, 0)]           : 在目标内清零 scratch 槽3. (APC #2)    [GetThreadDescription(-2, scratch)]      : 目标进程自行堆分配,把缓冲区指针写入 scratch4. (APC #3)    [NtSetEvent(hRemoteEvent)]               : 从目标内部向 Writer 发出信号5.             NtWaitForSingleObject(hLocalEvent)       : 阻塞,直至 APC #3 触发6.             NtReadVirtualMemory(scratch)             : 把堆指针拉回来7. (APC #4)    [RtlMoveMemory(dst, heapPtr, len)]       : 把堆中暂存的内容拷贝到最终目的地8.             NtAlertThread                            : 在下一次迭代前先排空 APC #4

空字节问题的对策

RtlInitUnicodeString 会通过扫描 L'\\0' 来设置 Length,遇到含内嵌空字节的 shellcode 就会被截断。我们的绕过办法:先用一段与分块等长的 'A' 填充缓冲区来初始化 UNICODE_STRING,然后在系统调用前用真实字节覆写 Buffer。

Scratch 槽——三种选项

目标内的 GetThreadDescription APC 需要一个目标进程中已知的固定地址来存放它的堆指针。项目提供三种选项;在 Main.c 顶部启用且仅启用一个:

选项 字段 优点 缺点
1 PEB.SpareUlongs Windows 从不使用 与 hasherezade 的 PoC 使用同一槽位
2 PEB.PostProcessInitRoutine 进程初始化后即不再使用,无需额外 API 有文档记载的字段
3 TEB.TlsSlots[63] (默认) 指纹不同,避开 PEB 需要 NtQueryInformationThread

编译期有一条 C_ASSERT 对各槽位进行校验。一个都不启用(或同时启用多个)将直接触发 #error。

同步

Writer 与目标通过一条同步事件协调。NtCreateEvent 创建一个本地 SynchronizationEvent(自动重置);NtDuplicateObject 把它的句柄镜像进目标,使目标内的 APC 链能够对其发信号。NtSetEvent 作为每个分块 FIFO 序列中的最后一个 APC(APC #3)运行,此时 scratch 槽已被 GetThreadDescription 填好。Writer 侧的 NtWaitForSingleObject(hLocalEvent) 在此刻返回,随后 NtReadVirtualMemory 从 scratch 槽拉出堆指针。

等待线程劫持(WTH)

基于:[等待线程劫持博客](Check Point,2025)。

WTH 改写的是阻塞在 NtWaitForWorkViaWorkerFactory(worker factory 等待,等待原因 WrQueue)中的线程已保存的 RET。当该线程的等待返回时,系统调用包装器的 ret 会弹出被改写的槽位,落点正是 WTH stub。

WTH stub(69 字节)

[+0x00 .. +0x07]  : 8 字节数据槽(保存原始 RET,由 Flipper 的 APC 填充)[+0x08 .. +0x44]  : pushf + push 全部 GPR + call +0x1F + pop 全部 GPR + popf                    + jmp qword ptr [rip-0x45]  (= jmp qword ptr [数据槽])[+0x45 ..      ]  : 用户载荷(必须以 'ret' 结尾)

执行流程:

1.线程的 ret 落在 stub+0x08 处的 pushf 上。2.保存全部 GPR 与标志位。3.call +0x1F 跳入 +0x45 处的用户载荷。4.载荷运行,以 ret 结束,返回 stub 的尾声。5.恢复全部 GPR 与标志位。6.最后的 jmp qword ptr [rip-0x45] 解引用数据槽,跳转到原始 RET。

原调用者以完整状态重新拿到控制权,宿主线程照常继续运行。

零写入劫持

Flipper 对目标执行的每一次内存操作都发生在目标进程内部、经由 APC 完成。Flipper 唯一的跨进程调用,是那一次把 PAGE_READWRITE 翻转为 PAGE_EXECUTE_READWRITE 的 NtProtectVirtualMemory。

执行序列:

•NtGetContextThread(CONTEXT_CONTROL) 取得 Rsp。•目标内的 RtlMoveMemory APC 把 [Rsp] 处 8 字节的已保存 RET 拷入 stub 数据槽 pAllocatedAddress[0..7]。Flipper 从未在自己的内存里取回这个已保存 RET 的值。•NtProtectVirtualMemory 将区域翻成 RWX。•目标内的 RtlMoveMemory APC 把预先植入的劫持指针从载荷尾部(pAllocatedAddress + cbSize - 8)拷贝进 [Rsp]。该指针由 Writer 在 TNC 投递期间就已烘焙进尾部,因此这里传递它不需要任何跨进程写入。•NtAlertThread 强制排空 APC。这会迫使载荷立即运行,而不必等线程在下一个工作项、事件或 IOCP 包上自然醒来。

支撑基础设施

•间接系统调用,经由 IndirectSyscalling.h——所有 Nt* 调用走 INITIALIZE_SYSCALL / INVOKE_SYSCALL 宏。•API 哈希,经由 ResolveAPIs.h,采用 CRC-32B。同时提供大小写敏感(HashStringCrc32bA / HashStringCrc32bW,用于 API 导出名)与大小写不敏感(HashIStringCrc32bA / HashIStringCrc32bW,用于模块名)两种变体。参见 github.com/Maldev-Academy/ApiHashing。•哈希再生成,经由附带的 Crc32bHasher.py:

python Crc32bHasher.py NtOpenProcess NtClose RtlFillMemory      # 大小写敏感(API)python Crc32bHasher.py -i ntdll.dll kernel32.dll                # 大小写不敏感(模块)

输出可直接粘贴进 Common.h 作为 CRC32B_* 定义。

•Unguard gadget,经由 ReadThroughGadgets.h,允许读取受保护内存,以对付启用了 EAF 的目标。•基于分配粒度的分配(FindSuitableAddress + AllocateVirtualMemory):先在一个可预测的候选基址上保留区域,再逐页提交。•加固子进程派生,经由 NtCreateUserProcess 携带 MITIGATION_POLICY_HARDENED 与 PS_ATTRIBUTE_HANDLE_LIST,实现跨进程句柄继承。

Shellcode

内嵌载荷是一个多态 x64 cmd.exe,由以下命令生成:

AlphabeticalPolyGen.exe --i x64CmdShellcode.bin --o x64CmdShellcode.bin.poly

参见 github.com/Maldev-Academy/AlphabeticalPolyShellGen。

原始(非 poly)shellcode 以注释形式保留在源码中供参考。

行为指纹

每次注入(N = 载荷分块数;P = 分配中的页数):

操作 次数 位置
目标上的 NtWriteVirtualMemory 0 —
目标线程上的 NtSetContextThread 0 —
NtSuspendThread / NtResumeThread 0 —
目标上的 NtCreateThread* 0 —
目标线程上的 NtQueueApcThread 4N + 2 每分块的 TNC + WTH 的 2 次
目标线程上的 NtAlertThread N + 1 TNC 排空 + WTH 排空
目标上的 NtReadVirtualMemory N TNC scratch 槽读取
目标上的 NtProtectVirtualMemory 1 WTH 的 RW → RWX 翻转
目标上的 NtAllocateVirtualMemory 1 + P 保留 + 逐页提交
目标上的 NtSetInformationThread(ThreadName) N + 1 TNC 分块暂存 + 清除

Writer 侧(不触碰目标):NtCreateEvent x1,NtDuplicateObject x1(镜像进目标),NtWaitForSingleObject xN。

演示

在 Windows 11 24H2 上针对 sihost.exe 测试成功。

完整注入轨迹:DebugView 输出,展示「祖父进程 → Writer → Flipper」链、向 sihost.exe 的线程名调用投递,以及 WTH APC 交接:

References

[1] Maldev-Academy/WhisperHijack: https://github.com/Maldev-Academy/WhisperHijack
[2]: https://research.checkpoint.com/2024/thread-name-calling-using-thread-name-for-offense/
[3]: https://research.checkpoint.com/2025/waiting-thread-hijacking/
[4]: https://research.checkpoint.com/2024/thread-name-calling-using-thread-name-for-offense/
[5]: https://research.checkpoint.com/2025/waiting-thread-hijacking/
[6]: https://github.com/Maldev-Academy/ApiHashing
[7]: https://github.com/Maldev-Academy/AlphabeticalPolyShellGen


    零内存写入注入杀器!WhisperHijack:线程名偷渡载荷,等待线程劫持执行" 网络安全文章

    零内存写入注入杀器!WhisperHijack:线程名偷渡载荷,等待线程劫持执行"

    本文编译介绍WhisperHijack项目,一种零跨进程内存写入的隐蔽进程注入PoC。其结合线程名调用与等待线程劫持两项技术,通过三个协作进程拆散行为特征,利用线程名偷渡载荷并劫持等待线程执行,全程避免常见注入API与写权限。文章详述架构、内存分配策略、TNC分块链投递及WTH执行流程,并致谢CheckPointResearch的原始研究。内容具深度,属高级恶意软件开发技术。
    蓝队复盘:一次护网演练里,我们怎么反制红队" 网络安全文章

    蓝队复盘:一次护网演练里,我们怎么反制红队"

    本文为蓝队护网演练防守复盘,核心观点是防守不是堆设备,而是把看到、判明、处置做快。关键发现包括:监测需集中日志、EDR与网络流量并建立基线;溯源用时间线与攻击链结合MITREATT&CK;反制是合法合规切断路径,可用蜜罐诱导观察;复盘需输出已修、待修、长期建设三张清单。可操作建议:盘清资产、启用多因素认证、集中日志、定期演练。
    等级保护建设:安全建设管理—测试验收" 网络安全文章

    等级保护建设:安全建设管理—测试验收"

    本文解析等级保护安全建设管理中测试验收环节的核心要求,强调制定测试验收方案、实施全面测试并形成报告,重点开展上线前安全测试及密码应用安全性测试,建立问题整改闭环机制,确保系统安全上线运行,为等级保护测评提供可靠证据支撑。
    三季度报告|中孚信息营收1.32亿,降27.7%毛利率反升14.9个百分点,AI安全成新抓手" 网络安全文章

    三季度报告|中孚信息营收1.32亿,降27.7%毛利率反升14.9个百分点,AI安全成新抓手"

    中孚信息2026年三季度营收1.32亿元同比降27.7%,但毛利率提升14.9个百分点,亏损收窄,现金流改善。公司主动调整业务结构,聚焦高价值产品型业务,并发布AI安全产品矩阵,以AI技术赋能与安全治理双轮驱动,聚焦保密安全、数据安全、电磁空间安全三大领域,AI安全成为新增长点。
    评论:0   参与:  0