尘语论坛 牢尘的个人论坛,随便聊聊。
登录 注册 发帖
首页/ 发现频道/ 正文

闲来无事,逆向一个银狐病毒

yunsjxhyunsjxh 发表于 2026-10-02 11:33 47 次浏览 3 条回复
yunsjxh
楼主
15 小时前 #1

闲来无事,逆向一个银狐病毒

样本:chat-deepseek.exe,2,085,620 字节,x64,无签名。样本下载在最后面。
SHA256 68ae0cabadce8467534f8a41e9ca139b4b9a066aa20d154254793bb4eb9a6b17
分两段:先在宿主机上纯静态把文件拆开(这段没运行样本的任何代码),确认它会做什么之后,再放进隔离虚拟机里跑,抓运行态证据——包括所有走错的路。


先别急着扔 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)。

当时它们只是一堆常量名。等我把运行态的注册表和计划任务拉出来,对上了:

静态配置串运行态对应物
ARMGID833B463ERun 键 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 字节里找到了真正的卡点:

asm
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 的极性是相反的:

asm
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进程对端
8140chat-deepseek.switch.exe45.64.52.179:443
9380chat-deepseek.switch.exe45.64.52.179:443
7428SearchApp.exe175.12.122.167:443
6892WinStore.App.exe23.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 后
10x040000821,773821,760 ← a64.dll
20x140000168,461168,448 ← 守护器
30x180000144,397144,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 和归因,靠的不是某个聪明方法,而是每次得出结论后都逼自己写清楚"什么证据能推翻它"。每一条被推翻的结论,都是下一次更精确的起点。真正拿到手的,都是被推翻到只剩骨架后还站着的那些。

样本下载:https://wwaux.lanzoul.com/b00jflpl5i
密码:4a97

回复

共 3 条
咸鱼喵
成员
15 小时前 #2

大佬awa

yunsjxh
成员
15 小时前 #3

很早之前写的一个逆向日记,用AI润了下色,写了个释放器就发出来了

牢尘
牢尘Lv.5 绘梨衣
站长
14 小时前 #4

看不懂思密达🙂

我要回复

登录后才能回复 去登录