CTF 逆向入门:侦查、反汇编与 GDB 调试

完整演示逆向三板斧:file/strings 侦查、objdump 反汇编定位校验函数、GDB 动态调试,并举例一个CrackMe。
一、现实背景:为什么需要逆向
商业软件只发布编译后的二进制文件,不发布源码。恶意软件分析、漏洞研究、授权校验分析都依赖逆向。CTF 逆向题最常见的形式:程序读取一个输入,判断对不对,对就输出 flag;不给你源码,只给你编译好的文件。
目标程序示例:
$ ./checkflag
Usage: ./checkflag <flag>
$ ./checkflag test
Wrong!
解题目标:通过分析二进制,找出正确输入。
二、环境准备
在 Linux(Kali/Ubuntu)上:
apt install file binutils gdb ltrace strace
本文以编译后的 checkflag 为例,逐步演示。
三、核心步骤 1:侦查
目的:在不运行程序的情况下,先收集"程序是什么、里面写了什么字符串"。
思路:程序类型决定用什么工具(ELF 用 objdump,PE 用其他);字符串往往直接暴露提示、密钥甚至 flag。先做最便宜的三件事,通常能直接定位简单题。
3.1 file:看文件类型
输入:
file checkflag
预期输出:
checkflag: ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped
重点看最后:not stripped 表示符号表还在,函数名(main、check)可见;如果是 stripped,函数名被删,难度更高。
目的:确认我们面对的是 ELF 还是 PE、多少位、是否被 strip。这决定后续用 objdump 还是其他工具,以及能否直接看到函数名。
3.2 strings:提取字符串
输入:
strings checkflag
预期输出:
/lib64/ld-linux-x86-64.so.2
Usage: ./checkflag <flag>
Correct!
Wrong!
三个提示字符串都出来了,但还没有 flag。注意 strings 默认只显示长度 ≥ 4 的字符串,短字符串用 -n 指定:
strings -n 3 checkflag
如果程序用 XOR 加密了 flag,strings 只能看到提示,看不到明文,需要继续分析。
目的:把文件里所有可打印字符串列出来,找"提示/密钥/硬编码密文"。
思路:输出里的 Usage: ./checkflag <flag>、Correct!、Wrong! 说明这是个接收参数的校验程序;如果看到可疑短串(如 k3y),先用 -n 3 确认——可能正是加密密钥。
3.3 运行程序观察行为
./checkflag test
输出:Wrong!。
./checkflag AAAAAAAAAAAAAAAAAAAAAAAA
输出:Wrong!。行为确认:带一个参数,对就 Correct,错就 Wrong。
目的:确认输入输出格式(参数个数、提示文字),为后续在反汇编里找"打印 Correct 的分支"做准备。
四、核心步骤 2:反汇编
目的:找到程序"判断对错"的那段代码,弄清校验逻辑。
思路:程序要么比较字符串(strcmp),要么逐字节运算(XOR 等)。反汇编里找 call(函数调用)和 je/jne(条件跳转)就能定位判断点。
4.1 objdump 看 main
输入:
objdump -d -M intel checkflag
输出很长,用 grep 只看 main:
objdump -d -M intel checkflag | grep -A 40 '<main>'
预期输出(节选):
0000000000001189 <main>:
1189: push rbp
118a: mov rbp, rsp
1191: cmp edi, 0x2
1194: jne 0x11a0
119e: call 0x1149 <check>
11a3: test eax, eax
11a5: je 0x11b5
11a7: lea rdi, [rip+0x2ea]
11ae: call puts@plt
逐行解释:
| 指令 | 含义 |
|---|---|
cmp edi, 0x2 | 比较参数个数 argc 是否为 2 |
call check | 调用校验函数 check |
test eax, eax | 检查返回值是否为 0 |
je 0x11b5 | 为 0 则跳到打印 Wrong 的地方 |
结论:逻辑在 check 函数里。
怎么想到看 check:call check 之后紧跟 test eax, eax 和 je,这是典型的"函数返回结果决定分支"结构——check 的返回值非 0 才打印 Correct。所以下一步只看 check。
4.2 看 check
objdump -d -M intel checkflag | grep -A 50 '<check>'
如果看到:
call strcmp@plt
test eax, eax
je ...
说明是字符串比较,flag 就藏在 .rodata 里。
验证方式:call strcmp@plt 前面通常有一条 lea rsi, [rip+...] 把要比较的字符串地址装进 rsi。记下那个地址,去 .rodata 里找内容即可。
4.3 看只读数据段
objdump -s -j .rodata checkflag
预期输出:
...
402000 636f6e67726174756c6174696f6e73 00 "congratulations"
ASCII 十六进制 63 6f 6e 67... 解出来就是 congratulations。
为什么 flag 会在 .rodata:字符串常量编译后存放在只读数据段 .rodata,objdump -s 直接按字节显示,右侧还附了 ASCII 明文。
4.4 用 Ghidra 反编译(更省力)
Ghidra 是 NSA 开源的免费反编译器。安装:
apt install openjdk-21-jdk unzip
wget https://github.com/NationalSecurityAgency/ghidra/releases/latest/download/ghidra_*.zip
unzip ghidra_*.zip
cd ghidra_*_PUBLIC && ./ghidraRun
操作步骤:
- File → New Project → 命名。
- 把
checkflag拖进项目,双击导入。 - 等待自动分析完成。
- 左侧 Symbol Tree 双击
main,右侧 Decompiler 显示伪代码。
预期伪代码:
if (strcmp(argv[1], "congratulations") == 0)
puts("Correct!");
else
puts("Wrong!");
flag 直接可见。
五、核心步骤 3:GDB 动态调试
静态分析看不懂时,让程序跑起来。
目的:在真实执行现场观察"比较发生时寄存器里到底装了什么"。
思路:下断点停在 check 入口 → 带参数运行 → 单步走到 call strcmp → 查看 rsi(第二个参数)指向的字符串,那就是程序期望的 flag。
gdb ./checkflag
进入 GDB 交互后:
5.1 下断点
(gdb) break check
预期输出:
Breakpoint 1 at 0x1149
5.2 带参数运行
(gdb) run test
预期输出:
Breakpoint 1, 0x0000555555555149 in check ()
程序停在 check 入口。
5.3 查看即将执行的指令
(gdb) x/20i $rip
输出 check 函数开头的 20 条汇编。
5.4 单步并观察比较
(gdb) stepi
反复执行直到看到 call strcmp@plt。然后:
(gdb) x/s $rsi
预期输出:程序正在比较的字符串,即 flag。
补充说明:x/s 的 s 表示按字符串显示;$rsi 是 x86-64 的第 2 个参数寄存器,strcmp(a, b) 的 b 就存在这里。
补充:如果嫌 GDB 麻烦,ltrace 直接跟踪库调用:
ltrace ./checkflag test
预期输出中会出现:
strcmp("test", "congratulations") = 1
flag 一目了然。
ltrace 的原理:它拦截并打印程序对共享库函数的调用,包括参数值。遇到 strcmp 类题目比 GDB 快得多,但不能用来调试自己写的代码。
六、必学工具
| 工具 | 核心功能 | 命令示例 |
|---|---|---|
| file | 看类型、位数、是否 stripped | file checkflag |
| strings | 提取字符串、指定最小长度、管道过滤 | strings checkflag、strings -n 3 a、strings a | grep flag |
| objdump | 反汇编、看数据段、Intel 语法 | objdump -d -M intel a、objdump -s -j .rodata a |
| GDB | 断点、运行、看内存/寄存器 | break check、run、x/20i $rip |
另:Ghidra 负责把汇编还原成伪代码;ltrace/strace 负责快速跟踪函数与系统调用。
七、小 CTF 实战:一个 XOR 加密的 CrackMe
题目:crackme,校验函数把输入和密钥异或后与密文比较。
第一步:侦查
目的:先找密钥与提示。
思路:strings 默认漏掉短串,所以用 -n 3;看到 k3y 猜测是 XOR 密钥,接下来去确认校验函数。
strings -n 3 crackme
预期输出中有 k3y——疑似密钥。
第二步:反编译 check
目的:确认密钥如何使用、密文数组是什么。
验证方式:伪代码 input[i] ^ key[i%3] != enc[i] 说明每一位输入与 key 循环异或后要和 enc 相等——逆向就是再异或一次。
用 Ghidra 打开,check 伪代码:
if ((input[i] ^ key[i % 3]) != enc[i]) return 0;
第三步:写脚本逆运算
目的:把"加密"反过来算,得到明文。
思路:异或的自反性 A^B=C => C^B=A。对密文数组的每一位,用 key 按位置循环异或,即还原明文。
enc = [0x08,0x47,0x1f,0x10,0x41,0x1c,0x1d,0x56,
0x0b,0x18,0x56,0x26,0x0e,0x5d,0x1e,0x02,
0x5d,0x1c,0x0e,0x41,0x10,0x05,0x54,0x04]
key = b"k3y" # 从 strings 找到的密钥
flag = bytes([c ^ key[i % 3] for i, c in enumerate(enc)])
# bytes([...]):把列表里的整数逐个转成字节,组成 bytes 对象。
# 列表推导式含义:对 enc 的第 i 个字节 c,与 key 的第 (i % 3) 个字节异或。
# i % 3 让密钥循环使用(0,1,2,0,1,2,...),与 C 源码 key[i % 3] 完全对应。
# 异或结果就是明文字节;因为 enc[i] = flag[i] ^ key[i%3],
# 所以 flag[i] = enc[i] ^ key[i%3]。
print(flag.decode())
# decode():把 bytes 按 UTF-8 解码成字符串显示,方便直接看到 flag。
输出:
ctf{reverse_engineering}
第四步:验证
./crackme ctf{reverse_engineering}
输出:Correct!。
八、小结
逆向入门三步:file/strings 侦查 → objdump/Ghidra 定位校验函数 → GDB 验证。大部分入门题要么字符串直接可见,要么只是一个异或,逆运算一行脚本即可解决。