暗色模式

Linux 二进制分析:从 file 类型识别到 ELF 结构与 objdump 反汇编

技术教程
2026-09-06
3
0
本文要点
  • 排查动态库缺失、确认程序是否被 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 反汇编:实测把 mainendbr64 开场到 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),而系统自带的一套工具(fileldd,以及 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

file 识别三种 ELF 类型

三行输出对应三种 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 表示符号表还在。这两个状态对后续的 nmobjdumpstrip 都至关重要。顺便说一句:file 远不止认 ELF,脚本、压缩包、图片、文档它都能从内容识别出来,是"这文件到底是什么"的万能回答者。

ldd:动态依赖链一目了然

hello 是动态链接的,运行时需要哪些共享库?ldd 直接回答:

ldd hello

ldd 查看动态链接依赖

三行依赖各有身份:

输出行身份
linux-vdso.so.1内核映射进每个进程的"虚拟共享对象",让 gettimeofday 这类系统调用免于陷入内核,不需要磁盘文件
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6glibc 标准库,后面的 => 指向它在磁盘上的实际路径
/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 hello
ELF 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

几处值得读懂的字段:

  • Magic7f 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 段,大写=外部可见)_startadd
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 最低版本——这行正是"动态链接的接缝",运行时就靠它把 hellolibc.so.6 缝在一起。-C 参数会把 C++ 符号 demangle 成可读形式(如 _Z3addiiadd(int, int)),排查 C++ 程序时几乎是必加项。

objdump:反汇编看机器码

符号、结构都清楚了,最后把代码摊开看。objdump -d 反汇编 .text,再定位到 main 函数前后 25 行:

objdump -d hello | sed -n '/<main>:/,+25p'

objdump 反汇编 main 函数

截图的左列是虚拟地址,中间是机器码字节,右侧是 x86-64 汇编(binutils 默认 AT&T 语法,习惯 Intel 语法可加 -M intel)。对照源码把 main 的完整执行流读一遍:

  1. endbr64——CET(控制流强制)技术的标记指令,现代编译器默认插入,遇到老 CPU 它就是一条 no-op;
  2. push %rbp / mov %rsp,%rbp / sub $0x10,%rsp——函数序言:保存栈底、建立新栈帧、分配 16 字节局部空间;
  3. mov $0x16,%esi / mov $0x14,%edi——把实参准备好:$0x16(22)和 $0x14(20),正好是源码里的 add(20, 22)
  4. call 401136 <add>——调用 add 函数,返回值进 %eax,随后两次 mov 把它存到栈上局部变量 s 再取回;
  5. mov $0x402004,%edi——把格式串 "sum=%d\n" 的地址装入第一个参数寄存器;
  6. call 401040 <printf@plt>——注意是 printf@plt 而非直接 printfPLT(过程链接表)是动态链接的跳板,第一次调用时经它去 libc.so.6 里解析真正的 printf 地址,之后直接命中;
  7. leave / ret——函数尾声:还原栈帧、返回调用者。

-O0 下每条语句都对应一串直白的指令,没有优化后的花活,正是初学汇编对照 C 源码的最佳形态。截图末尾还能看到 Disassembly of section .fini——那是编译器生成的收尾代码段,属于正常结构。

strip:给二进制瘦身

分析完就该做减法了。strip 删除符号表和调试信息(还记得 .debug_info.symtab 两节吗?):

strip hello
file hello
hello: 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 格式规范

发表评论

暂无评论,快来抢沙发吧!