从零开始,自己造一个可执行文件压缩器

本文根据系列博客 Making our own executable packer 完整翻译改写


系列缘起

作者在十二岁时发现一件事:把 .exe 文件重命名后,居然能用记事本打开,里面是一大堆乱码,但显然有某种秩序隐藏其中。这颗好奇的种子,几十年后长成了这个系列。

这个系列的目标是:理解 Linux 可执行文件的内部结构,理解它是如何被执行的,然后造出一个能把可执行文件压缩起来的程序------纯粹是为了好玩,为了学东西。

整个系列专注于 64 位 Linux,使用 Rust 作为主要实现语言。作者建了两个 crate:

  • delf(demystify ELF):用于解析 ELF 文件的库
  • elk (Executable & Linker Kit):调用 delf、实现加载和执行功能的命令行工具

第 1 篇:Linux 可执行文件里有什么?

ELF 格式基础

ELF 全称 Executable and Linkable Format,1983 年随 SysV 4 发布,至今仍是 Linux 的标准可执行文件格式。

一个最简单的汇编程序,打印"hi there"然后退出:

nasm 复制代码
; hello.asm
        global _start
        section .text

_start: mov rdi, 1      ; stdout fd
        mov rsi, msg
        mov rdx, 9      ; 8 chars + newline
        mov rax, 1      ; write syscall
        syscall

        xor rdi, rdi    ; return code 0
        mov rax, 60     ; exit syscall
        syscall

        section .data
msg:    db "hi there", 10

编译出来大概 8.68 KiB,用 gzip -9 能压缩到 372 字节------这就是整个系列要做的事情的动机。

手工解析 ELF 头

xxd 手工翻二进制:

bash 复制代码
$ xxd < hello | head
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............

ELF 64 位文件头结构包括:magic(4 字节)、class(1)、endianness(1)、version(1)、OS ABI(1)、padding(8)、type(2)、machine(2)、entry point(8)......等等。

根据 ELF 头里的偏移量,可以一步步找到段头表(section header table),再找到包含段名字的"名字段",最后看到熟悉的字符串:.symtab.strtab.shstrtab.text.data

nom 写解析器

nom crate 构建 ELF 解析器:

rust 复制代码
// delf/src/lib.rs
impl File {
    const MAGIC: &'static [u8] = &[0x7f, 0x45, 0x4c, 0x46];

    pub fn parse(i: parse::Input) -> parse::Result<Self> {
        use nom::{bytes::complete::{tag, take}, error::context, sequence::tuple};
        let (i, _) = tuple((
            context("Magic", tag(Self::MAGIC)),
            context("Class", tag(&[0x2])),       // 64-bit
            context("Endianness", tag(&[0x1])),  // little-endian
            context("Version", tag(&[0x1])),
            context("OS ABI", nom::branch::alt((tag(&[0x0]), tag(&[0x3])))),
            context("Padding", take(8_usize)),
        ))(i)?;
        Ok((i, Self {}))
    }
}

枚举类型处理------用 #[repr(u16)] 加上 derive-try-from-primitive 宏,既能把枚举值转成整数,也能从整数安全地转回枚举,省去大量重复代码:

rust 复制代码
#[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)]
#[repr(u16)]
pub enum Type {
    None = 0x0,
    Rel  = 0x1,
    Exec = 0x2,
    Dyn  = 0x3,
    Core = 0x4,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)]
#[repr(u16)]
pub enum Machine {
    X86    = 0x03,
    X86_64 = 0x3e,
}

注意:Rust 中 type 是关键字,字段名要写成 r#type

为地址类型做一个专用的 Addr 包装:

rust 复制代码
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Add, Sub)]
pub struct Addr(pub u64);

impl fmt::Debug for Addr {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{:08x}", self.0)
    }
}

跑起来:

bash 复制代码
$ ./target/debug/elk /bin/true
File {
    type: Dyn,
    machine: X86_64,
    entry_point: 00001040,
}

