ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从编译到逆向:ELF二进制分析实战指南

从编译到逆向:ELF二进制分析实战指南 1. 为什么非要从源码编译不可这个话题的起因其实很实际我经常收到一些来路不明的 ELF 文件有时候是甲方从生产环境里捞出来的可疑样本有时候是朋友转发过来问这玩意儿到底干了啥的小玩意儿。每次拿到这类东西我脑子里第一个冒出来的问题从来不是用什么工具分析而是这东西是怎么编译出来的。因为编译方式直接决定了你后续能用多低成本的姿势把它拆开。1.1 你拿到的二进制可能和你想的不一样很多人有个误区觉得ELF 二进制就是一个统一的、标准化的东西。实际上一个 ELF 文件是怎么来的这件事影响极其深远。同样是编译一个hello.c用 GCC 默认参数编译出来的和加了-g -O0、加了-O2、加了-s、用了静态链接、用了 musl 工具链、甚至用了 LTO链接时优化编译出来的完全就是不同的物种。拿我自己踩过的坑来说有一次分析一个四十几KB的 ELF一上来就用 Ghidra 自动分析结果函数列表稀稀拉拉符号表干干净净反编译出来的代码逻辑奇怪得一批。后来我无意间用readelf -p .comment看了一眼才发现这货是用 musl 工具链编译的还开了-Os。那一刻我才意识到不先搞清楚编译来源分析就是在盲人摸象。所以我要强调的第一件事就是如果你打算认真研究 ELF 二进制强烈建议自己亲手从源码编译若干个不同配置的版本。你不亲手编一次就永远不会理解为什么有的二进制在 Ghidra 里很好逆有的却逆得想骂人。1.2 编译选项如何影响后续分析我列一个实际对比表全部用同一个源代码hello.c只是编译参数不同你感受一下差距编译命令文件大小strip后的符号Ghidra 函数识别反编译可读性gcc -g -O0 hello.c偏大全量符号非常友好极高gcc -O2 hello.c中等无调试符号一般中低gcc -s -O2 hello.c中等偏小几乎无符号一般中低musl-gcc -s -Os hello.c小几乎无符号较差低gcc -static -s hello.c很大几乎无符号函数多到爆炸低-g选项会把 DWARF 调试信息塞进 ELF 的.debug_info、.debug_line等节里Ghidra 和 GDB 拿到这些信息后变量名、函数名、行号全部还原得像看源码一样。-O2之后编译器开始做内联、常量传播、死代码消除你看到的是编译器思考之后的代码而不是人写的代码。-s选项会 strip 掉符号表.symtab但不会动动态符号表.dynsym。很多人以为 strip 之后就万事大吉了其实如果这个 ELF 是动态链接的它为了能和 libc 等共享库交互.dynsym里仍然保留着printf、malloc这些导入函数的符号。这些信息对分析者来说是巨大的路标。1.3 实操最小可复现的编译流程这里给一套我平时做对比测试的通用流程你在任意 Linux 发行版上都能一键跑通# 先准备一个带逻辑的最小程序 cat demo.c EOF #include stdio.h #include string.h int check_key(const char *key) { return strcmp(key, s3cr3t_elf_key) 0; } int main(int argc, char **argv) { if (argc 2) { printf(Usage: %s key\n, argv[0]); return 1; } if (check_key(argv[1])) { printf(Access granted.\n); } else { printf(Access denied.\n); } return 0; } EOF # 带调试信息、不优化分析友好版 gcc -g -O0 -o demo_debug demo.c # 去符号、开优化真实世界常见版 gcc -s -O2 -o demo_stripped demo.c # 静态链接、去符号恶意样本常见版 gcc -s -O2 -static -o demo_static demo.c # 看看差别 ls -la demo_* readelf -S demo_debug | grep debug readelf -s demo_stripped | head -20编译完建议立刻用readelf和file观察这几个产物的差异。我在实际分析中还有个习惯把这几个二进制都拖进 Ghidra 建一个项目对比同一个函数在不同编译选项下的反编译结果。你会发现check_key函数在-O0版本里老老实实做strcmp调用在-O2版本里可能直接被内联进了main常量s3cr3t_elf_key被放进.rodata整个逻辑一眼可见。这一套做完你对二进制安全的理解会从玄学变成科学。因为你知道了哪些信息是编译过程必然保留的哪些信息是分析工具帮你补全的哪些信息是攻击者刻意抹掉的。2. strings人人都用过但大多数人没看懂输出strings 可能是整个逆向分析工具链里最没门槛的一个。任何一个新手拿到 ELF第一反应都是strings一把梭把输出哗啦啦拉出来然后一通找关键词。但我得说strings 的输出如果你不结合 ELF 的结构去理解那它只是一堆字符串的大杂烩甚至会把你带沟里去。2.1 strings 到底在做什么strings 的原理其实非常简单粗暴它在二进制文件的字节流里扫描连续的、可打印的 ASCII或其他编码字符序列默认情况下长度达到 4 个字符以上就输出一行。你说它是扫描器也好过滤器也罢总之它不关心这些字符串在 ELF 的哪个节、哪个段、什么上下文里它只关心这里有没有一串看起来像字符串的字节。但在实际分析中strings 的价值恰恰藏在这些字符串在 ELF 文件里的位置上。所以我的习惯是加上-t参数让它把每个字符串在文件中的偏移量也打出来strings -t x -n 8 demo_debug-t x表示用十六进制显示偏移-n 8要求字符串长度至少 8 个字符。这样输出长这样1140 /lib64/ld-linux-x86-64.so.2 1154 libc.so.6 ... 2080 s3cr3t_elf_key看到s3cr3t_elf_key在偏移0x2080附近再配合readelf -S看一下各节的偏移范围你就能判断出这个字符串到底是躺在.rodata里还是躺在.data里又或者躺在某个被压缩的自定义段里。这一步区分很重要.rodata是只读常量区.data是可变全局变量区。2.2 从 strings 输出中能读出什么门道我把常见的 strings 输出类型和它们背后的信息整理成一个速查表字符串特征典型例子说明的信息文件路径/etc/passwd、/tmp/x程序可能读写哪些文件域名/IPhttp://x.x.x.x/apiC2 地址或更新服务器格式串%s:%d、Usage: %s代码里 printf 类函数的使用点密钥/令牌s3cr3t_key、Bearer xxx硬编码凭据常见于恶意样本错误信息Failed to open异常路径提示库函数名printf、malloc动态链接依赖有一次我分析一个疑似窃密木马的 ELFstrings输出里直接看到了一个很像 base64 的字符串长度挺长以结尾。拿下来一解里面是http://192.168.1.101:8080/exfil。说实话攻击者如果连这种明文都为数不多那我只能说他们对安全的认知还停留在把文件藏起来别人就找不到的阶段。但我不希望你产生一个错误印象strings 能看到的东西多代表这东西不安全看不到什么东西代表这东西安全。实际上字符串可以被轻易加密、编码、压缩、分段拼接完全可以做到让strings什么都扫不到。后面我会演示这种情况。2.3 容易被忽略的 strings 误报与陷阱strings 有几个非常现实的坑我每个都踩过。第一个坑是编码。默认情况下 strings 只扫 ASCII 单字节序列。现代程序里如果用了宽字符字符串比如wchar_t、UTF-16LE你直接strings是看不到的需要加-e l# 扫描 UTF-16LE 编码字符串 strings -e l demo_debug第二个坑是阈值。默认最小长度是 4这个值在分析大型程序时会产生大量无意义的碎片输出比如\x01\x02\x03\x04这种字节碰巧落在可打印范围内就给你来一行。这时候加大阈值到-n 10甚至-n 16可以有效过滤噪声。第三个坑最隐蔽strings 输出里的字符串可能根本不在程序运行时的路径上。比如调试信息里会包含源码文件的绝对路径/home/user/project/demo.c这在二进制里但在运行时完全不会用到。如果你不加-t偏移量就会把这些静态线索和运行时数据混为一谈。所以我的建议是strings 非常适合做第一轮快速侦察但永远不要只靠 strings 下结论。它给你的是线索不是证据。证据必须通过反汇编和动态调试来锁定。3. objdump从节表到重定位的真相如果说 strings 是用眼睛看那 objdump 就是用手术刀切。它是 Binutils 套件里的主力工具也是我最常用的第一个深度分析工具。很多搞安全的人习惯了 GUI 工具一上来就给 Ghidra 砸个自动分析却忽略了 objdump 能给到的那种原始感。这种原始感在分析混淆、加壳、畸形 ELF 时特别重要。3.1 先学会看 ELF 头拿到一个 ELF第一件事不是反汇编而是看它的头。用readelf -h或者objdump -f都行我习惯用前者信息更全readelf -h demo_debug关键字段有这些TypeDYN位置无关可执行文件PIE还是EXEC传统可执行文件。如果是DYN说明开启了 ASLR 友好的编译模式Machinex86-64、ARM、AArch64还是别的Entry point address入口点地址分析时定位起点全靠它。我见过不少新手拿到一个 ARM 架构的 ELF直接在 x86 的 Ghidra 项目里硬开结果一堆乱码还说工具不行。工具很行是架构选错了。接下来是节表用readelf -S看。节表是整个分析的地图。我重点关注这么几个节.text代码段可执行指令都在这里.rodata只读常量字符串、跳转表、常量数组在这里.data/.bss全局变量.bss不占文件空间运行时清零.plt/.got动态链接的关键机制.symtab/.dynsym符号表strip 前后的一手对比在这里。3.2 重定位表静态链接和动态链接的分水岭很多教材讲到 ELF 的relocations重定位就一笔带过但我想专门拎出来讲因为relocations in generic elf这个热词背后透露出大量新手对这个概念的陌生。什么是重定位简单打个比方写代码的时候你调用了printf但编译成 .o 文件的时候编译器不知道printf这个函数最终在进程地址空间的哪个位置所以它留一个坑位写上这里需要填一个指向 printf 的地址但地址我现在不知道等链接的时候再算。这个等链接的时候再算的过程就是重定位。在分析 ELF 时重定位表能告诉你两件事第一这个二进制是静态链接还是动态链接。你如果看到大量针对printf、malloc、strcmp这类 libc 符号的重定位说明是动态链接。动态链接的 ELF 依赖外部共享库分析时你不仅要看自身代码还要看它调用哪些库函数路径是什么。静态链接的 ELF 把所有依赖都揉进了自己体内体积大但也意味着你去掉了外部依赖这个变量整个二进制是自洽的。第二模块之间的调用关系。看.rela.plt和.rela.dyn重定位表你能画出这个程序 import 了哪些外部函数# 查看动态重定位 readelf -r demo_debug objdump -R demo_debug输出里你会看到类似printf、strcmp的条目这些就是在 PLT过程链接表里等着被解析的函数。恶意软件分析中把这些动态导入函数列出来基本就能猜出程序意图的一半——如果一个 ELF 导入了socket、connect、system而且没有任何合法的业务逻辑解释那它的意图就非常可疑了。3.3 从 objdump -d 看控制流反汇编里的虚与实反汇编命令是objdump -d但我建议加上几个参数组合使用# x86 平台建议用 Intel 语法可读性更好 objdump -d -M intel demo_debug # 同时显示源码如果有调试信息 objdump -d -S -M intel demo_debug # 显示所有节的反汇编而不只是 .text objdump -D -M intel demo_debug-S在有调试信息时会把源码行穿插在汇编里对对比源码 vs 编译结果极其有用。很多人问我objdump -d和-D有什么区别简单说-d只反汇编可执行节通常是.text-D会尝试反汇编所有节。区别很重要因为有些恶意样本会特意把代码放在非标准节里比如.init、.fini或者干脆自造一个.evil节。-d会直接忽略这些让你漏掉真东西而-D会给你反汇编一切代价是很多数据会被误当成指令输出里掺杂大量垃圾。我的做法是两遍都跑先-d看主逻辑再-D检查有没有隐藏代码。看反汇编时我最关注的无非是这几种指令模式call的目标是内部函数还是 PLT 外部函数跳转表的实现一堆jmp [table index*8]说明有 switch-caselea和mov的源操作数常数地址往往是全局变量或字符串。一个小技巧在用objdump -d时同步开一个终端跑strings -t x看到某个字符串偏移后再到反汇编里搜这个偏移立刻就能定位到引用它的代码位置。这种字符串定位 - 反向交叉引用的方法是效率最高的分析套路之一。当然objdump 也有力所不及的地方。它的反汇编是线性的不会像 Ghidra 那样帮你分析函数边界、识别参数和返回值更不会帮你做类型推断。遇到稍微复杂一点的逻辑人肉看汇编的效率就直线下降。这时候就该请出重型武器了。4. Ghidra从加载到还原逻辑的完整实战Ghidra 是 NSA 开源的软件逆向工程框架个人认为它是目前性价比最高的分析工具。它最大的优势有三个免费开源、跨平台、自带反编译器。这三个优势组合在一起意味着你不需要花一分钱就能获得一个接近 IDA Pro 的分析体验尤其是在反编译这块我觉得 Ghidra 的输出质量已经非常能打了。4.1 环境与加载注意事项先解决环境问题。Ghidra 基于 Java所以你需要一个合适版本的 JDK。不同版本的 Ghidra 对 Java 版本要求不一样最新版本通常要求 JDK 17 或 JDK 21。我的建议是安装发行版官方源里的openjdk-17-jdk或者openjdk-21-jdk然后解压 Ghidra 压缩包进入目录后运行./ghidraRun即可。这里我提一个很多人不知道的小细节Ghidra 会自动识别 ELF 的文件头但如果你把后缀改成.bin或者没有后缀它可能会弹窗让你手动选择 Language架构。这时候你照着readelf -h里Machine字段选就行别选错了。导入项目后第一次自动分析会花点时间。有个经验分析选项里建议勾选Decompiler Parameter ID和Stack Depth这类增强分析选项它们能大大提升反编译质量代价是分析时间变长。对于恶意样本这种规模不大的文件这点时间完全值得。4.2 从入口点到 main定位真正逻辑用 Ghidra 分析 ELF 时我第一件事不是到处点而是先定位main函数。定位方法很有讲究。ELF 的入口点_start是汇编写的启动代码它最终会调用__libc_start_main并把main的地址作为参数传进去。在 Ghidra 的反汇编里找到_start你通常会看到类似这样的代码lea rdx, [rip main] mov rsi, rsp mov rdi, main call __libc_start_main__libc_start_main的第一个参数rdi指向的就是main。在 Ghidra 里点击那个地址就能跳到真正的入口逻辑。但如果二进制被 strip 过main符号可能不存在。这时候有几个替代方法在反编译窗口里搜索字符串比如Usage: %s它所在的函数通常就是main或者被main调用的参数解析函数查看Ghidra的函数列表名称entry和FUN_xxx中调用传rdi参数的那个地方用readelf -h拿到入口点地址在 Ghidra 的 Program Trees 里跳过去顺着调用树往下走。还有一个小技巧Ghidra 里有Function Call Graph视图可以把整个调用关系画成图注意我这里说的是 Ghidra 内置的图功能不是让我画 mermaid你从入口点开始往下走一眼就能看出哪些函数是关键节点哪些函数只被调用一次可能是辅助逻辑。4.3 实战案例一个带混淆的小程序这里我构造一个小案例讲清楚 Ghidra 的核心用法。假设有这样一个程序它从文件/tmp/flag.txt读内容逐字节异或 0x5A然后和内置的一段密文比较。如果相同就打印 Congratulations。在strings阶段你只看到Congratulations和失败提示根本看不到明文 flag。在 objdump 阶段你能看到一堆xor al, 0x5a的操作但要把散落的字节拼起来很费劲。而到了 Ghidra 这里反编译输出直接会给你类似这样的伪代码void main(void) { char buf[64]; FILE *f fopen(/tmp/flag.txt, r); if (f NULL) { /* error */ } fgets(buf, 64, f); for (int i 0; i strlen(buf); i) { buf[i] ^ 0x5a; } if (strcmp(buf, \x72\x3f\x3d\x6e\x3c\x28\x7a\x5e) 0) { puts(Congratulations); } // ... }看到没有异或逻辑、比较密文、字符串路径全部清清楚楚。虽然变量名是 Ghidra 自动生成的但整个逻辑已经达到了看伪代码等于看源码的级别。这种还原能力靠的是什么是 Ghidra 的反编译器对 x86 指令做的深度语义分析包括寄存器和栈变量的数据流追踪、常量池折叠、函数栈帧重建等。作为使用者你不需要理解反编译器内部的每一个算法但你要知道你在伪代码里看到的每一个buf[i] ^ 0x5a背后都对应着反汇编窗口里的一堆mov、xor、add指令。把它俩对照着看是提升逆向能力的最快路径。4.4 Ghidra 脚本化批量处理与自动化分析单个文件手动分析没问题但如果碰到十几个同类样本呢这时候 Ghidra 的脚本化能力就派上用场了。Ghidra 支持用 Java 和 Python通过 Jython写脚本。我日常用得最多的是一个非常简单的批量导出脚本它会把所有函数的反编译 C 代码导出到文件里from ghidra.app.decompiler import DecompInterface # 获取当前程序 program getCurrentProgram() ifc DecompInterface() ifc.openProgram(program) with open(/tmp/decompiled.c, w) as f: fm program.getFunctionManager() for func in fm.getFunctions(True): # 反编译 res ifc.decompileFunction(func, 30, None) if res and res.decompileCompleted(): f.write(res.getDecompiledFunction().getC()) f.write(\n)这个脚本的直接价值在于你不用挨个函数点开看把所有函数的伪代码一股脑导到文本文件里然后用 grep 搜strcmp、memcpy、system等危险函数迅速定位敏感逻辑。我做恶意样本初筛时经常用这个方式批量处理几十个文件效率翻倍。Ghidra 脚本管理器在Window - Script Manager直接新建 Python 脚本粘贴即可运行。对于想系统掌握 Ghidra 的人来说学会写脚本是必须跨过的一道坎。我甚至建议你在跑第一个脚本时故意把代码写错一次看看报错堆栈长什么样——这个调试过程本身就是最好的学习。5. ELF 真的安全吗结论比工具更重要工具链说完了回到标题最初的那个问题ELF 二进制真的安全吗我的答案是不安全的点太多了但格式本身的安全性责任不在格式而在使用者。5.1 安全的假象编译期和加载期的防御先从防御侧说起。现代 Linux 系统上的 ELF 并不是裸奔的编译器工具链和操作系统加载器一起提供了好几层防护机制NX不可执行栈通过 GNU_STACK 段的标志控制防止代码在栈上执行ASLR地址空间随机化配合 PIE 编译让每次运行的装载地址都不同RELRO重定位只读把.got设为只读防止 GOT 覆写攻击Stack Canary栈上放一个随机值函数返回前校验防止栈溢出改写返回地址。用checksec这个工具可以快速检查这些防护是否开启。它对demo_static这种自编译 ELF 的检测结果和分析实战中碰到的攻击样本的检测结果往往天差地别——后者经常所有防护全开因为攻击者自己也在防分析。# 安装 checksec # 对 ELF 做安全特性检查 checksec --filedemo_debug checksec --filedemo_static但注意这些机制防御的是漏洞利用exploitation不是逆向分析reverse engineering。它们挡不住 strings、objdump、Ghidra 这些分析手段。5.2 攻击面更多在运行时从分析者的视角看ELF 的不安全主要体现在几个层面。第一信息泄露几乎是必然的。只要默认编译.comment节里就留着 GCC 版本号.rodata里就躺着所有字符串常量.dynsym里就挂着动态导入函数列表。这些信息不是漏洞但它们是拼图的第一块。攻击者写程序时如果不刻意清理这些信息分析者的工作难度会直线下降。第二反汇编免疫几乎是不存在的。除非用专门的混淆器比如 OLLVM、加壳工具比如 UPX、控制流平坦化否则任何 ELF 在 Ghidra 面前基本是裸体。UPX 加壳本质上也只是把原始代码压缩起来运行时再解压静态分析时用upx -d就能还原就算用了自实现的壳分析者也总有办法把它 dump 出来。我之前分析过一个加壳样本strings只能看到 UPX 相关字符串Ghidra 导入后反汇编全是pusha/popa的桩代码。用upx -d脱壳后再来一轮 Ghidra 分析逻辑清清楚楚。加壳拖延了分析时间但没有阻断分析。第三密钥和敏感逻辑的藏匿极度困难。我经常说一句话只要程序要在运行时验证某个密钥、解密某个数据、校验某个输入那么验证逻辑和密钥信息必然以某种形式存在于二进制中。你可以混淆、可以加密、可以白盒化但最终程序自己必须知道怎么验证。而这个知道的过程分析者总能通过调试和反编译还原。5.3 不同工具的定位互补写了这么多最后再说点工具选择的经验。strings是侦察兵负责快速收集线索优点是快和简单缺点是浅且容易被伪装信息欺骗objdump是侦察兵的放大镜帮你确认线索定位代码引用、看清重定位表和节表结构缺点是反汇编粒度太细遇到复杂逻辑效率低Ghidra是主力实验室优点是从汇编到伪代码一步到位还能脚本化批量处理缺点是对特殊壳、虚拟机保护的样本无能为力且反编译器偶尔输出错得离谱的代码这时候必须回到 objdump 验证。这三个工具不是替代关系而是互为补充。我完整的分析流程历来是file-strings-readelf-objdump -d- Ghidra 导入和反编译 - 回到 objdump 验证可疑点。这个流程每一步都有明确目的不是工具炫技。一开始接触 ELF 的人往往会觉得我必须用一个最强工具搞定它。实际恰恰相反真正的高手是在几个工具之间来回切换用最短的路径逼近真相。我的体会是把 strings、objdump、Ghidra 这一套组合打熟你就已经超过了大多数只会点开 Ghidra 自动分析的人。而关于 ELF 是否安全这个问题我的最终观点是ELF 格式本身谈不上安全或不安全它只是规则的集合真正的安全性取决于编译你的人是否在意被分析分析你的人是否愿意深挖以及两者之间的耐心比拼。你需要信心的不是工具能还原一切而是在能还原的范围内我能看到最接近真相的那一层。
返回列表