把 400 多 MB 资源嵌进单个可执行文件:objcopy -I binary 手法分析

十月 16, 2026 [C/C++] #elf #编译链接 #gcc

把 400 多 MB 资源嵌进单个可执行文件:objcopy -I binary 手法分析

一句话结论:用 objcopy -I binary 把裸字节包装成合法的 ELF 目标文件(.o),和普通 C 代码一起链接进可执行文件的 .data 段——数据真正进入 PT_LOAD 映射,运行时通过链接期符号直接取地址,零解析、strip 安全;目录树等结构化资产则由生成器拼成"纯字节 blob + 编译期 C 索引表"两部分,等价于一个把解析逻辑在编译期消化掉的 tar。

背景与需求

BugHunterAgent(Python 写的漏洞猎手框架)需要交付一个"单文件、只依赖 libc"的可执行程序 build/bughunter,里面要背三块东西:

  1. bughunter 框架本体 —— Nuitka 编译出的 standalone 目录树 cli.dist/(含插件资产 opencode-project/、用户级依赖 opencode-user/),几百 MB、上千个文件、带 symlink 和可执行权限位;
  2. opencode —— bun 现场编译出的单文件 TUI 宿主;
  3. ripgrep —— musl 静态链接的 rg。

运行时(一个几百行的 C bootstrap)首次启动把三块解到 $XDG_CACHE_HOME/bughunter/{bughunter,opencode,ripgrep}-<版本>/,然后 exec 内嵌的 opencode 并注入环境变量。

本文整理这套"把任意目录树/二进制嵌进 ELF"的手法:基础用法 → 三个容易踩坑的替代方案 → 实际实现 → 最终产物上的实测验证。

基础手法:objcopy -I binary 五分钟入门

先看一个最小实验,5 个字节就够:

$ printf 'ABCDE' > demo.blob
$ objcopy -I binary -O elf64-x86-64 -B i386:x86-64 demo.blob demo.o
$ nm demo.o
0000000000000005 D _binary_demo_blob_end
0000000000000005 A _binary_demo_blob_size
0000000000000000 D _binary_demo_blob_start
$ objdump -h demo.o

demo.o:     文件格式 elf64-x86-64

节:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .data         00000005  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, DATA

内容一个字节不动地进了 .data 段:

$ objdump -s -j .data demo.o

Contents of section .data:
 0000 41424344 45                          ABCDE

三个参数的含义:

参数含义
-I binary输入是没有任何格式的裸字节,整个文件就是数据,不解析任何结构
-O elf64-x86-64输出为 x86-64 的 ELF relocatable(.o),cc/ld 的标准链接输入
-B i386:x86-64打上架构标记,否则链接器报"体系结构不匹配"拒绝混合链接

符号名由输入文件名推导:加 _binary_ 前缀、非字母数字字符替换为下划线、加 _start/_end/_size 后缀。demo.blob → _binary_demo_blob_start。注意它是链接期符号,不是运行时的查找键——C 侧一个 extern 声明就能用:

extern const unsigned char _binary_demo_blob_start[];

这手法历史悠久了,等价写法还有 ld -r -b binary -o blob.o blob(《你所不知道的 C》里演示过)和汇编的 .incbin 指令。objcopy 版本的好处是目标平台显式写在参数里,跨机器构建行为一致。

三个"想当然"方案的坑

方案 A:链接完再 --add-section 加段?

"对最终 ELF 跑 objcopy --add-section .rg=rg.bin 不就行了?"——不行,有个致命问题:

section 不在运行时内存里。 ELF 加载器(内核 + ld.so)只映射 PT_LOAD 程序头描述的段;section header table 是链接期元数据,不会被映射。--add-section 追加的数据只登记在 section header 里,运行中的进程根本看不到它。要读就必须 open("/proc/self/exe") → 解析 ELF header → 遍历 section table → 按名查找 → pread,等于在程序里内置一个小型 ELF 解析器;而且 strip 一下 section header 就没了(strip 保留 PT_LOAD 但删 section 表)。

