1. 先泼一盆冷水:逆向不是“背题”,是“拆积木”
如果你刚接触 CTF,大概率在某个晚上刷到过这样的帖子:“三天入门逆向”“从汇编小白到逆向大神”,点进去一看,全是罗列指令手册、贴几道签到题 writeup,看完还是不会自己解。我当初也是这样踩过来的,所以这篇东西我不想再写一份“指令词典”,而是想把这条路真正拆开:CTF 逆向到底考什么、汇编怎么学才不是白学、拿到一个陌生二进制该按什么顺序下手、以及那些 writeup 里从来不写的坑。
先说结论:CTF 逆向的核心不是“看懂汇编”,而是“在有限时间内还原程序意图”。你看得懂每一行指令,不代表你能找到 flag;反过来,你甚至不需要完全看懂整个程序,也能靠关键特征把 flag 抠出来。这个“找关键特征”的能力,才是从入门到能独立解题的分水岭。
这篇文章适合三类人:
- 刚学完 C 语言、想转 CTF 逆向但不知道从哪下手的新手;
- 已经会看简单汇编,但一拿到真实题目就懵、不知道先干嘛的选手;
- 以及那些卡在“逆向题只能抄 writeup”状态、想真正建立自己解题套路的人。
我尽量用“拆积木”的思维来讲,每一步都告诉你“为什么这样做”,而不是只丢给你一串命令。你跟着照做,至少能把入门到中阶的逆向题啃下一大半。
2. 汇编到底学到什么程度才够用?——别死磕指令表
很多人一上来就背mov、add、lea、jmp的英文全称和机器码,背了两周还分不清eax和rax,然后心态崩了。我换个说法:汇编之于逆向,就像语法之于阅读理解——你不需要背完整本《英语语法手册》才能读英文文章,但你得知道主谓宾、定语从句大概长什么样,才能拆句子。
2.1 先掌握这三类指令,覆盖 80% 的题目
CTF 逆向里,你真正高频碰到的指令其实就三类:
第一类:数据搬运和运算类mov(赋值)、lea(取地址)、add/sub(加减)、xor(异或,加密和清零神器)、and/or、shl/shr(移位)。这些东西组合起来就是各种加密算法的雏形,比如异或加密、移位加密、加法混淆。
第二类:栈与调用约定类push/pop(压栈弹出)、call/ret(函数调用和返回)。你要理解函数调用时,参数是怎么传的:32 位程序通常用栈传参,64 位程序前几个参数用rdi、rsi、rdx、rcx传(Windows 下是rcx、rdx、r8d、r9d)。这块不懂,看函数逻辑会非常吃力。
第三类:比较与跳转类cmp/test(比较)、jz/jnz(等于/不等于跳转)、jg/jl(大于/小于跳转)、jmp(无条件跳转)。这是程序分支和循环的骨架,也是你逆向时判断“什么时候走向 flag 分支”的依据。
注意:不要上来就背 PTEST、CPUID 这些冷门指令,CTF 里偶尔出现也是考点明确,到时候现场查手册就行。你的目标是“够用”,不是“成为指令百科”。
2.2 用 C 语言反向学汇编,效率翻倍
我的建议是:不要孤立地学汇编,而是把 C 代码编译成汇编对照着看。具体操作:
# Linux 下用 gcc 生成汇编 gcc -S -o test.s test.c -O0 # 如果想同时看到源码和汇编,用 objdump gcc -o test test.c objdump -d test比如你写一个简单的循环:
int sum = 0; for (int i = 0; i < 10; i++) { sum += i; } return sum;然后看它对应的汇编,你会看到mov eax, 0(i=0)、cmp eax, 10(i<10)、add(sum+=i)这些指令在循环里是怎么组织的。对着看十来个例子,你就能建立“C 语句 ↔ 汇编指令”的映射感。这比死背指令快得多,而且理解更深。
我当时练了三个晚上,把《C 程序设计语言》里的经典小函数(strlen、strcmp、阶乘、斐波那契)全部用gcc -S过了一遍,再看 CTF 题目的汇编,瞬间觉得“这不就是 C 代码加了点混淆嘛”。
2.3 64 位和 32 位的差异,必须刻进脑子里
CTF 里 32 位(x86)和 64 位(x64)程序都很常见,因为历史原因很多题目还是 32 位的。两者关键区别:
| 项目 | 32 位 (x86) | 64 位 (x64) |
|---|---|---|
| 寄存器名 | eax, ebx, ecx, edx... | rax, rbx, rcx, rdx... |
| 参数传递 | 压栈传递 | rdi, rsi, rdx, rcx, r8, r9 |
| 指针宽度 | 4 字节 | 8 字节 |
| 常见工具 | IDA 默认 32 位反编译 | IDA 默认 64 位反编译 |
这个差异直接影响你读汇编的效率。比如你在 64 位程序里看到mov edi, offset aHello,那是在给printf传第一个参数“Hello”的地址;如果是在 32 位程序里,你通常会看到push offset aHello然后call printf。
3. 工具链怎么选?——IDA 为主,其他为辅
工具没有绝对的好坏,只有顺手不顺手。我个人推荐新人的组合是:IDA Pro(或者免费版 IDA Freeware)+ x64dbg + GDB + 一个十六进制编辑器,这套组合能覆盖 90% 的 CTF 逆向场景。
3.1 IDA:静态分析的绝对主力
IDA 是我见过最强大的反汇编工具,它最牛的地方是F5 一键反编译。能把汇编代码还原成接近 C 语言的伪代码,这对新手极其友好——你甚至可以先不看汇编,直接读伪代码理解逻辑,再回过去验证关键指令。
新手拿到 IDA 后,我建议先练三个操作:
第一步:看函数窗口(Functions Window)。打开程序后,按Shift+F12查看字符串,这是最快的找 flag 入口方式。大多数 CTF 逆向题会有一个字符串叫"flag{"或者"congratulation",双击它就能定位到引用它的代码。
第二步:看交叉引用(Xrefs)。在字符串或关键函数上按X键,可以查看谁引用了它。比如你看到"Wrong!"字符串,查看它的交叉引用,就能跳到判断失败的分支,附近一定有个判断成功分支。
第三步:F5 反编译。在关键函数上按F5,先读伪代码,再看汇编补充细节。这样省力且不容易漏掉逻辑。
3.2 动态调试:用 x64dbg / GDB 验证猜测
静态分析能解决 50% 的题,但有些题做了反调试、加壳、混淆,或者需要输入后动态反馈,这时候必须上调试器:
- Windows 下用 x64dbg:界面友好,断点、单步、内存查看都很直观。如果你解的是 Windows PE 文件(
.exe),x64dbg 是首选。 - Linux 下用 GDB:命令行调试,界面朴素但功能强大。可以用
gef或pwndbg插件增强体验,能看到寄存器高亮、堆栈变化。
调试的核心思路:在关键跳转处(jz、jnz)下断点,修改标志位(ZF)或寄存器值,强行让程序走向 flag 分支。这个方法在 CTF 里叫“patch 分支”或“爆破”,是最有效的入门技巧之一。
3.3 二进制文件分析工具:file / strings / checksec
拿到一个文件,第一步不是丢进 IDA,而是先用命令行做基础检查:
file flag_checker # 看文件类型,是 ELF 还是 PE,32 位还是 64 位 strings flag_checker # 提取可打印字符串,快速找 flag 线索 strings flag_checker | grep -i flag checksec flag_checker # 看保护措施,如 NX、PIE、Canary( pwntools 自带)如果你看到 strings 输出里直接有flag{...},那这题就是送分题。如果是base64编码的字符,那可能还需要解码。这些基础操作虽然简单,但经常能让你 5 分钟解决一道题,别看不起它们。
4. 拿到一道逆向题,按这个流程做(照搬即可)
我曾经见过很多新手拿到题目后先双击运行,输入几个字符,看到 “Wrong” 就懵了,然后打开 IDA 开始从头到尾看汇编,看到一半放弃。这是最错误的做法。一个高效流程应该是这样的:
4.1 第一步:黑盒测试,收集行为特征
在逆向之前,先在终端里运行程序(如果允许)。输入不同长度的字符串、特殊字符,观察输出规律。比如:
./challenge Enter the flag: test Wrong! ./challenge Enter the flag: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa Wrong!如果输入长度不同,程序的响应时间或行为有变化(比如长输入崩溃),那可能涉及栈溢出;如果程序打印了堆栈地址,那可能是 PIE(位置无关可执行文件)绕过题。黑盒测试能给你一堆重要的线索。
4.2 第二步:静态分析,从上到下找关键函数
打开 IDA,按Shift+F12看字符串窗口。如果有congrats、Correct、flag等字样,双击进入代码。
然后按X查看交叉引用,跳到引用该字符串的函数。通常这个函数就是验证逻辑所在。
接着按F5查看伪代码。一般来说,你会看到一个类似这样的函数:
int __cdecl main(int argc, const char **argv) { char v4[32]; printf("Enter the flag: "); scanf("%s", v4); if ( !strcmp(v4, "flag{abc123}") ) puts("Correct!"); else puts("Wrong!"); return 0; }这种就是最简单的“硬编码比较”题,直接看到 flag。但更常见的是:程序把你的输入经过一系列加密(如异或、AES、RC4),然后和某个密文比较。此时你要分析加密函数,写出逆向脚本。
4.3 第三步:动态调试,验证关键逻辑
如果静态分析看不明白某个加密函数,就在关键call前下断点,动态观察输入变成什么样了。比如:
- 输入
12345678; - 在加密函数执行后查看寄存器或内存;
- 看输入变成了什么值,再和密文对比。
这样你就能确定加密是异或、加法,还是查表替换。动态调试能帮你验证“猜”的逻辑,避免自己推导错。
4.4 第四步:编写 exp(求解脚本)
逆向的最后一步是写求解脚本,通常是 Python。简单题直接在脚本里复现加密过程,然后逆运算得到 flag。复杂题可能需要用pwntools与程序交互,或者用z3约束求解器自动解方程。
举个 z3 的例子,当程序校验逻辑是一个方程时:
from z3 import * flag = [BitVec(f'flag_{i}', 8) for i in range(12)] s = Solver() # 假设程序约束:flag[0] + flag[1] == 133 ... s.add(flag[0] + flag[1] == 200) s.add(flag[2] ^ flag[3] == 90) # ... 依次加约束 if s.check() == sat: m = s.model() result = ''.join(chr(m.eval(flag[i]).as_long()) for i in range(12)) print(result)z3 在 CTF 逆向里简直是“作弊器”,尤其是逻辑复杂、你懒得一步步逆运算的时候。建议新人尽早接触。
5. 实战拆解:从“hardcode”到“简单加密”——两道经典题
光说不练假把式,这里我拆两道最常见的题型,把完整思考过程还原出来,你可以照着跑一遍。
5.1 第一题:硬编码比较型
题目文件叫check.exe,是一个 Windows 程序。拿到后先跑一下:
input: abcd output: wrong然后用 IDA 打开,Shift+F12搜字符串,找到"right"和"wrong"。双击"right",然后按X查看交叉引用,跳转到 main 函数。按F5:
int main() { char input[64]; scanf("%63s", input); if ( strcmp(input, "s3cr3t_k3y_1s_h3r3") == 0 ) puts("right"); else puts("wrong"); }直接看到硬编码字符串s3cr3t_k3y_1s_h3r3,但是注意,CTF 题的 flag 格式通常是flag{...},所以这题的 flag 可能是flag{s3cr3t_k3y_1s_h3r3}。如果你提交s3cr3t_k3y_1s_h3r3不对,就试试套上格式。
这种题就纯粹考你是否会用工具,不涉及任何逆向思维。但别小看它,很多新手是卡在“不知道按 F5”这一步。
5.2 第二题:简单异或加密型
第二个例子稍微巧妙一点。伪代码如下:
int main() { char input[32]; char cipher[] = {0x0a, 0x0e, 0x1e, 0x39, 0x1f, 0x1d, 0x2b, 0x04}; printf("input: "); scanf("%s", input); for (int i = 0; i < 8; i++) { if ( (input[i] ^ 0x66) != cipher[i] ) { puts("wrong"); return 0; } } puts("correct"); }这里输入字符按位异或0x66后,必须等于密文字节数组。所以 flag 的计算方式是:cipher[i] ^ 0x66。Python 一行搞定:
cipher = [0x0a, 0x0e, 0x1e, 0x39, 0x1f, 0x1d, 0x2b, 0x04] flag = ''.join(chr(c ^ 0x66) for c in cipher) print(flag) # 输出形如 l3tm3in!这种题教会你一个重要思维:程序里的“校验逻辑”就是加密逻辑的反函数。你要做的是看到xor,把常量提取出来,然后反向异或得到输入。
从这两道题能看出,CTF 逆向题其实就是在“还原验证算法”和“求解输入满足条件”之间反复循环。
6. 从入门到进阶,必须翻过的那几座山
基础题刷够 30 道以后,你会进入一个“瓶颈期”。大部分题目不再能一眼看穿,你需要掌握更多进阶技巧。这些技巧不一定在每一道题里出现,但你必须知道它们长什么样。
6.1 认识“花指令”和“反调试”
有些题目会在汇编里插入无用的字节码(花指令)来干扰反汇编。比如正常的指令中间夹着EB 03这样跳到后面的指令,却插入了一些垃圾字节,导致 IDA 反汇编错乱。
面对花指令,常见解法:
- 用
nop把垃圾字节填充掉,然后按P重新定义函数; - 用 IDC 脚本 / Python 脚本批量 nop;
- 或者用
U取消误定义的代码,再手动修正。
反调试则更恶心,程序会检测调试器(如IsDebuggerPresent、ptrace),检测到就跑偏。常规应对是 patch 掉检测函数,或者在调试器里修改寄存器返回值。
6.2 应对 UPX 壳:脱壳是基本功
很多题目会加壳,最常见的是 UPX 壳。怎么判断有没有壳?用工具查一下:
upx -d 程序名 # 尝试脱壳如果提示“Not a packed UPX file”,再用 PEiD 或 DIE(Detect It Easy)查壳类型。对 CTF 而言,绝大多数壳是 UPX,upx -d一键解开;碰到其他壳就需要手动单步跟踪找 OEP(原始入口点)了。
脱壳后再用 IDA 打开,你会看到正常代码。这里有个小技巧:脱壳前先把程序中特有的字符串、输入输出函数的位置记住(比如scanf的导入地址),脱壳后直接用这个地址找到 main 逻辑。
6.3 识别常见加密算法特征
一道题目如果实现了一个自定义的“复杂变换”,其实很多时候它就是把标准加密算法简化了一下。如果你能在伪代码里认出这些特征,解题速度瞬间提升:
- 异或加密:看到
^运算符,常数 0xFF 或者某个固定值,属于简单混淆; - RC4:看到 256 字节的 S 盒、
i = (i + 1) & 0xff、j = (j + S[i]) & 0xff、交换 S[i] 和 S[j]——这几乎是 RC4 的标准骨架; - AES:看到 10 轮或 14 轮循环、S 盒查表、列混合运算,基本就是 AES;
- MT19937(梅森旋转):CTF 里常遇到“伪随机数预测”题,特征是有 624 个状态的数组,生成 32 位随机数。知道这个特征,你就能直接用网上现成的破解脚本。
我个人的经验是,第一次遇到 RC4 题时,花了一晚上才在伪代码里认出 S 盒。后来我专门建了一个“算法特征笔记”,把每个常见算法的 C 语言骨架存下来,再做题时先跟伪代码比对。强烈建议你也这么做。
6.4 应对“混淆”:OLLVM 控制流平坦化
进阶题目,尤其是国内一些比赛,喜欢用 OLLVM 的flatten选项,把正常的 if-else 逻辑变成一长串while+switch的平坦结构,让你读伪代码如同看天书。
碰到这种题,新手最容易心态崩溃。我的建议是:
- 不要尝试理解每一行,而是找状态变量(通常是
switch里的那个变量); - 追踪状态变量在每个分支的赋值,还原出原始逻辑流程图;
- 如果题量太大,考虑使用符号执行工具(如 angr)直接求解。
Angr 是解决这类题目的终极大杀器。你只需要给它起始地址、需要到达的地址,然后让它想办法找出能走到目标地址的输入。虽然运行效率不高,但很多混淆题它就是能出答案:
import angr p = angr.Project('./challenge', auto_load_libs=False) state = p.factory.entry_state() simgr = p.factory.simulation_manager(state) simgr.explore(find=0x401234, avoid=0x40124F) # 目标地址 if simgr.found: found = simgr.found[0] print(found.posix.dumps(0)) # 打印输入不过 angr 不是银弹,它在大规模输入空间下可能跑不动。所以本质上还是需要你自己会静态分析。
7. 逆向思维这样练:从“看答案”到“会出题”
很多人刷题到了 50 道左右,还是会觉得“题目一变就不会”。究其原因,是太依赖 writeup,没有建立起自己的逆向框架。这里我分享几个我自己用过、带过的新人也觉得有效的方法。
7.1 逆向一个你认识的程序
别只刷 CTF 题,拿你系统里熟悉的程序来逆向。比如 Linux 下的cat、ls,或者 Windows 下的cmd.exe(复杂了点,可以先从notepad开始),打开 IDA 分析它们的入口、字符串、调用逻辑。你会惊讶地发现,真实程序的 main 函数和 CTF 题差不多,无非是处理参数、输入输出、调用 API。
先逆向自己的程序库,再反过来去看 CTF 题目,会有“原来考题是简化版的真实程序”这种通透感。
7.2 试着自己写一个“逆向题”
这是我强烈推荐的进阶玩法。写一个简单的 C 程序,输入 flag,校验逻辑用异或、查表、甚至模仿 RC4 写上几十行,然后编译成二进制,丢给朋友解(或者过一周自己解)。你会发现,出题人视角和解题人视角非常不同:
- 出题时,你会刻意把关键字符串藏起来、把校验逻辑打散;
- 解题时,你才会意识到底哪些特征(比如常量、算法骨架)容易暴露,哪些加密方式最容易被工具自动破解。
自己出题,你才能真正理解“逆向”和“正向”之间的关系,也才能掌握最基本的反逆向思路。
7.3 建立你的“套路库”
每个逆向玩家都应该有一个笔记库,记录的是“当看到这个特征,就做那个操作”的套路。比如:
| 看到的现象 | 立刻要想到的套路 |
|---|---|
| 大量 0xFF 异或 | 计算时直接对密文逐字节异或 0xFF |
字符串里有base64字母表 | 可能存在 base64 加解密 |
出现rol/ror指令 | 可能是循环移位加密,逆向时反向循环移位 |
checksec提示 PIE 开启 | 不能直接写死地址,需要动态计算基址 |
函数开头有pushad/popad | 可能加了壳,先脱壳 |
这个库可以不断积累,慢慢你就会发现 CTF 逆向题其实就那么几板斧,核心是见招拆招。
8. 工具与脚本集锦:我常用的效率神器
除了 IDA 和调试器,下面这些工具/脚本能让你的解题效率翻倍。按使用频率排个序:
8.1 pwntools
CTF 必备的 Python 库,动态交互一把梭:
from pwn import * # 本地调试 p = process('./challenge') # 远程连接 p = remote('123.45.67.89', 10001) p.recvuntil(b'input: ') p.sendline(b'flag{test}') print(p.recvall())在逆向里,你写好了 exp 脚本,可以用它自动输入、自动收 flag,再也不用手动点窗口复制粘贴了。
8.2 angr(符号执行)
刚才说过,太复杂的混淆题可以交给它。但注意,angr 对输入长度大的题目可能会爆内存,需要你手动设置输入长度范围。它的核心概念是“符号化输入”——把输入当作符号变量,通过路径求解找出符合条件的值。
8.3 CyberChef
一个网页版的数据处理神器,支持各种编码转换:Base64、十六进制、ROT13、AES 解密、二进制转换等等。当你从程序里提取出一堆未知格式数据时,用 CyberChef 的“magic”功能能自动推测编码类型,省去大量手工尝试的时间。
8.4 010 Editor 或 HxD
十六进制编辑器。有时候你需要直接改二进制文件的字节,比如把jz(0x74)改成jnz(0x75)来 patch 跳转逻辑,这时候用十六进制编辑器最直接。
# 也可以用命令行 dd + printf 来 patch,但工具更直观8.5 FLOSS
一个自动化工具,它可以提取二进制文件中的混淆字符串(比如通过 API 动态解密出来的字符串)。遇到静态分析看不出字符串的题,运行一下:
floss challenge.exe输出里往往藏着关键线索。
9. 常见问题排雷:这些坑我替你踩过了
9.1 为什么 IDA 打开的 main 函数不是真正的入口?
很多程序用__libc_start_main封装入口,真正的主函数在main符号里。如果你找不到main,或者看到的是start函数,那就用字符串交叉引用的方式跳到关键函数,而不是死磕入口。
9.2 为什么 F5 失效,提示 “Decompilation failure”?
可能原因:程序有反编译器不支持的花指令,或者汇编代码含有非标准的控制流。处理方法:先手动分析反汇编窗口,找到异常指令,用nop填充后再按P重新建函数;如果还是不行,用动态调试单步跟过去。
9.3 为什么我的 exp 是对的,但我觉得答案是乱码?
确认字符编码。程序中的字符串可能是 UTF-8、ASCII,也可能是 UTF-16(Windows 内部字符串常是 UTF-16)。在 Python 里用encode('utf-16le')或者decode('latin1')等方式转换。
9.4 什么时候该放弃静态分析?
当你看到一个指数级的递归函数,或者一个超大的状态机,手动逆向明显会花很久。这时候要么用 angr 硬解,要么用动态调试观察中间结果。记住:CTF 是限时游戏,合理放弃也是策略。
10. 一个更贴近实战的案例:拆“baby_xor”全过程
为了让你把上面的内容串起来,我这里给一个稍微综合一点的题目作为结尾练习。题目叫baby_xor,ELF 文件,64 位。运行提示输入一串字符,输出Wrong。以下是完整思路流水账,你可以自己试一遍:
文件检查:
file baby_xor # ELF 64-bit LSB executable, dynamically linked, not stripped未去符号,说明有函数名,简单题。
strings 查找:
strings baby_xor # ... # Correct! # Wrong! # Good job!IDA 定位:找到"Correct!"的交叉引用,进入 main。F5 伪代码大意:
int main() { int v[10]; int key[] = {5, 12, 23, 7, 3, 42, 88, 15, 0, 33}; int xor_val = 0x23; printf(...); read(0, v, 40); // 读入 10 个整数? 也可能是个数组 for (int i = 0; i < 10; i++) { if ( v[i] != (key[i] ^ xor_val) ) return puts("Wrong!"); } puts("Correct!"); }发现这里不是字符比较,而是整数比较。你用 Python 计算:
key = [5, 12, 23, 7, 3, 42, 88, 15, 0, 33] xor_val = 0x23 flag = ''.join(chr(k ^ xor_val) for k in key) print(flag)这题其实很简单,但如果你第一次接触,可能会卡在“为什么是整数数组而不是字符串数组”。这说明做题时一定要留意变量的类型和大小。
11. 学习路径建议:两个月能到达什么水平?
如果你完全是从零开始,我建议按下面这个节奏来:
第 1 周:搞定 Linux 基础命令、C 语言复习,学会用gcc -S看汇编,认识常用指令。
第 2 周:学会 IDA 基本操作(打开文件、字符串窗口、F5、交叉引用),刷 10 道简单的 hardcode 和异或题。可以从 CTF 题库平台找那些reverse分类中低分值题目。
第 3-4 周:学会动态调试(x64dbg/GDB),掌握断点、单步、修改寄存器;刷 20 道涉及简单加密(异或、加解密、查表)的题目。
第 5-6 周:掌握常见算法识别,能解决 RC4、TEA、Base64 变种这类中等难度题。学会用 Python 写解密脚本,能独立解出至少 15 道模式不同的题目。
第 7-8 周:触碰进阶内容——花指令、脱壳、反调试、控制流平坦化,了解 angr 的基本使用。刷一些知名比赛的真题(比如各类全国大学生信息安全竞赛CTF的 reverse 题目)。
这是一个比较理想化的路径,实际中你的进度可能有快有慢,但底线是:在前两周内你必须完成能看懂汇编并找到 flag 的最小闭环。如果两周后你还连 F5 都没按过,那说明方向需要调整。
再强调一点:光看不练是学不会的。我见过太多人收藏了十几个教程,最后一道题都没解出来。逆向的经验完全来自实践,你要保证每周至少解 3 道新题,并且把做错的、卡住的题记录下来,复盘卡点在哪里。坚持两个月,你回头看第一篇感觉“像天书”的 writeup,会发现自己居然能看懂七成了——这就是进步。