从 NeedyMantis 中初学样本的包装、加载与落地
从 NeedyMantis 学样本的”包装、加载与落地”
读完 Microsoft Threat Intelligence 的报告 NeedyMantis: unpacking a post-compromise malware family used in targeted operations(2026-09-28)之后的理解与复盘,原文见文末链接。
本文是我的学习笔记,技术事实以原报告为准。
一、背景:这是一个”第二梯队”的植入体
NeedyMantis(微软对它的家族命名)是一个模块化的后渗透(post-compromise)恶意软件家族,攻击目标集中在电信、高校、政府间组织、医疗非营利机构和政府承包商,活动最早可以追溯到 2025 年 10 月,微软将其归因到威胁行为者 Storm-3069。有意思的发现路径:它最初是在排查 DAEMON Tools 供应链攻击(Kaspersky 先报告)的相关 IOC 时被顺藤摸瓜挖出来的——但 NeedyMantis 本身并不通过供应链分发。
这个定位很关键。它不是那种自己负责敲门、负责钓鱼、负责提权的”先锋”,而是攻击者已经拿到环境访问权之后才被下发的植入体。报告里描述的典型手法是:操作者用 Impacket 走 hands-on-keyboard,从网络共享把”合法软件 + 恶意 DLL + 文件存档”三件套拷到目标机器上再执行。
二、包装:DLL side-loading(DLL 侧加载)
NeedyMantis 的样本由 C++ 和 x64 shellcode 组成,包装方式是经典的 DLL side-loading(DLL 侧加载)三件套:
- 一个合法的、带签名的可执行程序(真正会运行的载体);
- 一个恶意 DLL,伪装成这个程序依赖的某个库;
- 一个与 DLL 同名的无扩展名文件存档(custom archive),藏着真正的载荷。
被滥用的合法软件包括 Poedit、curl、Vim、TightVNC,DLL 则伪装成 Microsoft Office、Broadcom、Intel、NVIDIA 组件的样子。报告给出的落盘路径很能说明问题:
1 | %ProgramFiles%\Poedit\WinSparkle.dll ← 冒充 Poedit 的更新检查组件 |
这套包装的精髓在于”借依赖之名”。WinSparkle.dll 本身是 Poedit 真的会去加载的更新库,将恶意 DLL 放置在合法程序会优先查找的位置,使程序在运行时加载恶意 DLL。对于 EDR 来说,”Poedit.exe 加载了 WinSparkle.dll”是一条完全正常的调用链——父进程是白名单软件,被加载的 DLL 名字也对得上,唯一的破绽是 DLL 自身没有有效签名,或者路径与已知的好版本不一致。
另一个细节:与 DLL 同名(无扩展名)的存档文件(如 WinSparkle、libcurl)就静静躺在旁边。这是数据与代码分离的设计——DLL 本身可以做得极小(只负责一个动作),真正的载荷全部打包在这个看似无害的存档里。
三、第一层加载:唯一任务就是”拆包”
以分析样本 WinSparkle.dll(SHA-256: e842dd76…)为例,这个第一阶段加载器的任务清单很简单:从旁边的存档里提取第二阶段加载器,然后执行它,完事。
它在实现上叠了几层反分析手段:
- 混淆栈字符串(obfuscated stack strings):字符串不是明文躺在 .rdata 里,而是在函数执行时逐字节在栈上”搭”出来,再用数学运算解码。这样静态扫描器在文件里根本搜不到
kernel32.dll、VirtualAlloc这类特征串。多数被混淆的字符串恰恰是 Windows 的 DLL 名和 API 名——用于运行时动态解析 API。常量值同样做了混淆存储。
- 双反调试:基于
ProcessDebugFlags和ThreadHideFromDebugger的检查。
自定义存档格式:构造工具分析不了的文件格式
第一阶段的”包”不是 ZIP 也不是 RAR,而是自定义加密压缩格式。拆开后的逻辑是:
- 外层数据先 XOR 解码,再用
RtlDecompressBuffer(Windows 自带的压缩 API)解压; - 存档里每个文件条目:文件名 XOR 解码,内容同样走
RtlDecompressBuffer; - 偏移量、XOR 密钥和编码值随样本变化——也就是说格式虽同,每个样本的”钥匙孔”都不一样,通用解包脚本写不了,只能逐样本跟。