-I binary 转 .o 则相反:数据落进普通 .data 段、被链接器放进 PT_LOAD,extern 符号在链接期解析完毕——运行时零查找、strip 安全、PIE 无关。

--add-section(链接后处理)-I binary → .o(参与链接)
运行时可用❌ 不在内存,需解析自身 ELF✅ 普通符号直接取地址
strip 后❌ section header 被删✅ 不受影响
树形元数据(路径/权限/symlink)❌ 仍需另配 manifest✅ 配合索引表解决
构建流程链接后追加步骤单次 cc 完成

方案 B:xxd/bin2c 生成 C 数组?

小文件没问题,几百 MB 会把编译器打爆:巨型字符串/数组字面量让 gcc/clang 的内存和时间失控。所以大 payload 的正确分工是字节走 objcopy(O(n) 拷贝,开销极小),C 代码里只放小索引。

方案 C:内嵌 tar.gz,运行时自解压?

单文件自解压安装器的经典做法(makeself 一类),但和"只依赖 libc 的 bootstrap"目标冲突:

它反而点破了本方案的核心思想:归档格式没问题,问题是"解析"不该发生在运行时。

实际方案:blob + 编译期索引表

一句话概括:自制一个 tar,但把解析逻辑在编译期消化掉——tar 的 512 字节头变成 C 结构体数组的字段,运行时只剩一个 for 循环。

构建数据流:

cli.dist/ 目录树 ──generate_payload.py──► bughunter.blob(纯字节拼接)+ payload.c(C 索引表)
opencode 单文件 ──┐                        opencode.blob + 表
ripgrep  单文件 ──┘                        ripgrep.blob + 表

三个 blob ──objcopy -I binary──► 三个 .o(各带 _binary_*_blob_start 符号)

cc main.c payload.c bughunter-payload.o opencode-payload.o ripgrep-payload.o -o bughunter

生成器 generate_payload.py 做两件事:

  1. collect():遍历目录树,每个条目记录 (相对路径, 类型 file/dir/symlink, 字节, 权限位),symlink 存的是链接目标字符串;
  2. emit_table():所有非目录条目的字节顺序拼进 .blob;同时在 payload.c 里生成 C 数组,data 字段直接写 _binary_<name>_blob_start + <offset>——偏移在编译期就算好,链接后指针就是最终内存地址。

生成的 payload.c 长这样(节选示意):

#include "payload.h"

extern const unsigned char _binary_bughunter_blob_start[];
extern const unsigned char _binary_opencode_blob_start[];
extern const unsigned char _binary_ripgrep_blob_start[];
const char bughunter_version[] = "1.2.3";

const struct payload_entry bughunter_payload[] = {
    { "bughunter.bin",                      _binary_bughunter_blob_start + 0,       1234567, 0755, PAYLOAD_FILE },
    { "opencode-project",                   NULL,                                  0,       0755, PAYLOAD_DIRECTORY },
    { "opencode-project/opencode.json",     _binary_bughunter_blob_start + 1234567, 812,     0644, PAYLOAD_FILE },
    /* ... */
};
const size_t bughunter_payload_count = sizeof(bughunter_payload) / sizeof(bughunter_payload[0]);

索引表结构(payload.h):

enum payload_kind { PAYLOAD_DIRECTORY = 1, PAYLOAD_FILE = 2, PAYLOAD_SYMLINK = 3 };

struct payload_entry {
    const char *path;              /* 相对路径 */
    const unsigned char *data;     /* 指向 blob 内偏移,目录为 NULL */
    size_t size;
    mode_t mode;                   /* 权限位随表保留 */
    enum payload_kind kind;
};

为什么拆三个 blob 而不是合并成一个大 blob:三块资产版本独立(bughunter/opencode/ripgrep 各有版本号),运行时分别装到 <name>-<版本> 三个缓存目录,失效粒度细——只升 ripgrep 时,另两个缓存目录不用重解几百 MB。

