样本:chat-deepseek.exe,2,085,620 字节,x64,无签名。样本下载在最后面。
SHA256 68ae0cabadce8467534f8a41e9ca139b4b9a066aa20d154254793bb4eb9a6b17
分两段:先在宿主机上纯静态把文件拆开(这段没运行样本的任何代码),确认它会做什么之后,再放进隔离虚拟机里跑,抓运行态证据——包括所有走错的路。
拿到文件时唯一的线索是一句很含糊的话:"看着像仿冒 DeepSeek 的东西,但双击之后什么都没有。"
"什么都没有"是最没用的一句话。它至少指四种完全不同的情况:进程没被创建、起来了但秒退、活着但什么都不做、在跑但被什么东西拦住了。四种的排查方向完全相反。所以第一步不是打开 IDA,而是把它翻译成几个可测量的量:
最后一行是关键。崩溃退出的话方向会是"找 bug、找兼容性";而它是自己决定不干、正常返回——直接指向"内部有个判断把它拦下来了"。后面所有"推翻重来",本质都是某条测量结果和判断对不上。
第一次打开 PE,节表长这样:
.boot、.pkcfg、.fptable 是 Armadillo / SoftwarePassport 的招牌节名,加上 ARMPKCFG 魔数,很容易一眼就下"这是商业壳"。但我吃过看节名判壳的亏,所以定了三条更硬的判据:熵、导入表能不能正常解析、函数的序言模式。节名可以伪造,这三样不行。
结果第一条就出人意料:三个内嵌模块反而是明文。.text 熵 6.44(正常 MSVC 代码水平),导入目录解析出 13 个 DLL / 293 个函数全部正常,RTTI 完整可读 35 个类名,跳转表 100 项目标全落在 .text 内没损坏。我最初"主控被加壳"的悬念就此关掉——代码就在那儿,全都能读。
同样,我对 stub.exe 也误判过一次"加壳":解析节表时把 VirtualSize 当成了 VirtualAddress(字段顺序读反),导致 RVA→偏移错位、导入表解析落到全零区。改过来后:导入目录 rva=0x206ac size=80 正常,.text 熵 6.471,头部 48 8b c4 48 89 58 08 是标准 MSVC x64 序言——明文,没壳。这提醒我:工具给你一个输出,不代表那个输出是对的。
接下来要定位 \deputies.bin 这个串在代码里被谁用。我先用最直观的办法:从函数起点线性反汇编整个 .text,扫所有 RIP 相对引用。扫出来是——零引用。
一个明明存在于 .rdata 里的字符串,居然没有任何代码引用?我回头查反汇编输出,发现从 0x2630C 往后指令流整体"错位"了。0x2630C 是一张跳转表(switch 分派表),里面是纯数据;线性反汇编器把表里的数据当指令解析,从那里开始一路错到段尾——后面所有引用自然全扫不到。
改法是不再相信反汇编器,改字节级暴力扫描:逐字节匹配 48/4C 8D|8B + ModRM(mod=00 rm=101) + disp32,target = rva + 7 + disp;call [rip+disp32] 匹配 FF 15。同一字符串立刻扫出 30 多处引用。这条教训进了我的固定流程:先在字节层确认引用,再看反汇编——反汇编是解释,字节才是事实。
最关键的转折点,是发现它其实是个"壳 + 加密载荷"的封装结构。
第二个 .rdata(1.75MB,权限 RWX,chars=0xE0000040 展开就是 MEM_READ|MEM_WRITE|MEM_EXECUTE)最可疑——可写又可执行,典型的"运行时解密落地区"。我按它的文件偏移整段读出来异或,在段内偏移 0x4000D 处解出了一个合法 PE32+:ImageBase 0x180000000,入口 0x526F0,821,760 字节。密钥是 8 个字节:5A 3C 91 E7 2B 64 D8 0F。
我不是"猜到"的。定位方式是找到解密循环本身——sub_1400023D4 里 mov al,[rax+r8+0x1c550] / xor [rdx+r8+0x29000],al,密钥表就摆在 .rdata 的 0x1C550,逐字节读出来即可。能读到循环,就不需要猜。
同一把密钥顺手把 .data 0x29000 起的一片数据解成了完整的编译期配置串——这张表后来比密钥本身有用得多。
解出来的 login.dll 只有 821KB,导出表只有 7 个函数,每个都很有信息量:
这是恶意代码自己报出的名字,和后面磁盘上的 ArmouryLogin.log、ArmouryStager.log、Roaming\Armoury 目录完全互证。beacon 说明它是信标型 C2(被动上线、心跳、回调驱动),而 on_conn_closed 尤其关键——它意味着"连接断开"是预期内的常态事件,后来直接解释了日志里的"故障转移阶梯":那不是异常,是设计。
RTTI 里有 armoury::transport::ITransport(抽象基类)和 TcpTransport(实现),配合 std::function + shared_ptr——这是面向接口的现代 C++17 框架,不是 Gh0st 那种老式 C++。这个判断决定了后面归因不能乱套家族。
Main 那几个参数(host / port / proto / mark)的顺序我也没靠猜,而是从日志格式串反推:Main enter proto=%d port=%u host=%s mark=%s——格式串里的占位符顺序就是实参排布顺序。用"日志长什么样"反推"函数签名是什么",是这时最省力的一招。
armoury_beacon_on_packet 是命令总入口:取 packet[0],判两个前置值,其余走跳转表——TBL @ 0x2630C(100×u32)+ IDX @ 0x26348(100×u8),索引 = cmd - 0x88。
解表不难,难的是给每个命令定性。我用的方法不是读一遍逻辑,而是"把调用链上的 API 按顺序排出来"——API 的调用顺序几乎自带语义。最典型的一次纠错是 0xC8:一开始我看到包里有两个数值(默认 120 / 68),加上"参数设置"的联想,判成了"运行参数设置"。后来把它整条链的 API 排出来:
这是教科书级的截屏序列。而且 120×68 和上限 640×360 都是 16:9——两个数字都是图像尺寸,不是时间间隔。于是 0xC8 从"参数设置"改成"按需截图,服务端可指定分辨率"。
另一个硬结论是报文格式:[0]=命令字节、[1..16]=16 字节 payload、后面跟几个定长字段,最小长度 0x1B。协议头第 1 字节就是命令号——这条是从机器码里 cmp byte[rcx],0x88 这种硬编码比较直接读出来的,没有推断成分。
接下来想知道它靠什么活着、重启后怎么回来。这段的推法不一样——不是从代码推行为,而是拿静态里解出的配置串,去运行态产物里找对应物。那张编译期配置表里有一批长得像 ID 的串(ARMAUTO1、ARMVMS1、ARMUAC0、ARMGRD1、ARMGID833B463E、ARMGN10、ARMPRS01、ARMPIDA30BAE5E、ARMSIDA30BAE5E、ARMOBF1、ARMSELP1、ARMSCMAP)。
当时它们只是一堆常量名。等我把运行态的注册表和计划任务拉出来,对上了:
这是整段分析里最舒服的一次——静态和动态第一次严丝合缝扣上。 一个本来只是"常量"的字符串,突然告诉你它就是那几个持久化机制的名字,等于凭空拿到了一份作者的设计图。计划任务的 XML 也让规律更清楚:CalendarTrigger + Repetition PT1H(每小时重注入一次)、Hidden=true、StartBoundary 是落盘后 120 秒内的时间戳——它落地不到两分钟就把持久化做完了。
还有个细节值得单说:守护器文件名是 LeafChatHost.exe,我差点把它当 IOC 写进报告。后来在投递器里翻到一张12 个名字的池子:
运行时随机挑一个当文件名,我拿这 12 个名字去公开情报里批量检索,零命中。所以正确写法是:文件名不是 IOC(LeafChatHost.exe 只是这次抽到的),能当锚点的是路径形态(%LOCALAPPDATA%\Microsoft\<8位十六进制>\*.exe)和命令行参数(--arm-grd / --arm-src / --arm-boot / --arm-payload)。图省事把文件名当特征,检测规则下个月就失效。
顺带一提,守它们的目录里有个 target.path,90 字节。我一开始不知道它是什么,直到发现 90 = 44 个字符 × 2(UTF-16)+ 2 字节 BOM——按 UTF-16LE 读出来,正好是那个桌面样本的完整路径。算术先对上了,语义才敢下结论。
stub.exe 我只说了它是"进程镂空投递器",做法值得展开,因为它解释了"为什么杀进程没用"。定性还是排 API 序列:
再配上 CreateProcessW 和一个字符串 e%s\notepad.exe——顺序连起来就是完整的 **RunPE(傀儡进程)**链:
为什么不直接 CreateThread? 因为镂空之后,进程在任务管理器里显示的是 notepad.exe,模块列表里没有可疑 DLL,磁盘上也没有可执行文件——查杀软件按"可疑进程名 / 可疑模块"去匹配,什么都找不到。
配套看那个被写进去的 payload.bin(39,541 字节):它不是 PE,没有 MZ 头,是一段纯 x64 位置无关 shellcode——入口三条指令 sub rsp,0x38 → 在栈上拼出 "boot" → lea rcx,[rsp+0x20] → call,也就是说它自己会去找并加载 a64.dll。"投递器不落地可执行文件、载荷也不是 PE"——这条链上每一步都在躲静态特征。
真正联网的只有 a64.dll。这一点是从导入表反过来确认的:stub.exe(3 个 DLL / 95 函数)与守护器(2 个 DLL / 97 函数)都不导入 WS2_32 / WININET / WINHTTP 中的任何一个;只有 a64.dll 是 16 个 DLL / 301 个函数,网络、截屏、令牌、设备枚举一应俱全。"守护器和投递器不联网"是设计,不是被断网了。
然后我在 a64.dll 字符串里翻到一段很扎眼的东西——powershell.exe … -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand,紧接着一段 Base64,解码后是内联 C# 代码。四段脚本:① 单次截屏;② 连续截屏,超 1600×900 等比缩小、先写 .tmp 再原子 Copy,200 ms/帧(5 fps);③ 内联类 ArmFg 取前台窗口标题 + 空闲秒数,每 500 ms 写 arm_fg_%u.txt;④ 内联类 ArmWinQ 导出全部窗口信息到 arm_winq_%u_%u.txt。
为什么用 PowerShell 而不是写 C++? 因为脚本是 -EncodedCommand 传进去的,命令行里只有一串 Base64,磁盘上不留 .ps1,进程树里也看不到脚本内容。5 fps 连续截屏 + 每 500 ms 一次前台窗口/空闲采样,合起来就是"他在看什么、有没有在操作"——社工诈骗最需要的情报。
还有一条业务线索,藏在同目录的 FILTER 文件里(2,940 字节,UTF-16),是一张两列对照表:
一份即时通讯软件的监控/劫持名单。 结合前面的浏览器扩展注入串 ArmouryIntercept 和 Chrome / Edge / Brave 的扩展目录路径,变现路径就清楚了:盯着 IM,找机会冒充本人去骗联系人——技术分析到这里,第一次看到"它图什么"。
到这时我已有完整的代码骨架,却始终没找到最该有的东西——C2 地址。先假设它写得比较直接,做了七轮搜索,每轮换一种"藏法":
七轮全空之后,反而不该继续硬搜,而是回头看代码里那些"看起来在配置网络"的地方。a64.dll 里有一段协议配置块——"ARPLWire"、"/arpl/ws"、"AFR2"、"ArmouryFrameV2-AUTH"、"ArmouryFrameV2-ENC",以及一句日志格式串:Main enter proto=%d port=%u host=%s mark=%s。
host 是 Main 的入参。 也就是说,这个样本从设计上就不带 C2——它等着外面的人把地址递进来(通过 deputies.bin 配置、或 armoury_beacon_start 的入参结构体)。这也解释了为什么"下载/更新"通道一直在报 pullfail:它在拿到配置之前,本来就无事可做。
所以"没见过的东西"和"不存在的东西"要分开。45.64.52.179 是存在的,只是不在文件里——它是从外部投递进来的。这条"排除得出外置"的结论,正好把注意力推向文件里唯一一块还没打开的地方:末尾那 50,932 字节。
第一次。 我在静态里全量核对了经典反 VM 手法:GetSystemFirmwareTable、GlobalMemoryStatus、IsDebuggerPresent、CPUID hypervisor leaf、各种 MAC OUI……全部 0 命中。那些 VMware / VirtualBox / WireGuard 字符串我也追到了消费点,发现它们是网卡打分器的扣分项(给虚拟网卡扣 90 分,满分 120),只影响排序,没有任何退出/自杀逻辑。于是第一版结论是:没有反 VM,只有 5 个"静默闸门",最可能是环境变量 ARMOURY_ENABLE_TRANSPORT_LAYER 没设。
第二次(被实测打脸)。 实测结果是 C:\Windows\Temp\ArmouryLogin.log 根本不生成——第一版说的"传输层/C2 问题"完全错了。重查发现两件事:一是程序真的起来了,跑到 sub_2010 尾部是一个 Sleep(0x36EE80) = 1 小时的死循环;二是 login.dll::Main 要求 5 个非空参数(前置 test rdx,rdx / test r8,r8 / … 任一为空就 je 直接返回),双击时宿主传参不满足,所以"写日志"那行从来没被执行过。这版结论:它不是"能双击跑的完整木马",而是需要特定 loader / 参数才"活"的组件。
第三次(我把样本真的跑起来,推翻了第二版)。 我在 VMware 里跑,亚秒级静默退出,任务管理器都看不见。重查启动链,在 sub_2010 前 0x60 字节里找到了真正的卡点:
sub_54A8 是打分子函数。在 VMware 里,注册表键 SOFTWARE\VMware, Inc.\VMware Tools 和进程 vmtoolsd.exe 必然命中,得分达阈值就直接返回 1 → 静默 ret 退出。"任务管理器看不到进程、日志不生成、无网络、无持久化"——现象全部吻合。
第四次(反汇编精化)。 我把 sub_54A8 逐条拆开,发现之前的"4 个因子"理解错了:那几个传进去的 10 / 9 / 10 / 7 是数组长度,不是权重——真实规模是 8 路检测、其中 4 路是数组、共 36 个检查项。同时发现"第二道门"sub_4ADC 开头 cmp byte[0x2901E],'1'(配置 ARMUAC0,实测 '0'),条件不成立就直接走到"放行"出口,它天然放行。结论:唯一的阻断点就是那个打分器。
定案(单字节差分)。 最后我用桌面上另一个版本的样本做差分——两个文件只差 1 个字节:
一个字节,虚拟机检测开关就关掉了——这也证明那张编译期配置表不是"清单",而是运行时读取的功能开关板。
这四次推翻里我做对的只有一件事:每轮结论都写清楚"怎么证伪"——所以每被推翻一次,下一次的起点都更靠后、更精确。
顺手一提,打补丁还有个坑。两道 jne 的极性是相反的:
第二道是"通过才跳转",如果我按第一道的思路把它也 NOP 掉,控制流会顺序落进退出块,反而保证退出;正确做法是改成无条件跳转(75 07 → EB 07,只动一个字节)。这种"看起来对称、实际方向相反"的地方,是补丁最容易翻车的位置。
绕过了反 VM、样本在 VM 里上线之后,接下来是拿到它到底连谁。netstat -an 出来一串 ESTABLISHED。如果直接按 IP 排序,175.12.122.167 会排在头号嫌疑——175.0.0.0/8 是中国段,看着最可疑。但我坚持做了一步"逐 PID 映射进程名":
那个"头号嫌疑"其实是 Windows 搜索索引器。而 45.64.52.179 独占两条连接,两条都来自样本自身。铁律一条:不映射到进程名的 IP 排序,全是噪声。
有了 IP 还要拿域名。查 DNS 缓存时又踩了一个坑:ipconfig /displaydns 里 findstr "Record Name" 零命中,我一度以为"DNS 缓存是空的"。真相是——中文 Windows 的表头是「记录名称」,不是英文。改成 Get-DnsClientCache 按 IP 反查(语言无关),立刻拿到:
C2 = jd.macio-china.com → 45.64.52.179:443。
顺手验证了一件事:两个进程 PPID 相同、命令行相同 → 是兄弟进程而非父子派生(排除了"loader 注入子体"的常规模型);域名归属放到最后归因一节。
拿到 C2 之后,我一度很有把握地判定协议是 WSS(WebSocket over TLS):内存里挖到了 RFC6455 的魔数 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,还有 Sec-WebSocket-Key、Upgrade: websocket,以及 websocket.dll 和 SChannel 相关串。看起来板上钉钉。然后 VM 里取到了两份明文日志(ArmouryLogin.log / ArmouryStager.log):
三个字把上面的判断推翻了:
再加一个反证:我早先用裸 IP 去做 TLS 握手,对端直接断开——现在解释通了,因为那上面根本不是 TLS 服务。
最终定性:443 端口上跑的是裸 TCP + 自研 ARPLWire 帧 + 自研加密(ArmouryFrameV2-AUTH / ArmouryFrameV2-ENC 两阶段帧,魔数 AFR2)。WS / SChannel 的代码编译在内但默认关闭——这正是 transport_disabled 的含义。防御意义很大:443 上没有 TLS,基于 JA3 / 证书的检测手段全部失效;选 443 就是为了混进 HTTPS 流量,又不吃 TLS 检测。日志还顺手给了故障转移阶梯:
所以封禁时 443 和 80 要一起拦,只封 443 它会掉到 80。
线索来自磁盘:样本跑起来后在磁盘上落了三个文件——a64.dll(821,760 B)、a64u.dll(与 a64.dll 同 SHA256,说明更新槽没被写入),以及同样大小但头部是 7D 6A(不是 4D 5A)的 a64。同尺寸 + 明文可得 ⇒ 直接算密钥流:keystream = a64 XOR a64.dll。手工核算前几行:
从偏移 2 起出现周期 10 的重复。这是已知明文攻击,不需要知道任何算法。
回到投递器本体。第二个 .rdata(那 1.75MB RWX 节)我按 256KB / 1MB / 256KB / 256KB 切成四个槽,逐个看占用长度: