本文根据系列博客 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 文件,格式与可执行文件几乎相同,区别是 Type 为 Dyn,且没有 _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 需要做的事情变多了:
- 解析可执行文件的
PT_DYNAMIC段,找到它依赖的共享库列表(DT_NEEDED条目) - 从
LD_LIBRARY_PATH等路径搜索共享库文件并加载 - 在共享库里解析符号表,找到
msg对应的地址 - 把这个地址填进可执行文件的 GOT
第 6 篇:加载多个 ELF 对象
依赖图的广度优先遍历
当可执行文件有多个依赖库,每个库又可能有自己的依赖时,加载顺序非常重要。Linux 的 ld-linux(系统动态链接器)使用广度优先 遍历依赖图,elk 也跟进实现了同样的策略。
把 ELF 对象的加载逻辑从 main 函数里抽取出来,用一个 LoadedLibrary 结构管理每个已加载的 ELF 对象,包含:
- 文件的内容(
Vec<u8>) - 对应的
delf::File(已解析的 ELF 结构) - 内存基地址
- 字符串表
- 符号表
加载流程变成:
- 加载可执行文件
- 解析它的
DT_NEEDED列表,加入工作队列 - 从队列里取出下一个,加载,解析它的
DT_NEEDED,加入队列(广度优先) - 重复直到队列为空
- 对所有已加载的对象按顺序执行符号解析和重定位
- 跳转执行
第 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 需要:
- 识别
STT_GNU_IFUNC符号类型(值为 10) - 对于 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_PC32 和 R_X86_64_PLT32:PC 相对 32 位重定位,用于代码里的相对跳转和调用。公式为:
ini
value = sym_value + addend - reloc_offset
这些重定位让代码可以用 32 位有符号整数表示函数调用的偏移量,而不需要完整的 64 位绝对地址。
函数通过 PLT 调用的完整流程
调用一个外部函数(如 puts)时,实际上调用的是 PLT(Procedure Linkage Table)里的一个桩代码(stub)。PLT 桩代码的结构:
- 通过 GOT 间接跳转
- GOT 里初始时存的是 PLT 自己的下一条指令地址(延迟绑定)
- 第一次调用时,会跳转到
ld-linux的解析代码,把真实地址填进 GOT - 后续调用就直接通过 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(即段保护形同虚设),但
FS和GS段寄存器是例外------它们的基地址可以设置为任意值,通过MSR(Model Specific Registers)控制
Linux 用 GS 段(在内核里)和 FS 段(在用户空间里)来实现 TLS。每个线程的 FS.base 指向该线程的 TLS 数据块。
ELF 里的 TLS 实现
ELF 里有专门的 TLS 段(PT_TLS),描述了 TLS 变量的模板。动态链接器需要:
- 为每个线程分配 TLS 块
- 把 TLS 段的内容复制进去(初始化有初始值的变量)
- 其余部分填零
- 把
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-linux 和 libc。
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 篇开始,正式进入可执行文件打包器的实现。
打包器的基本思路:
- 读入一个普通 ELF 可执行文件("guest")
- 压缩它(用 zstd 或类似算法)
- 生成一个新的 ELF 可执行文件("packed"),包含:
- stage1:一段解压缩代码,运行时把 guest 解压到内存,然后跳转执行
- 压缩后的 guest 数据
打包器不能依赖 libc
打包器生成的 stage1 会在非常早期的环境中运行,没有 C 运行时,没有动态链接器。它必须用纯系统调用。
这就是 no_std Rust 派上用场的地方。作者建了一个新 crate encore,只依赖 libcore(Rust 标准库的最小子集,不依赖 OS),并在其中手工实现了打包器需要的功能:
- 系统调用包装(
write、read、mmap、munmap、exit......) - 安全的
mmap包装器(把原始指针封装进 RAII 结构) - 简单的内存操作(
memcpy、memset)
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 实现了一个简单的参数解析器,直接从栈上读取 argc、argv(按照 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 运行时:
- 从自身二进制里找到压缩数据的位置(用一个固定的 magic 标记)
- 用
mmap申请一块匿名内存 - 用 zstd 解压缩到这块内存
- 按照 ELF 程序头的要求,把解压后的各段映射到正确地址
- 执行重定位
- 跳转到 entry point
麻烦来了
但这里遇到了一个根本性问题:stage1 本身也是一个 ELF,它需要被加载到某个地址。而 guest 也需要被加载到它自己要求的地址。如果两者地址空间重叠,怎么办?
更深的问题:当 stage1 正在运行,它的代码和数据占据着某块地址空间。当它想把 guest 映射到同一块地址空间时,就会把自己的代码覆盖掉------然后崩溃。
第 17 篇:从内存运行一个自重定位 ELF
问题的核心
stage1 在运行时占据了某块虚拟地址空间。要加载 guest,就需要释放这块空间。但释放之后,stage1 自身的代码也消失了------这像是在锯自己坐着的树枝。
解决方案:让 stage1 先把自己整个复制到另一块内存,然后从新地址继续执行,再把原来的地址空间腾出来给 guest 使用。
这需要 stage1 是位置无关的(PIE),而且能够在任意地址自我重定位。
自重定位的实现
stage1 被编译成 PIE。运行时,它:
- 用
mmap申请一块新内存 - 把整个 stage1 二进制(包含代码和数据)复制进去
- 修正新地址空间内的重定位
- 跳转到新地址处的代码继续执行
- 解除原来地址的映射
- 加载并运行 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_ARRAY、brk、R_X86_64_IRELATIVE no_stdRust :libcore、内联汇编、手写系统调用包装- 自重定位:运行时把自己搬到新地址,腾出空间给 guest
每一步都是"为了让下一步成为可能"而做的铺垫。整个系列既是一个 ELF 教程,也是一个"如何用 Rust 在底层和操作系统打交道"的实战案例。
最重要的不是最终产出了什么,而是沿途把那些原本神秘的概念------系统调用、内存映射、动态链接、重定位------一个个亲手摸清楚了。
原系列:Making our own executable packer --- fasterthanli.me 全系列 18 篇,共约 9 小时阅读量,配有大量 SVG 图示和完整代码