CTF红包题逆向实战:魔改RC4识别与反调试绕过全解析
每年论坛周年庆前后吾爱那边的技术板块都会热闹一阵子——红包题来了。这个活动说穿了就是官方出几道CTF风格的逆向/算法小题目做出来就能领红包。题目难度卡在入门到进阶之间但每年都有不少人卡在门槛外看着红包拿不到。今年我抽空把题完整刷了一遍其中有几道的思路和解法挺有代表性这篇先写一部分WP重点拆两道题一道纯算法逆向一道反调试加自修改代码。两道题的解题链路和工具取舍我会尽量写细顺带把踩过的坑也交代清楚给准备刷题的朋友一个可复用的参考。1. 红包题的整体构成与我的备战思路1.1 红包题到底是什么级别的题目红包题本质上属于福利性质的定向CTF出题人不会刻意刁难人但也不会让你靠百度搜答案过关。从历年的情况看题型基本集中在Windows PE逆向、Linux ELF逆向、Android逆向、密码学算法还原这几类偶尔会混入流量分析或简单的取证题。今年的题目构成大概是这样题型程序载体考察重点难度算法逆向Windows PEMFC程序RC4变种识别、校验逻辑还原中等反调试逆向Linux ELF反调试对抗、自修改代码、内存Patch偏难Android简单题APK字符串定位、smali阅读入门流量分析题PCAP包协议识别、字段提取入门~中等我这次主要把精力和篇幅放在前两道也就是算法逆向和反调试逆向。原因很简单这两类题最能暴露一个人逆向基本功的短板而且解法可以迁移到其他题上。Android题和流量题相对直白属于看了答案就能会的那种价值密度不高。1.2 开工前把工具链捋顺很多人做不出题不是不会逆向而是动手前工具没准备利索。我这里说的准备不是装个IDA就完事而是要把三个环节的武器都备齐静态分析、动态调试、脚本辅助。静态分析我用IDA Pro 8.3配合几个自用的插件FindCrypt用于快速识别加密常量KeyPatch用来在反汇编里直接修改字节。动态调试在Windows上用x64dbgLinux上用gdb加pwndbg插件。脚本这块我事先把Python环境整理好装好z3、pycryptodome、requests这几个库并且确认frida的服务端版本和目标机器架构匹配——这一点特别容易忽略Frida版本不匹配会浪费大量时间在处理奇怪的连接错误上。我的经验是工具链必须提前一天验证跑通不要等题目发布后边做边装环境。因为红包题的窗口期通常只有几天万一卡在网络环境或者某个依赖库的安装问题上就白白浪费了大把解题时间。1.3 拿到题目后先做的三件事无论什么题我的流程都是固定的先看文件类型和壳信息再跑一遍字符串提取最后决定从哪个入口切入。第一件事用file命令配合PEiD或者Detect It Easy查程序特征确认有没有加壳是什么编译器生成的是32位还是64位。这个信息直接决定了后续是用IDA还是x64dbg以及需不需要提前准备脱壳工具。第二件事提取字符串常用的命令是strings -n 8过滤掉短字符串然后对比那些看起来像提示语、错误信息、成功信息的内容。程序里的报错字符串往往就是校验分支的路标比如Wrong key和Congratulations之间的代码路径就是解题的目标路径。第三件事是过一遍程序的导入表看看它调用了哪些系统API。如果看到CreateThread、VirtualAlloc、VirtualProtect这类函数多半有运行时修改代码的行为如果看到GetTickCount、QueryPerformanceCounter那十有八九做了时间差反调试。这些信息能让你在正式分析前就对题目难度有个预判。2. 第一道题魔改RC4的逆向还原与注册码生成2.1 从字符串交叉引用定位关键函数第一道题是个MFC写的Windows程序界面很简单一个输入框、一个验证按钮。用IDA打开后第一件事就是搜索字符串我直接搜Wrong、还有flag这些词很快就定位到了两个关键字符串的交叉引用位置。双击交叉引用跳到的是sub_401230这个函数。F5看伪代码程序逻辑一目了然取输入框内容去掉前后空格长度必须大于8然后把输入交给一个函数处理最后拿着处理结果跟一段硬编码的字节序列比较。比较相等就弹窗提示恭喜否则就提示再试试。所以题目的核心就落在两个地方处理输入的那个函数做了什么以及那串硬编码字节是什么。前者决定算法后者决定校验值。2.2 识别出这是RC4的骨架进入处理函数后我先粗略看了一遍指令规模大概两百多条汇编长度很符合一个加密算法的实现。用FindCrypt插件扫了一下常量没有发现MD5、AES、DES这类算法的固定常量排除了常见的分组加密。继续手动翻汇编发现了两个关键特征第一代码里有一块256字节的数组初始化操作往数组里依序写入0到255。这个结构太眼熟了RC4的S盒初始化就是这样一个256字节的排列。第二代码里有个两层循环结构外层循环256次内层根据输入密钥字节对S盒进行置换。翻译下来就是RC4的KSA密钥调度算法阶段。到这里我基本确定程序用的是RC4加密。但有个细节让我警觉在KSA的置换过程中代码额外插了一个异或操作把某个常量和S盒当前元素异或后再做交换。这个操作标准RC4里没有属于出题人的魔改点。正是这个魔改点决定了我们不能直接抄现成的RC4脚本必须把算法完整还原后再处理。2.3 用Python还原完整算法并暴破求解既然确定了是RC4魔改版接下来的任务就是写Python脚本把算法完整复刻出来。逻辑是这样的输入字符串作为密钥经过魔改KSA生成初始S盒后续按标准PRGA流程生成密钥流但密钥流生成后要与目标字节序列逐字节异或比较程序并没有直接比较明文而是先从输入生成了密钥流再拿密钥流跟硬编码的目标序列做比较。由于RC4是对称的加解密过程逆向思路就简单了用硬编码的目标字节序列当作密文用输入字符串作为密钥做一次解密得到的就是期望的明文。也就是说只要我能找到正确的密钥就能得到flag。但问题来了密钥是什么程序里输入框的内容就是密钥而校验并不要求解密后的明文等于某个固定值它比较的是密钥流与目标序列。仔细看伪代码我发现程序把输入字符串第一个字节的值作为循环次数、额外执行了一次S盒置换。这就是出题人的关键设计校验结果同时依赖输入的内容和长度。这样一来就不能简单地将目标序列解密得到flag了因为校验逻辑包含了输入本身的特征。换个思路目标序列和生成的密钥流一致而密钥流是输入经过PRGA生成的。这等价于已知一个RC4的密文要还原初始密钥——这本来是个困难问题。2.4 尝试z3失败RC4不是约束求解器能啃的骨头我先想到的是z3约束求解。把RC4的KSA和PRGA每一步用BitVector建模约束条件设为最终密钥流等于目标字节序列然后让z3去反推输入字符串的长度和内容。z3跑了十分钟直接报unknown。原因我总结下来很简单RC4的置换操作本质上是基于索引和值相互依赖的查表操作每一步的状态都依赖于前面几百步的结果等价于把数千个逻辑表达式串在一起而且中间有大量的分支依赖——在某一步S盒的某个位置是否被交换取决于当前密钥字节的取值。这种情况下约束求解器的路径爆炸问题会被彻底放大。这也算一次宝贵的失败经验不是所有算法逆向都能靠z3硬解。RC4这种流密码正确姿势永远是老老实实捋逻辑、找漏洞、暴破输入空间而不是跟约束求解器较劲。2.5 放弃黑盒直接读汇编找校验漏洞从z3坑里爬出来后我重新回到IDA仔细看了校验比对那一段的伪代码。这下发现了关键点程序比较的不是全部密钥流字节而是只比较了前64个字节中的某一段而且这64字节的生成过程中每16个字节会插入一次S盒的重置操作——相当于把输入拆成了若干独立的小块分别加密每块的加密都从同一个初始S盒状态出发。这个发现直接改变了复杂度量级。原本需要还原一个足够长的密钥流现在只需要针对每个16字节块单独处理。每个块内的逻辑是标准RC4只是密钥来源是同一个输入字符串——而校验只关心块内的密钥流前缀是否等于目标序列的对应片段。于是可行的暴破方案就出现了目标序列片段很短比对的只是PRGA的前几个字节输出。PRGA的输出主要受S盒初始状态影响而S盒初始状态又由输入字符串的魔改KSA决定。我可以把输入字符串的每个字符当作独立的变量逐个字符地调整观察哪个字符能让对应位置的密钥流字节匹配目标值。我写了个Python脚本用回溯填充的方式逐字节试探输入字符。伪代码如下import sys TARGET bytes.fromhex(66 4D 8A 1B 6E 7C 33 5F ...) # 从IDA中提取的目标字节 S_BOX list(range(256)) def ksa(key): s S_BOX[:] j 0 for i in range(256): j (j s[i] key[i % len(key)]) 0xff # 魔改点中间插入了一个与常量0xAA的异或 s[i] ^ 0xAA s[i], s[j] s[j], s[i] return s def prga(s, n): out [] i j 0 for _ in range(n): i (i 1) 0xff j (j s[i]) 0xff s[i], s[j] s[j], s[i] out.append(s[(s[i] s[j]) 0xff]) return out KEY bytearray(bflag{??????????}) for idx in range(5, 15): # flag{} 中间的位置 if TARGET[idx] 0: continue for c in range(32, 127): guess KEY[:] guess[idx] c s ksa(guess) stream prga(s, len(TARGET)) if stream[idx] TARGET[idx]: KEY[idx] c print(f[] position {idx}: {chr(c)}) break这个脚本利用了每个位置只由该位置的输入字符主要控制的局部性假设逐位锁定了每个字节。实际运行后很顺利几个位置的字符依次爆破出来最后拼出完整的flag。2.6 复盘魔改点才是出题人的传送门做完之后回头看这道题最核心的考点其实是两个识别RC4变种以及发现分段重置S盒这个设计漏洞。如果出题人没有做分段处理整串密钥流会是一个连续的整体暴破复杂度会指数级上升但有了分段重置每个16字节块都是独立的小RC4攻击难度立刻下降了好几个量级。后来的经验告诉我逆向时需要盯住这类非标准的设计。标准实现通常意味着算法安全性有保障需要走常规还原路线而非标准的设计往往就是出题人留下的解题入口因为这些改动本质上破坏了原始算法的安全性。越看着奇怪的操作越值得仔细琢磨。另外还想提醒一句不要一上来就上z3。z3适合解那种逻辑关系明确、变量有限的约束问题比如CRC校验值的线性方程、某段简单的字节运算逆推。而RC4、DES这类强非线性置换结构能不用z3就别用会把大量时间耗在无意义的求解等待中。3. 第二道题反调试、自修改代码与内存Patch的拉锯战3.1 从ELF文件开始三处反调试侦察第二道题是个Linux ELF程序64位无壳。按老流程先过字符串和导入表导入表里出现ptrace、gettimeofday、sigaction这三个函数我的警觉性立刻拉满——这三个函数组合在一起基本上就是经典的反调试三板斧ptrace自跟踪检测如果自身已经被调试器附加那么再次调用ptrace(PTRACE_TRACEME)会失败程序据此判断自己被调试时间差检测用gettimeofday记录关键函数运行的前后时间戳如果差值超过某个阈值判定有人在单步跟踪信号异常处理注册了sigaction专门捕获SIGTRAP信号——如果检测到软件断点int 3触发就跳入一个死循环或者直接退出。我继续静态跟踪每一处调用确认了这三个点分别位于主函数、某个字符串处理函数、以及加密函数内部。这意味着我只能用Patch掉反调试后再分析的方式而不是硬扛着调试器跟下去。3.2 动态调试的第一步先过ptrace和信号处理用gdb启动程序果不其然刚打上第一个断点程序就直接异常退出输出了一行伪装成报错的字符串实际上就是反调试的提示。此时我彻底放弃边调试边分析的思路决定先把反调试点Patch掉。具体操作方式用二进制编辑工具直接修改ELF文件把ptrace调用处的条件跳转改成无条件跳转跳过失败分支把gettimeofday比较处的jne改成jmp让时间差检测永远不成立把sigaction注册函数的入口改成直接ret让异常处理器失效。这里有个细节值得说明为什么不用gdb的set follow-fork-mode或者LD_PRELOAD钩子而是选择直接改文件因为这道题的反调试点互相联动Patch一个位置后程序会马上用另一个点重新检测。与其反复切换策略不如一次性把三处都改掉确保动态调试的环境是干净的。改完之后重新运行程序正常等待输入gdb也能顺利附加了。我实际操作中改文件用的是010 Editor你也可以用IDA的Edit-Patch Program-Change byte改完记得导出到新文件。文件校验和校验在这种题里一般不常见瑞雪兆丰年改了再说。3.3 自修改代码静态分析看到的全是幻觉反调试解决后真正的挑战浮出水面。用IDA静态看这个程序发现加密校验函数的代码全是混乱的字节反汇编出来的指令根本不成逻辑夹杂着大量无意义的mov、jmp伪代码更是没法看。这种情况基本可以断定是自修改代码——程序在运行到关键逻辑之前会先用自己的解密例程把代码段的密文解码成真正的指令然后再跳进去执行。对策只能切换到动态调试在代码自解密完成后转到对应内存地址查看真实指令。操作链路是用catch syscall的gdb命令捕获mprotect系统调用这个调用负责修改代码段的内存权限是自修改的前兆在mprotect返回后对目标内存区域下硬件断点等程序跳进真实代码时断下断下后用gdb的x/50i $rip反汇编当前指令流把真实的指令逐条记录下来。这个过程试了两次才成功。第一次我把断点下在解密完成前的位置结果捕获到的还是密文字节后来发现解密例程会循环处理多个内存页必须等到最后一个mprotect调用返回后再下断。第二次调通了完整的真实代码展现在眼前确认核心逻辑是读取输入、逐字节异或一个密钥数组、再执行一个自创的置换表查表操作最终与目标字符串比较。3.4 内存Patch代替输入暴破直接改比较结果拿到真实代码后按通常的思路接下来应该是写出解密脚本反推出正确输入。但我看了一眼置换表的大小——1024字节每个输入字节会经过多次查表和置换逆向推导需要追踪完整的数据流工作量很大。这时候我用了一个更直接的方案内存Patch。具体做法是找到最终比较指令的位置。它是一组cmp指令逐个比较用户输入经算法变换后的字节和目标字符串字节。我不再去逆向那个变换算法而是直接改变比较的逻辑把所有的cmp结果强制设为相等也就是把条件跳转指令全部改成nop让程序无论如何都认为校验通过。这个操作在ollydbg或者gdb里都能完成。gdb改法示例# 先定位cmp指令地址假设是 0x401600 (gdb) break *0x401600 (gdb) run # 断下后动态修改跳转指令 (gdb) set *(unsigned char*)0x401604 0x90 # 把jne改成nop跳过失败分支 (gdb) continue这样程序会一路跑完所有校验分支最终进入验证成功的路径。执行完成后程序打印出恭喜信息题目判定为完成。我知道有些朋友会觉得Patch内存有点作弊但是CTF解题过程中拿到旗标往往不是唯一目的。对于反调试自修改这类题目把程序调到正常输出成功信息本身就证明了你已经理解了程序的执行流程和关键分支。而且在真实的安全分析工作中修改程序行为来观察后续逻辑也是常见操作。只需要在Writeup里如实写清楚你的操作方式不存在道德问题。3.5 时间差反调试引发的Frida翻车这道题我还踩了一个Frida的坑值得单独写一段。我在Patch完内存、程序正常运行之后想用Frida把它跑起来做一次动态插桩看看关键函数的参数。Frida是个功能很强的动态插桩工具可以不打断程序运行直接hook函数对付时间差反调试理论上也够用。我用Frida把gettimeofday给hook住让它固定返回同一个时间值以此绕过时间差检测。启动后Frida倒是正常注入了但程序立刻进入死循环——不是崩溃是CPU占用100%。排查半天才明白问题不在Frida的hook逻辑而在我的hook写得太暴力我没有保持gettimeofday原有的调用约定和返回值语义而是把两个时间参数直接置零。程序后续在计算时间差时出现了负数导致它的循环条件永远无法结束。解决方式hook的时候返回一个真实的时间戳且保证两次调用的返回值严格递增。也就是在hook函数里维护一个全局计数器每次调用就在初始时间上加上一毫秒。这样程序看到的始终是一个正常流逝的时间反调试逻辑发现时间差合理就放行了。这算是一次典型的工具反噬案例工具本身够强但用错了方式反而制造了比原题更难缠的问题。后来我再遇到时间差检测都是直接Patch程序里的比较指令不再动hook省事得多。4. 容易被忽略的出题人彩蛋与常见丢分点4.1 出题人藏在异常分支里的提示刷完两道核心题后我还花时间把所有题目的细节过了一遍发现两个很有意思的出题人彩蛋这两个彩蛋很容易被忽略但对理解题目的考察意图很有帮助。第一处在算法逆向题里程序的资源段藏了一张PNG图片图片内容是一段ASCII字符画画的是一个RC4密钥调度算法的流程图旁边配了一句标准实现不一定是安全的——这就是出题人在暗示魔改点所在。用binwalk扫描或者资源查看工具都能发现这张图但我一开始完全没看直接闷头读汇编花了额外的时间。第二处在反调试题里程序的异常处理函数sigaction注册的处理器内部有一段永远不会被执行到的分支——因为上面的指令已经被改成无条件跳转了。但这个分支里面隐含了一组字节常量恰好是自修改代码解密用的密钥。也就是说如果你不是直接Patch掉sigaction注册而是耐心跟着异常处理逻辑走一遍就能直接拿到解密密钥省去后面动态跟踪的麻烦。这两个彩蛋我总结为同一个道理出题人藏提示的位置往往是初学者最容易忽略非主干路径的位置。做题时除了盯着校验函数看一定要把资源段、异常处理、未引用的数据段都翻一遍。CTF题不同于真实软件没有冗余代码——每一段数据都是出题人精心设计过的找到它们就等于找到了捷径。4.2 三处几乎人人都踩的丢分点结合今年做题和历年刷题的感受有三处丢分点可以说是专杀新手单独列一下**字符串交叉引用只查一层。**很多人搜到Congratulation字符串后直接跳到交叉引用的函数看到里面有一大堆代码就懵了。实际上字符串的下一个交叉引用才是真正的校验逻辑前面那个往往只是初始化或者消息框创建的代码。务必顺着交叉引用的链一路查到底。**明文比较陷阱。**不少题目不会直接比较输入变换后是否等于目标值而是先把目标值做一次哈希或者再一次加密拿哈希结果比较。如果你没有算上这层变换直接搜字符串找目标会得到一堆无法理解的匹配结果。解题前先用FindCrypt扫一把常量把哈希、加密的可能排除掉。**编码问题。**MFC程序里的字符串有两类窄字符和宽字符。搜索字符串的时候如果不注意编码选项很容易漏掉关键字符串。像今年的第一题提示字符串是UTF-16宽字符而校验算法操作的是窄字符字节两套编码混在一个程序里搜索的时候全选Unicode 和 ASCII两个选项才不会漏。4.3 意外崩溃暴露出的隐藏校验逻辑在解第一道算法的过程中我还犯过一个很有价值的错误输入了一个超长字符串结果程序直接崩溃。当时第一反应是程序存在栈溢出漏洞想往Pwn方向靠后来冷静下来发现不对——这是红包题不是Pwn题出题人不会挖一个内存破坏漏洞让选手去打。我用gdb挂上崩溃现场看了栈回溯之后发现崩溃原因是程序在校验前会对输入长度做一次左移操作左移结果被当作数组索引。如果我输入太长索引就超出了缓冲区边界。这个崩溃实际上揭示了一个隐藏的校验规则程序只接受特定长度范围的输入短于或长于这个范围程序会在主校验逻辑之前直接拒绝。这个发现反而帮了大忙它帮我锁定了输入长度只能是14到20字节。后来暴破flag的时候我直接把长度范围写死在脚本里显著缩小了搜索空间。这也是我想单独强调的一点——做题过程中遇到的异常行为往往比正常行为透露更多信息。程序崩溃不一定是坏事只要你能控制住崩溃现场并理解崩溃背后的原因就可能获得额外线索。5. 从这次红包题里提炼的逆向基本功自查清单5.1 一份可以照着打勾的能力列表每次刷完题我都会回头做一次能力复盘把题中用到的知识点列出来对照检查自己在哪些环节熟练、哪些环节生疏。这次红包题让我最有感触的是题目本身不刁钻但考察的都是最基础的基本功如果有任何一环不熟整条解题链路就会卡死。我梳理了一份自查清单你可以直接拿来对照是否能在10分钟内完成对未知二进制的文件类型识别、壳检测、导入表分析是否能熟练使用IDA的交叉引用、重命名、F5伪代码、结构体标注这些基础操作是否能快速识别常见的加密算法特征RC4的S盒初始化、AES的S盒常量、MD5的初始向量是否能独立在gdb/x64dbg中设置硬件断点、软件断点、内存断点并在断点处检查寄存器与内存是否理解ptrace自跟踪、时间差检测、int 3陷阱这三类常见反调试手段并知道如何Patch绕过是否具备使用Python快速复刻算法逻辑的能力并且在需要时知道怎么用z3而不是滥用z3是否掌握自修改代码的基本应对思路等mprotect返回后再下断点先dump真实代码再分析这次刷题我自己的薄弱项是第5项时间差检测的绕过方式以及在Frida hook中过度修改返回值导致的反噬。写进清单里下次遇到同类问题就能直接跳过坑。5.2 我常用的两条省时间心法给不太熟悉动态调试的朋友分享两条心得是我自己从踩坑里总结出来的。第一条能在静态分析阶段解决的问题坚决不要等到动态调试才去解决。静态分析读代码虽然慢但它是可控的、确定的你可以随时停下来思考而动态调试天然带着竞态程序一跑起来你的注意力就会被断点、单步、寄存器这些信息牵扯走容易迷失在主线的逻辑之外。今年第二题如果我先耐心把ELF的导入表和反调试点确认清楚再切换到动态调试整个过程的节奏会好很多。第二条凡是遇到目标字符串比较类的校验先考虑Patch内存直接让校验通过而不是急着逆向算法。原因很简单如果出题人把算法设计得很深比如自修改代码置换表逆向算法需要的时间可能数倍于Patch内存。在红包题这种本身就带有福利性质的场景里先拿结果为重把算法还原留作后续深化理解即可。要分清哪一步该暴力拿下哪一步该优雅还原这是时间管理能力的一部分。5.3 红包题刷完以后怎么让经验沉淀下来很多人刷题就是做个痛快解完一道丢一道等下次做题又遇到同类题型依然没有思路。我的做法是每做完一套题整理一份笔记不写成标准Writeup的格式而是写成我当时怎么想、怎么卡住、最后怎么跳出来的过程记录。这种过程记录的价值在于它记录的并不是答案而是路径。下次你再遇到类似的程序你不会只记得要用RC4脚本而是会回忆起当时我看到S盒置换里有异或操作我就知道这是魔改点魔改点往往就是解题入口这种思维链路后者才是真正可迁移的能力。我把这次部分题目的WP汇总成这份笔记除了分享具体解法更希望读者能带着为什么这样做的眼光去重读每一个操作步骤。如果读完你有自己的一两条心得欢迎记下来下次刷题时对照看看是不是能帮你更早地看到出口。做题这事不怕慢就怕不回头看。