rea-reverse

CTF 逆向入门:从 file、strings 到拿到第一个 flag

基于 REA 6.4.0更新于 约 7 分钟读完
本页目录

结论先行:大多数入门级 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 代码;知道要找什么之后,反汇编也不难读。

为什么我的地址和文中不一样?

编译器、优化级别或操作系统不同,生成的代码和布局就不同。方法不变:找到长度检查、那张表和那个运算。

逆向这些文件合法吗?

自己编译的程序、专门发布用于练习的题目,可以。遵守各平台的规则,不要把这些技术用在你无权分析的软件上。

下一步

请在自己编译的程序、专门用于练习的 CTF 题目,或经授权分析的文件上练习。运行任何下载来的文件之前,请先阅读 REA 的 DISCLAIMER.md。