☰
Ghidra逆向分析实战:stripped ELF反编译与脚本化验证
2026/11/4 14:57:39 网站建设 项目流程

简介:Ghidra 9.0.2 是由 NSA 研究理事会设计维护的开源逆向工程平台,面向安全研究人员、恶意软件分析人员、CTF 选手及逆向工程学习者,用于对已编译二进制程序进行反汇编、控制流梳理、漏洞发现与软件安全评估。压缩包为 7z 格式,包体约 217.69MB;文件总数暂未提供(显示为 0),文件类型明细暂无数据,具体目录与文件构成需下载后查看。目前已有 801 人学习或下载。工具提供图形化控制流图与多视图浏览,可查看内存、函数和数据结构;反汇编器支持 x86、ARM、PowerPC 等架构,并具备自定义结构/枚举/联合的数据类型管理、Python 与 Java 脚本、函数边界识别与调用关系分析、插件扩展、Git 版本控制集成、网络协议解析及反混淆等能力,便于快速理解程序内部逻辑,可用于恶意代码分析、漏洞挖掘、版本比对和教学研究,适合希望系统提升二进制安全分析技能的读者参考实践。

1. 拿到一份 stripped 的二进制,我先开的不是反汇编视图

前段时间内部靶场放了一道逆向题,产物是个 32 位 ELF,符号表剥得干干净净,strings出来的全是异或过的乱码。我用惯用的商业反汇编器打开,伪代码里一水儿的sub_8048xxx和*(int *)(ebp - 0x24),读起来像在看天书。换到 Ghidra 9.0.2,自动分析跑完,反编译器把那段跳转表直接还原成了switch的各个case,栈上那块缓冲区也被认成了char[64],同一段逻辑,可读性差了不止一个档。

从那以后,凡是拿到没有符号的样本,我第一遍必定先过 Ghidra 逆向分析这套流程:导入、分析、定位入口、重建类型、脚本批处理,最后再做验证。这篇就把这条链路上每一步的判断依据和能直接抄的命令写清楚,新手可以照着顺序跑一遍,熟手能对照着看我在哪些参数的取舍上和默认值不一样。

2. 场景拆解:一枚 stripped 样本从导入到能读

先交代样本背景。模拟项目X 里的一个命令行小工具,32 位 ELF,动态链接 libc,main之前有一层壳式的初始化函数,字符串全被一个xor 0x5A的循环处理过,原始明文在文件里一条都搜不到。这种样本的麻烦不在于算法难,而在于你连入口在哪、字符串怎么还原都得自己摸出来,一开始就得把工具链调对。

2.1 把运行环境固定下来:JDK 与 9.0.2 的匹配

Ghidra 9.0.2 这个版本对 JDK 的要求比较挑,它需要 JDK 11。机器上如果默认java指向 JDK 8,启动脚本会直接报错退出;指向太新的版本,也会在模块化相关的反射调用上出问题。我一般不去动系统默认的java,而是在 Ghidra 自己的配置里把 JDK 路径固定在合适版本上。

解开压缩包之后,目录结构大致是这样的:

ghidra_9.0.2_PUBLIC/ ├── ghidraRun # Linux / macOS 启动脚本 ├── ghidraRun.bat # Windows 启动脚本 ├── support/ │ ├── launch.properties # 启动配置,JDK 和内存都在这 │ └── analyzeHeadless # 命令行分析入口 └── Ghidra/ └── Features/ # 各功能模块

改support/launch.properties里的两行就够了:

# 指向 JDK 11 的安装目录,Windows 下路径里的反斜杠要写成双反斜杠 JAVA_HOME_OVERRIDE=/usr/lib/jvm/jdk-11 # 分析大样本时默认堆内存容易不够,4G 起步,8G 更稳 MAXMEM=4G

改完之后先跑一次./ghidraRun,能正常拉起主界面说明环境没问题。这一步很多人会跳过,结果后面 headless 批量分析跑到一半抛内存溢出,回头排查半天,才发现是MAXMEM没调。

提示:JAVA_HOME_OVERRIDE只影响 Ghidra 自己,不会污染系统环境变量,多版本 JDK 共存的机器上这是最省事的做法。

2.2 自动分析选项:默认值起不了决定作用

导入走File -> Import File...,选文件后 Ghidra 会识别格式(这里是 ELF),在弹窗的 Options 里确认两件事:Language 是不是x86:LE:32:default,以及 Base Address 对不对。ELF 一般能自动识别,但如果你导的是裸二进制(Raw Binary),基址和语言都得手填,填错了后面所有交叉引用全是错的,这是新手最容易吃的一记闷棍。

