每年论坛周年庆前后,吾爱那边的技术板块都会热闹一阵子——红包题来了。这个活动说穿了就是官方出几道CTF风格的逆向/算法小题目,做出来就能领红包。题目难度卡在入门到进阶之间,但每年都有不少人卡在门槛外,看着红包拿不到。今年我抽空把题完整刷了一遍,其中有几道的思路和解法挺有代表性,这篇先写一部分WP,重点拆两道题:一道纯算法逆向,一道反调试加自修改代码。两道题的解题链路和工具取舍我会尽量写细,顺带把踩过的坑也交代清楚,给准备刷题的朋友一个可复用的参考。
1. 红包题的整体构成与我的备战思路
1.1 红包题到底是什么级别的题目
红包题本质上属于福利性质的定向CTF,出题人不会刻意刁难人,但也不会让你靠百度搜答案过关。从历年的情况看,题型基本集中在Windows PE逆向、Linux ELF逆向、Android逆向、密码学算法还原这几类,偶尔会混入流量分析或简单的取证题。
今年的题目构成大概是这样:
| 题型 | 程序载体 | 考察重点 | 难度 |
|---|---|---|---|
| 算法逆向 | Windows PE(MFC程序) | RC4变种识别、校验逻辑还原 | 中等 |
| 反调试逆向 | Linux ELF | 反调试对抗、自修改代码、内存Patch | 偏难 |
| Android简单题 | APK | 字符串定位、smali阅读 | 入门 |
| 流量分析题 | PCAP包 | 协议识别、字段提取 | 入门~中等 |
我这次主要把精力和篇幅放在前两道,也就是算法逆向和反调试逆向。原因很简单:这两类题最能暴露一个人逆向基本功的短板,而且解法可以迁移到其他题上。Android题和流量题相对直白,属于看了答案就能会的那种,价值密度不高。
1.2 开工前把工具链捋顺
很多人做不出题不是不会逆向,而是动手前工具没准备利索。我这里说的准备不是装个IDA就完事,而是要把三个环节的武器都备齐:静态分析、动态调试、脚本辅助。
静态分析我用IDA Pro 8.3,配合几个自用的插件:FindCrypt用于快速识别加密常量,KeyPatch用来在反汇编里直接修改字节。动态调试在Windows上用x64dbg,Linux上用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(b"flag{??????????}") 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汇总成这份笔记,除了分享具体解法,更希望读者能带着"为什么这样做"的眼光去重读每一个操作步骤。如果读完你有自己的一两条心得,欢迎记下来,下次刷题时对照看看是不是能帮你更早地看到出口。
做题这事,不怕慢,就怕不回头看。