使用 GDB 和 Iaito/Radare2 的逆向工程示例

2026-05-27T23:56:34+08:00

过去几周里,我一直在学习逆向工程,并且写了一篇博客文章来分享我的第一次体验。最近,我又深入了一些,正在用静态分析和动态分析相结合的方式解决一个 CTF 题目,而我觉得这正是进一步解释逆向工程的一个很好的例子。

我之前原本打算写一篇关于使用 GDB 进行动态分析的文章。不过,我发现这个题目同样非常适合用来解释 GDB。为了解出这个题目,我还会使用另一个工具,iaito,它是 radare2 的图形前端。

题目

cIMG 可以说是一种图像格式,用来在终端中表示由不同颜色字符组成的图像。它由以下部分构成:

  1. 魔数,也就是 cIMG
  2. 版本号;
  3. 宽度和高度;
  4. 数据,每个像素都包含 R、G、B 和 ASCII 字符。

这个题目要求你向一个可执行程序提供一个 cIMG 文件,只有当 cIMG 满足某些条件时,它才会给出 flag。

源代码

在这里获取题目的source。这份源代码看起来使用的是 BSD 2-Clause 许可证。

使用 GCC 编译它:

$ gcc -O3 challenge.c -o challenge

打开 Python 并创建一个示例 cIMG 文件:

