本文根据 tmp.0ut 第五期中 dominikr 的文章编译整理,非逐字翻译;文中观点均属于原作者。
原文:Creating polyglot ELF files for fun and anti-forensics
文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:5371
引言
本文是《深入理解 Linux 内核如何加载可执行文件》的续篇:把上一篇学到的内核知识用于创造一种新型 ELF——同一个文件能根据环境被解释成两个不同的 ELF。
术语约定:文中 ELF32/ELF64 指头部数据结构为 32 位/64 位的 ELF;除非特别说明,假设 e_machine == EM_X86_64。“polyglot 文件”指语法同时匹配两种或更多文件格式的文件。作者特意使用 polyglot 而不是“歧义文件”(ambiguous file),因为 ELF32 与 ELF64 是相关但不同的两种格式,数据结构的定义明确且互不相同。
polyglot ELF 可行吗?
上一篇讨论里作者说过:若内核把 x32 ELF 当 64 位 ELF 加载,e_phentsize 字段“很可能读到垃圾”。那如果读到的不是垃圾呢?能否造出一个同时是合法 32 位与 64 位 ELF 的文件?这样的文件会带来一个有趣的难题:它同时是两种格式,该怎么解析?
对 Linux 内核,答案是已知的:除非 e_phentsize64 错误或有非法 PT_INTERP 头,否则内核先按 ELF64 执行。而几乎所有工具都只用 EI_CLASS 与 EI_DATA 头来识别 ELF 类别。
ELF 与其他文件类型的 polyglot 早已存在,但据作者所知,从来没有人做过 ELF32+ELF64 的 polyglot。
文件用 NASM 汇编器制作:从零构造可执行文件需要创建二进制结构并按字节偏移引用头部——这正是汇编器擅长的;汇编器还允许在文件合适位置插入代码。
制作 polyglot ELF 的关键障碍
下面是生成 polyglot ELF 的 NASM 文件关键部分。setpos 宏用于设置绝对文件位置(若已经越过该位置则致命报错),默认用 NUL 字节填充空隙:
setpos 0
db 0x7F, "ELF" ; EI_MAG
db 1 ; EI_CLASS / bits
db 1 ; EI_DATA / endian
db 1 ; EI_VERSION
db 0 ; EI_OSABI
times 8 db 0 ; EI_ABIVERSION, EI_PAD
setpos 16
dw 3 ; type
dw 0x3e ; machine
dd 1 ; version
文件开头没有意外:标准 ELF 头,ELF32 与 ELF64 在这里还没有分叉。EI_CLASS 设为 1(32 位 ELF),后面会讨论原因。
setpos 24
dd _start ; entry32 / entry64a
dd phoff32 ; phoff32 / entry64b
dq phoff64 ; shoff32,flags32 / phoff64
这是制作 polyglot 的第一个关键点:ELF64 入口的高半部分与 ELF32 程序头偏移重叠。问题不大,因为两种 ELF 变体的起始地址都可以自由选择。接下来 ELF64 程序头偏移与 ELF32 节头偏移及 flags 重叠。flags 可以忽略(x86 上实际不用),但 phoff64/shoff32 更麻烦:若想在 ELF32 里用节,就要面对“至少一个 PHDR64 与节重叠”的事实。
setpos 40
dw 0x34 ; ehdrsiz32 / shoff64a
dw 0x20 ; phdrsiz32 / shoff64b
dw PHNUM ; phdrcnt32 / shoff64c
dw SHENT ; shentsize32 / shoff64d
dw SHNUM ; shnum32 / flags64a
dw SHSTR ; shstrndx32 / flags64b
这是另一个关键点:ELF64 节头偏移与多个关键 ELF32 头部重叠。phdrsiz32 固定为 0x20,phdrcnt32 至少为 1。所以:
- 若 ELF64 部分要有节,最小偏移是
0x100200000(约 4GB 出头,如果把ehdrsiz32设 0),同时shentsize32也必须为 0——也就是 ELF32 部分不能用节; - 若两边都要有节(保持
shentsize为 0x28),节头偏移和最小文件大小会超过 11TB。
4GB 的可执行文件或许还在“合理”边缘(有文章提到 Google 内部有 25GB 二进制),11TB 就超出范围了,也会撞上某些文件系统的最大文件大小限制(实际存储可用稀疏文件解决,所以不是问题)。
结论:
- ELF32 可以有节,但节会与 PHDR64 重叠;
- ELF64 可以有节,但最小文件 4GB,前提是禁用 ELF32 的节;
- ELF64 可以有节、ELF32 也要节的话,最小文件约 11TB。
目标是造一个被内核与工具不同解析的文件。要成为“正经”ELF,需要有节(objdump 之类工具不会反汇编无节 ELF)——至少工具看到的那一侧要有。所以想要小文件,只能让工具看到 ELF32、让内核执行 ELF64。另外 Ubuntu 最近的版本似乎禁用了 x32 支持,这也是内核侧用 ELF64 的理由。简而言之:工具看起来是 ELF32、内核看起来是 ELF64,是最佳方案。
两条不同的代码路径
如果两种变体最终执行相同代码,那费劲做 polyglot 就没意义了(注意 entry32 与 entry64 的低半部分位于相同字节位置)。入口地址几乎相同,如何得到两条不同路径?一个想法是加代码检查自己运行在 x32 还是 64 位,但这样的代码在取证分析师眼里会非常扎眼。
最简做法是让两个 LOAD 程序头使用不同偏移:代码加载到相近地址,但内核从文件不同偏移取内容。Linux 里程序头 offset 字段的粒度是 4096,因此这也是文件大小的下限。
两个载荷都很简单:x32 载荷只做 exit(0)。值得注意,这段汇编本身也是 polyglot——在 32 位、x32 与 64 位模式下都能运行(把 e_machine 改成 EM_386 或 EM_486 即可验证)。ELF64 载荷打印字符串 EVIL 然后退出。
SHDR32 与 PHDR64 的重叠
如前所述,SHDR32 与 PHDR64 重叠。因为内核侧选了 ELF64,可以让它尽量精简,以便给工具造一个更有意思的“假”ELF32。具体做法:只建一个 PHDR64 条目,并让它对 32 位节头干扰最小。
一个 PHDR64 条目 56 字节,而 SHDR32 条目只有 40 字节,所以单条目 PHDR64 至少跨过 2 个 SHDR32 条目。规范要求第一个节头全为 null,与程序头重叠时做不到,但作者尽量让重叠处保持零值:
sh_name 0x00 p_type == 1 (LOAD)
sh_type 0x04 p_flags == 15 (exec)
sh_flags 0x08 p_offset == 0x1000
sh_addr 0x0c
sh_offset 0x10 p_vaddr == 0x(phof32)00000000
sh_size 0x14
sh_link 0x18 p_paddr
sh_info 0x1c
sh_addralign 0x20 p_filesz == size + 0x100000000
sh_entsize 0x24
sh_name2 0x28 p_memsz == size + 0x100000000
sh_type2 0x2c
sh_flags2 0x30 p_align
sh_addr2 0x34
程序头类型必须是 1(LOAD)才能真的加载代码,flags 最低位必须是 1 才能标记段可执行。p_flags 被设为 15——正好对应 FINI_ARRAY 节类型;选它是因为 readelf 能解析,且不会再去读其他数据结构。sh_name 的值 1 无法改变,所以在节头字符串表位置 1 放了字符串 .fini 来匹配 sh_type。
简单 ELF 里 p_filesz 与 p_memsz 通常相同。但如果与 p_memsz 重叠的 sh_name2 非零、与它相邻的 sh_type2 为零,eu-elflint 会抱怨“NULL section 有非零 sh_name”。解决办法:给 64 位 size 字段加上 0x100000000,这样 32 位视角的 sh_entsize 与 sh_type2 字段都变成 1,eu-elflint 就不再抱怨。
类似地,第二个节头在字符串表偏移 (shdr-$$)+size 处放了名字 .cfi——听上去合理但其实是胡诌的。size 的值也要选得不与文件其他部分冲突。最后 p_offset 必须是 0x1000;唯一的规避办法是调换偏移、把 ELF32 代码放在 0x1000,但那会让 ELF64 内存映像里包含所有假头部,作者没有继续评估这个方案。
其他节与头部
除了上述内容,为了让文件看起来正常(主要针对 ELF32 侧,因为大多数工具按它解释),还需要:
- 加一个指向可执行部分的
.text节,这样objdump -d等工具才能用; - 头部字段尽量兼容:EI_VERSION == 1、EI_OSABI == 0、e_version == 1;
- 程序头里 p_paddr 等于 p_vaddr,p_align 设为 0x1000,与实际用法一致。
最终的 polyglot 二进制
原文给出了完整十六进制转储(约 0x1050 字节),主要区段都以内联名字标出,便于肉眼浏览。几个要点:
- ASM32 段紧接 ELF 头放置,让入口地址尽量低,因为 64 位代码的偏移决定了最小文件大小 = 入口地址 + 4096;
- 偏移 0x50 与 0x1050 的两个入口恰好相差 0x1000(4096);ASM64 代码入口处往回跳几个字节到 0x1030,以节省文件大小;
- 偏移 0x1a0 处的字符串
.cfi是第二节条目的名字;字符串表在偏移 0xa0,可推得size选为 0x100(256)。
可以用 xxd -r 把十六进制转回二进制。需要精确字节请查看原页。
乐趣与反取证
除第一篇论文的 elfid 外,所有被测试的工具都把文件识别为 x32 二进制。测试过的工具包括:file、GNU objdump、GNU readelf、eu-readelf、eu-elflint、Binary Ninja、Radare2/rabin2、Ghidra、gdb。
Radare2、Binary Ninja、Ghidra 与 objdump -d 做静态分析时都指向假的 x32 汇编。以 objdump 输出为例:
poly: file format elf32-x86-64
Disassembly of section .text:
00000000 <.text>:
0: ff c0 inc %eax
2: cd 80 int $0x80
gdb 的行为很有趣。加载 polyglot 后 show architecture 返回:
The target architecture is set to "auto" (currently "i386:x64-32")
但用 starti 启动时会警告:
warning: Selected architecture i386:x64-32 is not compatible with reported
target architecture i386:x86-64
程序能开始执行,但 info registers 只显示所有寄存器的低 32 位。手动 set architecture i386:x86-64 后 gdb 才正常。
eu-elflint 会显示一些警告,如前面讨论,SHDR32/PHDR64 重叠让这些警告基本不可避免:
zeroth section has nonzero name
zeroth section has nonzero type
zeroth section has nonzero flags
zeroth section has nonzero align value
zeroth section has nonzero entry size value
zeroth section has nonzero size value while ELF header has nonzero shnum value
前面提到的其他工具都没有对文件显示任何警告。elfid 是唯一暗示“有问题”的程序:
Polyglot detected
Polyglot ELF wants you to think it's 32bit
poly: ELF 32-bit LSB pie executable, x86-64, version 1 (SYSV)
讨论与结论
“ELF32+ELF64 polyglot 可行吗?”——实践已经证明答案是肯定的。polyglot ELF 被证明是强大的反取证技术:此前所有工具都被误导,展示的是“假”ELF32 部分的信息或反汇编。
这引出另一个问题:如何防止这种对 ELF 格式的(滥)用?这不容易回答——因为 ELF32 与 ELF64 两侧都是合法 ELF。要可靠地把文件识别为 ELF32 或 ELF64,每个工具基本都得精确重实现内核的 ELF 加载器,而这几乎不可能,还可能依赖具体内核版本与选项。更现实的选择是像 elfid 那样做启发式检测:警告用户某 ELF 可能是 polyglot,或询问用户按 ELF32 还是 ELF64 分析。最后作者补了一句:当然,过程中也收获了乐趣(citation needed)。
后续工作
与上一篇相同,本研究的思路可以扩展到其他架构与操作系统。文中 ELF 还相当基础,可以进一步扩展,例如在“真实”可执行部分不受安全检查工具约束的前提下,继续误导 checksec 之类的安全工具。作者说这样的二进制已经造出来了,只是还没有时间写系列第三篇论文。
参考
关于汇编源码的说明
生成 polyglot ELF 的汇编器源文件在原文附录 B。该源文件本身也是“polyglot”——既是 NASM 汇编文件又是 shell 脚本:
bash poly.asm
会生成 polyglot 并显示基本信息;bash poly.asm -DBIT=2 会生成 EI_CLASS 为 64 位的变体。原页以 base64 + gzip 形式给出完整源文件,此处不转录数据块,需要精确源码请从原页复制。
下一页:用侧信道检测系统调用挂钩