在过去的几周里,我一直在学习 x86_64 汇编、AArch64 汇编,以及各种基础知识。最近,我终于第一次接触了逆向工程。我很高兴能分享更多关于这次经历的细节。
什么是逆向工程
计算机实际上是一种相当愚笨的机器。它不会自己解决任何问题;它只会执行解决方案——更准确地说,它只会按顺序逐条执行来自人类的指令。要用这样的机器解决问题,我们通常会:
- 分析一个 问题;
- 为这个问题找到一个 解决方案;
- 通过设计一个 算法 来实现这个解决方案;
- 通过编写 代码 来实现这个算法;
- 通过编译,从代码生成 可执行文件;
- 把这个可执行文件交给那台愚笨的计算机去解决问题。
这是一种把 解决方案 转换为 代码,再转换为 指令 的过程。
通常,我们可以通过阅读代码轻松理解一个程序做了什么。代码介于解决方案和指令之间。上世纪 70 年代,代码通常在人民之间共享,因此每个人都可以在社区中学习、改进并分享解决方案。
然而,自从那些极其邪恶的专有软件开发怪物出现之后,情况就变了。它们选择剥夺人民接触 代码 的权利,夺走用户对计算的控制,加入各种恶意功能来剥削用户,并采用各种手段掩盖程序究竟做了什么——也就是 解决方案。它们在筑墙。它们在破坏共享。它们在制造灾难和绝望。它们所做的一切都在与人民、社区和人类作对。它们做这一切,都是为了它们不道德且不公正的利润。
软件应该服务人民,而不是资本家的不道德且不公正的利润。然而,正是那些本不该存在、理应从世界上被清除的、不道德且不公正的专有软件开发者,正在让世界变得更糟。但我们不可能一下子消灭专有软件。我们要为此开发自由软件替代品,并逐步重新夺回对计算的控制。
因此,现在该把上面的过程倒过来做了。换句话说,我们:
- 分析 可执行文件;
- 尝试恢复该可执行文件所执行的 代码;
- 找出这段代码实现的 算法;
- 反推出该算法所实现的 解决方案;
- 重新实现这个解决方案来解决 问题。
这就是把 指令 转换为 代码,再转换为 解决方案。
以相反顺序做这件事的实践,就是逆向工程,简称 RE。
为什么逆向工程很重要
逆向工程之所以重要,最主要的原因是它能帮助我们将自由软件操作系统,例如 postmarketOS 和 LineageOS,移植到各种设备上。这也是我学习逆向工程的主要原因。
在桌面电脑上,实现软件自由要容易得多,因为我们通常可以运行各种自由的 GNU/Linux 发行版,而不会遇到严重问题。然而,在智能手机或平板电脑等移动设备上,情况就不是这样了。
这些设备往往有许多厂商特定和设备特定的调整,要找到一种能让自由操作系统在这些设备上普遍运行的通用方案几乎是不可能的。更糟糕的是,制造商选择隐藏这些技术细节,甚至 Google 也不再发布设备树和厂商二进制 blob。因此,我们现在必须分析 二进制 固件 blob,并推断其逻辑,才能让 postmarketOS、LineageOS 或其他自由软件操作系统在这些设备上运行。
事实上,自由软件基金会也在通过 Librephone 项目处理这个问题。没有逆向工程,这个项目就无法继续。
不过,逆向工程的应用绝不仅限于自由操作系统移植。它在以下方面也非常重要:
- 弄清专有协议的细节,
- 找出专有软件中存在恶意功能的证据,
- 绕过数字限制管理,
- 以及更多其他用途。
如何进行逆向工程
典型的逆向工程包含这两个步骤:
- 将可执行文件转换为汇编;这个过程叫做 反汇编。
- 通过阅读汇编代码,尝试恢复思路,或者重写等价代码。
我们可以直接对可执行文件进行这项工作,而不运行它,这叫做 静态分析;也可以在它运行时进行,这叫做 动态分析。
静态分析示例
来看这个例子:
#include <stdio.h>
#include <string.h>
const char *PASS = "weakpasswd";
int main()
{
char buf[64];
for (int i = 0; i < 3; i++)
{
printf("%d retries remaining.\nPassword: ", 3 - i);
fgets(buf, sizeof buf, stdin);
if (strncmp(PASS, buf, strlen(PASS)))
puts("Wrong password!\n");
else
{
puts("Correct!\nWelcome to the world "
"of reverse engineering!\n");
break;
}
}
return 0;
}
用 GCC 编译它:
$ gcc -o ./demo ./demo.c
在 radare2 中打开可执行文件:
$ r2 ./demo
你会看到:
[0x00000800]>
让我们分析这个可执行文件:
[0x00000800]> aaa
INFO: Analyze all flags starting with sym. and entry0 (aa)
INFO: Analyze imports (af@@@i)
INFO: Analyze entrypoint (af@ entry0)
INFO: Analyze symbols (af@@@s)
INFO: Analyze all functions arguments/locals (afva@@F)
INFO: Analyze function calls (aac)
INFO: Analyze len bytes of instructions for references (aar)
INFO: Finding and parsing C++ vtables (avrr)
INFO: Analyzing methods (af @@ method.*)
INFO: Finding function preludes (aap)
INFO: Emulating functions to find computed references (aaef)
INFO: Recovering local variables (afva@@@F)
INFO: Type matching analysis for all functions (afft)
INFO: Propagate noreturn information (aanr)
INFO: Use -AA or aaaa to perform additional experimental analysis
INFO: Finding xrefs in noncode sections (e anal.in=io.maps.x; aav)
WARN: Skipping aav because base address is zero. Use -B 0x800000 or aav0
列出函数:
[0x00000800]> afl
0x00000750 1 16 sym.imp.strlen
0x00000760 1 16 sym.imp.__libc_start_main
0x00000770 1 16 sym.imp.__cxa_finalize
0x00000780 1 32 sym.imp.strncmp
0x000007a0 1 16 sym.imp.abort
0x000007b0 1 16 sym.imp.puts
0x000007c0 1 16 sym.imp.printf
0x000007d0 1 20 sym.imp.fgets
0x00000800 1 48 entry0
0x00000834 3 20 sym.call_weak_fn
0x00000860 4 48 sym.deregister_tm_clones
0x00000890 4 60 sym.register_tm_clones
0x000008cc 5 80 entry.fini0
0x00000920 1 8 entry.init0
0x000009f8 1 24 sym._fini
0x00000928 7 208 main
0x00000708 1 28 sym._init
0x00000730 1 32 fcn.00000730
现在我们进入 main 函数并打印反汇编代码:
[0x00000800]> s main
[0x00000928]> pdf
; DATA XREF from entry0 @ 0x820(r)
; DATA XREF from entry.fini0 @ 0x8e0(r)
┌ 208: int main (int argc);
│ `- args(x0) vars(5:sp[0x4..0x70])
│ 0x00000928 fd7bb9a9 stp x29, x30, [sp, -0x70]!
│ 0x0000092c fd030091 mov x29, sp
│ 0x00000930 f30b00f9 str x19, [var_10h]
│ 0x00000934 ff6f00b9 str wzr, [var_6ch] ; argc
│ ┌─< 0x00000938 29000014 b 0x9dc
│ │ ; CODE XREF from main @ 0x9e4(x)
│ ┌──> 0x0000093c 61008052 mov w1, 3
│ ╎│ 0x00000940 e06f40b9 ldr w0, [var_6ch]
│ ╎│ 0x00000944 2000004b sub w0, w1, w0
│ ╎│ 0x00000948 e103002a mov w1, w0
│ ╎│ 0x0000094c 00000090 adrp x0, 0
│ ╎│ 0x00000950 00a02891 add x0, x0, str._d_retries_remaining._nPassword: ; 0xa28 ; "%d retries remaining.\nPassword: " ; const char *format
│ ╎│ 0x00000954 9bffff97 bl sym.imp.printf ; int printf(const char *format)
│ ╎│ 0x00000958 e00000f0 adrp x0, 0x1f000
│ ╎│ 0x0000095c 00e447f9 ldr x0, [x0, 0xfc8] ; [0x1ffc8:4]=0
│ ╎│ ; reloc.stdin
│ ╎│ 0x00000960 010040f9 ldr x1, [x0] ; int size
│ ╎│ 0x00000964 e0a30091 add x0, sp, 0x28 ; char *s
│ ╎│ 0x00000968 e20301aa mov x2, x1 ; FILE *stream
│ ╎│ 0x0000096c 01088052 mov w1, 0x40
│ ╎│ 0x00000970 98ffff97 bl sym.imp.fgets ; char *fgets(char *s, int size, FILE *stream)
│ ╎│ 0x00000974 00010090 adrp x0, reloc.strlen ; 0x20000
│ ╎│ 0x00000978 00600191 add x0, x0, 0x58
│ ╎│ 0x0000097c 130040f9 ldr x19, [x0] ; [0xa18:4]=0x6b616577 ; "weakpasswd"
│ ╎│ 0x00000980 00010090 adrp x0, reloc.strlen ; 0x20000
│ ╎│ 0x00000984 00600191 add x0, x0, 0x58
│ ╎│ 0x00000988 000040f9 ldr x0, [x0] ; [0xa18:4]=0x6b616577 ; "weakpasswd" ; const char *s
│ ╎│ 0x0000098c 71ffff97 bl sym.imp.strlen ; size_t strlen(const char *s)
│ ╎│ 0x00000990 e10300aa mov x1, x0
│ ╎│ 0x00000994 e0a30091 add x0, sp, 0x28
│ ╎│ 0x00000998 e20301aa mov x2, x1 ; size_t n
│ ╎│ 0x0000099c e10300aa mov x1, x0 ; const char *s2
│ ╎│ 0x000009a0 e00313aa mov x0, x19 ; const char *s1
│ ╎│ 0x000009a4 77ffff97 bl sym.imp.strncmp ; int strncmp(const char *s1, const char *s2, size_t n)
│ ╎│ 0x000009a8 1f000071 cmp w0, 0
│ ┌───< 0x000009ac a0000054 b.eq 0x9c0
│ │╎│ 0x000009b0 00000090 adrp x0, 0
│ │╎│ 0x000009b4 00402991 add x0, x0, str.Wrong_password__n ; 0xa50 ; "Wrong password!\n" ; const char *s
│ │╎│ 0x000009b8 7effff97 bl sym.imp.puts ; int puts(const char *s)
│ ┌────< 0x000009bc 05000014 b 0x9d0
│ ││╎│ ; CODE XREF from main @ 0x9ac(x)
│ │└───> 0x000009c0 00000090 adrp x0, 0
│ │ ╎│ 0x000009c4 00a02991 add x0, x0, str.Correct__nWelcome_to_the_world_of_reverse_engineering__n ; 0xa68 ; "Correct!\nWelcome to the world of reverse engineering!\n" ; const char *s
│ │ ╎│ 0x000009c8 7affff97 bl sym.imp.puts ; int puts(const char *s)
│ │┌───< 0x000009cc 07000014 b 0x9e8
│ ││╎│ ; CODE XREF from main @ 0x9bc(x)
│ └────> 0x000009d0 e06f40b9 ldr w0, [var_6ch]
│ │╎│ 0x000009d4 00040011 add w0, w0, 1
│ │╎│ 0x000009d8 e06f00b9 str w0, [var_6ch]
│ │╎│ ; CODE XREF from main @ 0x938(x)
│ │╎└─> 0x000009dc e06f40b9 ldr w0, [var_6ch]
│ │╎ 0x000009e0 1f080071 cmp w0, 2
│ │└──< 0x000009e4 cdfaff54 b.le 0x93c
│ │ ; CODE XREF from main @ 0x9cc(x)
│ └───> 0x000009e8 00008052 mov w0, 0
│ 0x000009ec f30b40f9 ldr x19, [var_10h]
│ 0x000009f0 fd7bc7a8 ldp x29, x30, [sp], 0x70
└ 0x000009f4 c0035fd6 ret
Radare2 不仅会给出汇编代码,还会给出每条指令对应的地址,以及它们之间的调用关系。
不过现在看起来还是不够清楚。所以我们把反汇编以图形方式显示出来:
[0x00000928]> VV
你现在会看到:

在这个图中,t 表示条件为真时跳转。f 表示条件为假时跳转。v 表示无条件跳转。
这样一来,事情就容易多了。Radare2 已经帮我们理清了汇编代码各部分之间的逻辑关系,我们只需要理解这些汇编代码块本身即可。
在图中,我们很容易在 [0x93c] 区块的末尾找到密码检查逻辑:
; int strncmp(const char *s1, const char *s2, size_t n)
bl sym.imp.strncmp
cmp w0, 0
b.eq 0x9c0
这里调用了 sym.imp.strncmp,然后检查这个函数在 w0 中返回的值。如果它等于 0,程序就跳转到 [0x9c0] 区块,表示成功。否则,它会继续到 [0x9b0] 区块,表示失败。
根据 AArch64 调用约定,要比较的两个字符串指针分别位于寄存器 x0 和 x1 中,然后我们调用 sym.imp.strncmp。结果随后存放在 x0 寄存器中(w0 只是 x0 的低 32 位),如果两个字符串完全相同,我们得到 0。
现在我们往回看:
mov x0, x19
然后再往前找一条对 x19 进行操作的指令:
; [0xa18:4]=0x6b616577
; "weakpasswd"
ldr x19, [x0]
Radare2 已经告诉了我们那个最高机密。 让我们试试:
$ ./demo
3 retries remaining.
Password: weakpasswd
Correct!
Welcome to the world of reverse engineering!
就是这样。
如果我们更仔细地看这个图,还能学到比密码更多的东西。例如,我们可以在图中看到一个环:[0x9dc] -> [0x93c] -> [0x9b0] -> [0x9d0] -> [0x9dc]。在图中,环通常表示循环控制流。这是逆向工程中非常重要的一种思维方式。
在 [0x9dc] 区块中:
ldr w0, [var_6ch]
cmp w0, 2
b.le 0x93c
它把变量 var_6ch 载入寄存器,并与 2 比较。如果它更大,就会进入 [0x9e8] 区块并退出。否则,就会进入 [0x93c] 区块。
看一下 [0x93c] 区块的末尾。我们已经知道,如果密码检查成功,它会跳转到 [0x9c0] 区块,打印表示成功的信息,然后跳转到 [0x9e8] 区块,程序在这里结束。
但如果检查失败呢?它会进入 [0x9b0] 区块。这里仅仅打印错误信息,所以没有什么有趣的内容;让我们跳到 [0x9d0] 区块。在这里我们发现了一些有趣的东西:
ldr w0, [var_6ch]
add w0, w0, 1
str w0, [var_6ch]
它把变量 var_6ch 载入寄存器,将值加一,然后再把值存回去。接着它会回到 [0x9dc] 区块,也就是检查 var_6ch 的值。
按两次 q 返回提示符。运行 afv 和 afvd 来列出变量及其信息:
[0x000009b0]> afv
arg int argc @ x0
var int64_t var_70h @ sp+0x0
var int64_t var_70h_2 @ sp+0x8
var int64_t var_10h @ sp+0x10
var char * s2 @ sp+0x28
var int64_t var_6ch @ sp+0x6c
[0x000009b0]> afvd
arg argc = 0x00000000 0x00010102464c457f .ELF.... @ pstate
var var_6ch = 0x0017806c = (qword)0x0000000000000000
var s2 = 0x00178028 = ""
var var_10h = 0x00178010 = (qword)0x0000000000000000
var var_70h = 0x00178000 = (qword)0x0000000000000000
var var_70h_2 = 0x00178008 = (qword)0x0000000000000000
我们可以看到,var_6ch 的值是一个 int64_t,而它的初始值是 0。
现在我们可以推断,var_6ch 是一个计数器。它的初始值是 0。如果密码检查失败,计数器就会加一。一旦它超过 2,程序就不再询问密码并退出。现在我们就可以看出,这对应于我们的 C 代码 for (int i = 0; i < 3; i++)!
不过,这只是一个非常简单的例子。在真实世界中,情况会更困难,因为专有软件开发者常常使用反分析和反调试技术。
理解编译器优化
现在,让我们编译这个更简单的代码:
#include <stdio.h>
int main()
{
int a;
scanf("%d", &a);
printf("%d", a % 65536);
return 0;
}
它从输入中读取一个整数,并输出它对 65536 取模的结果。
反汇编它的 main 函数:
[0x00000700]> aaa
INFO: Analyze all flags starting with sym. and entry0 (aa)
......
WARN: Skipping aav because base address is zero. Use -B 0x800000 or aav0
[0x00000700]> s main
[0x00000828]> pdf
; DATA XREF from entry0 @ 0x720(r)
; DATA XREF from entry.fini0 @ 0x7e0(r)
┌ 76: int main (int argc, char **argv, char **envp);
│ afv: vars(3:sp[0x4..0x20])
│ 0x00000828 fd7bbea9 stp x29, x30, [sp, -0x20]!
│ 0x0000082c fd030091 mov x29, sp
│ 0x00000830 e0730091 add x0, sp, 0x1c
│ 0x00000834 e10300aa mov x1, x0
│ 0x00000838 00000090 adrp x0, 0
│ 0x0000083c 00602291 add x0, x0, 0x898
│ 0x00000840 94ffff97 bl sym.imp.__isoc23_scanf
│ 0x00000844 e01f40b9 ldr w0, [var_1ch]
│ 0x00000848 e103006b negs w1, w0
│ 0x0000084c 003c0012 and w0, w0, 0xffff
│ 0x00000850 213c0012 and w1, w1, 0xffff
│ 0x00000854 0044815a csneg w0, w0, w1, mi
│ 0x00000858 e103002a mov w1, w0
│ 0x0000085c 00000090 adrp x0, 0
│ 0x00000860 00602291 add x0, x0, 0x898 ; const char *format
│ 0x00000864 97ffff97 bl sym.imp.printf ; int printf(const char *format)
│ 0x00000868 00008052 mov w0, 0
│ 0x0000086c fd7bc2a8 ldp x29, x30, [sp], 0x20
└ 0x00000870 c0035fd6 ret
你可能会觉得困惑,因为这里看不到任何关于取模操作的内容(sdiv、mul、sub 等)。相反,你看到的是:
and w0, w0, 0xffff
and w1, w1, 0xffff
这是因为现代编译器足够聪明,能够识别某些特殊的计算模式,并把它们优化成更简单的指令。65536 是 2^16,所以一个数对 65536 取模,实际上就是取它的最低 16 位。只需要位运算就够了,不需要加法器或乘法器。
但是,如果我们把 65536 换成别的数,比如 50000,那就不是这样了:
; DATA XREF from entry0 @ 0x720(r)
; DATA XREF from entry.fini0 @ 0x7e0(r)
┌ 80: int main (int argc, char **argv, char **envp);
│ afv: vars(3:sp[0x4..0x20])
│ 0x00000828 fd7bbea9 stp x29, x30, [sp, -0x20]!
│ 0x0000082c fd030091 mov x29, sp
│ 0x00000830 e0730091 add x0, sp, 0x1c
│ 0x00000834 e10300aa mov x1, x0
│ 0x00000838 00000090 adrp x0, 0
│ 0x0000083c 00602291 add x0, x0, 0x898
│ 0x00000840 94ffff97 bl sym.imp.__isoc23_scanf
│ 0x00000844 e01f40b9 ldr w0, [var_1ch]
│ 0x00000848 016a9852 mov w1, 0xc350
│ 0x0000084c 020cc11a sdiv w2, w0, w1
│ 0x00000850 016a9852 mov w1, 0xc350
│ 0x00000854 417c011b mul w1, w2, w1
│ 0x00000858 0000014b sub w0, w0, w1
│ 0x0000085c e103002a mov w1, w0
│ 0x00000860 00000090 adrp x0, 0
│ 0x00000864 00602291 add x0, x0, 0x898 ; const char *format
│ 0x00000868 96ffff97 bl sym.imp.printf ; int printf(const char *format)
│ 0x0000086c 00008052 mov w0, 0
│ 0x00000870 fd7bc2a8 ldp x29, x30, [sp], 0x20
└ 0x00000874 c0035fd6 ret
现在我们看到了 sdiv、mul 和 sub 指令,它们组合起来完成了取模操作。
那么动态分析呢?
对于动态分析,你需要 GDB。使用 GDB,你可以打印反汇编,设置断点,检查内存和寄存器,甚至在程序运行时修改寄存器和值变量。
我还没有深入探索动态分析。将来我可能会写一篇新的博客文章来解释动态分析。
推荐资源
我上面解释的内容只是冰山一角。要学习逆向工程,你需要继续深入、多加练习,并在实践中学习。下面是一些推荐资源:
- Pwn.college 是一个很好的入门地点。你可以在他们的入门 dojo 里学习 x86_64 汇编,然后继续进入 Intro to Cybersecurity 模块中的 Reverse Engineering 部分。
- Reverse Engineering for Beginners 是一本非常好的逆向工程书籍。它是自由的(自由如自由),采用 CC BY-SA 4.0 许可,并可在这里下载。
- ARM 为其 AArch64 架构提供了官方文档。
- 这里可以下载 crackmes,用来练习你的逆向工程技能。
- 这里 是一个包含许多逆向工程学习资源的 GitHub 仓库。
免责声明
本文仅用于教育目的。请查阅你所在地区的法律,了解哪些行为是你不可以做的。本人不对你的任何行为负责。