导入完会问要不要分析,选 Yes,进到分析选项对话框。默认勾选一堆分析器,我的取舍是这样的:

分析器默认状态我的取舍判断依据
ELF开开解析节表、PLT、动态符号,动态链接样本必开
Decompiler Parameter ID开小样本开,大样本关全量跑最慢,几十 MB 的二进制能耗掉半小时
Stack开开栈帧恢复直接决定伪代码里局部变量能不能认出来
Demangler(GNU / Microsoft)开开C++ 样本不开的话,符号全是修饰名,没法看
Non-Returning Functions开开让__stack_chk_fail、abort这类不污染控制流
Aggressive Instruction Finder关遇到花指令再开会产生大量误判函数,正常样本开了反而是噪声

Decompiler Parameter ID这一项值得单独说。它会遍历所有函数做参数推断,效果是反编译出来的函数签名更准,代价是慢。我的习惯是:样本在 5 MB 以内就开着图省事;超过这个量级先关掉,等定位到目标函数之后,在反编译窗口里针对那一个函数手动触发一次分析,按需付费。

2.3 从 entry 找到真实入口:三步定位法

符号被剥掉之后,main这个名字是没了,但entry还在,因为它是 ELF 头里写死的。第一步永远是去 Symbol Tree 的 Functions 里找entry,点进去看反编译结果:

void entry(void) { /* 省略前面的栈和寄存器准备 */ __libc_start_main(0x8048a3f, param_2, &stack0x00000004, 0x80488d0, 0x8048920, param_6, &stack0x00000004); }

__libc_start_main的第一个参数就是main的地址。这不是什么玄学,glibc 的启动流程就是这么写的。拿到0x8048a3f,按G跳过去,如果那一片还没被识别成函数,按F手动创建,再刷新反编译窗口,主体逻辑就出来了。

第二步是从字符串反推。原始字符串被异或过,Defined Strings 里查不到明文,但异或解码函数通常会在某个位置被调用一次,而那个函数的调用点往往就在main附近。我的做法是在main里找形如for (i = 0; i < n; i++) buf[i] ^= 0x5a;的循环,把它的输出缓冲区地址记下来,跳过去看运行期状态。静态看不到运行后的内存内容,那就靠后面的模拟器去跑。

第三步是交叉引用兜底。碰到入口藏得深的样本,直接在main里没找到有用的东西,就退回entry附近,用Ctrl+Shift+F看谁引用了__libc_start_main,或者看.init_array段里有没有函数指针被调用。.init_array是壳最爱藏身的地方之一,很多时候main前面那层反调试的初始化就写在这儿。

三步走完,样本的整体骨架基本就有了,接下来才是真正的读代码环节。

3. 反编译窗口怎么读:把伪代码还原成能看懂的代码

反编译器给出的第一版伪代码,本质上是一份"能编译回去但不保证好读"的结果。它不知道什么是指针、什么是结构体、什么是有意义的变量名,全靠类型信息推。你给它的类型越准,它还给你的代码越像人写的。

3.1 变量重命名与类型重建的顺序

刚上手的人容易犯的错,是先花十分钟把uVar1、iVar3一个个改成len、idx,改完发现只要动一下函数签名,所有反编译结果重排,之前的重命名全白做。

正确的顺序是自顶向下:先改函数签名,再改变量类型,最后才改名字。

在反编译窗口选中函数名按F,弹出 Edit Function Signature,把undefined4 param_1这类占位符改成实际类型。怎么判断类型?看它在这个函数体里怎么被用的。如果全程都是*(int *)(param_1 + 8)这种解引用,那它就是个指针,改成一个结构体指针;如果只做数值比较和加法,那多半是int或size_t。

函数签名改对之后,反编译器会重新做一遍数据流分析,局部变量的推断类型也会跟着更新,原先那些undefined4 uVar1有机会自己变成int或指针。这时候再按Ctrl+L给变量改类型、按L改名字,返工率就低得多。

我一般还会利用调用点来推断方向。如果同一个函数被三处调用,每处传进来的实参分别是一个malloc的返回值、一个栈数组地址,那这个参数必然是指针,而且是可写的——三个调用点一对照,类型就确定下来了,比盲猜靠谱。

3.2 结构体与数组:从偏移访问推断布局

判断结构体的方法很土但很有效:把一个指针参数上所有出现过的常量偏移列一遍,按大小排序,字段宽度看访问语句的类型。

比如原始反编译出来是这样的:

