本文根据 tmp.0ut 第五期中 elfmaster 的文章编译整理,非逐字翻译;文中观点均属于原作者。
原文:Fine grained load time ASLR for ELF executables in X86_64 Linux
文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:7806
带一点背景的引言
本文分享作者在自定义 ELF 动态链接器、ELF ABI 增强与“链式动态链接”(多个动态链接器同时工作)方面的研究,用于解决诸如“加载时细粒度 ASLR 的进程映像加固”这类复杂安全问题——这正是本文要演示的安全特性。
论文起源
本文在很大程度上是作者在 tmp.0ut 第二期文章《preloading the linker for fun and profit》的后续:那篇文章展示了如何把自定义动态链接器与 ld-linux.so 链式拼接,加载 ET_REL 对象来实现 ELF 病毒。该研究最终孕育出 Shiva 项目。
原始研究目标
那篇论文展示了链式自定义 ELF 解释器可以当作 slick 的新型 Linux 病毒技术,但作者知道它还有更大潜力:安全加固模块、调试模块,后来意识到还能用于 ELF 二进制修补。Shiva 由此诞生——它最初是为 DevSecOps 插桩设计的自定义 ELF 动态链接器。作者希望让开发者能快速设计复杂的进程内安全特性与加固模块;早期版本还带有一个类似 PTRACE 的接口 ShivaTrace,为自定义 tracer 与调试引擎提供极快的插桩(见其 Toorcamp 2022 演讲)。
Shiva:二进制修补引擎
Shiva 后来获得 DARPA AMP 资助,演变成二进制修补引擎:没有逆向知识的开发者也能用 C 写强大的二进制补丁。本文不探索 Shiva 的二进制修补能力(AArch64 的 ELF micropatching 手册与 2023 DEFCON 演讲可作参考;x86_64 的深度博文作者在等 DARPA 批准发布)。
Shiva 是多用途动态链接器,有两类主要模块(都是 ELF 可重定位对象):
- ELF 微程序(ELF microprograms):用 C 编写、编译成 ET_REL 对象、由 Shiva 在加载时安装。对写安全模块(调试引擎、漏洞缓解、tracer)非常强大。
- ELF 微补丁(ELF micropatches):与微程序相似但没有 init 函数,改用符号定义与自定义 Shiva 宏来直观地插桩与变换代码/数据。开发者可以修补已经编译好的软件,几乎不需要逆向知识。
Shiva 用 ET_REL 对象作模块,因为这类 ELF 不只含代码与数据,还含从零搭建程序映像所需的元数据;其中 .text 重定位对重新链接与变换代码极其宝贵。本文使用 ELF 微程序加载能力(与修补能力不同,虽然都用可重定位模块)。
模块进程映像示意(原图见原文链接):Shiva 模块位于约 0x6000000–0x8000000 的独立地址区(TEXT/DATA 段),补丁代码与可执行代码在运行时被链接到一起;主程序(ET_DYN/PIE)仍是正常布局。Shiva 模块使用大代码模型,所有 call 都经 PLT/GOT 间接进行,全局变量引用同理。
用 ELF 微程序给进程映像做安全加固
把自定义动态链接器链进执行流,架起了让开发者构建复杂安全特性的桥梁——无需成为 glibc、gcc、binutils、ld、ld-linux.so 与 Linux 的开发者。Linux 目前对进程映像的加固,分散在编译器、链接器、库与 OS 各层的二进制缓解与安全特性中,通常需要多项目协调。
ELF 微程序可以在 ld-linux.so 运行前加载执行,让开发者对整个进程映像拥有执行优先权。作者用它做过极快的函数 tracer、专用调试器、漏洞缓解、病毒,甚至后门,还写过一个针对 ld-linux.so 的模糊器。
ASLR 与 gASLR
ASLR 定义
ASLR(地址空间布局随机化)是 Linux 等现代操作系统在每次加载程序时随机化可执行文件与库的基址的特性,阻止漏洞利用使用硬编码地址、整体提高内存破坏利用的难度。ASLR 依赖 PIE ELF(ET_DYN):代码全部位置无关,运行时无需文本重定位。
弱点在于 ASLR 只随机化基址。只要漏洞利用拿到读原语就能泄露基址;攻击者一旦确定基址,就能轻易算出代码 gadget 与全局数据的位置。
gASLR 定义
gASLR 即 “Granular ASLR”,做细粒度随机化:不只随机化基址,还在加载时随机化每个函数的地址,可选地随机化 PLT 条目与全局数据位置。目标是像 ASLR 一样加固进程映像,但即便攻击者泄露基址,也无法再按相对偏移定位代码或数据。
本文将介绍一个 gASLR 原型:以 Shiva ELF 微程序的形式实现。当前实现会在加载时把主可执行文件里的每个函数重写到内存中的随机位置,并在程序执行前即时重新链接代码与数据。
链式动态链接概念
作者强烈建议先读原论文理解链式动态链接:有完整算法把 PT_INTERP 从 /lib/x86_64-linux-gnu/ld-linux.so 替换成 /lib/shiva。新的 ELF 解释器完成工作后,负责把原 ld-linux.so 映射进内存并在最后移交控制权。
执行流:
[Linux kernel]
-> [New ELF Interpreter /lib/shiva]
-> [Microprograms init function]
-> [ld-linux.so]
-> [_start()]
工作流(原图见原文):内核执行 sys_exec → Shiva(/lib/shiva)把补丁模块链进进程(如 /opt/shiva/modules/patch1.o)→ Shiva 把 RTLD(ld-linux.so)映射进内存并把控制权交给它(4)→ RTLD 映射依赖、应用重定位(5)→ 最终进入目标程序 /bin/test(6),此时它已被补丁并与 patch1.o 链接。
写一个基础 Shiva 模块(C)
与 Linux LKM 类似,Shiva 模块有 init 函数作为模块入口。模块可以用 SHIVA_MODULE_PRE_LDSO 或 SHIVA_MODULE_POST_LDSO 指定 init 在 ld-linux.so 之前或之后运行(不指定默认 POST_LDSO)。例如要写模糊 ld-linux.so 的模块,需要在它运行前执行,以便在 ld-linux.so 执行前修改动态辅助数据。
Shiva 模块可以访问静态链接进 Shiva 解释器二进制的结构与库:
struct shiva_ctx(shiva/shiva.h):Shiva 主上下文,内含其他重要结构;模块的shiva_init把它作为唯一参数,从而获得 Shiva 内部 ELF 链接与二进制例程的完全访问权。- libelfmaster:Shiva 内部所有 ELF 解析等重活都靠它。
- musl-libc:编译 Shiva 用的 C 库,烤进 Shiva 二进制,模块内可访问;用
SHIVA_MODULE_FORCE_MUSL_RESOLUTION可强制优先解析 musl,有时必须用它避开使用 STT_IFUNC 重定位的 glibc 函数(Shiva 不支持)。 - 动态链接符号:目标程序加载时可访问的共享库符号,模块也可访问;
dlopen()加载的库符号需特殊处理,未来版本修复。 - libcapstone:Shiva 内部用它反汇编,模块也可用来反汇编目标进程代码或任何打开的内容。
构建 Shiva(x86_64 Linux)
克隆 Shiva 的 x86_64_port 分支并阅读 README.md:
git clone https://github.com/advanced-microcode-patching/shiva
第一个模块 sections.c
示例模块用 Shiva 上下文指针与 libelfmaster API 遍历已加载 ELF 可执行文件的节头,在目标程序运行前打印到 stdout:
#define _GNU_SOURCE
#include "shiva_module.h"
#include "shiva.h"
#include "libelfmaster.h"
SHIVA_MODULE_POST_LDSO; // 告诉 Shiva 在 ld-linux.so 运行后执行 shiva_init()
int
shiva_init(struct shiva_ctx *ctx)
{
elfobj_t *elfobj = &ctx->elfobj;
elf_section_iterator_t shdr_iter;
struct elf_section shdr;
size_t i = 0;
printf("Printing ELF section headers of program before runtime!\n");
elf_section_iterator_init(elfobj, &shdr_iter);
while (elf_section_iterator_next(&shdr_iter, &shdr) == ELF_ITER_OK) {
printf("[%02x] %s\n", i++, shdr.name);
}
}
模块必须用大代码模型编译,以便正确链接进“模块离主可执行文件超过 2GB”的进程映像:
gcc -I /opt/shiva/include -I /opt/elfmaster/include -mcmodel=large -c sections.c
测试:先单独运行 /bin/ls 建立基线,再设置 SHIVA_MODULE_PATH=./sections.o 并用 shiva /bin/ls 运行——会先打印 /bin/ls 的全部节名(.interp、.gnu.hash、.dynsym、.text…… 直到 .shstrtab),再正常输出目录列表。
真实模块不能每次都手动用 Shiva 启动程序,否则用户直接运行程序就绕过了安全特性。因此用 shiva-ld “预链接”模块到目标。
用 shiva-ld 预链接 sections.o 到 /bin/ls
Usage: shiva-ld -e test_bin -p patch1.o -i /lib/shiva -s /opt/shiva/modules/ -o test_bin_final [-cdN]
[-e] --input_exec 输入 ELF 可执行文件
[-p] --input_patch 输入 ELF 补丁(用 -N 时应为 .so 补丁)
[-i] --interp_path 解释器搜索路径,如 "/lib/shiva"
[-s] --search_path 模块搜索路径
[-o] --output_exec 输出可执行文件
[-d] --disable-cflow 不生成 CFG 数据(.shiva.xref / .shiva.branch)
[-N] --needed-injection 通过 DT_NEEDED 注入共享对象依赖
示例:
cp /bin/ls .
shiva-ld -e ls -p sections.o -i /lib/shiva -s /opt/shiva/modules -o ls.new
sudo cp sections.o /opt/shiva/modules
./ls.new
ls.new 的 PT_INTERP 变成 /lib/shiva;动态段被移进新增的 PT_LOAD 段以容纳几个新的自定义动态段类型,并新增 .shiva.xref、.shiva.branch、.shiva.strtab 三个节(放在新 PT_LOAD 段内,含 Shiva 在加载时生成重定位元数据所需的控制流信息)。动态段里还能看到 0x60000018/17/19 之类的 OS 特定条目,即 Shiva 记录模块基名与搜索路径的 SHIVA_DT_NEEDED/SHIVA_DT_SEARCH。运行时内核把 /lib/shiva 作为主解释器,Shiva 从动态段找到模块并加载、重定位,然后把控制权交给原解释器完成共享库加载。sections.o 声明了 POST_LDSO,所以模块在 ld-linux.so 完成后运行并打印节表。此时直接运行 ./ls.new 不再需要手动调用 shiva。
把 gASLR 实现成 Shiva 模块
设计与实现
理论上 gASLR 可以应用于现有 ELF 可执行文件(例如 Ubuntu 24 自带的二进制),但会慢且不可靠:那些二进制默认没有保留的 .rela.text 节,且用小代码模型构建,随机移动的函数相距超过 2GB 时无法正确重新链接。
gASLR 需要保留的文本重定位
通常 .text 的重定位用于 /bin/ld 链接编译单元;这类重定位默认只存在于 ELF 可重定位对象里——.o 文件有 .rela.text,可执行文件没有,因为编译单元已经链接成 ELF 可执行文件,不再需要文本重定位。
想在加载时重新链接 .text,理想情况下可执行文件应保留 .rela.text。gASLR 模块需要把目标程序每个函数移到随机内存位置,这要求重新链接每个函数,因此需要可执行文件的文本重定位:
gcc -Wl,--emit-relocs test.c -o test
可执行文件里的 .rela.text 与 ET_REL 中的不同:ET_REL 里 r_offset 相对 .text 起点,可执行文件里则相对程序基址。函数位置被随机化后,这些 r_offset 会失效,所以模块必须先更新每个函数的重定位记录再用来重链接。
move_function() 的核心(gASLR.c 第 466 行附近):
bool
move_function(struct shiva_ctx *ctx, struct aslr_ctx *aslr, struct func_entry *fe)
{
size_t delta;
struct reloc_entry *rel_entry;
aslr_debug("Moving function %s to %p\n", fe->symbol.name, fe->n_mem);
/*
* 把函数代码从旧地址复制到新地址
*/
memcpy(fe->n_mem, (uint8_t *)fe->runtime_vaddr, fe->func_len);
/*
* 更新该函数的重定位条目,使其在被移动后 r_offset 正确
*/
TAILQ_FOREACH(rel_entry, &fe->reloc_list, _linkage) {
delta = rel_entry->rel.offset - fe->base_vaddr;
rel_entry->rel.offset = delta;
}
/*
* 函数已移到新位置,用修改后的重定位记录修复它
*/
return relocate_function(ctx, aslr, fe);
}
即把每个函数的基址从它的 r_offset 减去,得到新的 r_offset。当前实现给每个函数一个独立 mmap 出来的随机基址;作者计划在后续版本让每个函数位于新基址的随机偏移处,避免函数在页边界上可预测。
可执行文件必须用大代码模型
如前述,Shiva 模块必须用大代码模型编译,而普通 ELF 可执行文件默认小代码模型。gASLR 随机重定位可执行文件的 .text,mmap 可能把代码放到相距超过 2GB 的位置;MAP_32BIT 之类的变通会降低熵、削弱安全性。因此当前 gASLR 要求目标可执行文件也用大代码模型构建(作者称未来有让 gASLR 支持小代码模型的黑客手段)。
构建兼容目标:
gcc -mcmodel=large -Wl,--emit-relocs test.c -o test
gASLR 源码结构
完整 gASLR.c 在 shiva/modules/x86_64_modules/aslr,核心流程:
build_func_list():遍历符号表,收集位于.text内的STT_FUNC全局函数;为每个函数建立func_entry,遍历.rela.text收集落在该函数范围内的重定位条目;为每个函数 mmap 一块新内存(n_mem)。relocate_function():按重定位类型(R_X86_64_PC32、R_X86_64_GOTPCREL(X)、GOT64、GOTPC64、GOTOFF64、PLTOFF64 等)修复被移动函数内部的引用;对入口点附近的main引用,还会把lea (%rip),rdi整条替换成movabs <new_main>,rdi+ 间接 call 序列,因为 main 的新位置可能超出 4 字节偏移可编码范围(init 例程如 _start、__libc_start_main 来自 crt*.o,不在大代码模型里)。move_function():memcpy 函数体到新位置,更新重定位偏移,再调用relocate_function()修复。remove_old_function():把旧位置的函数体用零擦除(作者说将来或许用随机字节)。randomize_func_locations():入口点函数(_start 之类)不移动,只修复其重定位使其指向新的 main 等;其余函数逐个 move + scrub。shiva_init():先 build_func_list 再 randomize_func_locations。
作者把这种技术称作 “JIT function transplantation”(JIT 函数移植)。
gASLR 演示
演示程序 test.c 在运行时打印自身函数地址:
#include <stdio.h>
#include <stdlib.h>
int test1(void)
{
int i = 0;
return 0;
}
static int ignore_me(void) // gASLR.o 不会重定位静态函数
{
int i = 7;
return 0;
}
int main(void)
{
printf("base address: %p\n", (unsigned long)&ignore_me & ~4095);
printf("main() is at %p\n", &main);
printf("test1() is at %p\n", &test1);
test1();
}
标准 ASLR 下(gcc -Wl,--emit-relocs -mcmodel=large test.c -o test)多次运行,main 与 test1 相对基址的偏移固定:
base address: 0x5a3b72d0a000
main() is at 0x5a3b72d0a19d
test1() is at 0x5a3b72d0a149
应用 gASLR 后(shiva-ld -e ./test -i /lib/shiva -s $PWD -p gASLR.o -o test.gASLR),每次运行 main 与 test1 都落在各自独立、互不相关的地址上:
$ ./test.gASLR
base address: 0x62e0aa32d000
main() is at 0x790adc599000
test1() is at 0x790adc597000
$ ./test.gASLR
base address: 0x55d1bb26e000
main() is at 0x705a9c5f5000
test1() is at 0x705a9c5f3000
函数相对基址不再固定。
未来工作
目前只是原型,还缺少处理所有重定位类型的能力,只实现基本安全目标。更完善版本会加入:
- 随机化 PLT 条目;
- 随机化全局数据;
- 随机化本地绑定函数;
- 让函数位于新内存地址的随机偏移处;
- 支持小代码模型程序。
作者说再多给一点时间就能打磨到生产可用——只是需要时间。
结束语
Shiva 弥合了二进制修补与动态插桩之间的鸿沟,对构建解决硬安全问题的 ELF 微程序极其强大。感谢阅读。
想学习 Shiva 用于二进制修补、构建安全特性、模糊器与调试器等,可查看 Arcana 网页上的训练介绍。联系邮箱:elfmaster [at] arcana-research.io。
参考
- Preloading the linker for fun and profit(tmp.0ut 2/6)
- Shiva 项目页 与 GitHub
- Toorcamp 2022 演讲
- Shiva 用户手册
- 2023 DEFCON 演讲:Revolutionizing ELF binary patching with Shiva
- gASLR.c 源码
- libelfmaster
- ELF 二进制修补训练介绍
- Shiva UserlandExec 源码
原文还附有模块进程映像与动态链接工作流等图示的链接。
下一页:静态内核补丁再探