CTF 逆向入门:从 file、strings 到拿到第一个 flag
本页目录
结论先行:大多数入门级 CTF 逆向题,都能按同样的四步、同样的顺序解决。第一步识别文件:它是 ELF、PE、Mach-O、Python 字节码、APK 还是 WebAssembly?第二步跑 strings,简单的题有时会把 flag 明文留在里面。第三步看导入函数和节区,猜输入是怎么被校验的。第四步在反汇编器或反编译器里读懂校验代码,写一个小脚本把它反过来算。本文用两个你自己编译的练习程序把四步走一遍:第一个用 strings 就能解,第二个不行,正好说明原因。
适合谁读:刚接触 CTF、打开一道 “rev” 题看到一个二进制文件却不知道从哪下手的人。你需要一个终端,大致知道 C 程序是什么就够了。不要求会汇编,需要的那一点会在过程中讲到。
读完你能学到:
- 怎么识别文件类型,并选对工具
strings能找到什么、找不到什么,以及为什么- 导入函数和节区大小怎样透露校验逻辑
- 怎么读懂一个短小的校验循环,把它变成解题脚本
- 怎么在原程序上证明你的 flag 是对的
入门题从哪里来
有专门为练习而建的平台,可以合法地练。卡内基梅隆大学的 picoCTF 把它的 picoGym 定位为非竞赛性质的练习场,收录往届比赛的题目。截至 2026-10-14,picoctf.org 首页写着 “picoCTF.org is now CyLab Security Academy”,所以你访问时品牌名称可能已经变了。CTFlearn 自称 “the most beginner-friendly way to learn cyber security”。crackmes.one 的自我介绍是 “a simple place where you can download crackmes to improve your reverse engineering skills”,上面的文件由用户上传,只应在虚拟机里运行。
本文不用这些平台的题,而是自己出题。这样每一步都能对照源码核实,文件能怎么用也没有任何疑问。
编译练习程序
保存下面两个文件。level2.c 先别细看,弄懂它正是练习本身。
// level1.c: the flag is compared as a plain string.
#include <stdio.h>
#include <string.h>
int main(void) {
char buf[64];
printf("Enter the flag: ");
if (!fgets(buf, sizeof buf, stdin)) return 1;
buf[strcspn(buf, "\n")] = '\0';
if (strcmp(buf, "practice{strings_finds_plain_text}") == 0) {
puts("Correct!");
return 0;
}
puts("Nope.");
return 1;
}
// level2.c: the flag is stored XOR-encoded, so strings cannot see it.
#include <stdio.h>
#include <string.h>
static const unsigned char enc[] = {
0x1a, 0x18, 0x0b, 0x09, 0x1e, 0x03, 0x09, 0x0f, 0x11, 0x12, 0x05, 0x18,
0x35, 0x02, 0x03, 0x0e, 0x0f, 0x19, 0x35, 0x1e, 0x02, 0x0f, 0x35, 0x08,
0x13, 0x1e, 0x0f, 0x19, 0x17
};
#define KEY 0x6a
static int check(const char *s) {
if (strlen(s) != sizeof enc) return 0;
for (size_t i = 0; i < sizeof enc; i++)
if ((unsigned char)(s[i] ^ KEY) != enc[i]) return 0;
return 1;
}
int main(void) {
char buf[64];
printf("Enter the flag: ");
if (!fgets(buf, sizeof buf, stdin)) return 1;
buf[strcspn(buf, "\n")] = '\0';
puts(check(buf) ? "Correct!" : "Nope.");
return 0;
}
clang -O2 -o level1 level1.c # or: gcc -O2 -o level1 level1.c on Linux
clang -O2 -o level2 level2.c
下面的一切都是我在 Apple Silicon Mac 上用 Apple clang 21 编译、运行的,所以文件是 Mach-O arm64。在 Linux 上用 gcc 编译会得到 ELF x86-64,步骤完全一样,但地址、寄存器名和部分工具输出会不同。
第 1 步:识别文件
file level1 level2
level1: Mach-O 64-bit executable arm64
level2: Mach-O 64-bit executable arm64
文件类型决定下一步用什么工具,所以哪怕扩展名看起来很明显,也别跳过这一步。CTF 附件经常没有扩展名,或者扩展名是误导。真正说了算的是开头几个字节:7F 45 4C 46 是 ELF,MZ 是 Windows PE,CF FA ED FE 是 64 位 Mach-O。下面是 file 5.41 对我手头另外几个文件的输出:
file 的输出 |
实际是什么 | 下一步去哪 |
|---|---|---|
PE32+ executable (DLL) (GUI) x86-64, for MS Windows、Mach-O 64-bit executable arm64(ELF 的输出以 ELF 开头) |
原生代码 | 本文,然后上 Ghidra 或 REA |
PE32 executable (DLL) (console) Intel 80386 Mono/.Net assembly(nuget.org 上的 Newtonsoft.Json) |
.NET | .NET 反编译器;先用 .NET 查看器看一眼 |
Zip archive data, …(一个测试用 APK) |
APK、JAR 或任意 ZIP | 看里面有没有 AndroidManifest.xml,参见 Android APK 分析 |
WebAssembly (wasm) binary module version 0x1 (MVP) |
WASM | WASM 转 WAT |
data(一个 Python 3.14 的 .pyc) |
这个版本的 file 不认识 |
自己看魔数;.pyc 要用认识对应 Python 版本的反编译器 |
最后一行说明:data 只代表 file 没认出格式,并不代表文件是随机数据。
不想装任何东西的话,把文件拖进本站的原生二进制概览,它在浏览器里运行,不上传文件。对 level2,它报告格式 Mach-O 64-bit,架构 arm64,文件类型 MH_EXECUTE,依赖一个库 /usr/lib/libSystem.B.dylib。
第 2 步:试试 strings
strings level1
Enter the flag:
practice{strings_finds_plain_text}
Correct!
Nope.
第一个 flag 到手。strings 会打印文件里所有连续的可打印字符(默认至少 4 个),而用来比较的字符串就放在只读字符串节里(这里是 __TEXT,__cstring,Linux 上是 .rodata)。浏览器工具的字符串页也在 __TEXT,__cstring、文件偏移 1511 处找到了同一个 flag;它默认最短长度是 6,所以不显示 Nope.,页面上可以改。真实比赛里一般会过滤一下:strings level1 | grep -i 'flag\|ctf\|{'。
再看第二个:
strings level2
+ih8Lih8
Enter the flag:
Correct!
Nope.
没有 flag。+ih8Lih8 是噪声,是机器码里恰好可打印的几个字节(浏览器工具把它归在 __TEXT,__text)。这才是常态。 出题人都知道 strings,所以会把 flag 编码、计算出来或者拆开存。但 strings 依然值得花这五秒:它失败了,就说明该去读代码了;而它显示出来的字符串(Correct!、Nope.)正好把你引向关键代码。
第 3 步:看导入函数和节区
导入表告诉你程序调用了哪些库函数:
nm -u level2
___stack_chk_fail
___stack_chk_guard
___stdinp
_fgets
_printf
_puts
_strcspn
_strlen
fgets 读入你的输入,strlen 暗示会检查长度。没有 strcmp 或 memcmp,说明比较是在某处手写的。这是线索,不是证据:level1 的源码明明调用了 strcmp,导入表里却也没有它,因为 clang 在 -O2 下把这个调用换成了内联的 8 字节比较。导入函数只能当提示看。
节区往往更说明问题。浏览器工具列出了 level2 的六个节,其中一个很显眼:
| 节 | 地址 | 大小 |
|---|---|---|
__TEXT,__cstring |
0x1000005f0 |
34 字节 |
__TEXT,__const |
0x100000612 |
29 字节 |
level1 没有 __const 节,level2 却多了一块 29 字节、又不是文本的常量数据。一张小常量表加一个长度检查,是编码 flag 的典型特征。看看原始字节:
xxd -s 0x612 -l 29 level2
00000612: 1a18 0b09 1e03 090f 1112 0518 3502 030e ............5...
00000622: 0f19 351e 020f 3508 131e 0f19 17 ..5...5......
第 4 步:读懂校验,然后解题
现在要找使用这些字节的代码。常用的是免费的 Ghidra:打开二进制,让它自动分析,找到 main,读反编译出来的循环。用 REA 和 Ghidra 免费分析原生二进制讲了怎么让 AI agent 通过 REA 驱动 Ghidra。我写这篇文章的机器上没装 Ghidra,所以下面是 macOS otool -tV level2 的反汇编,只截取校验部分(Linux 上用 objdump -d 效果一样):
0000000100000514 bl 0x1000005e4 ; symbol stub for: _strlen
0000000100000518 cmp x0, #0x1d
000000010000051c b.ne 0x100000564
0000000100000520 mov x8, #0x0
0000000100000524 add x9, sp, #0x8
0000000100000528 adrp x10, 0 ; 0x100000000
000000010000052c add x10, x10, #0x612
0000000100000530 ldrb w11, [x9, x8]
0000000100000534 ldrb w12, [x10, x8]
0000000100000538 eor w11, w12, w11
000000010000053c and w11, w11, #0xff
0000000100000540 cmp w11, #0x6a
0000000100000544 ccmp x8, #0x1c, #0x4, eq
0000000100000548 add x8, x8, #0x1
000000010000054c b.ne 0x100000530
不需要认识每条 ARM64 指令也能读懂。strlen 把输入长度放进 x0,cmp x0, #0x1d 要求长度为 29,否则跳到 0x100000564,那里打印 Nope.。x10 被设为 0x100000612,也就是那张 29 字节的表;x8 是下标 i。每一轮读入你的一个字符和表里的一个字节(ldrb),做异或(eor),要求结果等于 0x6a,然后 i 加一,一直循环到下标 28(0x1c)。编译器把源码里的 (s[i] ^ KEY) == enc[i] 改写成了 (s[i] ^ enc[i]) == KEY,条件是等价的。异或可以自己抵消,所以每个 flag 字符就是 table[i] ^ 0x6a:
import sys
TABLE_OFFSET = 0x612 # file offset of the encoded bytes (vmaddr 0x100000612 - 0x100000000)
LENGTH = 0x1d # cmp x0, #0x1d -> the flag is 29 bytes
KEY = 0x6a # cmp w11, #0x6a after eor
data = open(sys.argv[1], "rb").read()
enc = data[TABLE_OFFSET:TABLE_OFFSET + LENGTH]
print(bytes(b ^ KEY for b in enc).decode())
python3 solve.py level2
practice{xor_hides_the_bytes}
换成 ELF 文件,表的偏移会不一样:从节区表里取,或者用反汇编里的地址减去所在段的加载地址。
第 5 步:在原程序上验证
算出来的 flag,要原程序认了才算数。用管道喂进去:
python3 solve.py level2 | ./level2
Enter the flag: Correct!
如果想留一份以后能拿出来看的记录,可以用 REA(morluto 的开源项目),它能在伪终端里运行程序并记录输出。rea capture-process 在 macOS 上可以正常使用。我的场景文件:
{
"executable": "/absolute/path/to/level2",
"working_directory": ".",
"events": [
{ "type": "input", "at_ms": 250, "data": "practice{xor_hides_the_bytes}\r" }
],
"timeout_ms": 5000
}
rea capture-process ./scenario-pass.json --json > capture-pass.json
记录的最后一帧是 Enter the flag: practice{xor_hides_the_bytes} / Correct!,退出码 0。接着我做了一次反向对照,把最后一个字母改掉(bytez),输出变成 Nope.。这里对照很重要:level2 无论对错都以 0 退出,只有打印的文字能区分结果。每次记录都有自己的 Evidence ID。用 AI agent 解 CTF 逆向题在此基础上加入了 REA 的静态分析工具。
新手常犯的错误
- 在主力电脑上直接运行来路不明的题目文件:自己编译的没问题;下载来的 crackme 请放到不含个人数据的虚拟机里。
- 跑完
strings就停下:结果为空也是信息,不是死路。 - 相信一个从没测试过的 flag:表偏移差一位、密钥搞错,都会算出长得像 flag 的乱码。一定要在原程序上跑一遍。
- 逐行死磕反汇编:先找关键字符串(
Correct!),顺着引用找到代码,只读比较附近的那个循环。
常见问题
要先学汇编吗?
不用,边做边学。大多数入门题的校验就是一个长度判断加一个带一次运算的循环,和这里一样。Ghidra 这类反编译器会把它还原成类 C 代码;知道要找什么之后,反汇编也不难读。
为什么我的地址和文中不一样?
编译器、优化级别或操作系统不同,生成的代码和布局就不同。方法不变:找到长度检查、那张表和那个运算。
逆向这些文件合法吗?
自己编译的程序、专门发布用于练习的题目,可以。遵守各平台的规则,不要把这些技术用在你无权分析的软件上。
下一步
- 用 REA 和 AI agent 解 CTF 逆向题:同样的流程,由 agent 来读代码,每个结论都有 Evidence ID 支撑。
- 用 REA 和 Ghidra 免费分析原生二进制:把本文跳过的反编译这一步搭起来。
- ELF / PE / Mach-O 在线查看:在浏览器里读节区、符号和字符串的更多细节。
- WASM 转 WAT 查看 WebAssembly:
file说是 WebAssembly 时看这篇。
请在自己编译的程序、专门用于练习的 CTF 题目,或经授权分析的文件上练习。运行任何下载来的文件之前,请先阅读 REA 的 DISCLAIMER.md。