首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
逆向工程
发新帖
0
0
[原创]闲来无事,逆向一个银狐病毒
发表于: 2026-10-2 11:54
332
[原创]闲来无事,逆向一个银狐病毒
云散皆星河
2026-10-2 11:54
332
# 闲来无事,逆向一个银狐病毒 > 样本:`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`)。 当时它们只是一堆常量名。等我把运行态的注册表和计划任务拉出来,对上了: | 静态配置串 | 运行态对应物 | |---|---| | `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 字节里找到了真正的卡点: ```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 | 进程 | 对端 | |---|---|---| | 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 和归因,靠的不是某个聪明方法,而是**每次得出结论后都逼自己写清楚"什么证据能推翻它"**。每一条被推翻的结论,都是下一次更精确的起点。真正拿到手的,都是被推翻到只剩骨架后还站着的那些。 样本下载:<a href="elink@ae1K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6$3q4#2P5q4)9J5k6h3I4S2L8Y4A6G2N6h3I4Q4x3X3g2U0L8$3#2Q4x3V1k6T1x3o6m8B7k6X3I4H3L8o6g2A6"><mark class="encrypted">242K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6$3q4#2P5q4)9J5k6h3I4S2L8Y4A6G2N6h3I4Q4x3X3g2U0L8$3#2Q4x3V1k6T1x3o6m8B7k6X3I4H3L8o6g2A6</mark></a> 密码:4a97
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
0
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
1
)
mb_izdxjcwf
雪 币:
45
活跃值:
(35)
能力值:
( LV2,RANK:10 )
在线值:
发帖
0
回帖
6
粉丝
0
关注
私信
mb_izdxjcwf
2
楼
牛怎么联系楼主
6天前
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
云散皆星河
6
发帖
6
回帖
0
RANK
关注
私信
他的文章
[原创]我写了半个用户态杀软!!?
165
[原创]从黑盒到可交互画面:极域 V6.0 连续投屏与远程操控协议逆向
244
[原创]疏影:面向 Weeback 的专项破解与工程实现
218
[原创]闲来无事,逆向一个银狐病毒
330
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部