本文根据 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,这里必须是 0x003eEM_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_shoffe_flagse_ehsizee_shentsizee_shnume_shstrndx 等字段当前都不被内核加载器读取。加载器甚至不检查 ELF 头大小字段。

程序头呢?前面已确认至少要一个 56 字节的 Elf64_Phdr,难道总大小是 58 + 56 = 114?没人规定 ELF 头和程序头不能重叠。而且程序头条目类型不合法也没关系——内核只对某些坏类型(如损坏的 PT_LOADPT_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_type0x464c457f(即 \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_LOADe_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 的结构和代码会随内核大版本变化,无法跨内核版本可靠使用。

向栈神献祭

另一个可用区域是栈。它有两个问题:

  1. 自 Linux 5.8 起,x86 栈默认不可执行。解决方法是显式声明 PT_GNU_STACK(程序头类型 0x6474e551),并在 p_flags 里设置可执行位 PF_X(值为 1),其他字段都不需要。
  2. 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) 之前完成。

两条路都需要在二进制外部做手脚。

把代码外包给文件名

栈已经可执行、地址已知,但怎么把指令放上去?这像鸡生蛋问题:没有加载任何内容,也没有另一个进程可以交互。需要两样东西:把代码送上用户栈的方法,以及代码的地址。

可用的数据来源有四类:

  1. 参数个数(argc);
  2. 程序名和参数(argv);
  3. 环境变量(env);
  4. 文件名(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 还是 ./catcat 字符串都位于 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-)从零下溢到目标值,且每次减的数自身也要全部落在可打印区间:0x7270f-pr0x474ff-OG0x4132f-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_binaryload_elf_phdrsprepare_binprmSTACK_TOP_MAX 等)、作者之前关于微型 ELF 与损坏工具的系列文章、lm978 的 73 字节最小 PT_LOAD ELF、x86 指令编码参考与可打印 shellcode 论文。完整清单可在原页底部查看。


上一页:Doug McIlroy 访谈:Unix、管道与组合式设计

下一页:一个 440 字节的变形 ELF-64 病毒