用 RtlDecompressBuffer 而不是自己写解压算法,它牺牲了一点自定义性,换来了极小的代码体积和极低的开销——系统 API 不会崩溃、不会有 bug、还能顺带让流量里的解压行为看起来更像”正常程序在干活”。XOR 只负责把数据在磁盘上变得”不可读”,真正的压缩交给系统,各司其职。
拆出来的东西:真假混装的文件堆
以 WinSparkle 存档(SHA-256: 9cb68f98…)为例,里面共 11 个文件,其中大量是真实的合法文件:7-Zip 组件(7-zip.chm/dll、7-zip32.dll、7z.exe)、Sysinternals 的 Disk2vhd.dll、Ctrl2Cap 的 main.dll,甚至还有一个 kernel32.dll。
真正的恶意文件混在其中,而且全部用合法系统 DLL 的名字命名:
| 文件名 | 真实身份 |
|---|---|
encryptbase64.ps1 |
第二阶段加载器(实为 x64 shellcode) |
dnsapi.dll |
恶意软件配置数据 |
ws2_32.dll |
基于 WebSockets 的 C2 通信模块 |
msvcrt140.dll |
负责加载模块并解析导出的 shellcode |
旧版样本(libcurl 存档,SHA-256: c82520eb…)更精简,只有 4 个文件:300.c(配置)、300.s(通信模块)、is(基于 Windows 服务的持久化模块)、m.l(主组件)。
四、第二层加载:披着 .ps1 外皮的 shellcode
第二阶段加载器的文件名叫 encryptbase64.ps1——尽管文件扩展名为 .ps1 PowerShell,但其中包含x64 shellcode。
这个”文件扩展名欺骗”的手法有一石二鸟的效果:
- 对人:分析师在文件列表里看到 .ps1,会默认它是脚本,按脚本思路去分析;
- 对检测:
.ps1常见于正常运维,脚本文件的静态扫描逻辑(找 cmdlet、找混淆的命令行)对一段二进制 shellcode 完全无效。
它的执行流程是教科书式的 shellcode loader:
- 在自身的计算偏移处定位两块编码数据和 XOR 密钥:第一块存 Windows DLL/API 名,第二块存 DLL 名与 API 哈希;
- 用一个基于 ROR(循环右移)的哈希算法在运行时解析 API——本样本的轮转值是 11。这是 shellcode 的经典手法:不写任何
import表,靠哈希逐个比对导出名,静态分析时完全看不出它要调什么; - 提取内嵌数据 → XOR 解码 →
RtlDecompressBuffer解压; - 得到的结果是一个自定义可执行文件格式的 DLL——注意,不是标准 PE,而是一个”最小化 PE 版本”的裁剪格式。
作者把 PE 头里所有非必需的字段全砍了,只保留加载所需的最小结构。体积更小、特征更少(标准工具的 PE 解析器可能直接解析失败),
Loader 手动解析 embedded header,手动映射.text/.rdata/.reloc等段、修复导入与重定位,在内存中分配空间、跳转入口执行。
这样使样本执行留下的痕迹更少。
五、落地:主组件、配置与”借名”的模块体系
拆完两层壳,真正的主角登场——主组件(旧版样本里的 m.l)。它的职责是编排 C2 通信和处理后续下发的模块,运行后创建名为 <用户名>-<进程名> 格式的互斥体(如 Contoso-Poedit.exe)来保证单实例。与第一阶段加载器类似,大部分主组件的字符串和常数值都以混淆堆栈字符串的形式存储。
配置也伪装成 DLL
配置数据(3448 字节的结构体)存放在 dnsapi.dll 里。包括以下字段:
- 0x00:未知(配置中包含“300”,但组件也引用“400”)
- 0x1c:通信组件名称(ws2_32.dll)
- 0x128:C2端口(443)
- 0x12c:C2 主机(corp.tripswithengine[.]com)
- 0x334:C2 URI(/library/zip/)
- 0x53C:WinHttpOpen AccessType (0)
- 0x954:代理用户名(未设置)
- 0xB5C:代理密码(未设置)
- 0xD64:与睡眠时间相关的(300)
- 0xD68:与睡眠时间相关的(300)
把配置藏进一个”系统 DLL 名”的文件里,和第二层加载里 .ps1 的思路一致:每一份持久化在磁盘上的数据,名字都在向系统借信任。
模块化架构
主组件并不自己实现功能,而是通过 C2 下发模块:加载(命令 1020)、卸载(1030)、向模块分发数据(1050/1150)、关闭活动标志(1070)。通信模块 ws2_32.dll 只导出一个函数 SystemInfo,暴露 10 个内部函数用于建立和维护 WebSockets C2 连接,基于 WinINet API 实现,硬编码 User-Agent 为 firefox/21.0;另有一版改用开源的 Libwebsockets实现了相同的通信API。
六、C2 协议:从 HTTPS 信标到自定义二进制 WebSocket
链路走通之后,C2 通信分两个阶段:
初始信标:HTTPS GET 请求。服务器的 Set-Cookie 响应头里夹带系统信息——Base64 解码再解压后是一个 JSON,字段包括计算机名、用户名、进程名、父进程、ProgramFiles 文件列表、进程列表等。用 Cookie 传数据是个妙招:Cookie 是 HTTP 里最不值得怀疑的头,日志和中间设备一般不会深究。
WebSocket 二进制协议:连接升级后切换到自定义二进制格式,44 字节的头部:
1 | 0x00 16 字节 XOR 密钥 |
数据用 RtlCompressBuffer 压缩,可选 RC4 加密。
第一条WebSocket消息是与C2服务器的密钥交换:
客户端从 C2 收 32 字节(然后直接忽略),自己生成 1024 字节随机缓冲、取前 32 字节当 RC4 密钥,再构建一个以 “google.com” 开头的 256 字节缓冲做 RC4 加密,包长随机(292–1282 字节),
C2 读取 0x104 位置的 1024 字节 buffer,提取前 32 字节恢复 RC4 密钥
使用恢复出来的 RC4 密钥解密 0x04 位置的 256 字节数据,校验是否以google.com开头(校验密钥是否同步成功)
C2 生成一个1~45 之间随机命令编号返回给后门,作为握手成功 ACK 确认。
七、复盘
1. 包装的本质是”信任转移”。
整条链里,恶意代码自己从不携带任何可信度——它的所有可信度都是从别处借来的:倚托进程的白签名,通过劫持白进程的执行流来达到恶意代码执行,让系统认为是正常的程序在执行。
2. 加载的核心是”逐层解冻”。
NeedyMantis 是一个三层冻结的结构:存档冻结(XOR + 自定义格式)→ shellcode 冻结(XOR + 压缩 + 自定义 PE 格式)→ 运行时冻结(栈字符串 + ROR 哈希动态解析 API)。每一层都遵守同一条纪律:磁盘上不出现明文的 API 名和关键数据,一切在运行时现场构造。
3. 落地的思路是”轻植入”。
它自己只做最基本的事:单实例互斥体、C2、模块调度。持久化交给 Windows 服务模块,功能通过 C2 下发的模块按需扩展。