$ python3
Python 3.13.5 (Jun 25 2025, 18:55:22) [GCC 14.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> with open("example.cimg", "wb") as file:
...     file.write(b"cIMG\x02\0\x0a\x0a")
...     file.write(b"\x50"*(10*10*4))
...     
8
400

使用题目的二进制程序打开这个示例 cIMG 文件:

user@learnaarch64asm:~$ ./challenge ./example.cimg 
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP

这个文件会输出内容,但不会给出 flag。

静态分析

我们需要构造一个能 给出 flag 的 cIMG 文件。先做静态分析。

在 iaito 中打开文件。执行 aaa 分析,并打开 main 函数的图形视图。你会得到:

main 函数的图形

我们可以看到在 0x00000cac 处读取文件头,在 0x00000cc4 处检查魔数,在 0x00000cd0 处检查版本号,在 0x00000ce0 处读取宽度和高度。然后它使用 malloc 为图像数据分配一块内存,并把数据读入其中。

魔数是 cIMG,换成十六进制就是 63 49 4d 47;版本号是一个 32 位小端整数;高度和宽度都是 8 位整数。

flag 条件

我们直接看函数结尾。显然,sym.win 是会给出 flag 的函数。它在 0x00000e8c 被调用,而这一步是在 0x00000e58 处的一次检查之后执行的。这项检查要求 w19 不能为零。

篡改控制流

该启动 GDB 了。

$ gdb --args ./challenge ./example.cimg 
GNU gdb (Debian 16.3-1) 16.3
Copyright (C) 2024 Free Software Foundation, Inc.
License GPLv3+ or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "aarch64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from ./challenge...
(No debugging symbols found in ./challenge)

main 函数处打断点并启动程序:

(gdb) break *main
Breakpoint 1 at 0xc44
(gdb) run
Starting program: /home/user/challenge ./example.cimg
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1".

Breakpoint 1, 0x0000aaaaaaaa0c44 in main ()

我们注意到 GDB 的数据地址有一个 0xaaaaaaaa0000 的偏移。

在 flag 条件检查之前打断点,然后继续执行:

(gdb) break *0x0000aaaaaaaa0e58
Breakpoint 2 at 0xaaaaaaaa0e58
(gdb) continue
Continuing.
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP

Breakpoint 2, 0x0000aaaaaaaa0e58 in main ()

修改寄存器 w19 的内容并继续执行:

(gdb) set $w19 = 1
(gdb) continue

然后这个可执行程序就会给出 flag。至此任务完成,祝你有美好的一天 :-p

开玩笑的。如果我们真的这样结束这个题目,逆向工程的乐趣就没了。让我们继续深入。

数据检查

回到 iaito。点击图中的 w19。这会在图里的所有指令中高亮该寄存器。向前回溯图形,寻找线索。

我们可以在图中看到一些对 sym.imp.memcmp 的调用。

memcmp 是 C 标准库中的一个函数,声明在 <string.h> 中,它会逐字节比较两块内存:

int memcmp(const void *ptr1, const void *ptr2, size_t n);

ptr1ptr2 分别指向那两块内存,而 n 指定要比较的字节数。如果两块内存完全相同,该函数返回 0;否则返回一个非零值。

这里有四次 memcmp 调用。每次调用后面都跟着 cmp w0, 0。除了第一次调用之外,cmp 后面还有 cset w0, eqand w19, w19, w0。我们可以得出结论:如果 memcmp 发现差异,w19 就会被清零,因为那时 w0 会变成零。因此我们猜测,需要让四对内存块完全相同。

x0x1 是要比较的两个地址。x2 包含大小;mov x2, 0x18 告诉我们大小是 24 字节。

再次打开 GDB,并在第一次 memcmp 之前检查内存:

(gdb) break *0x0000aaaaaaaa0e78
Breakpoint 1 at 0xaaaaaaaa0e78
(gdb) run
Starting program: /home/user/challenge ./example.cimg
[Thread debugging using libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1].
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP
PPPPPPPPPP

Breakpoint 1, 0x0000aaaaaaaa0e78 in main ()
(gdb) info registers x0 x1
x0             0xaaaaaaac12a0      187649984565920
x1             0xaaaaaaac00c0      187649984561344
(gdb) x/24xb $x0
0xaaaaaaac12a0: 0x1b    0x5b    0x33    0x38    0x3b    0x32    0x3b    0x30
0xaaaaaaac12a8: 0x38    0x30    0x3b    0x30    0x38    0x30    0x3b    0x30
0xaaaaaaac12b0: 0x38    0x30    0x6d    0x50    0x1b    0x5b    0x30    0x6d
(gdb) x/24xb $x1
0xaaaaaaac00c0 <desired_output>:        0x1b    0x5b    0x33    0x38    0x3b   0x32     0x3b    0x32
0xaaaaaaac00c8 <desired_output+8>:      0x33    0x31    0x3b    0x30    0x31   0x37     0x3b    0x31
0xaaaaaaac00d0 <desired_output+16>:     0x33    0x30    0x6d    0x63    0x1b   0x5b     0x30    0x6d

随机字节。我看不出它们是什么意思。

回到 iaito。第一块内存是从 x22 载入的,而 x22 又是从地址 var_48h 载入的。afv 告诉我们它距离 sp0x48

[0x00000e70]> afv
arg signed int argc @ x0
arg char ** s @ x1
var int64_t var_50h @ sp+0x0
var int64_t var_50h_2 @ sp+0x8
var int64_t var_10h @ sp+0x10
var int64_t var_10h_2 @ sp+0x18
var int64_t var_20h @ sp+0x20
var int64_t var_20h_2 @ sp+0x28
var void * buf @ sp+0x38
var int64_t var_3ch @ sp+0x3c
var int64_t var_3eh @ sp+0x3e
var int64_t var_3fh @ sp+0x3f
var int64_t var_40h @ sp+0x40
var int64_t var_48h @ sp+0x48

重新启动 GDB,并在 main 被调用后跳过函数序言:

(gdb) break *main
Breakpoint 1 at 0xaaaaaaaa0c44
(gdb) run
Starting program: /home/user/challenge ./example.cimg
[Thread debugging using libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1].

Breakpoint 1, 0x0000aaaaaaaa0c44 in main ()
(gdb) si
0x0000aaaaaaaa0c48 in main ()
(gdb) si
0x0000aaaaaaaa0c4c in main ()

查看栈指针的值并设置一个观察点:

(gdb) info registers sp
sp             0xfffffffff290      0xfffffffff290
(gdb) watch *(long long *)0xfffffffff2d8
Hardware watchpoint 2: *(long long *)0xfffffffff2d8

继续执行:

```(gdb) continue Continuing.

Hardware watchpoint 2: (long long )0xfffffffff2d8

Old value = 281474840286680 New value = 0 0x0000aaaaaaaa0c5c in main ()

 iaito 中定位这个地址。我们可以看到,在 `0x00000c58`(也就是 `0x00000c5c` 前一条指令)处,`var_48h` 通过一个 `str` 被清零了。

继续往下,看看这个变量是在什么地方被设置的:

(gdb) continue Continuing.

Hardware watchpoint 2: (long long )0xfffffffff2d8

Old value = 0 New value = 187649984565920 0x0000aaaaaaaa1358 in initialize_framebuffer ()

我们看到一个我们还没探索过的新函数:`initialize_framebuffer`

 iaito 中打开这个函数:

![initialize\_framebuffer 函数的图形](https://rebel1725.codeberg.page/images/pic_20260527_002.png)

我们可以看到,`0x00001354` 处的一条指令把 `malloc` 分配出来的内存地址写入了变量。不过它被表示为 `[x22, 0x10]`。我们来检查一下 `x22` 的内容:

(gdb) info registers $x22 x22 0xfffffffff2c8 281474976707272

`0xfffffffff2c8 + 0x10 = 0xfffffffff2d8`。这正好就是 `var_48h` 的地址。

此外,传给 `malloc` 的大小也很有意思。它是通过把偏移 6  7 处的值相乘,再把结果乘以 `w1` 得到的,而 `w1`  `0x18`(24),最后再加上 `x0`,即 1。

我们也来查看这些偏移处的值:

(gdb) x/1xb 0xfffffffff2ce 0xfffffffff2ce: 0x0a (gdb) x/1xb 0xfffffffff2cf 0xfffffffff2cf: 0x0a

两个都是 10。它们看起来就是我们之前指定的图像宽度和高度。让我们通过设置硬件观察点来验证一下。由于 AArch64 需要对齐,我们把硬件观察点设在 `0xfffffffff2cc`:

(gdb) watch (unsigned int )0xfffffffff2cc Hardware watchpoint 1: (unsigned int )0xfffffffff2cc (gdb) run Starting program: /home/user/challenge ./example.cimg

跳过 GNU C Library 的触发

```Hardware watchpoint 1: *(unsigned int *)0xfffffffff2cc

Old value = 0
New value = 65535
0x0000fffff7fccd08 in ?? () from /lib/ld-linux-aarch64.so.1
(gdb) continue
Continuing.

Hardware watchpoint 1: *(unsigned int *)0xfffffffff2cc

Old value = 65535
New value = 0
0x0000fffff7fd5668 in ?? () from /lib/ld-linux-aarch64.so.1
(gdb) continue
Continuing.
[Thread debugging using libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1"]

Hardware watchpoint 1: *(unsigned int *)0xfffffffff2cc

Old value = 0
New value = 65535
0x0000fffff7fcc3a4 in ?? () from /lib/ld-linux-aarch64.so.1
(gdb) continue
Continuing.

这里我们看到内存在 0x00000c54 处被清零了:

Hardware watchpoint 1: *(unsigned int *)0xfffffffff2cc

Old value = 65535
New value = 0
0x0000aaaaaaaa0c58 in main ()

我们看到一个 stp xzr, xzr, [buf]。从上面的 afv 我们知道 buf 距离栈指针是 0x38

0x00000cb8 处,我们可以看到一个从 buf 读取的 ldr,然后是魔数检查。

0x00000c9c 处,我们可以看到一些有意思的内容:

0x00000c9c      add      x21,    sp,     0x38
0x00000ca0      mov      x2,     8                          ; size_t nbyte
0x00000ca4      mov      x1,     x21                        ; void *buf
0x00000ca8      mov      w0,     0
0x00000cac      bl       sym.imp.read                       ; ssize_t read(int fildes, void *buf, size_t nbyte)
; ssize_t read(0, 0x0000000000000000, 0x00000000)

因此我们已经确认,这些偏移处的两个值就是宽度和高度。

回到 iaito 和 GDB 中的 initialize_framebuffer 函数。设置一个观察点并检查内存:

(gdb) break *0xaaaaaaaa1354
Breakpoint 1 at 0xaaaaaaaa1354
(gdb) run
Starting program: /home/user/challenge ./example.cimg
[Thread debugging using libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1].

Breakpoint 1, 0x0000aaaaaaaa1354 in initialize_framebuffer ()
(gdb) info registers x0
x0             0xaaaaaaac12a0      187649984565920
(gdb) watch *(char *)0xaaaaaaac12a0
Hardware watchpoint 2: *(char *)0xaaaaaaac12a0
(gdb) continue
Continuing.

Hardware watchpoint 2: *(char *)0xaaaaaaac12a0

Old value = 0 '\000'
New value = 27 '\033'
0x0000aaaaaaaa13bc in initialize_framebuffer ()
(gdb) x/24xb 0xaaaaaaac12a0
0xaaaaaaac12a0: 0x1b    0x5b    0x33    0x38    0x3b    0x32    0x3b    0x32
0xaaaaaaac12a8: 0x35    0x35    0x3b    0x32    0x35    0x35    0x3b    0x32
0xaaaaaaac12b0: 0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00

把它和上面的数据对比一下:

(gdb) x/24xb $x0
0xaaaaaaac12a0: 0x1b    0x5b    0x33    0x38    0x3b    0x32    0x3b    0x30
0xaaaaaaac12a8: 0x38    0x30    0x3b    0x30    0x38    0x30    0x3b    0x30
0xaaaaaaac12b0: 0x38    0x30    0x6d    0x50    0x1b    0x5b    0x30    0x6d

0xaaaaaaac12a7 是第一个不同的字节。我们可以对它设置观察点:

(gdb) watch *(char *)0xaaaaaaac12a7
Hardware watchpoint 3: *(char *)0xaaaaaaac12a7
(gdb) continue
Continuing.

Hardware watchpoint 3: *(char *)0xaaaaaaac12a7

Old value = 50 '2'
New value = 48 '0'
0x0000aaaaaaaa128c in display ()

出现了一个新的函数需要探索。在 iaito 中打开它。

initialize_framebuffer 函数的图形

我们可以看到,数据变化发生在 0x00001288,是一条 stp 指令。0x0000128c 处的 str 指令也很有意思;我们来看看这里的内存:

(gdb) x/24xb 0xaaaaaaac12a0
0xaaaaaaac12a0: 0x1b    0x5b    0x33    0x38    0x3b    0x32    0x3b    0x30
0xaaaaaaac12a8: 0x38    0x30    0x3b    0x30    0x38    0x30    0x3b    0x30
0xaaaaaaac12b0: 0x35    0x35    0x6d    0x20    0x1b    0x5b    0x30    0x6d
(gdb) si
0x0000aaaaaaaa1290 in display ()
(gdb) x/24xb 0xaaaaaaac12a0
0xaaaaaaac12a0: 0x1b    0x5b    0x33    0x38    0x3b    0x32    0x3b    0x30
0xaaaaaaac12a8: 0x38    0x30    0x3b    0x30    0x38    0x30    0x3b    0x30
0xaaaaaaac12b0: 0x38    0x30    0x6d    0x50    0x1b    0x5b    0x30    0x6d

0xaaaaaaac12b0 开始的 8 字节块已经被 str 指令改写了。寄存器说明了一切:

(gdb) info registers x2
x2             0xaaaaaaac12a0      187649984565920

我们不必太在意 x2。重点关注 x4x5x1。在 0x000012680x00001270 处,我们看到一个非常重要的寄存器 x25。它保存着一块内存的地址,而这块内存里装着将要写入 x2 指向内存的数据。检查这块内存:

(gdb) x/24xb $x25
0xfffffffff270: 0x1b    0x5b    0x33    0x38    0x3b    0x32    0x3b    0x30
0xfffffffff278: 0x38    0x30    0x3b    0x30    0x38    0x30    0x3b    0x30
0xfffffffff280: 0x38    0x30    0x6d    0x50    0x1b    0x5b    0x30    0x6d

0x00001238 处,x25 中的地址被复制到 x0。然后,后面的几个寄存器被填入相应的数据。接着调用了 snprintfsnprintf 会把格式化输出写入字符数组:

int snprintf(char *str, size_t size, const char *format, ...);

它会把格式化字符串写到 x25 所指向的内存中。重新启动 GDB,并检查 x2 指向的内存:

(gdb) break *0xaaaaaaaa1258
Breakpoint 1 at 0xaaaaaaaa1258
(gdb) run
Starting program: /home/user/challenge ./example.cimg
[Thread debugging using libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1].

Breakpoint 1, 0x0000aaaaaaaa1258 in display ()
(gdb) x/s $x2
0xaaaaaaaa1510: "\033[38;2;%03d;%03d;%03dm%c\033[0m"
(gdb) x/29xb $x2
0xaaaaaaaa1510: 0x1b    0x5b    0x33    0x38    0x3b    0x32    0x3b    0x25
0xaaaaaaaa1518: 0x30    0x33    0x64    0x3b    0x25    0x30    0x33    0x64
0xaaaaaaaa1520: 0x3b    0x25    0x30    0x33    0x64    0x6d    0x25    0x63
0xaaaaaaaa1528: 0x1b    0x5b    0x30    0x6d    0x00

这就是格式字符串。如果你对 x/s 返回的字符串不太放心,可以用 Python 逐字节打印这个字符串:

>>> print(b"\x1b\x5b\x33\x38\x3b\x32\x3b\x25\x30\x33\x64\x3b\x25\x30\x33\x64\x3\
b\x25\x30\x33\x64\x6d\x25\x63\x1b\x5b\x30\x6d\x00")
b'\x1b[38;2;%03d;%03d;%03dm%c\x1b[0m\x00'
>>> blob = b'\x1b[38;2;%03d;%03d;%03dm%c\x1b[0m\x00'
>>> for i1 in range(len(blob)):
...     blob[i1:i1+1]
...     
b'\x1b'
b'['
b'3'
b'8'
b';'
b'2'
b';'
b'%'
b'0'
b'3'
b'd'
b';'
b'%'
b'0'
b'3'
b'd'
b';'
b'%'
b'0'
b'3'
b'd'
b'm'
b'%'
b'c'
b'\x1b'
b'['
b'0'
b'm'
b'\x00'

这个格式字符串有四个占位符。前三个是 %03d,它会把整数格式化为至少三位的十进制字符串(例如 7 会变成 007,15 会变成 015)。最后一个是 %c,它只匹配一个字符。

现在我们有了这个字节模式:

0x1b    0x5b    0x33    0x38    0x3b    0x32    0x3b    [N1]
[N1]    [N1]    0x3b    [N2]    [N2]    [N2]    0x3b    [N3]
[N3]    [N3]    0x6d    [CH]    0x1b    0x5b    0x30    0x6d

现在我们可以检查这些占位符究竟填入了什么:

(gdb) info registers x3 x4 x5 x6
x3             0x50                80
x4             0x50                80
x5             0x50                80
x6             0x50                80

它们全都是 0x50,看起来就是我们在示例 cIMG 中使用的数据。现在我们重建这个 cIMG,让这一点更清楚:

>>> with open("example.cimg", "wb") as file:
...     file.write(b"cIMG\x02\0\x0a\x0a\x12\x34\x56\x78")
...     file.write(b"\x50"*(10*10*4-4))
...     
12
396
(gdb) break *0xaaaaaaaa1258
Breakpoint 1 at 0xaaaaaaaa1258
(gdb) run
Starting program: /home/user/challenge ./example.cimg
[Thread debugging using libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1].

Breakpoint 1, 0x0000aaaaaaaa1258 in display ()
(gdb) info registers x3 x4 x5 x6
x3             0x12                18
x4             0x34                52
x5             0x56                86
x6             0x78                120

因此,上面十六进制模式中的 N1、N2 和 N3 分别对应 R、G 和 B,而 CH 就是我们要填入的字符。

现在我们可以回到 GDB,在每个 memcmp 之前设置断点,运行 x/24xb $x1 来找出期望的 framebuffer,然后利用这些 framebuffer 还原出期望的 cIMG 数据。接着用十六进制编辑器把数据填进去。

大小检查

然而,在填入期望数据之后,可执行程序仍然没有给出 flag。用 GDB 检查内存之后,我们可以知道这些 memcmp 的返回值都如预期那样是零。因此,应该还有别的条件没有满足。

回到 iaito,从第一个 memcmp 往回滚动。我们可以看到在 0x00000d60 处有一个 cmp,它把 var_40h 与 4 进行比较。在 initialize_framebuffer 中的 0x00001348 处,我们可以找到一个 str,它保存了宽度与高度的乘积。

随后 0x00000d60 处的 cmp 决定 w0 的值。如果 var_40h 等于 4,那么 w0 将变成 1,否则变成 0。

0x00000d78 处,w0 与 0 比较,这会影响 0x00000d84ccmp 指令的行为。[x22, 0x13] 的内容被载入到 w0,而 [x1, 0x13] 被载入到 w2。根据 memcmp,我们很容易知道 x22 是实际 framebuffer 的地址,而 x1 是期望 framebuffer 的地址。既然我们已经让数据与期望值一致,并且偏移 19 处是一个 ASCII 字符,那么 w0w2 应该相同,而且都不应为零。

因此我们可以得出结论:如果 var_40h 等于 4,那么 w2 会与 w0 比较,这会把 w19 设为 1。否则,w2 会与 0 比较,从而把 w19 置零。

在第一次 memcmp 之后,因为 0x00000e7cw0 等于 0,所以 w19 会与 0 比较。然后 cset 会在不相等时把 w19 设为 1,否则设为 0。

现在用十六进制编辑器修改 cIMG,把宽度和高度改成乘积为 4 的值。例如,我们可以设为 0202。这样可执行程序就应该会给出 flag。

结论

上面的过程包括:

  1. 使用 radare2 和 iaito 进行静态分析;
  2. 使用 GDB 进行动态分析,其中包括:

  3. 篡改控制流;

  4. 检查内存;
  5. 检查整数值。

这就是逆向工程中非常典型的一套流程。

顺带一提,逆向这个可执行程序可能还有别的方法。不过无论采用哪种方法,通常都会同时涉及静态分析和动态分析。

Tildeverse Banner Exchange