零内存写入注入杀器!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
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论