rea-reverse

ELF / PE / Mach-O 在线查看:识别架构、节区、导入导出,判断是否 stripped

基于 REA 6.4.0更新于 约 8 分钟读完
本页目录

结论先行:原生二进制的前 4 个字节就能说明它是什么格式。7F 45 4C 46 是 ELF(Linux、Android、大多数固件),4D 5A(“MZ”)后面再跟一个 PE\0\0 头的是 PE(Windows 的 EXE、DLL、SYS),CF FA ED FE 或 CE FA ED FE 是 Mach-O(macOS、iOS)。其余信息,把文件拖进本站的 原生二进制概览 就能看到:架构、文件类型、入口点、依赖库,带熵值的节区,导入、导出和字符串;如果是 ELF,还会直接告诉你有没有被 strip。整个过程在浏览器里完成,文件既不上传也不执行。

适合谁读:手里拿到一个 .so、.dll 或者干脆没有扩展名的文件,想在发布、链接或调试之前先弄清楚它是什么的开发者;以及需要确认构建产物已经 strip(或者确认它还留着符号、方便解析崩溃日志)的发布工程师。

读完你能回答:

  • 这个文件是 ELF、PE 还是 Mach-O?面向哪种 CPU?
  • 它依赖哪些库,导出了什么?
  • 它有哪些节区,有没有哪一节看起来是压缩过的?
  • 它还带着符号表吗,还是已经 strip 了?
  • 用哪些命令行工具能得到同样的结论?

示例文件:我自己写的一个小 C 程序,在 macOS 上分别编译成三种格式。ELF 和 PE 用 Android NDK r29 的工具链(clang 和 lld-link),Mach-O 用 Xcode 自带的 clang:

#include <stdio.h>
#define HELLO_VERSION "hello 1.2.0"
int add(int a, int b) { return a + b; }
int main(void) { printf("%s: %d\n", HELLO_VERSION, add(2, 3)); return 0; }
B=$ANDROID_HOME/ndk/29.0.14206865/toolchains/llvm/prebuilt/darwin-x86_64/bin
$B/aarch64-linux-android29-clang -O2 -o hello-arm64-android hello.c
$B/llvm-strip -o hello-arm64-android.stripped hello-arm64-android
clang -O2 -arch arm64 -mmacosx-version-min=12.0 -o arm64 hello.c
clang -O2 -arch x86_64 -mmacosx-version-min=10.15 -o x86_64 hello.c
lipo -create arm64 x86_64 -output hello-macos-universal

Windows 版(hello-x64.exe)是一个类似的程序,不带 C 运行时,用 lld-link 只链接了 kernel32.dll。你可以对自己的构建产物或开源项目的二进制做同样的操作。如果接下来要运行或附加调试不是你自己构建的软件,请先确认已获得授权,并阅读 REA 的 DISCLAIMER.md。

第 1 步:分辨三种格式

三种格式开头都有固定的“魔数”,所以没有扩展名也能识别:

开头字节 格式 说明
7F 45 4C 46(\x7fELF) ELF 第 4 字节是位数(1 = 32 位,2 = 64 位),第 5 字节是字节序
4D 5A(MZ) PE DOS 头在偏移 0x3C 处指向真正的 PE\0\0 头
CF FA ED FE / CE FA ED FE Mach-O 分别是 64 位 / 32 位小端(字节反过来就是大端)
CA FE BA BE 通用 Mach-O 或 Java class 两者用的是同一个魔数

最后一行最容易出错。通用(fat)Mach-O 在魔数后面紧跟架构数量,而 Java 的 .class 文件在同一位置存的是版本号。工具用的规则和 file(1) 一样:“架构数”超过 30 就不是通用二进制。我用 javac 编译了一个一行的 Hello.java(文件头是 cafe babe 0000 003d),拖进去后工具拒绝解析,提示 “不是 ELF、PE 或 Mach-O 文件。”,技术细节里写着 “cafebabe magic but not a universal binary (Java class file?)”。

第 2 步:读基本信息

在 原生二进制概览 里打开文件,最上面是 基本信息。下面是三个示例的实际结果:

字段 hello-arm64-android hello-x64.exe hello-macos-universal(切片 1)
格式 ELF64 PE32+ Mach-O 64-bit
架构 AArch64 x64 (0x8664) arm64
位数 64 位 64 位 64 位
文件类型 ET_DYN EXE MH_EXECUTE
角色 可执行文件 可执行文件 可执行文件
入口点 0x4650 RVA 0x1010 (VA 0x140001010) entryoff 0x468 (VA 0x100000468)
解释器 /system/bin/linker64 — /usr/lib/dyld
格式特有字段 OS/ABI UNIX - System V 子系统 WINDOWS_CUI,链接器 14.0 macOS,最低系统 12.0,SDK 27.0,UUID