一个容易忽略的耦合:objcopy 的符号名由 blob 文件名推导,所以 build.sh 里 blob 文件不能随便改名,否则 payload.c 里的 extern 声明就对不上了(链接报 undefined reference)。

运行时:install_payload

bootstrap(main.c)的消费逻辑很朴素:

  1. 目标目录 <cache>/<name>-<version> 已存在 → 直接返回(快速路径,二次启动零开销);
  2. 否则解到同级临时目录 .<name>-<version>.tmp-<pid>:for 循环遍历索引表,按 kind 建目录 / symlink() / open+write+fsync,mode 按表恢复;
  3. rename() 原子生效;并发启动撞车时容忍 EEXIST;
  4. 每个条目先做路径安全检查——拒绝绝对路径和 ../ 穿越(嵌入的路径本身是自己生成的,这是纵深防御)。

在真实产物上验证

最终 build/bughunter 435MB,用 nm/objdump 直接看到三块 payload 的真实布局:

$ ls -lh build/bughunter
-rwxrwxr-x 1 peter peter 435M 10月16日 16:57 build/bughunter

$ nm build/bughunter | grep _binary_
000000000fabf423 D _binary_bughunter_blob_end
000000000f90429b A _binary_bughunter_blob_size
00000000001bb188 D _binary_bughunter_blob_start
000000001ad0bca3 D _binary_opencode_blob_end
000000000b24c880 A _binary_opencode_blob_size
000000000fabf423 D _binary_opencode_blob_start
000000001b23452b D _binary_ripgrep_blob_end
0000000000528888 A _binary_ripgrep_blob_size
000000001ad0bca3 D _binary_ripgrep_blob_start

$ objdump -h build/bughunter | grep -E '\.data '
 25 .data         1b0793b3  00000000001bb178  00000000001bb178  001ba178  2**3

内存布局一目了然:

.data 段(VMA 0x1bb178,总大小 0x1b0793b3 ≈ 453MB,占整个文件的 99%)
0x01bb188  ┌─ bughunter blob   0x0f90429b ≈ 261MB
0x0fabf423 ├─ opencode blob    0x0b24c880 ≈ 187MB   ← 恰好等于上一个的 end
0x1ad0bca3 ├─ ripgrep blob     0x0528888  ≈ 5.4MB   ← 恰好等于上一个的 end
0x1b23452b └─ .data 结束

验算:261,112,475 + 186,960,000 + 5,408,904 + 16(开头少量普通全局变量)= 453,481,395 = 0x1b0793b3,与 .data 段大小严格一致——三个 blob 首尾相接、零填充(objcopy 生成的 .o 对齐是 2⁰,所以 end 严格等于下一个 start)。

"区分"确实全靠符号,但不是运行时查找:每张索引表在生成期就写死了引用哪个 _binary_*_blob_start,链接器把符号解析成最终地址,运行时拿到的 entries[i].data 已经是指向正确位置的指针——不存在"这段数据属于哪个 payload"的运行时判定。objcopy 顺带生成的 _end/_size 符号在这里是冗余的,因为每张表自带 size。

顺带一提 .data 段的 file-backed 特性:字节在文件里有真实内容,内核按需分页(demand paging),没访问过的页不占物理内存。对比"启动时解压进 tmpfs"的自解压方案,内存友好得多。

局限与适用场景

适合:

不适合:

总结

这套手法把三件事各归其位:

  1. objcopy 只负责"格式转换":裸字节 → 合法 ELF 目标文件,链接后数据进入 PT_LOAD,运行时取址零成本;
  2. 生成器负责"结构":目录树压平成 blob + 编译期 C 索引表,归档解析在编译期消化完毕;
  3. bootstrap 只负责"物化":for 循环 + 原子 rename + 版本化缓存目录,二次启动走零开销快速路径。

想自己上手验证,五行命令就够:printf 造 blob → objcopy -I binary 转 .o → nm 看符号 → objdump -s 看字节 → 写个 extern 的 C 程序链接进去。从 5 字节的 "ABCDE" 到 435MB 的完整框架,是同一个机制。