void process(int param_1) { int n = *(int *)(param_1 + 0x0c); char *p = *(char **)(param_1 + 0x10); for (int i = 0; i < n; i++) { p[i] = p[i] ^ *(unsigned char *)(param_1 + 8); } }

偏移0x08是个一字节的值(异或密钥),0x0c是个 4 字节的计数,0x10是个指针。三个偏移连起来就是{ 未知 8 字节; char key; 3 字节对齐; int len; char *buf; },在 Data Type Manager 里新建一个结构体,字段按这个布局填,然后把param_1整体重定型成这个结构体指针,伪代码立刻变成ctx->len、ctx->buf、ctx->key,可读性直接上一个台阶。

这里有个省事的功能:在反编译窗口右键那个指针变量,选Auto Create Structure,Ghidra 会自己扫一遍偏移访问,把结构体草稿生成出来,你再手工补字段名和类型。这功能在 9.0.2 上已经比较稳了,省掉大量手工数偏移的时间。

数组的识别靠的是访问模式。看到*(int *)(param_1 + i * 4 + 8)这种带索引变量的表达式,基本可以确定是数组,反编译器通常会自动把它标成int[i]。如果没标,选中那个地址按[定义数组,填上长度,后续反编译结果里的算术会清爽很多。

3.3 switch 跳转表与间接调用:让控制流显形

跳转表是逆向里最容易翻车的地方,因为它藏的是控制流的拓扑结构,一旦识别失败,你看到的可能是一个巨大的if-else嵌套,或者干脆一堆goto。

Ghidra 在这块的表现是我换过来的主要原因。跳转表被正确识别时,反编译窗口会直接给出:

switch (cmd) { case 1: handle_init(ctx); break; case 2: handle_read(ctx); break; case 3: handle_write(ctx); break; case 4: handle_exit(ctx); break; default: puts("unknown cmd"); break; }

如果出来的是(*(code *)(&PTR_LOOP_0804a020)[cmd])(),说明跳转表那个指针数组没被识别成数据。修法是在 Listing 视图跳到0804a020,选中目标区域按D定义成指针数组,长度按jmp dword ptr [eax*4 + 0x804a020]那条指令前面cmp的上界定,然后重新触发一次分析,反编译结果就会变成switch。

间接调用同理。看到(**(code **)(ctx + 0xc))(ctx),说明ctx的0xc偏移上挂了个函数指针,多半是虚表。这时候去0xc那个偏移找它的赋值来源,把赋值点定义成一个函数指针类型,后面所有调用点会自动带上类型,阅读量能砍掉一半。

4. 脚本化减少重复劳动:Headless 与 Script Manager

单个样本靠手点没问题,一旦要对一批样本做同样的统计——函数数量、可疑常量出现位置、加密函数清单——手工点就是纯粹的体力消耗。Ghidra 的脚本能力和 headless 模式就是干这个的。

4.1 analyzeHeadless 的正确调用姿势

support/analyzeHeadless是不带 GUI 的分析入口,跑批量、跑 CI 都靠它。基本形态是"项目路径 + 项目名 + 一堆选项":

# GHIDRA_HOME 指向解压目录,PROJ 是临时项目仓库路径 GHIDRA_HOME=/opt/ghidra_9.0.2_PUBLIC PROJ=/tmp/ghproj for f in samples/*; do "$GHIDRA_HOME/support/analyzeHeadless" "$PROJ" batch \ -import "$f" \ -processor x86:LE:32:default \ -scriptPath ./ghidra_scripts \ -postScript DumpFuncProfile.java \ -analysisTimeoutPerFile 300 \ -deleteProject done

几个参数值得单独解释:

-processor强制指定语言和编译器规格,不给的话 Ghidra 会自己猜,猜错的概率不低,尤其是裸二进制。x86:LE:32:default对应 32 位 x86 小端,64 位是x86:LE:64:default,ARM 是ARM:LE:32:v7这一类,写法和 GUI 里 Language 下拉框的显示值一一对应。

-scriptPath指明脚本搜索目录,-postScript指定分析完成后要跑的脚本。注意-postScript有执行时机,如果脚本依赖自动分析的结果,一定用postScript而不是preScript,否则你会在函数列表里只看到孤零零的几个入口。

-analysisTimeoutPerFile给单文件分析设个上限,防止某个畸形样本把整个批次卡死。-deleteProject在跑完之后把临时项目删掉,不加的话磁盘会被.rep和.gpr文件迅速填满,我有一次跑两百个样本,第二天早上发现磁盘直接满格。

4.2 写一个批量提取函数特征的脚本

脚本放在ghidra_scripts目录下,GUI 里通过Window -> Script Manager能看到并直接运行,headless 下用-postScript调。下面这个脚本导出每个函数的地址、名字、函数体大小、被引用次数和指令条数,用来快速筛出"小而热"的候选函数——加密逻辑往往就是这种特征。

// 导出函数画像:地址、名称、函数体字节数、被引用次数、指令条数 // 输出到用户主目录下的 func_profile.csv,可直接用表格工具筛 import ghidra.app.script.GhidraScript; import ghidra.program.model.listing.*; import java.io.FileWriter; import java.io.PrintWriter; public class DumpFuncProfile extends GhidraScript { @Override public void run() throws Exception { StringBuilder sb = new StringBuilder("addr,name,size,inRefs,insnCount\n"); FunctionManager fm = currentProgram.getFunctionManager(); // true 表示按地址正序遍历,顺序稳定,方便两次跑批做 diff for (Function f : fm.getFunctions(true)) { if (f.isThunk()) continue; // thunk 只是跳板,统计意义不大 long size = f.getBody().getNumAddresses(); int inRefs = currentProgram.getReferenceManager() .getReferenceCountTo(f.getEntryPoint()); int insnCount = 0; InstructionIterator it = currentProgram.getListing() .getInstructions(f.getBody(), true); while (it.hasNext()) { it.next(); insnCount++; } sb.append(f.getEntryPoint()).append(',') .append(f.getName()).append(',') .append(size).append(',') .append(inRefs).append(',') .append(insnCount).append('\n'); } String out = System.getProperty("user.home") + "/func_profile.csv"; try (PrintWriter pw = new PrintWriter(new FileWriter(out))) { pw.print(sb); } println("written: " + out); } }

currentProgram是GhidraScript基类提供的当前程序句柄,GUI 和 headless 下都能拿到。getBody().getNumAddresses()返回的是函数体覆盖的地址数量,不是指令数,遇到对齐填充会略大于实际代码量,做排序足够用。getReferenceCountTo统计的是有多少地方引用了这个函数入口,被引用次数高、指令数少的函数,通常是工具函数或者分发器,值得优先看。

想扩展的话,把insnCount那段换掉就行——比如统计函数体内出现0x5a这个立即数的次数,用来反查异或密钥;或者收集所有调用了memcpy的函数,缩小关注范围。

4.3 版本跟踪:两份样本差在哪

补丁对比是逆向里性价比最高的活。拿到新旧两个版本的二进制,最笨的办法是两个窗口来回切着看伪代码,稍微聪明一点的是导出函数画像做 diff,但这两招都会漏掉"函数挪了位置但内容一样"的情况。

Ghidra 的 Version Tracking(Tools -> Version Tracking)就是干这个的。建一个会话,指定 source 和 destination 两个已导入的程序,然后跑 correlator 做函数匹配。常用的几个 correlator 和它们的适用场景:

Correlator匹配依据什么时候用
Exact Function Bytes函数体字节完全一致快速锁定没改动的函数
Exact Function Instructions忽略地址重定位后的指令序列一致有重定位差异时比字节匹配更鲁棒
Symbol Name函数名相同有部分符号的样本
Reference调用关系相似度函数被内联或拆分时兜底

跑完会得到一个匹配列表,未匹配上的 destination 函数就是候选的改动点。我一般先用 Exact Function Bytes 过一遍,剩下的未匹配函数按大小排序,改动通常集中在少数几个函数里。这套流程在补丁分析里比手工对照快一个数量级,也是我把 Ghidra 当作主力工具的原因之一。

5. 避坑与排查:五个让我返工的坑

下面这五条,每一条我都真实踩过,且都不是"看一眼文档就能避开"的那种。

5.1 反编译窗口一片halt_baddata()

现象:函数体里大量出现halt_baddata(),或者整个函数只显示一行undefined。

原因:那一段字节没被反汇编成指令,Ghidra 不认为那里是代码,反编译器自然给不出东西。常见于壳把代码段加密后运行期解密,静态文件里那段是数据。

解决:在 Listing 视图跳到那一片,按D强制反汇编,再按F创建函数。如果解密是运行期做的,静态反汇编出来的是垃圾指令,正确做法是先定位解密循环,算出解密后的字节,用Patch功能把内存里的字节改掉再重新分析。

5.2 裸二进制导入后交叉引用全乱

现象:所有地址从0x0开始,字符串引用的目标全是错的。

原因:导入 Raw Binary 时没填 Base Address,也没指定 Language,Ghidra 按默认值处理了。

解决:重新导入,在 Options 里把 Base Address 设成实际加载地址(比如0x8048000),Language 选对。已经导入的项目不用删,在 Memory Map 里用File -> Add To Program重新映射也行,但最省事还是删掉重来。

5.3 脚本在 GUI 里好好的,headless 下抛空指针

现象:同一个脚本 GUI 里点运行正常,-postScript调用时报NullPointerException。

原因:脚本里用了只有 GUI 上下文才有的对象,比如getProvider()、弹窗类DialogComponentProvider的子类,或者依赖当前选中项的逻辑。

解决:把脚本改成只依赖currentProgram和state,所有交互逻辑去掉;需要参数就用-scriptPath配合脚本参数,或者在脚本里读环境变量。写脚本时养成习惯:先在 headless 下跑通,再拿去 GUI 里点。

5.4 自动分析跑半小时还没结束

现象:进度条卡在Decompiler Parameter ID或者Stack上,CPU 单核跑满。

原因:大二进制 + 全量分析器,Decompiler Parameter ID的复杂度随函数数量近似平方增长。

解决:先只在分析选项里留下 ELF、Stack、Demangler 这几个必需的,其余关掉。定位到目标函数后,在反编译窗口对该函数单独触发参数推断。另外 headless 下可以用-max-cpu提高并发,但注意内存会同步上涨。

5.5 重命名之后伪代码没更新

现象:变量改名、类型改完之后,反编译窗口里显示的还是旧名字旧类型。

原因:反编译器有缓存,尤其是函数被标记成"不再重新分析"之后。

解决:在反编译窗口上方的刷新按钮点一下,或者切到别的函数再切回来触发重编译。如果还是不更新,检查一下是不是把变量改到了错误的作用域上——反编译窗口里重命名的是反编译产物,Listing 视图里改的才是真实符号,两边偶尔会不同步。

6. 最后一步:用模拟器验证你的推测

读完伪代码之后,最难的一步是确认自己读对了。纯静态推导的问题在于,你没法验证"这段循环确实是把输入异或 0x5A"。我见过太多情况,伪代码看着像解密,实际跑起来是别的算法,猜错了后面全盘皆输。

Ghidra 9.0.2 自带 PCode 模拟器,在Window菜单里把 Emulator 面板调出来,配合反编译窗口使用。我的验证流程是固定的三步:

  1. 在反编译窗口定位到目标函数,把它当成一个黑匣子,只关心输入缓冲区和输出缓冲区的地址。
  2. 在 Emulator 面板里把 PC 设到函数入口,往输入缓冲区对应的内存地址写入一段已知测试数据,比如AAAA加上四个0x00。
  3. 单步或者直接跑到函数返回,读输出缓冲区的实际字节,和手工推算的期望值比对。

举个具体例子。上面那段process函数,我推测它对ctx->buf做了buf[i] ^= ctx->key。测试时把key写成0x5A,len写成4,buf指向的内存填0x41 0x41 0x41 0x41。跑完如果buf变成0x1B 0x1B 0x1B 0x1B,推测成立;如果变成了别的,说明循环条件或者密钥偏移读错了,回头重新对齐结构体。

几种验证手段的适用场景可以这样分:

验证方式成本适用场景局限
静态推导低逻辑简单、常量明确的函数无法证伪
Emulator 单步中纯计算、无系统调用的函数遇到syscall会断
Patch 后重跑中有分支判断、要验证路径需要能实际执行样本
外部调试器附加高完整运行期行为验证环境搭建麻烦

Emulator 最大的优势是安全:它在一个隔离的 PCode 解释器里跑,样本里的反调试、系统调用统统不生效,你可以放心地把任意字节喂进去看输出。碰到带syscall的函数,直接在调用点设个断点,把它替换成一个固定返回值继续跑,剩下的逻辑照样能验证。

还有一个我常用的技巧:验证通过之后,用 Patch 功能把验证过的解密结果直接写回程序字节,然后重新反编译那个区域。这时候原本的"乱码数据"会变成明文字符串,交叉引用一拉,整条业务链路就串起来了。这一招在分析字符串混淆的样本时是后悔药级别的存在,能省掉大量手工还原。

回过头看,我在逆向这条路上返工最多的,从来都不是"读不懂某条指令",而是"读懂了但没验证就去写结论"。从那以后我每次分析完一个关键函数,都会强制用模拟器跑一组已知输入,把输出比对一遍再往下走,哪怕这个函数看起来再简单。慢是慢了几分钟,但省下的是后面几小时的推倒重来。

希望这套流程能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询