逆向工程简介

2026-05-16T19:37:05+08:00

在过去的几周里,我一直在学习 x86_64 汇编、AArch64 汇编,以及各种基础知识。最近,我终于第一次接触了逆向工程。我很高兴能分享更多关于这次经历的细节。

什么是逆向工程

计算机实际上是一种相当愚笨的机器。它不会自己解决任何问题;它只会执行解决方案——更准确地说,它只会按顺序逐条执行来自人类的指令。要用这样的机器解决问题,我们通常会:

这是一种把 解决方案 转换为 代码,再转换为 指令 的过程。

通常,我们可以通过阅读代码轻松理解一个程序做了什么。代码介于解决方案和指令之间。上世纪 70 年代,代码通常在人民之间共享,因此每个人都可以在社区中学习、改进并分享解决方案。

然而,自从那些极其邪恶的专有软件开发怪物出现之后,情况就变了。它们选择剥夺人民接触 代码 的权利,夺走用户对计算的控制,加入各种恶意功能来剥削用户,并采用各种手段掩盖程序究竟做了什么——也就是 解决方案。它们在筑墙。它们在破坏共享。它们在制造灾难和绝望。它们所做的一切都在与人民、社区和人类作对。它们做这一切,都是为了它们不道德且不公正的利润

软件应该服务人民,而不是资本家的不道德且不公正的利润。然而,正是那些本不该存在、理应从世界上被清除的、不道德且不公正的专有软件开发者,正在让世界变得更糟。但我们不可能一下子消灭专有软件。我们要为此开发自由软件替代品,并逐步重新夺回对计算的控制。

因此,现在该把上面的过程倒过来做了。换句话说,我们:

这就是把 指令 转换为 代码,再转换为 解决方案

以相反顺序做这件事的实践,就是逆向工程,简称 RE

为什么逆向工程很重要

逆向工程之所以重要,最主要的原因是它能帮助我们将自由软件操作系统,例如 postmarketOS 和 LineageOS,移植到各种设备上。这也是我学习逆向工程的主要原因。

在桌面电脑上,实现软件自由要容易得多,因为我们通常可以运行各种自由的 GNU/Linux 发行版,而不会遇到严重问题。然而,在智能手机或平板电脑等移动设备上,情况就不是这样了。

这些设备往往有许多厂商特定和设备特定的调整,要找到一种能让自由操作系统在这些设备上普遍运行的通用方案几乎是不可能的。更糟糕的是,制造商选择隐藏这些技术细节,甚至 Google 也不再发布设备树和厂商二进制 blob。因此,我们现在必须分析 二进制 固件 blob,并推断其逻辑,才能让 postmarketOS、LineageOS 或其他自由软件操作系统在这些设备上运行。

事实上,自由软件基金会也在通过 Librephone 项目处理这个问题。没有逆向工程,这个项目就无法继续。

不过,逆向工程的应用绝不仅限于自由操作系统移植。它在以下方面也非常重要:

如何进行逆向工程

典型的逆向工程包含这两个步骤:

  1. 将可执行文件转换为汇编;这个过程叫做 反汇编
  2. 通过阅读汇编代码,尝试恢复思路,或者重写等价代码。

我们可以直接对可执行文件进行这项工作,而不运行它,这叫做 静态分析;也可以在它运行时进行,这叫做 动态分析

静态分析示例

来看这个例子:

#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

你现在会看到:

radare2 反汇编图形视图

在这个图中,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 调用约定,要比较的两个字符串指针分别位于寄存器 x0x1 中,然后我们调用 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 返回提示符。运行 afvafvd 来列出变量及其信息:

[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

你可能会觉得困惑,因为这里看不到任何关于取模操作的内容(sdivmulsub 等)。相反,你看到的是:

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

现在我们看到了 sdivmulsub 指令,它们组合起来完成了取模操作。

那么动态分析呢?

对于动态分析,你需要 GDB。使用 GDB,你可以打印反汇编,设置断点,检查内存和寄存器,甚至在程序运行时修改寄存器和值变量。

我还没有深入探索动态分析。将来我可能会写一篇新的博客文章来解释动态分析。

推荐资源

我上面解释的内容只是冰山一角。要学习逆向工程,你需要继续深入、多加练习,并在实践中学习。下面是一些推荐资源:

  1. Pwn.college 是一个很好的入门地点。你可以在他们的入门 dojo 里学习 x86_64 汇编,然后继续进入 Intro to Cybersecurity 模块中的 Reverse Engineering 部分。
  2. Reverse Engineering for Beginners 是一本非常好的逆向工程书籍。它是自由的(自由如自由),采用 CC BY-SA 4.0 许可,并可在这里下载。
  3. ARM 为其 AArch64 架构提供了官方文档
  4. 这里可以下载 crackmes,用来练习你的逆向工程技能。
  5. 这里 是一个包含许多逆向工程学习资源的 GitHub 仓库。

免责声明

本文仅用于教育目的。请查阅你所在地区的法律,了解哪些行为是你不可以做的。本人不对你的任何行为负责。

Tildeverse Banner Exchange