逆向工程入门:从程序运行到二进制分析 逆向工程(Reverse Engineering)是一项通过分析已经编译完成的软件,重新理解其内部工作原理的技术。 我们平时写代码时,通常经历的是: 而逆向工程做的事情恰好相反: 当然,逆向并不能真正意义上恢复最初的源代码。编译过程可能会丢失变量名称、注释、部分...
逆向工程(Reverse Engineering)是一项通过分析已经编译完成的软件,重新理解其内部工作原理的技术。
我们平时写代码时,通常经历的是:
源代码 ↓ 编译器 ↓ 目标文件 ↓ 链接 ↓ 可执行文件
而逆向工程做的事情恰好相反:
可执行文件 ↓ 反汇编 / 反编译 ↓ 汇编指令 ↓ 程序逻辑 ↓ 推测原始代码结构
当然,逆向并不能真正意义上恢复最初的源代码。编译过程可能会丢失变量名称、注释、部分类型信息以及开发阶段的大量语义信息。
因此,逆向工程更准确地说,是从程序的最终产物中恢复它的行为、结构和逻辑。
简单来说,逆向工程就是:
从已经存在的程序或二进制文件出发,通过各种分析手段理解它是如何工作的。
例如,我们拿到一个 Windows 可执行文件:
example.exe
我们不知道它的源代码,但是可以通过分析:
逐渐还原出程序的内部逻辑。
它和正常的软件开发过程基本是相反的。
正向开发: 需求 ↓ 设计 ↓ 源代码 ↓ 编译 ↓ 可执行文件 逆向分析: 可执行文件 ↓ 二进制分析 ↓ 反汇编 ↓ 反编译 ↓ 程序结构 ↓ 程序逻辑
需要注意的是,逆向工程并不意味着一定要“破解软件”。
逆向技术本身可以用于软件调试、安全研究、兼容性分析、漏洞研究、恶意软件分析以及计算机底层原理学习等多个领域。
对于普通开发者来说,平时更多是在高级语言层面工作。
例如:
const result = add(10, 20)
我们知道这段代码的含义,但是很少真正去思考:
这行代码最终是怎么被 CPU 执行的?
当我们学习逆向之后,可以继续往下追踪:
TypeScript / JavaScript ↓ 运行时 ↓ 底层实现 ↓ 机器指令 ↓ CPU
而在 C/C++ 这样的语言中,这个过程会更加直接。
例如:
int add(int a, int b) { return a + b; }
编译之后,最终不会存在一个叫做 `add` 的高级语言函数供 CPU 理解。
CPU 最终执行的是机器指令。
这也是逆向工程有意思的地方:
我们可以从机器最终执行的结果,反过来理解高级语言代码是如何工作的。
假设我们有下面这个程序:
#include <stdio.h> int add(int a, int b) { return a + b; } int main() { int result = add(10, 20); printf("%d\\\\n", result); return 0; }
从源代码角度来看非常简单:
main ↓ 调用 add() ↓ 得到结果 ↓ printf() ↓ 程序结束
但是编译之后,CPU 并不知道:
add result main printf
这些名称代表什么。
CPU 最终看到的是机器指令。
在 x86-64 环境下,某些简单的计算可能最终表现为类似:
mov eax, 10 mov edx, 20 add eax, edx
我们可以把它简单理解成:
eax = 10 eax = eax + 20
最后:
eax = 30
这就是从高级语言进入底层世界的第一步。
逆向并不是单独学习一门汇编语言就可以完成的。
比较重要的基础包括:
C / C++ ↓ 计算机组成原理 ↓ 操作系统 ↓ 汇编语言 ↓ 程序运行机制 ↓ 调试器 ↓ 二进制格式 ↓ 逆向分析
其中比较重要的是以下几个部分。
需要理解 CPU、寄存器、指令以及执行流程。
需要理解:
需要理解:
C/C++ 对逆向学习非常重要,因为它们和底层内存、指针、结构体之间的关系比较直接。
至少需要能够阅读常见的 x86-64 汇编指令。
寄存器是 CPU 内部非常快速的存储空间。
在 x86-64 中,经常可以看到:
RAX RBX RCX RDX RSI RDI RSP RBP RIP
其中:
例如:
mov eax, 10 add eax, 20
可以简单理解成:
eax = 10 eax = eax + 20
最终:
eax = 30
在实际逆向过程中,我们经常需要观察这些寄存器的变化,从而判断程序当前正在做什么。
栈(Stack)是逆向分析中非常重要的概念。
函数调用过程中,程序需要保存返回地址、局部变量以及其他调用相关的数据。
可以简单理解成:
栈空间 ┌─────────────────┐ │ 局部变量 │ ├─────────────────┤ │ 保存的数据 │ ├─────────────────┤ │ 返回地址 │ ├─────────────────┤ │ 其他数据 │ └─────────────────┘
例如:
int add(int a, int b) { return a + b; }
当其他函数调用:
add(10, 20);
CPU 需要处理参数传递、函数跳转、返回地址以及返回结果。
不同平台和 ABI 的具体实现方式并不完全相同。
因此学习逆向时,不应该只是背汇编指令。
更重要的是理解:
这条指令为什么会出现在这里?
静态分析指的是:
在不运行程序的情况下分析程序本身。
常见的分析内容包括:
常用工具包括:
IDA Ghidra Binary Ninja objdump strings readelf
静态分析的优势是可以快速建立程序的整体结构。
例如:
程序 ↓ 分析文件格式 ↓ 查看字符串 ↓ 查看函数 ↓ 查看交叉引用 ↓ 分析调用关系
动态分析则是在程序运行过程中观察程序的实际行为。
基本过程可以理解为:
启动程序 ↓ 设置断点 ↓ 运行 ↓ 命中断点 ↓ 查看寄存器 ↓ 查看内存 ↓ 单步执行 ↓ 观察程序行为
常见工具包括:
动态分析最大的优势是:
可以直接观察程序运行过程中真实发生了什么。
例如我们怀疑某个函数修改了一块内存,就可以在调试器中设置断点,然后观察这块内存什么时候发生变化。
反汇编(Disassembly)就是把机器码转换成汇编指令。
例如:
48 83 C0 01
可能对应:
add rax, 1
意思就是:
RAX = RAX + 1
反汇编是逆向工程最基础的能力之一。
但是刚开始学习时,不建议试图把每一条汇编指令都背下来。
更重要的是理解常见的:
mov lea push pop call ret cmp test jmp je jne jg jl
然后理解这些指令组合起来之后形成的程序逻辑。
反编译(Decompilation)比反汇编更进一步。
它尝试把汇编代码转换成类似 C/C++ 的高级语言。
例如原始程序可能是:
int add(int a, int b) { return a + b; }
反编译工具可能得到类似:
int FUN_00101120(int param_1, int param_2) { return param_1 + param_2; }
这里需要特别注意:
反编译结果并不等于原始源代码。
编译过程中已经丢失了很多信息。
例如:
变量名称 注释 部分类型信息 宏 代码结构 开发者语义
因此:
反编译结果 ≠ 原始源代码
逆向人员需要根据上下文重新理解这些代码。
学习逆向时,经常会接触到 Ghidra 和 IDA。
Ghidra 是一套免费的逆向工程工具。
它提供:
对于刚开始学习逆向的人来说,Ghidra 是一个很好的入门工具。
IDA 是非常经典的二进制分析工具。
它提供:
如果以后深入学习二进制安全领域,也会经常接触 IDA。
逆向分析中一个非常重要的概念是 XREF。
XREF 的全称是:
Cross Reference
也就是交叉引用。
假设我们在程序中发现一个字符串:
"Login failed"
我们想知道:
什么地方使用了这个字符串?
通过 XREF,我们可能找到:
"Login failed" ↓ 函数 A ↓ 函数 B ↓ main
这样就可以逐渐建立程序的调用关系。
所以逆向分析并不是:
从第一条汇编指令开始,一行一行看。
更常见的方式是:
找到关键特征,然后通过引用关系不断扩大分析范围。
程序并不是永远按照从上到下的顺序执行。
例如:
if (password == correct) { login(); } else { failed(); }
程序会产生条件判断和跳转。
可以简单表示为:
┌──→ login() │ check ───────┤ │ └──→ failed()
IDA 和 Ghidra 都可以把复杂的程序控制流转换成图形化结构。
对于大型程序来说,控制流图非常有帮助。
假设现在拿到一个未知程序:
example.exe
我们可以按照下面的方式进行分析。
首先判断:
PE ELF Mach-O APK DLL SO
不同平台使用的二进制格式不同。
例如:
x86 x86-64 ARM ARM64
架构不同,使用的指令集也不同。
例如:
入口点 Section 导入函数 导出函数 依赖库 字符串 符号信息
这一阶段不要急着分析具体代码。
首先建立对整个程序的认识。
字符串通常是一个非常好的入口。
例如:
"Login failed" "Connection error" "Invalid configuration" "File not found"
找到这些字符串之后,再查看它们的 XREF。
这样就可以找到使用这些字符串的函数。
进入函数之后,可以观察:
参数 ↓ 局部变量 ↓ 条件判断 ↓ 函数调用 ↓ 返回值
逐渐理解这个函数负责什么。
如果仅通过静态分析无法确定逻辑,可以使用调试器运行程序。
例如:
静态分析 ↓ 提出假设 ↓ 设置断点 ↓ 运行程序 ↓ 观察寄存器 ↓ 观察内存 ↓ 验证假设
这就是比较典型的逆向分析流程。
刚开始打开 IDA 或 Ghidra 时,经常会看到大量这样的代码:
push rbp mov rbp, rsp sub rsp, 30h mov eax, [rbp-04h] cmp eax, 01h jne 0000000140001234
第一次看到这些内容,很容易产生:
什么都看不懂。
其实这是非常正常的。
因为逆向不是单纯学习汇编语言。
你需要逐渐建立:
C/C++ + 计算机组成原理 + 操作系统 + 汇编 + 编译原理 + 二进制格式 + 调试器
这些知识之间的联系。
当这些知识逐渐串起来之后,再看到汇编代码时,就不会只看到一条条指令,而会开始看到:
函数 ↓ 条件 ↓ 循环 ↓ 数据结构 ↓ 调用关系 ↓ 业务逻辑
如果从零开始学习逆向,可以按照下面的路线进行。
重点掌握:
指针 数组 结构体 函数 函数指针 字符串 内存管理
尤其要理解指针和内存。
重点理解:
CPU 内存 寄存器 缓存 进程 线程 虚拟内存 系统调用
建议优先学习 x86-64。
重点掌握:
mov lea push pop call ret cmp test jmp je jne jg jl
同时理解:
寄存器 栈 函数调用 调用约定 参数传递 返回值
根据平台选择工具:
Windows → x64dbg / WinDbg Linux → GDB macOS → LLDB
掌握:
开始使用:
Ghidra IDA objdump strings readelf
最开始不要直接分析大型商业软件。
可以自己写一个简单的 C 程序,然后编译:
C 源码 ↓ 编译 ↓ 可执行文件 ↓ Ghidra ↓ 反汇编 ↓ 反编译 ↓ 重新理解程序逻辑
这样学习效率会高很多。
因为你知道原始代码是什么,可以把:
原始代码 ↕ 反编译代码 ↕ 汇编代码
三者进行对照。
逆向工程最有意思的地方,并不是记住多少汇编指令。
而是:
从结果反推出原因。
正常的软件开发过程是:
需求 ↓ 设计 ↓ 代码 ↓ 编译 ↓ 程序
而逆向工程则是:
程序 ↓ 机器码 ↓ 汇编 ↓ 控制流 ↓ 函数 ↓ 数据结构 ↓ 程序逻辑
你面对的已经不是一份完整的源代码,而是一堆已经失去大量语义信息的二进制数据。
你需要通过字符串、函数、交叉引用、控制流、内存以及动态调试等各种线索,一点一点把程序重新拼起来。
这也是逆向工程最核心的思维方式。
逆向工程并不是简单地使用 IDA 或 Ghidra。
工具只是帮助我们观察程序。
真正重要的是建立一套分析方法:
发现线索 ↓ 提出假设 ↓ 寻找引用 ↓ 分析代码 ↓ 动态验证 ↓ 建立函数关系 ↓ 恢复数据结构 ↓ 理解整体逻辑
如果刚开始学习逆向,最推荐的方式不是直接分析一个非常复杂的软件,而是:
自己写一个程序,然后亲手把它逆向回来。
当你能够从一个已经编译好的二进制文件中,重新推导出它的函数、数据结构和核心逻辑时,才算真正开始进入逆向工程的大门。
暂无评论,留下第一条声音吧。