读这一块时有几点要注意:

  • ET_DYN 不一定是共享库。PIE 可执行文件也是 ET_DYN。只要文件有 PT_INTERP 解释器或者带 DF_1_PIE 标志,工具就把它归为可执行文件,我的 Android 程序就是这样被识别出来的。
  • 解释器能看出平台。/system/bin/linker64 是 Android 的 64 位动态链接器;桌面 Linux 程序一般会写 ld-linux-x86-64.so.2 之类。
  • 通用二进制有多个切片。hello-macos-universal 有两个切片:x86_64(偏移 4096,12,592 字节)和 arm64(偏移 32768,33,464 字节),点哪个就分析哪个。lipo -detailed_info 和 REA 给出的偏移完全一致(见第 6 步)。

如果 PE 文件带 CLI 头,基本信息里还会提示 “这是一个 .NET 程序集”,并链接到 .NET 程序集元数据与 IL 查看器。托管代码光看 PE 头是不够的,可以接着看 查看 .NET DLL 的目标框架、版本号和依赖。

第 3 步:节区、段和熵

节区 表列出每一节的地址、文件偏移、大小、权限和香农熵(每字节比特数)。AArch64 版里,代码节 .text 是 308 字节、熵 5.53,.rodata 是 20 字节、3.82,.dynstr 是 73 字节、4.21。Windows 版 hello-x64.exe 只有三节:.text、.rdata 和 .pdata。

熵粗略衡量字节有多“随机”。在我的这些样本里,代码节大致在 5.2 到 6.8 之间,字符串表和符号表更低。只有当一节至少 1 KiB 且熵超过 7.2 时,工具才会标成 高熵,这通常意味着压缩或加密的数据:内嵌压缩包、图片、压缩资源或加壳代码。我的样本没有一节接近这个值(最高的是 Newtonsoft.Json 的 .text,5.93)。

高熵只是对字节的描述,不是结论。安装程序、游戏、带大量资源的应用,出于很普通的原因就会有高熵节区。它的作用是告诉你下一步该看哪里。

ELF 还有 段(program header):加载器要映射的 PT_LOAD 区间,以及 PT_INTERP、PT_DYNAMIC、PT_GNU_STACK、PT_GNU_RELRO 等。节区是给链接器和调试器用的,段才是加载器真正用的,所以一个 ELF 完全没有节区头也照样能运行。

第 4 步:依赖库、导入导出和字符串

链接的库 回答“它运行时需要什么”:

  • ELF:DT_NEEDED 条目。我的 Android 程序依赖 libdl.so 和 libc.so。
  • PE:导入目录。hello-x64.exe 只从 kernel32.dll 导入。
  • Mach-O:LC_LOAD_DYLIB 等加载命令。macOS 版加载 /usr/lib/libSystem.B.dylib。

导入与导出 列出符号名。对 ELF,工具读的是加载器使用的动态符号表 .dynsym,只有没有 .dynsym 时(目标文件、静态可执行文件)才退回 .symtab。所以我的 Android 可执行文件即使还没 strip,也只显示 4 个导入(printf、__libc_init、__cxa_atexit、__register_atfork),没有导出,因为 main 和 add 只在 .symtab 里。PE 的 DLL 会列出导出表,我的 add-x64.dll 只导出了一个函数 add。

字符串 会找出长度不少于 6 个字符(可调)的 ASCII 和 UTF-16LE 字符串,并标出它所在的节区。版本字符串 hello 1.2.0 分别出现在 .rodata(ELF)、.rdata(PE)和 __TEXT,__cstring(Mach-O)里。想确认发布包里打进去的是哪个版本的库,这是最快的办法。完整列表可以下载成 .txt。

第 5 步:二进制有没有被 strip?

所谓 stripped,是指去掉了完整符号表(调试器和性能分析工具靠它给函数起名字);链接需要的动态符号还在。不同格式的判断方法不一样。

ELF:看有没有 .symtab

安全特性 表里有一行 符号表已剥离:有 .symtab 节时显示“否”,没有 .symtab 或者根本没有节区头时显示“是”。我的样本在 llvm-strip 前后的结果:

hello-arm64-android hello-arm64-android.stripped
符号表已剥离 否(.symtab 38 entries) 是(no .symtab)
节区数 26 23(去掉了 .comment、.symtab、.strtab)
文件大小 6,088 字节 4,248 字节
导入 4 4

导入数量不变,因为 .dynsym 不会被 strip。标准工具的结果一致:

$ file hello-arm64-android hello-arm64-android.stripped
hello-arm64-android:          ELF 64-bit LSB pie executable, ARM aarch64, ... not stripped
hello-arm64-android.stripped: ELF 64-bit LSB pie executable, ARM aarch64, ... stripped

$ llvm-nm hello-arm64-android.stripped
hello-arm64-android.stripped: no symbols
$ llvm-nm -D hello-arm64-android.stripped
                 U __cxa_atexit@LIBC
                 U __libc_init@LIBC
                 U __register_atfork@LIBC
                 U printf@LIBC

