本文要点
- 排查动态库缺失、确认程序是否被 strip、分析拿到手的陌生二进制,都绕不开"读二进制"——核心工具链是
file/ldd+ binutils(readelf/nm/objdump/strip) file一眼认出 ELF 类型:实测同一次编译产物hello(executable 可执行)、hello.o(relocatable 可重定位)、libadd.so(shared object 共享库)三种形态并存,还能看到with debug_info, not stripped等关键状态ldd打印动态依赖链:linux-vdso(内核虚拟共享对象)、libc.so.6(glibc)、ld-linux(加载器);"error while loading shared libraries" 类报错用它一眼定位缺哪个库readelf -h读 ELF 文件头:魔术字节7f 45 4c 46(即 \x7fELF)、ELF64 小端、EXEC 类型、入口点地址0x401050、36 个节头nm列符号表,符号字母暗藏类型:T全局函数、t局部函数、U未定义(要链接时填)、D/B数据段/BSS——__libc_start_main@GLIBC_2.34这类 U 符号就是动态链接的接缝objdump -d反汇编:实测把main从endbr64开场到call printf@plt再到ret的完整机器码摊开成汇编strip去掉调试符号与符号表:实测 17144 字节 → 14408 字节(约 -16%);生产发布可以 strip,但要排障调试就别 strip- 全部命令在 Ubuntu 24.04.4(系统自带 binutils)上真实执行,截图即真实输出
Linux 二进制分析:从 file 类型识别到 ELF 结构与 objdump 反汇编
服务起不来提示缺动态库?拿到一个陌生程序想先看它是什么来路?怀疑自己编译的产物带了太多调试信息?这些场景都指向同一个能力:读懂二进制文件。Linux 的可执行文件、目标文件、共享库本质是同一种格式——ELF(Executable and Linkable Format),而系统自带的一套工具(file、ldd,以及 binutils 家族)就是为解剖它而生。
本文在一台 Ubuntu 24.04.4 服务器上,用自己的编译产物把这条工具链完整走一遍:file 认类型 → ldd 查依赖 → readelf 看结构 → nm 读符号 → objdump 反汇编 → strip 瘦身,全部命令真实执行,截图即真实输出。
准备一个可分析的二进制
分析的目标就用自己刚编译出来的程序,这样每个环节都能和源代码对应上。先写两个微型 C 文件——一个带 main 的可执行程序,和一个只含函数的共享库源文件:
/* hello.c */
#include <stdio.h>
int add(int a, int b)
{
return a + b;
}
int main(void)
{
int s = add(20, 22);
printf("sum=%d\n", s);
return 0;
}/* add.c */
int add(int a, int b)
{
return a + b;
}然后编译出三种不同形态的产物,正好对应 ELF 的三种主要类型:
gcc -g -O0 -fno-pie -no-pie -Wl,--build-id=none hello.c -o hello
gcc -g -O0 -c hello.c -o hello.o
gcc -g -shared -fPIC -Wl,--build-id=none add.c -o libadd.so三条命令分别产出:hello——可执行文件(-g 保留调试信息、-O0 不优化,方便看汇编);hello.o——只编译不链接的目标文件;libadd.so——共享库。实测三者大小:hello 17144 字节、hello.o 4016 字节、libadd.so 15976 字节。调试信息和符号占了相当比例,后面 strip 一节会验证这一点。
file:第一眼识别文件类型
file 是分析的第一站——它不看扩展名,而是读文件内容(魔术字节)来判断真实类型。一次喂给它三个文件:
file hello hello.o libadd.so
三行输出对应三种 ELF 形态,信息量其实很大:
hello: ELF 64-bit LSB executable——64 位、小端(LSB)、可执行文件,后面跟着dynamically linked(动态链接,运行时需要加载器)和interpreter /lib64/ld-linux-x86-64.so.2(负责加载它的动态链接器路径);hello.o: ELF 64-bit LSB relocatable——可重定位文件,没有入口点、没有 interpreter,它是链接器的"半成品原料";libadd.so: ELF 64-bit LSB shared object——共享对象,可以被多个程序复用,同样动态链接。
三个文件末尾都写着 with debug_info, not stripped——编译时加了 -g,所以带调试信息;not stripped 表示符号表还在。这两个状态对后续的 nm、objdump、strip 都至关重要。顺便说一句:file 远不止认 ELF,脚本、压缩包、图片、文档它都能从内容识别出来,是"这文件到底是什么"的万能回答者。
ldd:动态依赖链一目了然
hello 是动态链接的,运行时需要哪些共享库?ldd 直接回答:
ldd hello
三行依赖各有身份:
| 输出行 | 身份 |
|---|---|
linux-vdso.so.1 | 内核映射进每个进程的"虚拟共享对象",让 gettimeofday 这类系统调用免于陷入内核,不需要磁盘文件 |
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 | glibc 标准库,后面的 => 指向它在磁盘上的实际路径 |
/lib64/ld-linux-x86-64.so.2 | 动态链接器/加载器本身,ELF 头里 interpreter 字段指定的就是它 |
行尾括号里的 0x... 地址每次运行都不同——ASLR 地址随机化,不必纠结数值。ldd 的典型排障场景:程序启动报 error while loading shared libraries: libxxx.so.6: cannot open shared object file,跑一句 ldd 程序,哪个库显示 not found,缺的就是它——然后 apt-file search 或重装对应软件包即可。补充一个安全习惯:对来路不明的二进制,优先用 readelf -d 看依赖而非 ldd——ldd 在某些情况下会用加载器执行目标代码,分析可疑文件时多一事不如少一事。
ELF 结构:readelf 读文件头
file 给结论,readelf 给证据。先看 ELF 文件头:
readelf -h helloELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Type: EXEC (Executable file)
Entry point address: 0x401050
Number of program headers: 13
Number of section headers: 36几处值得读懂的字段:
- Magic:
7f 45 4c 46就是\x7f+ELF四个字节,任何 ELF 文件的开头固定如此,file就是靠它认出的; - Class / Data:ELF64、小端——与
file输出里的信息一一对应; - Type: EXEC:可执行文件(目标文件会显示
REL,共享库显示DYN); - Entry point address: 0x401050:CPU 开始执行的第一条指令地址,与反汇编里
_start的地址吻合; - Number of section headers: 36:文件被切分成 36 个节(section),各司其职。
想看具体有哪些节,readelf -S 列全表。其中和调试最相关的是这几行(实测摘录):
[14] .text PROGBITS 0000000000401050 00001050
[28] .debug_info PROGBITS 0000000000000000 00003075
[33] .symtab SYMTAB 0000000000000000 000033a8
[34] .strtab STRTAB 0000000000000000 000036f0.text 是代码本体;.debug_info 是 -g 生成的 DWARF 调试信息(供 gdb 等使用);.symtab/.strtab 是符号表及符号名字符串。.debug_info 和 .symtab 就是体积的大头,也是 strip 要清理的对象——记住这两节,后面瘦身结果就能对上账。
nm:符号表里读函数
符号表把"地址 ↔ 名字"对应起来,nm 负责展示它。看关键符号:
nm -C hello | head -n 25实测输出里的几行代表性符号:
0000000000401050 T _start
0000000000401136 T add
U __libc_start_main@GLIBC_2.34
0000000000404018 B __bss_start
0000000000404008 D __data_start每一行是 地址 + 字母 + 符号名,字母是符号类型,最常用的一套:
| 字母 | 含义 | 实例 |
|---|---|---|
T | 全局函数(text 段,大写=外部可见) | _start、add |
t | 局部函数(static,外部不可见) | deregister_tm_clones |
U | 未定义——这个符号不在本文件,链接/运行时从别处填 | __libc_start_main@GLIBC_2.34 |
D | 已初始化的全局数据 | __data_start |
B | 未初始化数据(BSS 段) | __bss_start |
w/W | 弱符号(W 全局弱) | __gmon_start__ |
add 显示为 T add,说明我们的函数以全局符号存在;而 U __libc_start_main@GLIBC_2.34 是 glibc 的入口引导函数,@GLIBC_2.34 标着它要求的 glibc 最低版本——这行正是"动态链接的接缝",运行时就靠它把 hello 和 libc.so.6 缝在一起。-C 参数会把 C++ 符号 demangle 成可读形式(如 _Z3addii → add(int, int)),排查 C++ 程序时几乎是必加项。
objdump:反汇编看机器码
符号、结构都清楚了,最后把代码摊开看。objdump -d 反汇编 .text,再定位到 main 函数前后 25 行:
objdump -d hello | sed -n '/<main>:/,+25p'
截图的左列是虚拟地址,中间是机器码字节,右侧是 x86-64 汇编(binutils 默认 AT&T 语法,习惯 Intel 语法可加 -M intel)。对照源码把 main 的完整执行流读一遍:
endbr64——CET(控制流强制)技术的标记指令,现代编译器默认插入,遇到老 CPU 它就是一条 no-op;push %rbp/mov %rsp,%rbp/sub $0x10,%rsp——函数序言:保存栈底、建立新栈帧、分配 16 字节局部空间;mov $0x16,%esi/mov $0x14,%edi——把实参准备好:$0x16(22)和$0x14(20),正好是源码里的add(20, 22);call 401136 <add>——调用add函数,返回值进%eax,随后两次mov把它存到栈上局部变量s再取回;mov $0x402004,%edi——把格式串"sum=%d\n"的地址装入第一个参数寄存器;call 401040 <printf@plt>——注意是printf@plt而非直接printf:PLT(过程链接表)是动态链接的跳板,第一次调用时经它去libc.so.6里解析真正的printf地址,之后直接命中;leave/ret——函数尾声:还原栈帧、返回调用者。
-O0 下每条语句都对应一串直白的指令,没有优化后的花活,正是初学汇编对照 C 源码的最佳形态。截图末尾还能看到 Disassembly of section .fini——那是编译器生成的收尾代码段,属于正常结构。
strip:给二进制瘦身
分析完就该做减法了。strip 删除符号表和调试信息(还记得 .debug_info 和 .symtab 两节吗?):
strip hello
file hellohello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, stripped注意文件描述末尾从 not stripped 变成了 stripped。实测体积对比:17144 字节 → 14408 字节,去掉约 16%——一个几千行代码的真实项目,剥离调试信息往往能瘦掉一半以上。
strip 的取舍:发布到生产环境的程序通常要 strip——体积小、不泄露内部符号名(符号表等于给逆向者送了目录);但排障调试的二进制不要 strip——gdb 的 bt 栈回溯、info functions 都依赖调试信息和符号。折中方案是"程序 strip、调试信息单独留档":编译时用 objcopy --only-keep-debug 把调试信息抽成独立文件存档,线上崩了再取出来对符号。顺带一提:发行版自带的二进制(如 /bin/ls)大多已是 stripped,所以 file 看系统命令时见不到 debug_info——这也是正常现象。
总结
六个工具一条主线,收进速查表:
| 需求 | 命令 |
|---|---|
| 这是什么文件 | file 文件... |
| 依赖哪些动态库 | ldd 程序 |
| ELF 头/节/段结构 | readelf -h/-S/-l 文件 |
| 符号表 | nm [-C] 文件 |
| 反汇编 | objdump -d 文件(Intel 语法加 -M intel) |
| 去调试信息瘦身 | strip 文件 |
这套工具链在排查"缺库起不来"、分析第三方二进制、理解崩溃地址时都是第一现场。若程序运行期出了问题(而不是静态文件本身),下一站是 strace 系统调用追踪——一个看"文件是什么",一个看"进程在做什么",正好互补。
进一步阅读:file(1) 手册、GNU binutils 官方文档(readelf / nm / objdump / strip 各章)、ELF 格式规范。
评论 (0)
暂无评论,快来抢沙发吧!