把 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,里面要背三块东西:
- bughunter 框架本体 —— Nuitka 编译出的 standalone 目录树
cli.dist/(含插件资产opencode-project/、用户级依赖opencode-user/),几百 MB、上千个文件、带 symlink 和可执行权限位; - opencode —— bun 现场编译出的单文件 TUI 宿主;
- 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"目标冲突:
- tar 解析(512 字节头、八进制字段、长文件名、typeflag)得手写;gzip/zstd 解压得再引依赖
- 解析逻辑在运行时执行,代码量和测试面都落在运行时
它反而点破了本方案的核心思想:归档格式没问题,问题是"解析"不该发生在运行时。
实际方案: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 做两件事:
collect():遍历目录树,每个条目记录(相对路径, 类型 file/dir/symlink, 字节, 权限位),symlink 存的是链接目标字符串;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)的消费逻辑很朴素:
- 目标目录
<cache>/<name>-<version>已存在 → 直接返回(快速路径,二次启动零开销); - 否则解到同级临时目录
.<name>-<version>.tmp-<pid>:for 循环遍历索引表,按kind建目录 /symlink()/open+write+fsync,mode按表恢复; rename()原子生效;并发启动撞车时容忍EEXIST;- 每个条目先做路径安全检查——拒绝绝对路径和
../穿越(嵌入的路径本身是自己生成的,这是纵深防御)。
在真实产物上验证
最终 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"的自解压方案,内存友好得多。
局限与适用场景
适合:
- 分发单二进制、离线/受控环境部署——
scp一个文件就完成安装; - 资产与代码强绑定、随版本整体发布的场景(运行时、工具链自带);
- 想要 file-backed 按需分页,而非启动即全量解压。
不适合:
- 资产频繁热更新——任何 payload 变更都要重发整个 ELF(几百 MB 全量传输);
- 极度在意二进制体积的场景;
- 需要对嵌入资产随机修改的场景(它是只读快照,改动要重新构建)。
总结
这套手法把三件事各归其位:
- objcopy 只负责"格式转换":裸字节 → 合法 ELF 目标文件,链接后数据进入
PT_LOAD,运行时取址零成本; - 生成器负责"结构":目录树压平成 blob + 编译期 C 索引表,归档解析在编译期消化完毕;
- bootstrap 只负责"物化":for 循环 + 原子 rename + 版本化缓存目录,二次启动走零开销快速路径。
想自己上手验证,五行命令就够:printf 造 blob → objcopy -I binary 转 .o → nm 看符号 → objdump -s 看字节 → 写个 extern 的 C 程序链接进去。从 5 字节的 "ABCDE" 到 435MB 的完整框架,是同一个机制。