Android 的 release 构建通常由 Gradle 在打包时自动 strip。记得保留未 strip 的 .so(或者至少记下它们的 Build ID),以后才能还原原生崩溃堆栈。

Mach-O 和 PE:没有单一标志

Mach-O 和 PE 都没有一个明确的“已 strip”标记,所以工具没有这一行。不过可以从符号数量间接判断:

  • Mach-O:工具读取 LC_SYMTAB 的 nlist 表。strip 之前,我的 arm64 程序有 3 个导出(__mh_execute_header、_add、_main)和 1 个导入(_printf);strip 之后只剩 __mh_execute_header 和 _printf,和 nm 的结果一致。strip 还重新做了一次 ad hoc 签名,所以 签名标识符 变成了新文件名。
  • PE:MSVC 一类的工具链(包括 lld-link)把调试符号放在单独的 .pdb 文件里,映像本身只有导入表和导出表,strip 也不会动它们。工具不读调试目录,所以没法告诉你有没有生成 PDB。MinGW 构建不一样,它可能把 .debug_info 这类 DWARF 节直接放在映像里,strip 之前会出现在节区列表中。

第 6 步:命令行对照,以及 REA

上面每个值都可以用标准工具复核。我在同样的文件上跑了这些命令:

$ llvm-readelf -h hello-arm64-android | grep -E "Class|Type|Machine"
  Class:                             ELF64
  Type:                              DYN (Shared object file)
  Machine:                           AArch64

$ llvm-readobj --file-headers hello-x64.exe | grep -E "Machine|Magic"
  Machine: IMAGE_FILE_MACHINE_AMD64 (0x8664)
  Magic: 0x20B

$ otool -hv hello-macos-arm64
      magic  cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
MH_MAGIC_64    ARM64        ALL  0x00     EXECUTE    17       1056   NOUNDEFS DYLDLINK TWOLEVEL PIE

$ lipo -info hello-macos-universal
Architectures in the fat file: hello-macos-universal are: x86_64 arm64

Linux 上的 binutils 自带 readelf、objdump、nm 和 strip;对应的 llvm-* 版本在 Android NDK 和 LLVM 里都有。otool、lipo、codesign 需要 macOS 上的 Xcode 命令行工具。

REA(morluto 的开源项目)能把这些信息整理成结构化 JSON,方便 agent 和脚本使用。6.4.0 里,rea list-architectures <path> 和 rea inspect-macho <path> 可以读 Mach-O,但它们调用的是 Apple 的命令行工具(file、lipo、otool、nm、dyld_info 等),所以只能在装了 Xcode 命令行工具的 macOS 上运行。在我的通用二进制上,list-architectures 返回的两个切片偏移和大小与浏览器工具一致,还多了对齐信息:

[{"name":"x86_64","cpu_type":"CPU_TYPE_X86_64","cpu_subtype":"CPU_SUBTYPE_X86_ALL","file_offset":4096,"size":12592,"alignment":4096},
 {"name":"arm64","cpu_type":"CPU_TYPE_ARM64","cpu_subtype":"CPU_SUBTYPE_ARM64_ALL","file_offset":32768,"size":33464,"alignment":16384}]

inspect-macho 也直说了 strip 的影响,它的 limitations 写着 “Imports and exports combine dyld_info and nm; stripped or toolchain-hidden symbols may be absent.”

对 ELF 和 PE,REA 6.4.0 没有和浏览器工具对等的文件头查看功能。inspect-binary-layout 能报告节区、符号和缓解措施,但初始版本只支持 ELF64 x86-64 文件,只能在 Linux x64 上运行,还要你自己装 pwntools 4.15.0。要读代码本身,下一步是 REA 配合 Ghidra:REA + Ghidra 免费分析原生二进制 带你在自己编译的程序上做反编译、字符串搜索和交叉引用。

常见问题

文件会被上传吗? 不会。原生二进制概览在浏览器里一个禁止联网的 Web Worker 中解析文件,不运行、不反汇编、不上传。

最大能打开多大的文件? 512 MiB。特别大的表会截断(例如节区最多 4,096 个、符号最多 100,000 个),截断时报告里会注明。

能打开 APK 里的 .so 吗? 能。把 APK 解压,打开 lib/<abi>/ 下的任意文件即可。如果要检查这些库是否满足 Android 的 16 KB 页面大小要求,可以看 Android 16 KB 页面大小适配,或者直接把整个 APK 放进 APK 分析工具。

安全特性里显示“是”,说明文件安全吗? 不说明。这些行描述的是文件怎么构建的(PIE、NX、RELRO、是否带签名),签名只检测是否存在,不做验证。

core 转储和目标文件呢? ELF core 转储(ET_CORE)和目标文件(ET_REL、MH_OBJECT)都能识别并标出角色,只是它们没有入口点和动态链接信息可显示。

下一步