闲来无事,逆向一个银狐病毒
样本:
chat-deepseek.exe,2,085,620 字节,x64,无签名。样本下载在最后面。 SHA25668ae0cabadce8467534f8a41e9ca139b4b9a066aa20d154254793bb4eb9a6b17分两段:先在宿主机上纯静态把文件拆开(这段没运行样本的任何代码),确认它会做什么之后,再放进隔离虚拟机里跑,抓运行态证据——包括所有走错的路。
先别急着扔 IDA:把"没反应"拆成能验证的问题
拿到文件时唯一的线索是一句很含糊的话:"看着像仿冒 DeepSeek 的东西,但双击之后什么都没有。"
"什么都没有"是最没用的一句话。它至少指四种完全不同的情况:进程没被创建、起来了但秒退、活着但什么都不做、在跑但被什么东西拦住了。四种的排查方向完全相反。所以第一步不是打开 IDA,而是把它翻译成几个可测量的量:
| 待测量 | 怎么测 | 后来的答案 |
|---|---|---|
| 进程有没有被创建、活了多久 | Process Monitor 的 Create → Exit 时间 | 创建了,但亚秒级退出 |
| 有没有落文件 | 看约定路径下有没有产物 | 没落任何文件(连日志都没有) |
| 有没有出网 | netstat、DNS 缓存 |
没有 |
| 退出方式 | 正常 ret(退出码 0、无崩溃报告)还是异常崩溃(有 WER 日志) |
干净的 ret |
最后一行是关键。崩溃退出的话方向会是"找 bug、找兼容性";而它是自己决定不干、正常返回——直接指向"内部有个判断把它拦下来了"。后面所有"推翻重来",本质都是某条测量结果和判断对不上。
它到底有没有壳:不要看节名,要看三件事
第一次打开 PE,节表长这样:
.boot vsize=0x592 entropy=6.00 ← 节名像 Armadillo 的引导节
.text vsize=0x19BB0 entropy=6.44
.pkcfg vsize=0xA0 entropy=0.22 ← 魔数 ARMPKCFG
.rdata vsize=0x1C0000 chars=0xE0000040 (RWX, 1.75MB)
.fptable vsize=0x100 entropy=0
.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 起的一片数据解成了完整的编译期配置串——这张表后来比密钥本身有用得多。
它到底能干什么:从导出表和 RTTI 反推作者思路
解出来的 login.dll 只有 821KB,导出表只有 7 个函数,每个都很有信息量:
ArmouryCapHelperW armoury_beacon_start
ArmouryUCapHostW armoury_beacon_stop
Main armoury_beacon_on_packet
armoury_beacon_on_conn_closed
这是恶意代码自己报出的名字,和后面磁盘上的 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——格式串里的占位符顺序就是实参排布顺序。用"日志长什么样"反推"函数签名是什么",是这时最省力的一招。
命令表:100 个槽位怎么逐个定性
armoury_beacon_on_packet 是命令总入口:取 packet[0],判两个前置值,其余走跳转表——TBL @ 0x2630C(100×u32)+ IDX @ 0x26348(100×u8),索引 = cmd - 0x88。
解表不难,难的是给每个命令定性。我用的方法不是读一遍逻辑,而是"把调用链上的 API 按顺序排出来"——API 的调用顺序几乎自带语义。最典型的一次纠错是 0xC8:一开始我看到包里有两个数值(默认 120 / 68),加上"参数设置"的联想,判成了"运行参数设置"。后来把它整条链的 API 排出来:
GetDC → GetSystemMetrics×2 → CreateCompatibleDC → CreateCompatibleBitmap →
SelectObject → SetStretchBltMode → StretchBlt → GetDIBits → 清理
这是教科书级的截屏序列。而且 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)。
当时它们只是一堆常量名。等我把运行态的注册表和计划任务拉出来,对上了:
| 静态配置串 | 运行态对应物 |
|---|---|
ARMGID833B463E |
Run 键 G-833B463E → …\Microsoft\833B463E\LeafChatHost.exe --arm-boot |
ARMPIDA30BAE5E |
计划任务 \P-A30BAE5E → stub.exe --arm-payload "…payload.bin" |
ARMS1-%s-%lu |
互斥标记文件 ARMS1-y0YUmsuGTDqIgQTb-1 |
其中的 A30BAE5E |
释放目录 %LOCALAPPDATA%\Microsoft\A30BAE5E\ |
这是整段分析里最舒服的一次——静态和动态第一次严丝合缝扣上。 一个本来只是"常量"的字符串,突然告诉你它就是那几个持久化机制的名字,等于凭空拿到了一份作者的设计图。计划任务的 XML 也让规律更清楚:CalendarTrigger + Repetition PT1H(每小时重注入一次)、Hidden=true、StartBoundary 是落盘后 120 秒内的时间戳——它落地不到两分钟就把持久化做完了。
还有个细节值得单说:守护器文件名是 LeafChatHost.exe,我差点把它当 IOC 写进报告。后来在投递器里翻到一张12 个名字的池子:
QuickNoteHelper PhotoGlanceHost SkySyncHost PlayLiteHelper
DeskCalcHost FileNestHelper GuardPulseHost TunePadHelper
SnapCamHost PageMarkHelper LeafChatHost PrefPanelHost
运行时随机挑一个当文件名,我拿这 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 序列:
NtAllocateVirtualMemory NtWriteVirtualMemory NtGetContextThread
NtSetContextThread NtResumeThread NtClose
再配上 CreateProcessW 和一个字符串 e%s\notepad.exe——顺序连起来就是完整的 RunPE(傀儡进程)链:
CreateProcessW(notepad.exe, CREATE_SUSPENDED) ← 宿主挂起
→ NtAllocateVirtualMemory( RWX, size = payload + 0x20000 )
→ NtWriteVirtualMemory → NtSetContextThread( 改入口点 )
→ NtResumeThread ← 跑起来的是 notepad,内容是载荷
为什么不直接 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 里还藏着一整套"无文件"工具链
真正联网的只有 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),是一张两列对照表:
signal → SL telegram → TG safeW → SW WhatsApp → WA
一份即时通讯软件的监控/劫持名单。 结合前面的浏览器扩展注入串 ArmouryIntercept 和 Chrome / Edge / Brave 的扩展目录路径,变现路径就清楚了:盯着 IM,找机会冒充本人去骗联系人——技术分析到这里,第一次看到"它图什么"。
C2 到底在哪:先排除,再下结论
到这时我已有完整的代码骨架,却始终没找到最该有的东西——C2 地址。先假设它写得比较直接,做了七轮搜索,每轮换一种"藏法":
| 通道 | 我假设它藏成什么样 | 结果 |
|---|---|---|
明文直搜 macio、IP 的 8 种编码(原样/反转/UTF-16/十六进制/点分十进制) |
明文或编过码 | 0 命中 |
| 重复密钥 XOR 的 crib-drag(6 crib × 周期 1–32 × 全文件每偏移) | 被 XOR 加密 | 唯一"命中"解出 mmmm… 噪声 → 假阳性 |
| 单字节加法/减法混淆、单字节 XOR(各全 255 密钥) | 简单混淆 | 0 命中 |
a64.dll 高熵区 / 版本资源 / 构建配置表 |
小段密文 / 写在资源里 | 全是查表数据,无相关字段 |
七轮全空之后,反而不该继续硬搜,而是回头看代码里那些"看起来在配置网络"的地方。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 字节里找到了真正的卡点:
0x2047 cmp byte[0x29016], '1' ; 配置开关 ARMVMS
0x2050 call sub_54A8 ; ★ 反 VM/沙箱 多因子打分
0x2057 jne 0x2062 ; al==1 → 落进退出块
0x2062 xor eax,eax
0x2064 jmp 0x2359 ; ← 直接返回
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 个字节:
文件偏移 0x27616 原始样本 0xE9 → 变体 0xE8
用同一把密钥解出: ARMVMS1 → ARMVMS0
一个字节,虚拟机检测开关就关掉了——这也证明那张编译期配置表不是"清单",而是运行时读取的功能开关板。
这四次推翻里我做对的只有一件事:每轮结论都写清楚"怎么证伪"——所以每被推翻一次,下一次的起点都更靠后、更精确。
顺手一提,打补丁还有个坑。两道 jne 的极性是相反的:
0x2057 jne 0x2062 ; al==1 → 跳到退出块 → 这一道可以 NOP
0x2060 jne 0x2069 ; al==1 → 跳到继续分支 → 这一道 NOP 就完蛋
第二道是"通过才跳转",如果我按第一道的思路把它也 NOP 掉,控制流会顺序落进退出块,反而保证退出;正确做法是改成无条件跳转(75 07 → EB 07,只动一个字节)。这种"看起来对称、实际方向相反"的地方,是补丁最容易翻车的位置。
去 VM 里抓 C2:先学会不把噪声当证据
绕过了反 VM、样本在 VM 里上线之后,接下来是拿到它到底连谁。netstat -an 出来一串 ESTABLISHED。如果直接按 IP 排序,175.12.122.167 会排在头号嫌疑——175.0.0.0/8 是中国段,看着最可疑。但我坚持做了一步"逐 PID 映射进程名":
| PID | 进程 | 对端 |
|---|---|---|
| 8140 | chat-deepseek.switch.exe | 45.64.52.179:443 |
| 9380 | chat-deepseek.switch.exe | 45.64.52.179:443 |
| 7428 | SearchApp.exe | 175.12.122.167:443 |
| 6892 | WinStore.App.exe | 23.199.2.97:443 |
那个"头号嫌疑"其实是 Windows 搜索索引器。而 45.64.52.179 独占两条连接,两条都来自样本自身。铁律一条:不映射到进程名的 IP 排序,全是噪声。
有了 IP 还要拿域名。查 DNS 缓存时又踩了一个坑:ipconfig /displaydns 里 findstr "Record Name" 零命中,我一度以为"DNS 缓存是空的"。真相是——中文 Windows 的表头是「记录名称」,不是英文。改成 Get-DnsClientCache 按 IP 反查(语言无关),立刻拿到:
Entry : jd.macio-china.com
Data : 45.64.52.179
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):
dial host=jd.macio-china.com port=443 proto=0 tmo=12000
dial OK port=443 tcp via=direct
三个字把上面的判断推翻了:
proto=0—— 全程固定为 0。真走 WS/TLS 会是 1/2/3。tcp via=direct—— 拨号层明确报的是 tcp,不是 tls、不是 ws。dial OK到send_login之间没有握手步骤,全程 203ms,没有 TLS 往返开销。
再加一个反证:我早先用裸 IP 去做 TLS 握手,对端直接断开——现在解释通了,因为那上面根本不是 TLS 服务。
最终定性:443 端口上跑的是裸 TCP + 自研 ARPLWire 帧 + 自研加密(ArmouryFrameV2-AUTH / ArmouryFrameV2-ENC 两阶段帧,魔数 AFR2)。WS / SChannel 的代码编译在内但默认关闭——这正是 transport_disabled 的含义。防御意义很大:443 上没有 TLS,基于 JA3 / 证书的检测手段全部失效;选 443 就是为了混进 HTTPS 流量,又不吃 TLS 检测。日志还顺手给了故障转移阶梯:
443 (tmo=12000) 失败 → 80 (tmo=5000) 失败 → backoff=200 → 回 443
所以封禁时 443 和 80 要一起拦,只封 443 它会掉到 80。
破解打包器:从一把小钥匙到全域密钥律
线索来自磁盘:样本跑起来后在磁盘上落了三个文件——a64.dll(821,760 B)、a64u.dll(与 a64.dll 同 SHA256,说明更新槽没被写入),以及同样大小但头部是 7D 6A(不是 4D 5A)的 a64。同尺寸 + 明文可得 ⇒ 直接算密钥流:keystream = a64 XOR a64.dll。手工核算前几行:
a64.dll: 4D 5A 90 00 03 00 00 00 04 00 00 00 | FF FF 00 00 B8 00 …
a64 : 7D 6A F0 89 86 9D A3 A5 73 84 74 30 | 9F 76 85 9D 1B A5 …
XOR : 30 30 60 89 85 9D A3 A5 77 84 74 30 | 60 89 85 9D A3 A5 …
└──── 周期 10 ────┘
从偏移 2 起出现周期 10 的重复。这是已知明文攻击,不需要知道任何算法。
回到投递器本体。第二个 .rdata(那 1.75MB RWX 节)我按 256KB / 1MB / 256KB / 256KB 切成四个槽,逐个看占用长度:
| 槽 | 节内偏移 | 实际占用 | 减去 13 后 |
|---|---|---|---|
| 1 | 0x040000 |
821,773 | 821,760 ← a64.dll |
| 2 | 0x140000 |
168,461 | 168,448 ← 守护器 |
| 3 | 0x180000 |
144,397 | 144,384 ← stub |
三个槽"多出来的部分"都精确等于 13 字节。围绕这 13 字节,我纠正了自己两次:
第一,它不是二进制结构,是 NUL 结尾的 ASCII 角色标签。 我一开始当成"长度/校验字段"穷举,一无所获;换成用同一把密钥直接解这 13 字节,得到可读英文:
槽1 → loginpattern\0 (a64.dll,C2 主体)
槽2 → guardpattern\0 (守护器)
槽3 → stubpattern\0+ (进程镂空注入器)
.text 里还有独立的交叉引用可以证实:0x626A–0x629A 逐字节比对槽 2 的首地址和 'g','u','a','r','d','p',0x62DE–0x630E 比对槽 3 和 's','t','u','b','p','a'。加载函数在 +13 处取址——和标签长度完全吻合。
第二,"每个模块一套密钥"是错的,真相是一把钥匙统治整个文件。 我原以为三个模块各自从相位 0 开始异或,推广验证后的规律是:
plain[file_off] = cipher[file_off] ^ KEY8[ (file_off + 3) mod 8 ]
KEY8 = 64 D8 0F 5A 3C 91 E7 2B
之所以会看成"每模块相位 0",是因为三个模块的 raw 起点 0x6A000 / 0x16A000 / 0x1AA000 刚好都 ≡ 0 (mod 8),偏移量恰好被吸收成了相位 3。最强旁证是:这条规律在完全不同的节(.data 配置表,偏移 0x27600)上也成立——同一把密钥解出 8 条语义完整的配置串。这不可能是巧合。
最后是第四份载荷。那个 256KB 的槽 0 我一开始当成"稀疏工作区"跳过(每 4096 字节只有 ~617 个非零字节)。直到把"非零字节总数"算出来:39,464——和 payload.bin 明文的 39,541 只差 77。追下去发现它就是用另一把独立密钥(2C 30 FE F5 74 5F EA 42 A4 23 EE 49 6A 82 96 73,16 字节)加密的 payload.bin,布局是 64 段 × 618 字节、步长 4043、首段起点 3425,有效载荷率只有 15.1%。
这里我踩过一次陷阱:一开始用"过滤掉所有零字节"的办法还原,得到 39,464 字节——错了。因为密文里有 77 个字节恰好等于 0x00,和间隙的填充零在视觉上无法区分,会被一起滤掉;必须按段几何(固定起点 + 固定长度)重建。改过来后,重建结果与原始槽 0 逐字节相同,SHA256 精确等于 VM 侧那份 payload.bin。把 39KB shellcode 切成 64 段散布在 256KB 里,意图很明显:让文件里不存在任何 ≥618 字节的连续 shellcode 片段,废掉字节序列特征码。
overlay:解不开的时候,把边界划清楚也是结论
前面"C2 外置"那段把注意力推到了最后一块没打开的地方:文件末尾有 50,932 字节不属于任何节(算法:文件总长减去最后一个节的结束偏移,算术自证)。它前 8 个字节是标准 PNG 签名 89 50 4E 47 0D 0A 1A 0A——看起来像图片。但它第 9 个字节起就破坏了 PNG 结构:规范要求紧接着是 00 00 00 0D + IHDR,实测完全不符;解析 chunk 长度得到 len=321167813(非法),没有 IHDR 也没有 IEND。所以那 8 字节只是明写的诱饵魔数。
接下来大量排除,一共试了 11 条路径:
| 攻击 | 结果 |
|---|---|
| 单字节 XOR / 周期 1–16 重复密钥 XOR(用已知 IHDR 反推) | 密钥互不一致;或解出的 comp/filter/interlace 非 0 → 排除 |
| IoC 逐周期扫(p 到 1024)+ ECB 重复 16 字节块 | 全落在随机基线;3,183 块重复 0 |
| zlib/gzip/bz2/lzma(off 0–23)、RC4 × 46 密钥、复刻多态引擎、模块密钥 8 相位、K16 全 16 相位 | 全失败;K16 解完熵仍 7.99645 |
统计量给出了终局答案:
body = 50,924 B(去掉 8 字节魔数)
entropy = 7.99636 真随机期望 = 7.99639 ← 差 3×10⁻⁵
chi² = 256.0 (df=255, 临界值 293 @p=0.05) ← 教科书级均匀
它统计上不可区分于真随机。 到这里我没有假装把它解开,而是给了一个有边界的结论:它要么是强加密(密钥运行时派生),要么就是纯随机填充——后者的话,那 8 字节 PNG 魔数就是纯粹的诱饵。我倾向"随机填充":这个 builder 在所有已知的加密环节都只用弱 XOR,overlay 如果真是关键配置,没理由独独升级密码强度;而且全文检索不到任何引用 50932 / 0x1F0C00 这个尺寸的代码。但在拿到第二份同源样本做差分之前,我不把这条写成结论。
这段看着像"没做完",其实是我最有把握写进报告的部分之一:知道自己卡在哪条边界上、并能证明这条边界在哪,比给一个含糊的"应该是加密配置吧"有价值得多。
归因:基础设施能定,代码血统不能硬套
归因我拆成两层,用完全不同的证据标准。
基础设施层:证据很硬。 C2 域名注册商是 Gname.com(IANA 1923,长期位居恶意域名注册量榜首);域名注册于 2026-06-07、只有 4 个月;macio-china.com 仿冒 macio.com.cn,子域 jd. 再仿冒京东;crt.sh 返回空(零证书透明度记录,说明它从不申请公开 CA 证书,与"TLS 握手被拒"互相印证)。和 ReliaQuest《Silver Fox》报告对一遍:Typosquatting、Gname.com、子域承载 C2、短命新域名、无公开证书、香港 IDC 托管——六项全部吻合。
代码血统层:我给不出,而且我拒绝硬套。 我逐条复核最初"像银狐"的依据:有没有 Gh0st 源码痕迹?0 条。RTTI 显示的是 armoury::transport::ITransport + C++17 的 std::function/lambda/shared_ptr——和 Gh0st 系那种"无命名空间、类名形如 CClientSocket"的老式 C++ 完全对不上;内部命名 "Armoury" 在公开情报里也查不到任何归因。所以我的写法是:代码层是一个自研的 C++17 远控框架,对外宣称代号 Armoury,与已知家族无代码同源证据;基础设施层高度符合银狐生态。 我会把它交给 CNCERT 做代码比对,而不是自己拍板说"这就是银狐"。
中间还有一次必须做的证伪。 公开情报里确实有三个 "Armoury":微软的检测名 Trojan:Win32/Armoury,安天的 ArmouryLoader(x86、劫持华硕 Armoury Crate 的 ArmouryA.dll、用 OpenCL/GPU 解密),以及 Zscaler 记录的 CoffeeLoader 的 "Armoury" 壳。名字撞得很厉害。我做了五个阴性检测:OpenCL、clCreateBuffer、ASUS、ArmouryA、freeBuffer——5 个文件里全部 0 命中;而且本样本是 x64 原生(不是 x86),注入手法是 Nt* 进程镂空 + notepad.exe(没有天堂之门/系统调用号搜索)。结论:命名碰撞,非同源,归因时不能合并。 偷懒把两个 "Armoury" 当成一家,整个归因就毁了。
顺带说个容易被当成"归因铁证"的东西:三个模块里都有个叫 .fptable 的节,我一度想拿它当家族锚点——但它在所有模块里都是 512 字节全零,而且多个互不相关的恶意家族里都有同名同形的空节。那是构建工具链留下的,不是团伙留下的:能当"狩猎 pivot",不能当归因。
收尾:把样本和补丁做成一个能安全复现的释放器
分析写完了,但东西交不出去。手上有五个文件:原始样本 + 四个补丁版(switch 1 字节 / nop 3 字节 / kill 5 字节 / all 9 字节)。直接扔文件夹有两个问题:误触(手滑双击就真跑了)、留不住(本机实时防护会在落盘后约 1 秒内按内容特征删掉,改后缀也没用)。
打包: AES-256 压缩包,口令 infected。坑在于必须在内存里拼完再写出去——先落一个临时明文文件再压缩的话,实时防护会在压缩途中把它删掉,包就残缺了。流程:读原文件 → 校验 SHA-256 → 内存里 writestr → 一次写出 → 再从内存回读逐条比对。
自包含启动器: 解压出来还是五个独立文件,照样被吃;干脆把五个样本作为 RCDATA 资源塞进一个 EXE(成品 10,467,328 字节),运行时再释放,磁盘上不存在"单独的样本文件"。核心是三道关:① 免责声明默认按钮是「不同意,退出」,回车即放弃且不释放任何文件;② 默认勾着「仅释放到磁盘,不要运行」;③ 取消安全选项后才出现的最后确认。手滑双击不会跑起来。
几个细节:释放文件保持原名(样本从自身路径推导守护参数,改名会改行为);防自覆盖(启动器若叫 chat-deepseek.exe,自动转存 payload\);被杀软删掉要说清楚(Sleep(1200) 后核对大小,对不上就弹「被实时防护拦截」)。另外留了两个模式:--verify 不写盘、不执行,只从内存资源算 SHA-256;--extract <目录> 纯命令行释放。--verify 和做静态分析的纪律是同一个思路:证明字节是对的,但一个字节都不落地、也绝不运行。
先拿无害测试载荷(只弹一个消息框)跑通全链路——不同意→不释放、仅释放不运行、真启动、改名自覆盖、--verify/--extract 哈希核对,七个场景全过才换真实样本重建。 构建坑:windres 要加 -c 65001(否则中文目录名按 CP936 解码找不到)、.rc 必须写 #define IDR_PAYLOAD_*(否则被当字符串资源名)、链接 -municode -mwindows -static -lbcrypt -lshell32。顺手还写了个只读的本机 IOC 排查脚本——每条判据都是从前面那些分析结论来的。
交付物:
银狐样本启动器.exe 10,467,328 B sha256 10c91cc3cd88c6a375d5dbcf427b2dcde4ab436ef1a131b5a5818c1b4f46cdb5
样本包.zip 4,594,752 B sha256 595589ebf36b332b032877288d616da42bf36c684f98784bc747669e94ddafd6 (AES-256,口令 infected)
五个变体哈希在包里和 --verify 报告里是同一组(68ae0cab… / 8879984a… / 31620c4b… / 30927d0a… / bd93e88f…),两处对得上。
复盘:这几条纪律是踩出来的
一、读到最后一章再下判断。 这份分析里"为什么双击没反应"被推翻了四次,"协议是什么""各模块是不是一套密钥""高熵区是不是密文"各被推翻过一次。只读前三分之一就写总结,写出来的东西会全错。
二、工具会给你"看起来正常"的错误答案。 线性反汇编遇跳转表会静默失步,把"30 处引用"报成"零引用";把 VirtualSize 读成 VirtualAddress 会让导入表解析落到空区,然后你判"它加壳了"。工具不报错不代表它是对的——最终以字节为准。
三、高熵不等于密文,随机也不等于随机。 字节表可以熵 7.9;真随机要用 χ² 和熵的偏离度验。反过来,我一直以为的高熵密文最后发现是 8 字节短周期 XOR——"高熵 ⇒ 强加密"这个直觉在本案被实证推翻了。
四、搜到 2 字节魔数先算随机期望命中数。 4D 5A 在 1.75MB 里的随机期望命中约 28 处——单次命中就是噪声级别。判据必须是长串或整文件哈希。
五、不映射进程名的 IP 排序全是噪声。 那个中国段 IP 一度是头号嫌疑,实际是 Windows 自带进程。
六、能当 IOC 的东西,先问"它会不会变"。 文件名从 12 个里随机抽,真正的锚点是路径形态和命令行参数。同理,静态串与运行态产物对得上号(ARMGID833B463E ↔ G-833B463E)才是最强证据——这种互证我遇到四五次。
七、操作系统会骗你,是因为它没说英文。 中文 Windows 的 ipconfig /displaydns 表头是「记录名称」;PowerShell 5.1 不支持三元运算符;.ps1 双击默认不执行。每个都能让你得出错误结论或浪费一整天。
八、取证产物不要落在受快照管辖的盘上。 我在虚拟机里跑完采集、打完快照后做硬盘还原,C:\forensics 连同产物一起被回滚吃掉了——白做一遍。每跑完一个阶段立刻外传。
九、交付物也要设计"防误触"和"防被吃"。 样本直接放文件夹里,可能被误双击,也会被实时防护悄无声息删掉。所以做成了"内存里打包 + 内嵌 EXE + 默认选项偏安全"的释放器——默认值不是随手定的,它是最后一道防线。
最后一句实话:这份分析能推进到 C2 和归因,靠的不是某个聪明方法,而是每次得出结论后都逼自己写清楚"什么证据能推翻它"。每一条被推翻的结论,都是下一次更精确的起点。真正拿到手的,都是被推翻到只剩骨架后还站着的那些。
样本下载: 密码:4a97