简介:面向CTF逆向学习者与安全竞赛选手,这套资料收录了第24至31关逆向题目,覆盖静态分析、动态调试、汇编阅读、字符串混淆、栈帧分析及隐写取证等常见考点,并穿插抓包数据分析,适合从入门到中高级阶段练习。压缩包内共352个文件,大小约8.8MB,主体为196个txt文本(多含题目描述与解题思路)、62个dll动态库、31个udd调试数据、22个exe可执行程序,以及少量chm手册、pcap抓包文件、c源代码和idb数据库等,便于对照分析。已有2400余人学习下载,资源整体经过整理,按关卡组织,可帮助读者快速定位所需工具与脚本,结合OllyDbg、IDA等工具逆向真实样本,深入理解软件加密逻辑与隐写隐藏手法,综合提升逆向工程实战能力。
1. 几个CTF逆向题放到一起看:这个方向练的到底是什么
把几个ctf逆向题放在一起对比着刷,你会发现一件反直觉的事:逆向没有很多新手想的那么神秘,它绝大多数时候只做两件事——静态读明白程序在干什么,动态验证你读得对不对。不管是入门题里的字符串比较,还是进阶的RC4、迷宫、虚拟机壳,最后都落在同一个点上:把二进制还原成一段能解释的逻辑。这个方向很适合正在找ctf练习网站练手的入门玩家,不要求你先背整套知识体系,反而是借一道道题把汇编、内存、调用约定这些地基补扎实。下面按我做题的习惯,从环境搭建到分析流程,再到翻车现场,完整过一遍。
2. 搭起逆向题最小分析环境:工具怎么选、装到什么程度够用
2.1 按题型决定工具边界:别一上来就装全家桶
新手拿到第一道题,第一反应往往是去搜“ctf 逆向 工具合集”,然后一口气装上十几个。真做题的时候会发现,常用到的就四类,多出来的都是干扰。第一类是文件识别与查壳,file和 Detect It Easy(DIE)就够了;第二类是静态分析,IDA 或 Ghidra 二选一;第三类是动态调试,Linux 下就是 gdb 加 pwntools;第四类是辅助计算,Python3 带上 z3-solver,配合 CyberChef、随波逐流CTF编码工具这类做编码转换,大部分题到这里就闭环了。
选 IDA 还是 Ghidra,我的建议是:有条件用 IDA Pro,反编译伪代码的准确度确实高,尤其在面对 OLLVM 混淆时 IDA 的抗压能力明显更强;没有授权就用 Ghidra,开源免费,Java 写插件方便,而且 NSA 团队维护的 x86 反编译引擎质量不差。radare2 和 rizin 这类命令行工具我一般只在服务器环境里用,或者写自动化脚本批量分析时才会碰,交互体验对新手上手是劝退级的。
还有一类工具是专门做安卓逆向的,后面算法题里如果遇到 native 层,常见做法是把 APK 里的.so拖出来继续用 IDA 分析,Java 层则交给 jadx。Frida 在这类题里是动态插桩的头号选择,等到了验证环节我会专门讲它。初期别急着碰,先把 ELF 题吃透,再扩展不迟。
2.2 用一段脚本把 Linux 分析环境装好
多数入门 CTF 逆向题都是 Linux ELF 文件,所以本地环境最稳妥的还是 Ubuntu 或 Kali。我一般会用一个脚本把基础分析链一次性装齐,避免做题做到一半发现缺工具:
# Ubuntu/Debian 系,Kali 同样适用 sudo apt update && sudo apt install -y \ gdb binutils file python3-pip \ unzip curl openjdk-17-jdk # 用 pip 装 pwntools 和 z3,注意装到 --user 避免权限问题 pip3 install --user pwntools z3-solver # Ghidra 需要解压即用,放到常见位置方便后续调用 cd /opt && curl -L -o ghidra.zip \ https://github.com/NationalSecurityAgency/ghidra/releases/latest/download/ghidra_11.0_PUBLIC_20240625.zip unzip ghidra.zip这段脚本做的事情很直接:gdb、binutils(里面带objdump、nm、readelf)、file是分析地基,python3-pip用来装逆向题的“计算器”,openjdk-17-jdk是 Ghidra 的运行前提。装好之后把 Ghidra 解压到/opt,启动脚本在ghidra_11.0_PUBLIC/bin/analyzeHeadless或ghidraRun,具体版本号不用刻意记,能跑起来就行。
注意pip3 install --user这个参数。很多新手不加--user,在系统 Python 环境里直接装,要么权限报错,要么把系统包搞乱。加上--user后装在当前用户目录,任何时候都不会污染系统 Python。z3-solver 在后面的约束求解环节几乎是必需品,建议装了之后立刻验证一下import z3是否成功。
2.3 文件识别:做题的第一步不是拖进 IDA
很多新手拿到题就把文件往 IDA 里一拖,然后面对一堆地址发愣。正确顺序是先做文件识别,花两分钟拿到三个关键信息:架构、链接方式、有无加壳。一条file命令就能解决大半:
file chall # 常见输出示例: # chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, stripped readelf -h chall | grep Type # EXEC 表示静态基地址,DYN 表示 PIE,后者在调试时要先算加载基址file输出的stripped意味着符号表被删了,main不能直接看到,后面定位入口要多花一点功夫。如果文件显示packed、UPX之类的字眼,说明加壳了,先脱壳再分析,硬着头皮拖进 IDA 只会看到压缩数据。
做完这两条命令,再丢进 DIE 看一眼具体是什么壳,UPX、VMP、Themida 的应对方式完全不同。这一步节省的时间远超两分钟。字符串搜索也建议在这时候顺手做掉,strings带-n控制最短长度,很多签到题的 flag 直接裸露在字符串里:
strings -n 6 chall | grep -iE 'flag|ctf|key|{'这里-n 6表示只打印长度不小于 6 的连续字符串,能过滤掉大量汇编指令碎片。如果strings里有看起来像 flag 的完整字符串,这题大概率就是送你分的;如果什么都没有,再进 IDA 不迟。
3. 从 main 函数开始:一道典型 C 逆向题的完整分析流程
3.1 用文本定位法和符号映射缩小入口区
拿到一个stripped的 ELF,第一步是找main。常规做法是先在strings输出里找提示文本,比如usage:、correct、wrong这类用户交互字符串,然后到 IDA/Ghidra 里搜索这个字符串,看交叉引用(xref)从哪个函数过来,那个函数基本就是main或者接近main的调度函数。
如果题目连字符串都刻意隐藏了,还有一招是从入口点顺藤摸瓜。readelf -h能看到程序入口地址,从入口_start往下,标准 glibc 启动流程会依次调用__libc_start_main,传给它的第一个参数就是main的地址。在 Ghidra 里自动识别不了的时候,我会手动看入口附近的前几十条指令,找lea rdi, [rip + xxx]配合call __libc_start_main的那条lea,目标地址就是main。
给一段参考流程,用 readelf 拿到入口后用 objdump 反汇编入口区:
readelf -h chall | grep Entry # Entry point address: 0x401050 objdump -d chall | grep -A 30 '<_start>:' # 在 _start 的指令序列里找 lea + call 的组合lea rdi, [rip + 0x...]加载的地址就是main或__libc_start_main的参数。如果是 PIE 程序,_start里的地址是相对寻址,需要结合运行时基址换算,逻辑一样。这个时序在 glibc 2.34 之前是稳定的,新版本里__libc_start_main少了部分参数,但main的地址仍然会经过某个寄存器传过去,耐心看几条汇编就能锁定。
3.2 静态分析主逻辑:不读伪代码,读数据流
定位到main之后,按 F5 生成伪代码,但别急着逐行读。我习惯先扫一遍伪代码里的全局数据引用,比如byte_4040C0、unk_402000这类地址,再看这些数据被怎么使用——是当作索引表、S 盒还是加密 key。CTF 逆向题里,算法藏得再深,数据流是藏不住的。举一个典型题的结构,代码大致长这样:
#include <stdio.h> #include <string.h> int check(char *input) { char *key = "Just_a_s3cret"; size_t len = strlen(key); for (size_t i = 0; input[i]; i++) { if ((input[i] ^ key[i % len]) != 0x54) { return 0; } } return 1; } int main(int argc, char **argv) { if (argc < 2) { puts("usage: ./chall <serial>"); return -1; } if (check(argv[1])) { puts("correct!"); } else { puts("try again."); } return 0; }在 IDA 的伪代码视图里,check函数会被还原成非常接近原样的循环:一个XOR运算、一个取模索引、一个立即数比较。看到^运算就要敏感,它是最常见的加密原语。如果还有((x << 3) | (x >> 5))这类位移拼接,那就是循环移位,常见于 TEA 系列加密。不要挣扎着逐行读,直接看三个要点:数据来源、运算类型、比较对象。
这段伪代码里,key是"Just_a_s3cret",比较目标是0x54,循环按i % len取 key 字符。还原逻辑非常简单:input[i] ^ key[i % len] == 0x54,所以input[i] = 0x54 ^ key[i % len]。异或运算自逆,拿到 key 和比较值,flag 直接手算都行。实际做题时建议写脚本算,避免人工出错。
3.3 动态调试验证:断点打在比较处
静态分析做完了,别急着写脚本拿 flag,先用动态调试验证一下你的猜测。很多时候静态代码被反编译器美化过,真正跑的指令和伪代码有出入,尤其遇到编译器优化时。我在 gdb 里的验证流程是:在关键比较指令处下断点,打印参与比较的两个操作数,看它们是不是你从伪代码里推测的那两个值。
gdb -q ./chall # 启动后先设置参数,否则 argc<2 直接退出 run abcdef # 在 0x4011a6 下断点,这个地址在反汇编里对应 XOR 比较处 break *0x4011a6 continue # 打印输入字符和 key 字符 x/s $rdi x/s $raxrun abcdef是给程序传入测试参数,如果程序用scanf读输入,那就在run之后手动输入。break *0x4011a6是给具体地址下断点,地址怎么确定?切到反汇编视图,找到cmp或test指令对应的行号,复制地址。x/s是把寄存器指向的内存按字符串打印,$rdi和$rax的来源要看当时的调用约定,x86-64 下前几个参数依次是rdi、rsi、rdx。
确认断点处两个操作数和你的预期一致,说明静态分析基本没跑偏。如果对不上,优先怀疑 key 的索引方式,比如是不是用了strlen(key),还是直接用固定长度,这两者的取模结果完全不同。另外,掉进一个常见误区:在call strcmp的返回后下断点,看到返回值不等于 0 就以为逻辑不对,实际上你要看的是 strcmp 的两个输入参数,不是返回值。断点位置下在调用前,批量打印参数,比单步调试高效得多。
4. 把已知结果逆回去:算法识别与约束求解实战
4.1 一眼认出常见加密特征:从常数表和结构入手
CTF 逆向题里的算法,翻来覆去就那几十种。识别速度决定了你做题的速度,而识别的抓手从来不是读代码,是认常数表。RC4 的 S 盒初始化代码里会出现连续 256 字节的置换循环;TEA 系列加密会有0x9e3779b9这个黄金分割常数;AES 的 S 盒是一个固定的 256 字节表,0x63, 0x7c, 0x77开头;Base64 变种则是一眼能看出的可打印字符索引表。
把这些特征记牢,做题时用 Ghidra 的搜索功能直接查立即数,比如搜0x9e3779b9,搜到基本就是 TEA,省下大半读代码时间。再用一张表把常见特征列出来,方便对照:
| 算法类型 | 特征常数 / 结构 | 典型识别方式 |
|---|---|---|
| RC4 | 256 字节 S 盒初始化,两轮置换 | 找 256 次循环的swap模式 |
| TEA / XTEA | 常数0x9e3779b9,每轮两次位移 | 搜立即数0x9e3779b9 |
| AES | 固定 S 盒0x63,0x7c,0x77开头 | 搜 S 盒前 8 字节 |
| Base64 | 索引表A-Za-z0-9+/或变体 | strings 直接可见 |
| XXTEA | 常数0x9E3779B9,但结构更复杂,循环多 | 结合循环层数判断 |
还有一类是自定义算法,出题人自己拼的变换,没有现成常数可查。遇到这种题,我的做法是先把每个函数做的事用一句话标注释,比如“把输入按 3 字节一组替换成 4 字节”,然后再拼整体的数据流。把算法“读出名字”是第一步,读出名字之后,逆向就变成了“翻译”——把加密过程的每一步写成反向脚本。
4.2 用 Z3 解线性约束:把代码逻辑变成方程
当加密过程不是简单的逐字节变换,而是牵涉到多轮循环、下标错位、多字节混合运算时,手推逆算法会算到怀疑人生。Z3 在这时候就是后悔药:你不需要手写逆运算,只需要把加密过程的正向逻辑抄下来,把未知输入声明成符号变量,然后让求解器把满足条件的输入找出来。
from z3 import * # 假设输入是 32 字节,先声明成 8 位位向量 flag = [BitVec(f"f{i}", 8) for i in range(32)] s = Solver() # 约束 1:每个字符都是可打印 ASCII for ch in flag: s.add(ch >= 0x20, ch <= 0x7e) # 约束 2:根据 IDA 伪代码抄回来的比较条件 # 伪代码里看到:if (enc[i] != ((flag[i] ^ 0x33) + i)) return 0 # enc 是二进制里提取出的已知数组 enc = [0x7f, 0x6a, 0x6b, 0x74, 0x53, 0x5c, 0x51, 0x5a, 0x51, 0x5e, 0x5d, 0x42, 0x40, 0x47, 0x56, 0x5c, 0x54, 0x4d, 0x52, 0x54, 0x45, 0x46, 0x42, 0x39, 0x5b, 0x36, 0x21, 0x1f, 0x0d, 0x0b, 0x17, 0x53] for i in range(32): s.add(flag[i] ^ 0x33 + i == enc[i]) # 求解并打印 if s.check() == sat: m = s.model() ans = bytes([m.eval(flag[i]).as_long() for i in range(32)]) print(ans) else: print("unsat: 约束条件写错了,回查伪代码")这里的BitVec(f"f{i}", 8)声明了一个 8 位宽的符号变量,名字随便取,关键是位宽必须要和程序里实际用的类型一致——程序里是char就用 8 位,是int就用 32 位,位宽不匹配求解结果一定错。s.add就是把伪代码里的判断条件原样翻译成约束,注意XOR运算在 Z3 里就是^,但 Python 里位运算符优先级低于+,所以flag[i] ^ 0x33 + i要搞清楚要不要加括号。上面这段示例为了展示两种经典 bug:运算优先级和位宽选择,实际写的时候建议严格按照伪代码的操作顺序逐行加括号。
4.3 流程还原:加密可以正向写,解密要逆着想
用 Z3 是一种思路,但很多题根本没必要上求解器,逆运算手写更快。核心是要记住各类运算的逆操作:异或的逆还是异或,加法的逆是减法,循环左移的逆是循环右移同一位数,乘法模运算的逆是模逆元。举一个常见模式:
// 加密 for (int i = 0; i < n; i++) { t = input[i]; t = (t << 3) | (t >> 5); // 循环左移 3 位 t ^= key[i % 8]; enc[i] = t; }对应解密脚本,就是反着来:
key = b"key12345" enc = bytes.fromhex("...") # 从二进制里提取的密文 n = len(enc) ans = bytearray() for i in range(n): t = enc[i] t ^= key[i % 8] # 循环左移 3 位的逆运算是循环右移 3 位 t = ((t >> 3) | (t << 5)) & 0xff ans.append(t) print(bytes(ans))注意& 0xff必须要有。Python 里的整数无限长,左移出来的高位如果不用掩码截断,会带着一堆多余比特,算出来的结果和 C 语言里的char截断行为不一致。这是我见过新手翻车最多的地方,网上抄的脚本跑不出正确答案,十个里有八个是这个原因。
另外,如果程序用的是unsigned char,C 语言右移是逻辑右移,高位补 0;如果用了signed char,右移是算术右移,高位补符号位。两种写的还原脚本完全不同。做题时第一件事就是确认变量类型,很多只差一个 unsigned 的题能把人折腾一小时。我一般会先在 IDA 里看伪代码声明的类型,再用调试器打印一两个中间值验证。
5. 逆向题避坑:新手最容易翻车的 5 个瞬间
5.1 花指令让你看到的伪代码和实际执行的不是一套
现象:F5 生成的伪代码里,某个分支逻辑明显很怪,甚至出现“跑不到正确分支”的死代码,可你动态调试却发现程序完全正常。
原因:出题人插了花指令(junk code),也就是一堆永不可达的垃圾字节,反编译器强行把它们解析成有效指令,导致控制流被“带偏”。最典型的是在正常代码中间插EB 01 FF这种结构,EB是短跳转,跳过FF这个垃圾字节。
解决:遇到伪代码和实际行为严重不符时,放弃 F5,切到汇编视图从函数头开始单步跟。看到可疑的垃圾字节,把它 patch 成90(nop)再重新反编译。Ghidra 里有自动分析选项能过滤一部分花指令,但对付手写的花指令还是得靠人眼。这个坑的价值在于:伪代码只是参考,永远以实际执行为准。
5.2 UPX 脱壳后程序跑不起来
现象:用upx -d脱壳后,运行报段错误,或者提示 “Segmentation fault”,甚至直接说找不到入口。
原因:UPX 脱壳时对区段的还原不完全,尤其是出题人用 UPX 的定制参数压过,或者原始文件的节区权限和 UPX 重建后的不一致。upx -d只适合标准壳,碰到魔改壳就失灵。
解决:手动脱壳。调试器里运行程序,在pushad之后找popad加jmp的跳转,那个跳转目标就是原始入口点(OEP)。跳到 OEP 后停下,用调试器的 dump 插件把内存镜像出来,再用导入表修复工具处理。这个流程对新手来说略重,所以也可以换个思路:用 OllyDbg 运行时有自动脱壳插件,或者干脆用gdb跳过壳代码直接下断点,只分析解压后的内存,不落盘,省掉修复导入表的步骤。
5.3 把调试器的报错当成了程序行为
现象:在 gdb 里run之后程序直接 SIGSEGV,你以为是题目坏了,换一台机器还是崩,于是卡住。
原因:程序自带反调试,常见的做法是在main之前调用ptrace(PTRACE_TRACEME, 0, 0, 0),如果当前进程已经被调试器 trace,这个调用会失败,程序直接退出或者走错误分支。gdb 默认会先 trace 子进程,所以撞了个正着。
解决:先看崩溃地址是不是在ptrace调用附近,再断到ptrace的调用点,直接return -1强制让它失败通过。更简单的做法是用LD_PRELOAD劫持 ptrace,写一个小 so 让它永远返回 0。这个坑告诉我们:程序崩溃先看汇编在哪崩,别急着否定工具。
5.4 环境检查制造假 flag
现象:strings里有一个完全格式正确的 flag 字符串,你输进去却提示错误,甚至程序直接闪退。
原因:出题人放了诱饵字符串,或者在比较之前先检查argv[0]、系统时间、环境变量、当前目录。比如程序要求必须以./chall这个名字运行,你用绝对路径跑,它就走到另一个分支,flag 藏在分支里但永远不会输出给你。
解决:用strace看程序启动后的系统调用序列,execve、openat、getenv这些调用会把程序在检查什么都漏出来。也可以用 gdb 在main入口处set args模拟并观察环境变量。遇到闪退,优先怀疑是环境问题,不是逻辑问题,把环境对齐再继续。
5.5 强符号覆盖弱符号导致静态分析指向错误
现象:IDA 显示funcA被调用,而且你把这个函数读透了,结果它的返回值从来不是程序实际用的那个;运行结果和静态分析完全对不上,翻来覆去也不知道哪里读错了。
原因:多个目标文件里定义了同名函数,链接器按强符号优先的原则选了一个。反编译器展示的符号名可能来自残留的符号信息,指向的是弱符号那个,而程序实际链接的是强符号那个。CTF 题里故意塞同名函数的做法不常见,但遇到多文件编译的题就有可能出现。
解决:在 IDA 里看函数地址,再在nm -S或objdump -t里查这个地址对应的实际符号。如果两个符号名一样但地址不同,说明你的目光锁定了错误的定义。这个坑的普适教训是:静态分析里看到的名字都可以骗人,只有地址不会,一切以地址为准。
6. 验证还原结果的一种可靠方法:用动态插桩对比中间值
静态分析还原完算法,脚本也跑出了疑似 flag,写 exp 提交之前。我习惯最后再做一道保险:把脚本算出来的 flag 填进程序里跑一次,确认程序真的提示 correct。但有些题不会给你这么直接的反馈,它只是把 flag 写进某个文件或者作为返回值输出,这时候就需要在更底层的层面做验证。
我的首选工具是 gdb 批处理脚本。在关键比较处打断点,把断点处的操作数打印出来,和脚本算出的中间值逐一对比,能确认程序执行路径跟我们还原的逻辑是一致的:
cat > verify.gdb << 'EOF' set pagination off break *0x4011a6 commands silent printf "rax=%02x rdi=%02x\n", $rax, *(unsigned char*)$rdi continue end run EOF gdb -q ./chall -x verify.gdb这个脚本做的事情是:每次命中0x4011a6地址,打印rax寄存器值和rdi指向的字节。$rax在 x86-64 里通常保存返回值或者当前比较的右操作数,*(unsigned char*)$rdi是取地址再取值,相当于读取内存里那个字符。如果打印出来的值和你脚本里对应位置的中间值一致,说明还原的算法没有理解偏。
遇到没有原生符号的 native 层或者安卓逆向题,gdb 就不太好使了,这时候换 Frida。它的思路是运行时注入,直接 hook 掉关键函数,在参数和返回值上打印信息:
import frida, sys js = """ Interceptor.attach(Module.findExportByName(null, "strcmp"), { onEnter(args) { send(Memory.readUtf8String(args[0])); send(Memory.readUtf8String(args[1])); } }); """ def on_message(msg, data): print(msg["payload"]) device = frida.get_local_device() session = device.attach("chall") script = session.create_script(js) script.on("message", on_message) script.load() sys.stdin.read()Module.findExportByName(null, "strcmp")是在进程里查找strcmp这个导出函数的地址,Interceptor.attach给这个函数挂上钩子,onEnter在函数被调用前执行,args[0]和args[1]就是 strcmp 的两个参数。对 ELF 程序跑 Frida 比 gdb 更接近黑盒视角,不改二进制就能看到函数层面的交互。
这类验证习惯也适用于 traceme.exe 这类入门题——程序本身不输出 flag,只响应你的输入是否正确。动态插桩能让你确认程序内部在比较什么,而不是瞎猜。我自己的习惯是:任何时候脚本跑不出预期结果,先别急着怀疑算法,先插桩打印中间值,最多十分钟就能定位是算法还原错了,还是参数类型弄错了。解题慢不丢人,来回瞎试才是真浪费时间,希望帮到你。
本文还有配套的精品资源,点击获取