文章总结: NocturneLdr是一个研究导向的Windowsx64shellcodeloader,核心目标是产生对WinDbg、ProcessHacker与IntelCETshadowstack都不可区分的合法调用栈。其技术内核是BYOUD的工程化实现,通过将代码注入合法模块.text段并注册真实unwind元数据来绕过CET校验。项目设计亮点包括高质量注释、可逆性变更跟踪、CRT-free与编译期哈希。但存在严重代码质量问题,如FindShieldGadget可能越界读未映射内存、硬编码偏移导致跨版本脆弱、大量重复代码与死代码。建议仅用于研究,生产使用前需修复稳定性问题。 综合评分: 84 文章分类: 红队,渗透测试
工具 | NocturneLdr — CET 兼容的调用栈伪装 Loader
原创
GLM-5.2 GLM-5.2
赛博生存指南
2026年7月29日 08:28 日本
在小说阅读器读本章
去阅读
仓库地址:https://github.com/xec412/NocturneLdr 本文是对开源 Windows x64 shellcode loader NocturneLdr 的源码级质量评估。阅读对象为红队研究者、蓝队/EDR 工程师,以及关注 Windows 底层对抗的开发者。NocturneLdr 围绕一个明确目标设计:产生对 WinDbg、Process Hacker 与 Intel CET shadow stack 都不可区分的合法调用栈。技术内核是 klezVirus BYOUD(Bring Your Own Unwind Data)的工程化实现,叠加 Zilean 风格的睡眠混淆、堆加密、EAF bypass 与线程池代理调用。本文侧重代码工程质量与实现正确性评估,不构成使用指引。
目录
- 项目定位与技术栈
- 架构与模块划分
- 设计亮点
- 代码质量问题
- 安全与健壮性
- 量化评分
- 改进建议与结论
1. 项目定位与技术栈
NocturneLdr 是一个研究导向的 Windows x64 shellcode loader。它不追求 payload 投递的通用性,而是把全部工程精力压在一个对抗点上:调用栈完整性(call stack integrity)。
现代 EDR 与人工分析师把调用栈作为首要检测信号。未 backed 的返回地址、错位的栈帧、动态函数表痕迹,都是强恶意指标。传统栈伪装能处理一部分,但在 Intel CET(Control-flow Enforcement Technology)的 shadow stack 校验下会失效——硬件维护了一份只读的返回地址副本,必须与软件栈一致。
NocturneLdr 的解法是换思路:不伪造假栈帧,而是把代码注入到合法模块的 .text 段,并用该模块真实的 donor unwind 元数据注册一条 RUNTIME_FUNCTION。Windows 展开器(unwinder)随后用真实 unwind 信息走栈,产生的帧指向一个签名、backed 的 DLL(windows.storage.dll)。因为代码物理上位于模块地址范围内,unwind 链结构合法,软件栈遍历与 CET 硬件校验都看到一条合法调用链。
构建特性(vcxproj 确认):
/GS-关闭栈 cookie,/EHA-关闭 SEH 表生成,/Gs999999关闭栈探测- 自定义入口、自实现
memset/memcpy、operator new/delete重载走HeapAlloc - 编译期 DJB2 哈希(
constexpr),二进制中零 API 名字符串 - IAT 只保留良性 USER32 导入作为伪装
2. 架构与模块划分
源码规模适中:6 个头文件、9 个 C++ 实现、5 段 MASM 汇编。按 namespace 干净拆分,职责单一。
include/
├── Common.h 全局 g_Win32 API 结构与跨模块函数声明
├── Structs.h NT 结构与自定义类型(约 1500 行,含大量未用模板)
├── Primitives.h 编译期哈希、CRT 替代、new/delete 重载
├── InitializeAPI.h 运行时 API 解析初始化
├── IatCamouflage.h IAT 伪装(良性 USER32 导入)
└── Debug.h 调试控制台与 DBGPRINT 宏
src/
├── Main.cpp 入口与编排
├── Resolver.cpp PEB 遍历与哈希解析(含 EAF bypass)
├── StackSpoofing.cpp 栈欺骗编排
├── Unwind.cpp unwind 解析与动态/反向函数表操作
├── Context.cpp 变更跟踪与回滚
├── StackUtils.cpp 栈原点与 cookie 检测
├── Proxy.cpp 线程池代理调用
├── Zilean.cpp 睡眠混淆、堆加密、ROP 链
├── Intrinsic.cpp memset
├── Debug.cpp 调试控制台分配
└── *.asm ShadowGate / Unguard / ApiStub / StackSearch / SetRegister
Common.h 用单一全局结构 g_Win32 集中管理所有动态解析的 API 指针,按 Nt/K32/AdvApi32/CryptBase 分组,跨模块查找方便。这是一个值得肯定的组织方式。
3. 设计亮点
3.1 注释质量很高,这是最大的优点
几乎每个函数都有块注释说明”做什么 + 为什么”。例如 Unwind.cpp 解释 SRW lock 定位逻辑,Resolver.cpp:260 解释三种 CALL 指令编码差异,Context.cpp:164 注释正确指出回滚必须逆序、PAGE_NOACCESS 必须先撤销。对研究型项目而言,这种”why 注释”远比”what 注释”有价值。
3.2 BYOUD 变更跟踪与回滚
Context.cpp 的 BYOUD_CONTEXT 配合 BYOUD_CHANGE 联合体,记录每一处篡改:缓存清零、.pdata 保护、反向表收缩、动态表插入、类型篡改、shellcode 注入。RevertAllSpoofChanges 逆序回滚。这是工程化的可逆性设计,比一次性破坏强得多,也使整体操作可以在 payload 返回后被干净撤销。
3.3 CRT-free 与编译期哈希
Primitives.h:161 重载全局 operator new/delete 走 HeapAlloc,Intrinsic.cpp 自实现 memset,Primitives.h:56 自实现 memcpy/memcmp/strlen。Primitives.h:22 用 constexpr DJB2,哈希在编译期求值为立即数,运行时只比对常量,二进制中不残留 API 名字符串。
3.4 EAF bypass:gadget 路由读
Unguard.asm 的 ShieldedRead 与 Resolver.cpp:83 配合:所有导出表读取经一个 ntdll 内的 MOV RAX,[RAX];RET gadget 完成,让 EAF 的硬件断点看到的是签名模块内的读,而不是 loader 自身的解引用。设计思路干净,ShieldedRead 还保留了 gadget 为空时退回普通解引用的兜底分支。
3.5 线程池代理调用
Proxy.cpp 把 LoadLibraryW、NtProtectVirtualMemory 经 RtlQueueWorkItem 转移到 ntdll 线程池 worker 上执行,调用源伪装成合法的 ntdll 线程池回调。WorkItemNtProtectVirtualMemory 用一段汇编 stub(ApiStub.asm)把参数重新摆进寄存器再 jmp 到真实 API,避免了直接 call。
3.6 多层栈欺骗编排
StackSpoofing.cpp:259 的 RegisterSpoofedRuntimeFunction 把整条链按正确顺序串起:donor unwind 借用 → 反向函数表收缩 → ntdll 缓存失效 → RtlAddFunctionTable 加 Type=0 篡改 → .pdata 置 PAGE_NOACCESS。每一步都登记进 BYOUD_CONTEXT。层次清晰,编排正确。
4. 代码质量问题
4.1 严重:潜在崩溃与正确性
FindShieldGadget 可能越界读未映射内存(Resolver.cpp:46-63)。
Memory = (ULONG_PTR)...ntdll.dll... + 0x1000;
for (ULONG_PTR Len = 0; Len < 0x1000 * 0x1000; Len++) { // 扫描 16 MB
if (Primitive::MemoryCompare((PVOID)(Memory + Len), Pattern, 4) == 0)
循环上限 0x1000 * 0x1000 是 16 MB,但 ntdll 的 SizeOfImage 通常远小于此。一旦扫描越过 ntdll 末尾进入未映射页,立即触发 access violation。应当用 SizeOfImage 收敛上界,并预留 sizeof(Pattern) 的读边界。
FindShieldGadget 每次解析都全量扫描(Resolver.cpp:83)。LdrShieldedSymbolResolveByHash 内部每次都调 FindShieldGadget(),而 InitializeAPI.h 会调用它几十次。结果是初始化阶段对 ntdll 做了几十次 16 MB 线性扫描。gadget 应解析一次后缓存。
RevertAllSpoofChanges 永远返回 TRUE,错误被吞(Context.cpp:161、Context.cpp:224)。
BOOL B0K = TRUE; // 注意是字母 O,命名也怪
...
return B0K; // 从不置 FALSE
回滚中任何一步失败都不会上报,调用方无法感知清理不完整。B0K 这个命名(字母 O 而非数字 0)也增加了阅读负担。
Random32 注释与实现不符(Zilean.cpp:33-45)。注释说”derived from KUSER_SHARED_DATA interrupt time fields”,实现却是 _rdrand32_step(硬件随机指令)。误导性注释。
堆加密:注释与 README 说 RC4,实际是自定义 XOR(Zilean.cpp:369)。MaskHeap 调用的是 XorCipher(Zilean.cpp:15,带位置相关 ^ j 的 XOR),不是 SystemFunction032(RC4)。而 SystemFunction032 在 InitializeAPI.h:115 被解析却从未使用——既是死代码,也是文档与实现不符。
跨版本脆弱的硬编码偏移与魔数。
Context.cpp:203:*(PDWORD)((ULONG_PTR)pEntry + 0x50)硬编码DYNAMIC_FUNCTION_TABLE.Type字段偏移 0x50。StackSpoofing.cpp:35、Unwind.cpp:653:+ 0x300000/0x200000范围检查,1004/1019/1017循环边界。Unwind.cpp:642:依赖RtlAddFunctionTable内33 C9 48 89 43 28 E8字节序列。
这些 pattern scan 与具体 Windows 构建强耦合,版本升级极易失效,且没有任何版本检测或兜底路径。这是该类技术的通病,但本项目没有做任何缓解。
4.2 中等:可维护性与重复
三段几乎逐行重复的代码。
StackSpoofing.cpp:15-105:FlushFunctionTableCache与FlushFunctionEntryCache约 40 行,只有目标函数名字符串(RtlLookupFunctionTable与RtlLookupFunctionEntry)不同,应抽公共函数。Zilean.cpp:53-357:SuspendThreads、ResumeThreads、GetRandomThreadContext各约 90 行,NtQuerySystemInformation双调用加线程遍历框架完全相同,应抽取EnumerateProcessThreads(callback)。这是全仓库最严重的重复。
Structs.h 大量未使用的结构(约 1500 行)。PS_CREATE_INFO、PS_ATTRIBUTE_*、FILE_INFORMATION_CLASS、RTL_USER_PROCESS_PARAMETERS、PROCESS_BASIC_INFORMATION 等 maldev 模板结构定义了却全项目无引用。可删减约三到四成行数,降低维护负担与编译产物体积。
GetRandomThreadContext 名不副实(Zilean.cpp:258)。名为 Random,实现是”取第一个非当前线程”,且不排除 worker 线程,与 SuspendThreads 的排除逻辑不一致。
SharedSleep 的沙箱漂移检测是空操作(Proxy.cpp:31-41)。
for (...; SharedTimeStamp() < Start64; Index++); // 已经 sleep 完
if ((SharedTimeStamp() - Start64) > 2000) return; // 检测在循环之后,return 也无意义
注释说检测时间加速,但检测点在 sleep 结束之后,且之后没有代码,return 没有实际效果。
4.3 轻微:风格与一致性
死代码。ConstructShadowGateParamsVa(StackSpoofing.cpp:223)全项目未调用;SystemFunction032 解析未用(见上)。
错误处理不一致。Main.cpp:105 等处对 NtCreateEvent、RtlQueueWorkItem 的返回值未检查就直接使用句柄,而 Zilean.cpp 内部却检查得很细。
README 与编译配置的表述张力。README 称”custom entry point”,vcxproj 实为标准 main(Nocturne.vcxproj:132);README 同时声称 /NODEFAULTLIB(CRT-free)与”static CRT /MT“,两者互斥,表述需澄清。
命名小瑕疵。B0K(字母 O)、STATUS_UNSUCCESSFULL(多一个 L)、头文件名 Primitives.h 在 guard 宏里拼成 PRIMIVITES_H。
5. 安全与健壮性
| 项 | 评价 |
| — | — |
| 内存安全 | LocateTextAppendCursor (Unwind.cpp:724)用 0xCC/0x00 启发式判断 .text 空闲区。若合法代码末尾恰有 0x00 立即数,会误判边界,可能覆盖合法指令。启发式不可靠。 |
| 可重入性 | AppendCodeToText 内 static DWORD s_dwBytesWritten(Unwind.cpp:777)是隐式累积状态,函数不可重入,并发或重入会错位。 |
| 栈使用 | MaskImage 在 worker 栈上放 CONTEXT Ctx[10](约 12 KB),偏大但可接受。 |
| 递归 | 转发导出 LdrShieldedSymbolResolveByHash 递归无深度上限(Resolver.cpp:143),理论上可被恶意转发链耗尽栈。 |
| EAF 一致性 | 主解析路径走 ShieldedRead,但转发名读取 MemoryCopy(Forwarder, (PVOID)FunctionAddress, ...)(Resolver.cpp:127)直接解引用,绕过了 gadget,削弱了 EAF bypass 的覆盖完整性。 |
6. 量化评分
| 维度 | 评分 | 说明 | | — | — | — | | 架构与模块化 | 8 | namespace 隔离清晰,职责单一 | | 注释与文档 | 8.5 | 几乎全函数”why 注释”,研究价值高 | | 技术深度 | 9 | BYOUD 加反向表/缓存/WinDbg bypass 加 Zilean ROP,少见的高完成度实现 | | 可维护性 | 5 | 大量重复、死代码、魔数、跨版本脆弱 | | 健壮性与正确性 | 5.5 | 潜在越界崩溃、错误吞没、注释与实现不符 | | 一致性 | 6 | 错误处理风格不统一,命名小瑕疵 |
综合来看,作为研究型与学习型项目,工程质量在同类 maldev 项目里属于上乘;作为可长期维护的工程代码,则存在明显的重复与技术债。
7. 改进建议与结论
按优先级给出改进方向:
- 修
FindShieldGadget边界(用SizeOfImage)并缓存 gadget——一次性消除崩溃风险,同时大幅提速初始化。 - 抽取线程枚举公共函数,消除
Zilean.cpp三段重复,收益最大的可维护性提升。 - 合并两个
Flush*Cache为参数化单函数。 - 修正注释与 README 与实现的不符(
Random32、MaskHeap的 RC4 与 XOR、SystemFunction032死代码、entry point 表述)。 RevertAllSpoofChanges真正传播失败,并修正B0K命名。- 清理
Structs.h未用结构与未调用函数,降低噪声。
结论:NocturneLdr 是一份技术含量很高的调用栈对抗研究实现,亮点在于编排完整的 BYOUD 管线、可逆的变更跟踪与高质量的”why 注释”。短板集中在工程化层面——重复代码、死代码、与 Windows 版本强耦合的 pattern scan、以及若干注释与实现脱节之处。它适合作为学习 Windows unwind 机制与高级栈欺骗的参考样本,若要投入实际长期使用,则需要先补齐边界检查、缓存与重复消除这些基础工程项。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:赛博生存指南 GLM-5.2 GLM-5.2《工具 | NocturneLdr — CET 兼容的调用栈伪装 Loader》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论