本文根据 tmp.0ut 第五期中 h4x.cz 的文章编译整理,非逐字翻译;文中观点均属于原作者。
原文:Inside and Outside a 57-Byte x86-64 Linux ELF
文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:12478
引言
Linux 的 ELF 加载器能忍受相当多“虐待”。事实上,一个只有 57 字节的 x86-64 ELF 也能通过 execve(2) 成为一个真正的进程。这篇文章来自 Fanda Uchytil(h4x.cz),他从 Linux ELF 加载器的实现细节出发,说明 ELF 结构至少要提供什么才能被正常加载,并构造了合法的 57 字节与 60 字节 ELF64 可执行文件,解释它们为什么能工作。
对这么小的 ELF 来说,主要问题最终会落在“代码执行”上:文件里几乎没有地方放指令。文章介绍了一种技巧——把文件名当作指令的存储介质。作者调侃道:“让我们穿上潜水服,潜入‘深入’的深处。”
先看一个“能跑”的例子
作者先用一个“工作”的例子(对“工作”一词采取非常宽松的定义)建立直觉。下面是注释过的 xxd 输出,一个被 Linux ELF 加载器认定为合法可执行文件的 57 字节 x86-64 ELF:
00000000: 7f45 4c46 0000 0000 0000 0000 0000 0000 .ELF............
; ^-------^ e_entry
; e_ident[EI_MAG] v-----------------v
00000010: 0200 3e00 0000 0000 0000 0000 0000 0000 ..>.............
; ^.-^ ^-.^
; | '--- e_machine = x86-64
; e_type = ET_EXEC
00000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000030: 0000 0000 0000 3800 01 ......8..
; ^.-^ ^^
; e_phentsize ---' '--- e_phnum
把它变成真正的二进制:
grep -v '^;' 57-byte_elf64.xxd | xxd -r > 57-byte_elf64
chmod 755 57-byte_elf64
运行它(当然要在虚拟机里,因为我们不是“动物”):
$ ./57-byte_elf64
Segmentation fault (core dumped)
作者说这不是普通的段错误,而是“好的”段错误——它没有让 execve(2) 失败。“别那样看着我!完全可运行的程序是给那些被系统奴役的普通人准备的。”真正重要的是它如何被加载。
ELF 结构
ELF64 头本身按规范是 64 字节,57 字节怎么可能够?作者指出,压缩任何东西时都要问:它如何实现?哪些字段真正需要?字段之间的依赖是什么?哪些可以去掉?
对 ELF 可执行文件,要看两个对象:ELF64 结构定义,以及 Linux ELF 加载器。可执行文件的布局由两个头定义:ELF 头(Elf64_Ehdr)和程序头。所有 ELF 二进制都必须恰好有一个 ELF 头,它是唯一必须从偏移 0 开始的结构,定义文件最基本的特征(ELF 类型、架构、入口点、第一个程序头的偏移等)。
主 ELF64 头结构如下(带注释的字段就是 57 字节版本需要或依赖的字段):
typedef struct {
unsigned char e_ident[16]; // 前 4 字节是 ELF magic
uint16_t e_type; // 例如:这是可执行文件吗?
uint16_t e_machine; // 架构,例如 x86-64
uint32_t e_version;
Elf64_Addr e_entry; // 代码起始地址
Elf64_Off e_phoff; // 程序头偏移
Elf64_Off e_shoff;
uint32_t e_flags;
uint16_t e_ehsize;
uint16_t e_phentsize; // 程序头结构大小
uint16_t e_phnum; // 程序头条目数
uint16_t e_shentsize;
uint16_t e_shnum;
uint16_t e_shstrndx;
} Elf64_Ehdr;
这里只关注可执行部分。e_type 必须是 ET_EXEC(静态可执行文件)或 ET_DYN(共享/动态可执行文件),因为 Linux ELF 加载器只处理这两种。把 e_type 设成其中之一后,加载器还要求另一个结构——程序头:
typedef struct {
uint32_t p_type;
uint32_t p_flags;
Elf64_Off p_offset;
Elf64_Addr p_vaddr;
Elf64_Addr p_paddr;
uint64_t p_filesz;
uint64_t p_memsz;
uint64_t p_align;
} Elf64_Phdr;
程序头告诉内核如何把二进制装入内存。Linux 6.12 的 ELF 加载器识别的主要类型有:
| 程序头类型 | 作用 |
|---|---|
PT_LOAD |
描述如何把二进制映射进内存 |
PT_GNU_STACK |
允许用户态栈可执行 |
PT_GNU_PROPERTY |
GNU 特殊属性(加固、ISA 特性等) |
PT_INTERP |
指向动态链接器(ELF 解释器) |
PT_LOPROC...PT_HIPROC |
处理器特定段类型区间 |
要在进程里执行代码,必须把代码装入主内存,干这件事的类型是 PT_LOAD。理论上,可执行 ELF 需要 ELF 头和程序头两个结构。
裁剪:哪些字段真的必要?
加载器的主体在 load_elf_binary 函数中。它做的第一件事是检查 e_ident 是否含 4 字节 ELF magic(\x7fELF):
if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0) // e_ident == "\177ELF"
goto out;
e_ident 是 16 字节数组,但对加载器只有前 4 字节重要,其余 12 字节可以任意使用(“懂的都懂”)。接着加载器检查文件是否可执行类型:
if (elf_ex->e_type != ET_EXEC && elf_ex->e_type != ET_DYN)
goto out;
所以 e_type 也是必需的。然后是架构检查 e_machine,这里必须是 0x003e(EM_X86_64):
if (!elf_check_arch(elf_ex)) // e_machine == EM_X86_64
goto out;
有些检查可以忽略(例如 elf_check_fdpic 不适用于 x86),但其他架构可能检查 e_ident[EI_OSABI],要小心。最后加载器调用 load_elf_phdrs,按 ehdr->e_phoff 把整个程序头(sizeof(Elf64_Phdr) * e_phnum)读入内存:
elf_phdata = load_elf_phdrs(elf_ex, bprm->file);
if (!elf_phdata)
goto out;
这一步失败会让整个加载失败,因此程序头是强制的。看 load_elf_phdrs 的实现,至少需要一个程序头记录:
if (elf_ex->e_phentsize != sizeof(struct elf_phdr))
goto out;
/* 检查程序头数量及其总大小。 */
size = sizeof(struct elf_phdr) * elf_ex->e_phnum;
if (size == 0 || size > 65536 || size > ELF_MIN_ALIGN)
goto out;
注意:程序头是从文件里单独读的,不是从读 ELF 头时所用的同一缓冲区取——这意味着后面“少一个字节”的缓冲区技巧不能用在程序头上,因为程序头不是来自已映射的内存:
/* 读入程序头 */
retval = elf_read(elf_file, elf_phdata, size, elf_ex->e_phoff);
回到 ELF 头:加载器要求 e_phentsize 恰好等于 56 字节(sizeof(struct elf_phdr)),并要求 e_phnum 非零(也要低于相关上限,但做最小文件可以忽略)。到这一步,ELF 头里需要 58 字节;e_shoff、e_flags、e_ehsize、e_shentsize、e_shnum、e_shstrndx 等字段当前都不被内核加载器读取。加载器甚至不检查 ELF 头大小字段。
程序头呢?前面已确认至少要一个 56 字节的 Elf64_Phdr,难道总大小是 58 + 56 = 114?没人规定 ELF 头和程序头不能重叠。而且程序头条目类型不合法也没关系——内核只对某些坏类型(如损坏的 PT_LOAD、PT_GNU_PROPERTY)失败。于是把 e_phoff 设为 0,让 ELF 头本身被“回收”成程序头:
ELF 头 <--- 程序头
-----------------------------------------------------
uint8_t e_ident[0..3] <--- uint32_t p_type
uint8_t e_ident[4..7] <--- uint32_t p_flags
uint8_t e_ident[8..15] <--- uint64_t p_offset
uint16_t e_type <-+- uint64_t p_vaddr:2
uint16_t e_machine | p_vaddr:2
uint32_t e_version <-' p_vaddr:4
uint64_t e_entry <--- uint64_t p_paddr
uint64_t e_phoff <--- uint64_t p_filesz
uint64_t e_shoff <--- uint64_t p_memsz
uint32_t e_flags <-+- uint64_t p_align:4
uint16_t e_ehsize | p_align:2
uint16_t e_phentsize <-' p_align:2
uint16_t e_phnum
此时 phdr->p_type 是 0x464c457f(即 \x7fELF),加载器不识别这个类型,于是忽略它。这个技巧把总字节数保持在 58。关键结论:Linux 加载器期望至少一个程序头记录,e_phnum 必须非零;e_phnum 之外的多余内容不会被检查,可以裁掉;不被识别的程序头类型会被忽略,所以 ELF 头和程序头可以安全重叠。
少一个字节
目前是 58 字节,怎么再少一个?作者用了两个技巧:x86 的小端编码,以及 Linux 读取主头的方式。
小端编码让类型转换很直接:设 uint32_t x = 1,十六进制是 01 00 00 00,转成 uint16_t 得到 01 00,值仍是 1,只是类型宽度不同。e_phnum 是已知必需的最后一个字段,类型 uint16_t、值为 1。原则上可以砍掉它的零字节——前提是 ELF 头被存放在更大的、预先清零的缓冲区里。加载器开头确实如此:
static int load_elf_binary(struct linux_binprm *bprm)
{
struct elfhdr *elf_ex = (struct elfhdr *) bprm->buf;
...
elf_ex 指向的 ELF 头位于 bprm->buf,缓冲区定义是 char buf[BINPRM_BUF_SIZE],BINPRM_BUF_SIZE = 256。进程调用 execve(2) 后、还没决定用哪种加载器之前,内核先准备若干结构;struct linux_binprm 是所有加载器共享的输入,它的初始化是:
static int prepare_binprm(struct linux_binprm *bprm)
{
loff_t pos = 0;
memset(bprm->buf, 0, BINPRM_BUF_SIZE);
return kernel_read(bprm->file, bprm->buf, BINPRM_BUF_SIZE, &pos);
}
也就是说,整个 256 字节缓冲区先被清零,内核再尝试从文件读入最多 256 字节。于是 e_phnum 的高位零字节可以安全省略——缓冲区里还有大量现成的零。但程序头不行:它是从文件直接读取的,如果在文件里被截短,加载器按必需大小读取就会失败。
57 字节 ELF64 小结
现在可以构造 57 字节 ELF64 了。下面是一段可读性很好的 NASM “源码”(db=1 字节、dw=2 字节、dd=4 字节、dq=8 字节),本质是带注释的结构化二进制数据。REQUIRED 列说明字段是否为 Linux ELF 加载器所需(yes),或可以随意存放数据(-):
BITS 64 ; EHDR PHDR REQUIRED
phdr: ; -------------------------------------------------
db 0x7F, "ELF" ; e_ident[EI_MAG] p_type yes
dd 0x00000000 ; e_ident[4..7] p_flags -
dq 0x0000000000000000 ; e_ident[8..15] p_offset -
dw 0x0002 ; e_type = ET_EXEC p_vaddr:2 yes
dw 0x003e ; e_machine = x86-64 p_vaddr:2 yes
dd 0x00000000 ; e_version p_vaddr:4 -
dq 0x0000000000000000 ; e_entry = 0 p_paddr -
dq phdr ; e_phoff = 0 p_filesz -
dq 0x0000000000000000 ; e_shoff p_memsz -
dd 0x00000000 ; e_flags p_align:4 -
dw 0x0000 ; e_ehsize p_align:2 -
dw 0x0038 ; e_phentsize = sizeof(phdr) p_align:2 yes
db 0x01 ; e_phnum = 1 entry yes
------------------------------------------------------------------------------
构建:
nasm -f bin 57-byte_elf64.nasm -o 57-byte_elf64
chmod 755 57-byte_elf64
从加载器条件看它应当能被加载。实际验证一下(注意它“失败了”,但失败方式正是文章想要的):
$ strace ./57-byte_elf64
execve("./57-byte_elf64", ["./57-byte_elf64"], ...) = 0
--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=NULL} ---
execve(2) 返回了 0——二进制被成功加载了!只不过随后在地址 0 处段错误。
段错误也分好坏
段错误大致分两类:一类在内核里就失败(例如 execve(2) 系统调用本身失败);另一类来自用户态(例如访问未映射内存)。后者是“好”的:它说明二进制已被成功加载,是用户态的代码执行失败,这给了我们可以操作的空间。
用 strace -i 看得更清楚:
$ strace -i ./57-byte_elf64
[00007ffff7e7fad7] execve("./57-byte_elf64", ["./57-byte_elf64"], ...) = 0
[0000000000000000] --- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR,
si_addr=NULL} ---
[????????????????] +++ killed by SIGSEGV (core dumped) +++
execve() 成功返回 0,然后在地址 0 失败,因为那里没有映射任何东西。为什么跳向 0?因为 e_entry 恰好是 0。这反而是好消息:进程已经成功运行并尝试执行用户态指令,只是无指令可执行。理论上,只要能跳到进程内存里某个可执行的位置就行——“只要足够虔诚地许愿,那里就会有这样的位置”。
代码在哪里:先看内存地图
用 gdb 观察进程内存:
(gdb) info proc map
Start Addr End Addr Size Offset Perms objfile
0x7ffff7ff9000 0x7ffff7ffd000 0x4000 0x0 r--p [vvar]
0x7ffff7ffd000 0x7ffff7fff000 0x2000 0x0 r-xp [vdso]
0x7ffffffde000 0x7ffffffff000 0x21000 0x0 rw-p [stack]
可用的不多——因为我们“故意”没有加载任何东西进内存:没有 PT_LOAD,e_phoff 指向文件开头,程序头记录也不合法。要么想办法构造一个能加载代码的 PT_LOAD 记录,要么使用内核已经映射好的区域。
被诅咒的 PT_LOAD
作者坦言和 PT_LOAD 搏斗了很久,一直没能把带 PT_LOAD 的 ELF64 压到 73 字节以下;而 73 字节的最小版本已经存在(tmp.0ut 第三期 lm978 的文章,他还展示了完全合法的 77 字节 “Hello, world!” ELF64,作者称赞那是杰作)。
但 PT_LOAD 只在需要把文件内容载入内存时才必要——而没人规定必须从程序文件加载任何东西。作者决定“加倍叛逆”:不加载文件内容!内存地图里其实已经有一个可执行区域:vdso。
vDSO 与 ROP:不,谢谢
用 vDSO 做入口有太多问题。vDSO 是内核映射的虚拟 ELF 文件,里面有可执行代码,这都很好;但和它的前身 vsyscall 不同,vDSO 从设计上就考虑了地址随机化。这不只是“启用 ASLR 时地址会变”这种常规问题,而是即便关闭 ASLR,也不能假设不同内核之间 vDSO 基址相同。另外 vDSO 的结构和代码会随内核大版本变化,无法跨内核版本可靠使用。
向栈神献祭
另一个可用区域是栈。它有两个问题:
- 自 Linux 5.8 起,x86 栈默认不可执行。解决方法是显式声明
PT_GNU_STACK(程序头类型0x6474e551),并在p_flags里设置可执行位PF_X(值为 1),其他字段都不需要。 - ASLR 会随机化栈地址,这个更严重。
对 5.8 之前的内核,x86 栈默认可执行,什么都不用做;第一个程序头指向文件开头即可(p_type 是“乱码”,内核忽略它,栈保持可执行),57 字节版本就够。5.8 之后则需要显式设置 PT_GNU_STACK。
把 PT_GNU_STACK 放在文件偏移 4、紧接 ELF magic 之后最合适:那里正好放得下 p_type(4 字节)和 p_flags(4 字节),而对应的 e_ident 字段内核根本不读。这会比 57 字节版本多出 3 个字节(原因见前文“少一个字节”一节),但总长仍是 60 字节。原型如下:
BITS 64 ; REQUIRED
db 0x7F, "ELF" ; e_ident[EI_MAG] yes
phdr:
dd 0x6474e551 ; phdr->p_type = PT_GNU_STACK - <-- 复用
dd 0x00000001 ; phdr->p_flags = PF_X - e_ident
db 0x00 ; e_ident[EI_PAD] -
db 0x00 ; e_ident[EI_PAD] -
db 0x00 ; e_ident[EI_PAD] -
db 0x00 ; e_ident[EI_PAD] -
dw 0x0002 ; e_type = ET_EXEC yes
dw 0x003e ; e_machine = x86-64 yes
dd 0x00000000 ; e_version -
dq 0x0000000000000000 ; e_entry = stack yes
dq phdr - $$ ; e_phoff = 4 yes
dq 0x0000000000000000 ; e_shoff -
dd 0x00000000 ; e_flags -
dw 0x0000 ; e_ehsize -
dw 0x0038 ; e_phentsize = sizeof(phdr) yes
dw 0x0001 ; e_phnum = 1 entry yes <-. 填充
dw 0x0000 ; e_shentsize => padding yes <-' 必需
可栈可执行了,但 ASLR 会随机化栈地址,加载器会跳到未映射区域并段错误。
ASLR 与 personality 问题
关闭 ASLR 有两条路(作者承认没找到从二进制内部解决的办法):
- 全局关闭:写
/proc/sys/kernel/randomize_va_space设置变量randomize_va_space;启动参数norandmaps也设置同一个变量。 - 通过进程的
personality(2):设置ADDR_NO_RANDOMIZE标志,但必须在execve(2)之前完成。
两条路都需要在二进制外部做手脚。
把代码外包给文件名
栈已经可执行、地址已知,但怎么把指令放上去?这像鸡生蛋问题:没有加载任何内容,也没有另一个进程可以交互。需要两样东西:把代码送上用户栈的方法,以及代码的地址。
可用的数据来源有四类:
- 参数个数(
argc); - 程序名和参数(
argv); - 环境变量(
env); - 文件名(
auxv[AT_EXECFN])。
前三类大多需要外部配合(特殊环境变量或参数)。作者不想再有更多“用户交互”(ASLR 已经让他 PTSD 了)。好在第 4 类“文件名”满足一切要求:它是程序的一部分(没有名字就无法执行),而且在栈上占据一个战略位置。
文件名不同于 argv[0],它在程序执行早期(远早于 ELF 加载器)就被放到用户栈上。在 Linux 6.12 上,它是进程用户栈上最先放入的记录之一,内容取自 bprm->filename,也就是执行程序时用的路径(/bin/cat、./cat 等)。内核把它放在栈上供 auxv[AT_EXECFN] 使用。它不像 argc/argv/envp 那样是稳定 ABI,位置可能变化甚至消失;但作者测试过的最老内核 2.6.32 到最新的 6.12.73 都这样存放。
关掉 ASLR 后 x86-64 用户栈的简化布局(低地址在上):
<STACK-TOP> (比 <STACK-BOTTOM> 地址更低)
...
auxv AT_EXECFN = ptr @ ----.
argc |
argv |
env |
filename\0 bprm->filename <--'
0x0000000000000000 8 字节(sizeof (void *))
<STACK-BOTTOM> 0x7FFFFFFFF000
文件名结束于离栈底固定 8 字节的位置,可控性最强。用负偏移(从高地址往低地址)还能自动剥掉路径前缀:无论运行 /bin/cat 还是 ./cat,cat 字符串都位于 STACK-BOTTOM - sizeof(void*) - strlen("cat") - 1(减 1 是因为文件名字符串以 NUL 结尾)。
无 ASLR 时栈底的地址取决于 CPU 支持并启用的页表级数。假设四级分页(启用五级分页时偏移变成 56):
STACK_BOTTOM = (1 << MASK_SHIFT) - PAGE_SIZE
= (1 << 47) - 4096
= 0x7FFFFFFFF000
可打印 shellcode
接下来构造住在文件名里的 shellcode——一个退出码为 66 的程序。普通 Unix 文件系统(ext4、xfs、zfs、tmpfs 等)对文件名有两条基本规则:不能含 NUL 字符(\0),不能含正斜杠(/)。其余约束(.、.. 保留名、NAME_MAX 长度限制)在这里都不重要。
概念验证:
BITS 64 ; 含义 C hex 字符串
; ------------------------------
mov dil,0x42 ; arg = 66 \x40\xb7\x42
mov al, 0x3c ; syscall exit \xB0\x3C
syscall ; exit (66) \x0F\x05
把编译输出(C hex 字符串)当作可执行文件名,或者建一个指向它的符号链接:
ln -s 60-byte_elf_x86-64 $'\x40\xb7\x42\xB0\x3C\x0F\x05'
shellcode 7 字节,所以 e_entry 设为 0x7FFFFFFFF000 - 8 - 7 - 1 = 0x7FFFFFFFEFF0,然后:
echo 0 > /proc/sys/kernel/randomize_va_space
./$'\x40\xb7\x42\xB0\x3C\x0F\x05'
echo $? # => 66
退出码正确。但文件名里带二进制字符很“逊”。作者随后给出了只用可打印字符、还带一点自修改代码的版本:
BITS 64
; 构造指令 'syscall' (= 0F 05)
sub ax, 0x7270 ; 0x0000 - 0x7270 = 0x8d90
sub ax, 0x474f ; 0x8d90 - 0x474f = 0x4641
sub ax, 0x4132 ; 0x4641 - 0x4132 = 0x050f => 0F 05
push rax
pop rsi ; rsi = 0F 05
; 构造 'exit' 的系统调用号
push byte 0x54
pop rax
xor al, 0x68 ; eax = 0x3c => syscall exit
push byte 66 ; 返回值
pop rdi
; 把下一条指令修改成 'syscall'
xor word [rel _syscall], si
_syscall: dd 0 ; 占位符
$ nasm -f bin printable_shellcode.nasm -o printable_shellcode
$ cat printable_shellcode
f-prf-OGf-2AP^jTX4hjB_f15
原理:可打印 ASCII 范围是 0x20(空格)到 0x7e(波浪号),范围外的都是不可打印/特殊字符。x86-64 上可选的指令很少,带参数和寄存器的完整可用指令更少。例如没有可打印的 mov,所以要用多条 sub ax(产生可打印操作码 f-)从零下溢到目标值,且每次减的数自身也要全部落在可打印区间:0x7270→f-pr、0x474f→f-OG、0x4132→f-2A,最终得到 0x050f。
把值放进 rsi 是为了最后一条指令 xor word [rel _syscall], si。它真的会修改后面的代码,但麻烦在于这条指令的机器码是 66 31 35 00 00 00 00——四个 NUL 字节,正是文件名里被禁止的字符!解决办法是回头利用栈布局:文件名终止符有 1 个 NUL,sizeof(void*) 的 8 字节里还有 8 个 NUL。执行时这些 NUL 本来就在那里,所以可以从 xor 指令里裁掉尾部 NUL 字节。
至于为什么用 push 0x54; pop rax; xor al, 0x68 而不是更简单的 push 0x3c:纯属美观考虑——0x3c 正是 < 字符,shell 里它用于文件描述符 0 的重定向;加引号或转义也能跑,但有条件做得更漂亮,为什么不做呢。最终 shellcode 是 25 个可打印字符:
f-prf-OGf-2AP^jTX4hjB_f15
它可以不加任何转义直接当文件名用,而且“看起来足够克苏鲁”。
60 字节的弗兰肯斯坦
所有部件齐了:裁剪后的 ELF 结构、5.8+ 内核的可执行栈、栈地址、要运行的代码、存代码的位置、可打印文件名。
入口点计算:文件名 f-prf-OGf-2AP^jTX4hjB_f15 长度 25:
e_entry = STACK-BOTTOM - sizeof(void*) - strlen(filename) - 1
= 0x7FFFFFFFF000 - 8 - 1 - 25
= 0x7FFFFFFFEFF7 - 25
= 0x7FFFFFFFEFDE
NASM 支持简单表达式,作者直接把算式留在源码里,改文件名时不用手工重算。最终代码:
BITS 64 ; EHDR REQUIRED
db 0x7F, "ELF" ; e_ident[EI_MAG] yes
phdr:
dd 0x6474e551 ; phdr->p_type = PT_GNU_STACK -
dd 0x00000001 ; phdr->p_flags = PF_X -
db 0x00 ; e_ident[EI_PAD] -
db 0x00 ; e_ident[EI_PAD] -
db 0x00 ; e_ident[EI_PAD] -
db 0x00 ; e_ident[EI_PAD] -
dw 0x0002 ; e_type = ET_EXEC yes
dw 0x003e ; e_machine = x86-64 yes
dd 0x00000000 ; e_version -
dq 0x7FFFFFFFF000-8-1 -25 ; e_entry = 栈上的文件名 yes
dq phdr - $$ ; e_phoff = 4 yes
dq 0x0000000000000000 ; e_shoff -
dd 0x00000000 ; e_flags -
dw 0x0000 ; e_ehsize -
dw 0x0038 ; e_phentsize = sizeof (phdr) yes
dw 0x0001 ; e_phnum = 1 entry yes
dw 0x0000 ; e_shentsize => padding yes
构建、加执行位、欣赏十六进制:
nasm -f bin 60-byte_elf_x86-64.nasm -o 60-byte_elf_x86-64
chmod 755 60-byte_elf_x86-64
xxd 60-byte_elf_x86-64
00000000: 7f45 4c46 51e5 7464 0100 0000 0000 0000 .ELFQ.td........
00000010: 0200 3e00 0000 0000 deef ffff ff7f 0000 ..>.............
00000020: 0400 0000 0000 0000 0000 0000 0000 0000 ................
00000030: 0000 0000 0000 3800 0100 0000 ......8.....
用符号链接把代码“植入”文件名,关掉 ASLR 后运行:
xxd -r 60-byte_elf_x86-64.xxd > 60-byte_elf_x86-64
ln -s 60-byte_elf_x86-64 f-prf-OGf-2AP^jTX4hjB_f15
echo 0 > /proc/sys/kernel/randomize_va_space
./f-prf-OGf-2AP^jTX4hjB_f15
echo $? # => 66
完成。如果不想全局关闭 ASLR,可以用 util-linux 的 setarch 按程序关闭:
setarch "$(uname -m)" -R -- ./f-prf-OGf-2AP^jTX4hjB_f15
-R 会设置 ADDR_NO_RANDOMIZE personality 标志。另外,如果跑的是 5.8 之前的内核,可以直接拿 57 字节版本、填入上面算好的 e_entry,效果与 60 字节版本相同。
结语:学到了什么
回顾全文:
- 当前 Linux ELF 加载器要求两个头才能正常执行:ELF 头和程序头。
- 程序头记录数(
e_phnum)非零是硬性要求,这基本决定了可执行文件 58 字节的下限;借助小端编码和 ELF 头所在缓冲区预先清零这两个事实,可以只存e_phnum的一个字节,另一个隐式为零,于是压到 57 字节。 - 程序头必须存在,但不需要含合法类型——Linux ELF 加载器不需要
PT_LOAD也能正确创建进程映像。又因为程序头是独立于 ELF 头读取的,文件本身必须大到能容纳一个完整程序头记录,文件大小下限是e_phoff + sizeof(Elf64_Phdr)。 - 没有
PT_LOAD意味着进程内存里没有来自二进制的可执行代码。好在文件名是内核最先放到用户栈上的记录之一;于是把可打印指令写进文件名、从 ELF 内直接声明栈可执行(并“羞愧地”在外部关闭 ASLR),再把e_entry指向栈上文件名即可。
57 字节是 x86-64 ELF 可执行文件的最小可能吗?作者的答案是“是,但也不是”:根本缺陷在于它不自包含——完全依赖关闭 ASLR;他也没有证明极小性,只是构造了能工作的例子。也许存在一种方式,让默认 ELF 加载器在不提供程序头的情况下把 ELF 加载成进程,那样文件还能大幅缩小。他没找到,但“这不代表别人找不到”。
原文附有大量参考链接,包括 ELF 手册、Linux 6.12 源码(load_elf_binary、load_elf_phdrs、prepare_binprm、STACK_TOP_MAX 等)、作者之前关于微型 ELF 与损坏工具的系列文章、lm978 的 73 字节最小 PT_LOAD ELF、x86 指令编码参考与可打印 shellcode 论文。完整清单可在原页底部查看。