解析程序头(Program Headers)

ELF 文件有两套"目录":段头表(section headers,面向链接器)和程序头表(program headers,面向操作系统和加载器)。加载程序时,真正起作用的是程序头。

程序头类型 PT_LOAD 描述了哪些数据需要被加载进内存,以及加载到哪里。关键字段:

  • vaddr:加载的虚拟地址
  • filesz:文件中的大小
  • memsz:在内存中的大小(可以比 filesz 大,多出的部分初始化为零)
  • flags:可读(R)、可写(W)、可执行(X)

flags 的组合决定了内存页的权限,这是现代操作系统安全的基础之一。


第 2 篇:不用 exec,自己加载并执行

ASLR 与内存布局

先用 elk 运行一个打印 main 地址的 C 程序:

bash 复制代码
$ ./target/debug/elk ./samples/entry_point
main is at 0x5571c34e9139
$ ./target/debug/elk ./samples/entry_point
main is at 0x55f7a0f27139

每次地址不同,末尾 ...139 不变------这就是 ASLR(地址空间布局随机化),是内核的安全特性。

程序里不同的数据分布在地址空间的不同区域:

  • 代码(.text):只读 + 可执行,地址空间低处附近
  • 只读数据(.rodata):只读
  • 栈上局部变量:读写,地址空间高处
  • 堆分配(malloc):读写,介于两者之间

尝试写入只读区域(常量、代码)会立刻触发段错误(segfault)。

内存权限与 mmap

把代码从文件里读进来直接执行会 segfault,因为用 read() 读进来的内存默认是不可执行的。

正确的做法是用 mmap 系统调用,申请一块既可读又可执行的内存,把文件内容映射进去,再跳转到入口点执行:

rust 复制代码
unsafe fn jmp(addr: *const u8) {
    let fn_ptr: fn() = std::mem::transmute(addr);
    fn_ptr();
}

光跳转还不够------要正确地加载一个 ELF,需要按程序头的指示,把每个 PT_LOAD 段映射到它要求的虚拟地址,还要正确设置内存权限(R/W/X)。

libc::mmap 把文件的各个段映射到内存:

rust 复制代码
let mem = libc::mmap(
    addr as *mut c_void,
    size,
    prot,
    libc::MAP_PRIVATE | libc::MAP_FIXED,
    fd,
    offset as i64,
);

MAP_FIXED 要求内核把内容精确地映射到指定地址,而不是随便找个空闲地方。


第 3 篇:位置无关代码(PIC)

ASLR 与 PIE 的矛盾

hello 是一个静态链接的可执行文件,要求加载到固定地址 0x401000。如果该地址已被占用,加载就会失败。

现代可执行文件几乎都是 PIE(Position-Independent Executable,位置无关可执行文件),可以加载到任意地址,这样 ASLR 才能发挥作用。

GOT 和相对寻址

PIE 文件里,代码不能用绝对地址引用数据。取而代之,代码通过 rip(指令指针寄存器)的相对偏移来寻址:

nasm 复制代码
; hello-pie.asm
_start: mov rdi, 1
        lea rsi, [rel msg]   ; 使用相对寻址
        mov rdx, 9
        mov rax, 1
        syscall

[rel msg] 让汇编器生成一条以当前指令地址为基准的相对地址引用,无论代码被加载到哪里,偏移量都是固定的。

两个 PT_LOAD

PIE 可执行文件通常有两个 PT_LOAD 段:

  • 第一个:可读可执行(代码段)
  • 第二个:可读可写(数据段)

加载时,两个段都要按照程序头里的 vaddr 字段加上一个随机的"基地址偏移"来映射。

elk 里,加载这样的文件需要先申请整块虚拟地址空间,再用 MAP_FIXED 把各段分别映射进去,最后跳转到入口点(入口地址 = 程序头中的 entry_point + 随机基地址偏移)。

内存段大小:filesz vs memsz

memsz 可以大于 filesz。多出的部分(memsz - filesz 字节)必须填零。这是 BSS 段(Block Started by Symbol)的实现方式------未初始化的全局变量在文件里不占空间,但在内存里需要清零的空间。


第 4 篇:ELF 重定位(Relocations)

为什么需要重定位

PIE 文件加载到内存后,代码里某些地方需要被"打补丁",把占位符 0x0 替换成实际地址------这个过程叫重定位

ELF 文件里有一个重定位表(.rela.dyn),每条记录包含:

  • offset:需要被修改的位置(相对于加载地址)
  • type:重定位的类型(决定怎么计算新值)
  • sym:关联的符号(可选)
  • addend:加数

最常见的重定位类型是 R_X86_64_RELATIVE,公式为:

ini 复制代码
*target = base + addend

其中 base 是文件被加载的基地址。这就是那些占位符 0x0 变成有效地址的全过程。

elk 里实现重定位

在程序头全部映射完成、跳转执行之前,遍历重定位表,对每一条记录执行对应的修改:

rust 复制代码
for reloc in file.read_rela_entries()? {
    match reloc.r_type {
        RelType::Relative => {
            let target = base + reloc.offset;
            let value  = base + reloc.addend;
            unsafe { *(target.as_mut_ptr::<Addr>()) = value; }
        }
        _ => todo!(),
    }
}

重定位必须在内存权限设置为可写时执行,完成后再把代码段保护改回只读/可执行。


第 5 篇:最简单的共享库

动态链接的基础

共享库(.so 文件)本质上也是 ELF 文件,格式与可执行文件几乎相同,区别是 TypeDyn,且没有 _start 入口。

写一个最简单的共享库------只暴露一个字符串符号 msg

nasm 复制代码
; libmsg.so.asm
global msg
section .data
msg: db "This message is from a shared library!", 10

调用这个库的可执行文件:

nasm 复制代码
; hello-dl.asm
global _start
extern msg       ; 声明这个符号来自外部

section .text
_start:
    mov rdi, 1
    mov rsi, msg     ; 地址从哪里来?
    mov rdx, 38
    mov rax, 1
    syscall
    ; ...

链接时,msg 的地址是未知的------它来自共享库,只有在运行时由动态链接器填入。这就引出了 PLT(Procedure Linkage Table)GOT(Global Offset Table)

  • GOT 是一张表,每个外部符号对应一个槽位,运行时由动态链接器填上实际地址
  • 代码通过 GOT 间接访问外部符号,而不是直接引用

elk 加载共享库

elk 需要做的事情变多了:

  1. 解析可执行文件的 PT_DYNAMIC 段,找到它依赖的共享库列表(DT_NEEDED 条目)
  2. LD_LIBRARY_PATH 等路径搜索共享库文件并加载
  3. 在共享库里解析符号表,找到 msg 对应的地址
  4. 把这个地址填进可执行文件的 GOT

第 6 篇:加载多个 ELF 对象

依赖图的广度优先遍历

当可执行文件有多个依赖库,每个库又可能有自己的依赖时,加载顺序非常重要。Linux 的 ld-linux(系统动态链接器)使用广度优先 遍历依赖图,elk 也跟进实现了同样的策略。

把 ELF 对象的加载逻辑从 main 函数里抽取出来,用一个 LoadedLibrary 结构管理每个已加载的 ELF 对象,包含:

  • 文件的内容(Vec<u8>
  • 对应的 delf::File(已解析的 ELF 结构)
  • 内存基地址
  • 字符串表
  • 符号表

加载流程变成:

  1. 加载可执行文件
  2. 解析它的 DT_NEEDED 列表,加入工作队列
  3. 从队列里取出下一个,加载,解析它的 DT_NEEDED,加入队列(广度优先)
  4. 重复直到队列为空
  5. 对所有已加载的对象按顺序执行符号解析和重定位
  6. 跳转执行

第 7 篇:动态符号解析

符号解析的规则

当可执行文件引用一个外部符号时,动态链接器按照以下规则解析:

  • 加载顺序搜索所有已加载的 ELF 对象
  • 找到第一个同名的全局符号就算解析成功
  • 这叫"symbol interposition"------后加载的库里的同名符号会被先加载的那个遮蔽

elk 里实现这个逻辑:

rust 复制代码
fn resolve_symbol(&self, name: &str) -> Option<Addr> {
    // 按加载顺序搜索所有对象
    for obj in &self.objects {
        if let Some(sym) = obj.sym_table.get(name) {
            if sym.is_defined() {
                return Some(obj.base + sym.value);
            }
        }
    }
    None
}

支持更多重定位类型

除了 R_X86_64_RELATIVE,还需要处理:

  • R_X86_64_GLOB_DAT:把 GOT 槽里填上符号的地址(用于数据符号)
  • R_X86_64_JUMP_SLOT:把 PLT 跳转槽里填上函数的地址(用于函数调用)

这两种重定位都需要通过符号名查找地址,再写入对应位置。


第 8 篇:动态链接器的速度与正确性

用命名类型消除混淆

随着代码规模增长,到处传递裸 u64 地址容易出错。引入 Addr 类型让地址和普通整数在类型层面区分开。

另一个问题:可执行文件里的符号地址是相对于该文件自身基地址的偏移 ,而不是最终的运行时绝对地址。在 elk 里统一了这个概念:解析阶段保存相对偏移,使用时加上基地址得到绝对地址。

R_X86_64_COPY 重定位

当可执行文件引用了共享库里的数据 (而非函数)时,生成的是 R_X86_64_COPY 重定位。它的语义是:把共享库里符号的内容复制一份到可执行文件的 BSS 段,然后可执行文件用自己的那份。

这是一种历史设计遗产,主要是因为早期实现动态数据共享比动态函数调用要复杂得多。


第 9 篇:GDB 脚本与间接函数(IFUNC)

当 C 程序出现

尝试用 elk 加载一个最简单的 C 程序:

c 复制代码
// samples/puts.c
#include <stdio.h>
int main() {
    puts("Hello from C");
    return 0;
}

结果:

vbnet 复制代码
Fatal error: Could not read symbols from ELF object:
  Parsing error: Unknown SymType 10 (0xa)

这是 STT_GNU_IFUNC------间接函数类型的符号。

IFUNC:运行时多态的 C 实现

IFUNC(Indirect Function)是 glibc 的一个机制:同一个函数(比如 memcpy)有多个实现,针对不同 CPU 特性(SSE2、AVX2......)各有一份。程序启动时,glibc 会检测 CPU 支持哪些指令集,然后把 memcpy 的地址指向最优的那份实现。

puts 也是类似的情况------它内部调用了 strlen 等函数,这些函数都可能是 IFUNC。

elk 里处理 IFUNC 需要:

  1. 识别 STT_GNU_IFUNC 符号类型(值为 10)
  2. 对于 IFUNC 符号的重定位,不是直接用符号地址,而是调用该符号指向的选择函数,用它的返回值作为最终地址

用 Python 写 GDB 扩展

为了方便调试 elk 加载程序的过程,作者写了几个 GDB 扩展命令(用 Python 实现):

python 复制代码
class ElkLoad(gdb.Command):
    """让 GDB 知道 elk 加载了哪些库,以便设置正确的符号和断点"""
    def invoke(self, arg, from_tty):
        # 读取 elk 的内部状态,告诉 GDB 各个库的基地址
        ...

这样就可以在 GDB 里对 elk 加载的目标程序里的函数设断点、单步调试。


第 10 篇:更安全的内存映射结构

来自 Rust 编译器团队的反馈

一位 Rust 编译器团队成员看了前几篇,给出了代码层面的反馈------主要是关于内存安全和 unsafe 的使用。

具体问题:elk 里有一些地方把内存映射的数据当作 Rust 结构体直接解引用(通过裸指针转换),这是很危险的,因为:

  • 对齐(alignment)可能不满足要求
  • 文件内容可能不合规范,导致 UB(未定义行为)

PackedStructField 解决方案

引入一个包装类型,专门用于从内存映射区域安全地读取结构体字段:

rust 复制代码
// 通过字节拷贝而不是指针投射来读取值
pub fn get<T: Copy>(&self) -> T {
    let mut result = std::mem::MaybeUninit::<T>::uninit();
    unsafe {
        std::ptr::copy_nonoverlapping(
            self.ptr,
            result.as_mut_ptr() as *mut u8,
            std::mem::size_of::<T>(),
        );
        result.assume_init()
    }
}

通过字节拷贝(copy_nonoverlapping)而不是指针重解释来读取数据,消除了对齐问题。这让大量 unsafe 块可以被封装进安全接口里。


第 11 篇:更多 ELF 重定位类型

运行更多真实程序

继续推进"用 elk 跑尽可能多的程序"这一目标。这一篇补全了几种之前跳过的重定位类型:

R_X86_64_64:把符号地址(加上 addend)写入目标位置,64 位整数。这用于静态初始化的数据:

c 复制代码
// 某个库里
const char *some_string = "hello";
// 这个指针在加载时需要 R_X86_64_64 重定位来填上 "hello" 的运行时地址

R_X86_64_PC32R_X86_64_PLT32:PC 相对 32 位重定位,用于代码里的相对跳转和调用。公式为:

ini 复制代码
value = sym_value + addend - reloc_offset

这些重定位让代码可以用 32 位有符号整数表示函数调用的偏移量,而不需要完整的 64 位绝对地址。

函数通过 PLT 调用的完整流程

调用一个外部函数(如 puts)时,实际上调用的是 PLT(Procedure Linkage Table)里的一个桩代码(stub)。PLT 桩代码的结构:

  1. 通过 GOT 间接跳转
  2. GOT 里初始时存的是 PLT 自己的下一条指令地址(延迟绑定)
  3. 第一次调用时,会跳转到 ld-linux 的解析代码,把真实地址填进 GOT
  4. 后续调用就直接通过 GOT 跳到真实函数了

elk 要跳过这套延迟绑定机制,在启动时就把所有 GOT 槽填好(提前绑定,也叫 eager binding)。


第 12 篇:no_std 的 Rust 二进制

最终目标的预演

为了最终制作可执行文件打包器,作者决定尝试用 elk 加载一个不依赖 libc 的 Rust 程序。这需要用 #![no_std] 和手写入口点:

rust 复制代码
#![no_std]
#![no_main]

use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

#[no_mangle]
pub extern "C" fn _start() -> ! {
    // 直接调用 write syscall
    unsafe {
        libc_syscall::write(1, b"hello from no_std Rust!\n", 24);
        libc_syscall::exit(0);
    }
    unreachable!()
}

系统调用包装

为了在 no_std 环境里做系统调用,需要用内联汇编(asm!)包装:

rust 复制代码
pub unsafe fn write(fd: i64, buf: *const u8, count: usize) -> isize {
    let ret: isize;
    core::arch::asm!(
        "syscall",
        in("rdi") fd,
        in("rsi") buf,
        in("rdx") count,
        inout("rax") 1_isize => ret,
        out("rcx") _,
        out("r11") _,
    );
    ret
}

no_std 的 Rust 二进制通过 elk 加载和运行,验证了 elk 能够处理真实的 Rust 可执行文件,为后续的打包器实现铺路。


第 13 篇:线程本地存储(TLS)

TLS 是什么

TLS(Thread-Local Storage)是一种机制,让每个线程有自己独立的某个变量副本:

rust 复制代码
thread_local! {
    static COUNTER: Cell<u32> = Cell::new(0);
}

这个 COUNTER 对每个线程来说都是独立的,不同线程的修改互不影响。

Intel CPU 分段的历史

理解 TLS 的实现需要先了解 x86 分段机制的演化历史:

  • 286 时代:引入段寄存器(CS、DS、ES、SS)的保护模式,每个段描述符存在段描述符表(GDT/LDT)里,包含基地址、大小、权限
  • 386 时代:加入页保护机制,段保护仍存在但主要靠页保护
  • 64 位时代 :大多数段寄存器的基地址被强制置为 0(即段保护形同虚设),但 FSGS 段寄存器是例外------它们的基地址可以设置为任意值,通过 MSR(Model Specific Registers)控制

Linux 用 GS 段(在内核里)和 FS 段(在用户空间里)来实现 TLS。每个线程的 FS.base 指向该线程的 TLS 数据块。

ELF 里的 TLS 实现

ELF 里有专门的 TLS 段(PT_TLS),描述了 TLS 变量的模板。动态链接器需要:

  1. 为每个线程分配 TLS 块
  2. 把 TLS 段的内容复制进去(初始化有初始值的变量)
  3. 其余部分填零
  4. FS.base 指向这块内存

访问 TLS 变量时,代码里生成的汇编大概是:

nasm 复制代码
; 访问 TLS 变量
mov rax, fs:[0]           ; 读取线程指针(TLS 块的地址)
mov rdi, [rax + offset]   ; 用固定偏移访问变量

elk 里实现这些,需要手动 mmap 一块内存作为 TLS 块,并用 arch_prctl(ARCH_SET_FS, ...) 系统调用把 FS.base 设置好。


第 14 篇:glibc 的内脏

尝试运行 /bin/ls

这一篇的大目标是:用 elk 运行 /bin/ls

第一个问题:/bin/ls 是"static PIE"还是"dynamic"?

bash 复制代码
$ file /bin/ls
/bin/ls: ELF 64-bit LSB pie executable, x86-64, dynamically linked, ...

动态链接,也就是说它依赖 ld-linuxlibc

brk 系统调用与堆

glibc 的 malloc 会用到 brk 系统调用来扩展"程序中断点"(program break)------堆的顶端。

elk 之前完全没有处理 brk,程序要分配堆内存就会崩溃。加上 brk 的支持后,glibc 的 malloc 才能正常工作。

谁来重定位重定位器?

ld-linux 本身也是一个 PIE 的 ELF 文件------它是动态链接器,但它自身在加载时没有别人来帮它处理重定位。这是靠一段叫做"bootstrap"的汇编代码实现的:ld-linux 在一开始用完全位置无关的代码运行,手动把自己的重定位处理掉,然后才进入正常的 C 代码流程。

ELF 初始化函数

ELF 文件可以有初始化函数,在 main 运行之前被调用:

  • DT_INIT:单个初始化函数指针
  • DT_INIT_ARRAY:初始化函数指针数组,按顺序调用

glibc 的很多初始化(比如设置 TLS、初始化 stdio)都通过 DT_INIT_ARRAY 完成。elk 必须在跳转到 main 之前调用这些函数。

间接重定位(R_X86_64_IRELATIVE

又遇到了 IFUNC 的变体------R_X86_64_IRELATIVE

  • R_X86_64_RELATIVE 类似,但目标位置存的不是直接的地址,而是一个选择函数的地址
  • 必须调用这个选择函数,用它的返回值作为最终地址

这是 glibc 内部优化 CPU 特性分派的机制的一部分。处理完这些之后,/bin/ls 终于能通过 elk 运行了。


第 15 篇:libcore 和 libstd 之间

终于到了:开始做打包器

前 14 篇是铺垫,从第 15 篇开始,正式进入可执行文件打包器的实现。

打包器的基本思路:

  1. 读入一个普通 ELF 可执行文件("guest")
  2. 压缩它(用 zstd 或类似算法)
  3. 生成一个新的 ELF 可执行文件("packed"),包含:
    • stage1:一段解压缩代码,运行时把 guest 解压到内存,然后跳转执行
    • 压缩后的 guest 数据

打包器不能依赖 libc

打包器生成的 stage1 会在非常早期的环境中运行,没有 C 运行时,没有动态链接器。它必须用纯系统调用。

这就是 no_std Rust 派上用场的地方。作者建了一个新 crate encore,只依赖 libcore(Rust 标准库的最小子集,不依赖 OS),并在其中手工实现了打包器需要的功能:

  • 系统调用包装(writereadmmapmunmapexit......)
  • 安全的 mmap 包装器(把原始指针封装进 RAII 结构)
  • 简单的内存操作(memcpymemset
rust 复制代码
// encore crate 里的 mmap 包装
pub struct Mapping {
    ptr: *mut u8,
    len: usize,
}

impl Drop for Mapping {
    fn drop(&mut self) {
        unsafe {
            syscall::munmap(self.ptr, self.len);
        }
    }
}

第 16 篇:ELF 之外的一切

参数解析

stage1 需要从命令行参数里找到压缩数据的位置。encore 实现了一个简单的参数解析器,直接从栈上读取 argcargv(按照 x86-64 System V ABI,内核在跳转到 _start 时把这些放在栈上)。

压缩可执行文件

引入 zstd 压缩:

rust 复制代码
// 打包器:压缩 guest 可执行文件
let compressed = zstd::encode_all(guest_bytes, 19)?;

stage1 的结构

生成的 packed 可执行文件布局:

css 复制代码
[ELF 头 + 程序头]
[stage1 代码:解压并运行 guest]
[压缩后的 guest 数据]

stage1 运行时:

  1. 从自身二进制里找到压缩数据的位置(用一个固定的 magic 标记)
  2. mmap 申请一块匿名内存
  3. 用 zstd 解压缩到这块内存
  4. 按照 ELF 程序头的要求,把解压后的各段映射到正确地址
  5. 执行重定位
  6. 跳转到 entry point

麻烦来了

但这里遇到了一个根本性问题:stage1 本身也是一个 ELF,它需要被加载到某个地址。而 guest 也需要被加载到它自己要求的地址。如果两者地址空间重叠,怎么办?

更深的问题:当 stage1 正在运行,它的代码和数据占据着某块地址空间。当它想把 guest 映射到同一块地址空间时,就会把自己的代码覆盖掉------然后崩溃。


第 17 篇:从内存运行一个自重定位 ELF

问题的核心

stage1 在运行时占据了某块虚拟地址空间。要加载 guest,就需要释放这块空间。但释放之后,stage1 自身的代码也消失了------这像是在锯自己坐着的树枝。

解决方案:让 stage1 先把自己整个复制到另一块内存,然后从新地址继续执行,再把原来的地址空间腾出来给 guest 使用。

这需要 stage1 是位置无关的(PIE),而且能够在任意地址自我重定位。

自重定位的实现

stage1 被编译成 PIE。运行时,它:

  1. mmap 申请一块新内存
  2. 把整个 stage1 二进制(包含代码和数据)复制进去
  3. 修正新地址空间内的重定位
  4. 跳转到新地址处的代码继续执行
  5. 解除原来地址的映射
  6. 加载并运行 guest

这整个过程必须用完全手写的汇编或者极其小心的 Rust(不能有任何依赖重定位的数据访问,因为在重定位完成之前,所有绝对地址引用都是错的)。

encore 里实现

rust 复制代码
unsafe fn relocate_self() -> *mut u8 {
    // 1. 找到自身在内存里的范围
    let (self_start, self_size) = find_self_range();

    // 2. 在随机地址申请新内存
    let new_base = mmap_anon(self_size, PROT_READ | PROT_WRITE | PROT_EXEC);

    // 3. 复制自身
    ptr::copy_nonoverlapping(self_start, new_base, self_size);

    // 4. 处理重定位
    apply_relocations(new_base, self_start);

    // 5. 跳转到新地址处的"继续运行"函数
    let continue_fn: fn() = transmute(new_base.add(continue_offset));
    new_base
}

第 18 篇:好吧,我们来自己重定位自己

最后的挑战

自重定位有一个最棘手的边界情况:当代码正在从旧地址跳到新地址的瞬间,要如何保证不会访问任何旧地址的数据?

这需要一段专门写的汇编桥接代码(trampoline),做到:

  • 不依赖任何通过绝对地址访问的栈数据
  • 不调用任何需要通过 GOT 间接跳转的函数
  • 在切换完 rip(指令指针)之后才开始用新地址的栈和数据

encore 的完整 stage1 代码

nasm 复制代码
; trampoline.s
; 此时 rdi = 新基地址,rsi = 旧基地址,rdx = continue_fn 偏移

; 把 rsp 从旧栈切换到新栈(如果在 mmap 区域之外的话可以不管)
; 跳转到新地址的 continue 函数
lea rax, [rdi + rdx]
jmp rax              ; 只要这条指令执行完,就再也不会回到旧地址了

最终验证

打包一个真实的程序(比如 hello),运行打包后的版本:

bash 复制代码
$ cargo run -- pack ./samples/hello -o ./samples/hello-packed
Packing ./samples/hello -> ./samples/hello-packed
  Original: 8.68 KiB
  Compressed: 312 B  (3.6%)

$ ./samples/hello-packed
hi there

整个系列的目标达成。


系列总结

这 18 篇的旅程,从一个简单的"想知道可执行文件里有什么"出发,走过了:

  • ELF 格式解析:文件头、程序头、段头、符号表、字符串表......
  • 内存映射mmap 系统调用、内存权限(R/W/X)、ASLR
  • 重定位 :7 种以上重定位类型,从最简单的 R_X86_64_RELATIVE 到复杂的 IFUNC
  • 动态链接:共享库、GOT/PLT、符号解析、延迟绑定 vs 提前绑定
  • CPU 底层 :x86 分段历史、TLS 的 FS.base 机制、arch_prctl 系统调用
  • glibc 内脏DT_INIT_ARRAYbrkR_X86_64_IRELATIVE
  • no_std Rustlibcore、内联汇编、手写系统调用包装
  • 自重定位:运行时把自己搬到新地址,腾出空间给 guest

每一步都是"为了让下一步成为可能"而做的铺垫。整个系列既是一个 ELF 教程,也是一个"如何用 Rust 在底层和操作系统打交道"的实战案例。

最重要的不是最终产出了什么,而是沿途把那些原本神秘的概念------系统调用、内存映射、动态链接、重定位------一个个亲手摸清楚了。


原系列:Making our own executable packer --- fasterthanli.me 全系列 18 篇,共约 9 小时阅读量,配有大量 SVG 图示和完整代码

相关推荐
UTF_82 小时前
一次NSMutableAttributedString误用的思考
ios·面试·github
zhang_adrian7 小时前
【使用Github Copilot自动按规范文档生成全部代码】
人工智能·github·copilot
代钦塔拉8 小时前
Git & GitHub 从入门到精通:全流程实战教程
git·github
阿里嘎多学长8 小时前
2026-05-30 GitHub 热点项目精选
开发语言·程序员·github·代码托管
lauo1 天前
从FunloomAI到ibbot:当你的手机不再是“手机”,而是你的AI副脑和生产节点
人工智能·智能手机·架构·开源·github
Hommy881 天前
【剪映小助手】贴纸处理接口
网络·开源·github·aigc·剪映小助手·视频剪辑自动化
AIMath~1 天前
向github中上传文件过大超过50M怎么办
网络·git·github
麷飞花1 天前
Github开源协议
github·开源协议
用户887665426631 天前
Git 和 GitHub 入门:从版本控制到团队协作,一篇文章讲清楚
面试·github