拿到题目《[ACTF新生赛2020]usualCrypt》第一眼看到这个名字我的反应是“这大概是一道加密算法的送分题”。“usual”这个词暗示用的是常见加密流程对新手来说这种题往往比那种自己发明的怪算法要友好得多。但实际做下来发现题目里到处都是“看似平常、实则埋坑”的细节如果不沉下心把逻辑捋清楚很容易在最后一步被卡住。这道题适合两类人参考一是刚接触CTF逆向、想练手密码算法识别的新手二是在做题时经常被函数命名、字符变换这类小细节坑到的选手。整道题的核心流程不算复杂程序接收一串输入经过一个“名字看起来很大路”的加密函数处理再和内部保存的一段密文比对。难点不在于算法本身有多深而在于你怎么从二进制里认出它并在还原正确的变形之后写出能跑通整个流程的解密脚本。这篇文章我就按照实际做题的顺序把从拿到文件到最终解出明文的全过程复盘一遍包括静态分析、算法识别、参数还原、脚本编写以及几个特别容易踩的坑。1. 拿题先别急着拖IDA信息收集与程序行为观察1.1 跑起来先看程序表现我习惯拿到一个陌生的二进制文件第一时间不是直接拖进反编译器而是先在虚拟机里跑一下。运行环境最好用独立虚拟机或者沙箱防止程序本身带什么意外的行为。这道题运行起来很直白就是提示输入一串字符串然后根据结果输出正确或错误的提示。举个例子运行后大概是这种感觉$ ./usualCrypt input: abc error!多试几个输入比如全数字、全字母、空字符串看看程序表现是否稳定。如果某个特殊输入会导致程序崩溃或异常输出说明可能在长度处理上有漏洞这往往是解题的突破口之一。但在这道题里程序表现很稳定输入什么都是“error”说明逻辑是标准的“加密后比对”模式。这一步看起来简单但价值在于帮你确认几个基本信息程序是命令行交互、输入长度应该是受限的因为加密通常按固定分组处理、输出信息只有正确和错误两个分支。这些信息会直接指导你后面的分析策略。1.2 查壳、字符串与保护机制运行完就开始静态信息收集。我会用一条命令组合把能拿的信息先拿全file usualCrypt checksec --fileusualCrypt strings usualCrypt | head -50用file确认这是一个32位还是64位的ELF这决定了后面IDApython脚本和动态调试的一些细节。用checksec看保护机制比如PIE是否开启、栈保护是否存在这些对理解程序运行方式有帮助。用strings看程序里有没有直接暴露的敏感字符串或提示信息。这道题里strings能看到input:和error!这类交互提示但没有直接出现明文flag。这很正常flag一般不会以明文存在而是被加密后硬编码在数据段里。看到这里基本可以确定是加密验证型的逆向题我们输入会被加密然后跟一个预设的密文比对如果一致就提示正确。信息收集阶段还可以用objdump -s -j .rodata之类的命令看看只读数据段有没有可疑的常量但更高效的做法还是直接用IDA或Ghidra做静态分析因为反编译器能直接把数据的使用关系串联起来。2. 静态分析定位核心逻辑2.1 用IDA找到入口与主函数打开IDA加载程序等待自动分析完成后先看main函数。这里有个小经验CTF题目里main往往不长反向工程的关键都在它调用的子函数里。所以我的关注点不是main本身的逻辑而是它调了谁、整个调用链是什么样的。在main里能看到明显的输入输出逻辑printf(input:\n); scanf(%s, input); if ( check_input(input) ) printf(right!\n); else printf(error!\n);check_input这种函数名字是典型的需要深入分析的校验函数。双击进去里面通常藏着加密算法和比较逻辑。要特别留意函数里是否有调用其他子函数以及是否有很大的循环和固定的常数。如果发现某个函数体看起来像一份“标准算法实现”那大概率就是加密核心所在。2.2 usualCrypt的函数命名陷阱接下来就进入到关键一步。在check_input的调用列表里我看到了一个名叫usualCrypt的函数。这个名字真的太有迷惑性了——它听起来就是“通常的加密”的意思好像就是个标准函数不需要多花心思。但在这个领域里“平常”恰恰是最大的伪装。实际经验告诉我CTF题目中的函数命名往往不能太当真因为出题人可能故意用一个看起来无害的名字内部却藏着一个经过改造的加密实现。真正判断算法是什么不能靠名字要看函数内部的运算特征。所以双击usualCrypt直接用IDA的F5伪代码视图来观察。这一步信息量很大我把关键的循环结构粗略看了一遍瞳孔一缩——这个函数内部的运算模式非常眼熟大量使用sum delta的固定步长累加对数据分块进行多种位运算和异或且块与块之间存在关联。这不是别的正是XXTEA算法的典型结构。2.3 通过常量与运算结构识别算法识别加密算法最可靠的方式之一就是查找算法中特有的魔数常量。比如XXTEA / TEA 使用固定常数0x9E3779B9MD5 里有0x67452301等初始向量RC4 初始化时使用0x01到0xFF的置换表用了findcrypt插件扫描这个函数所在的二进制段结果弹出了对0x9E3779B9的引用。配合伪代码中每次迭代以sum 0x9E3779B9推进的结构基本可以锁定这是XXTEA或其变体。但这里要特别注意“或其变体”这四个字。这年头纯原版XXTEA已经成为过去式出题人最喜欢在原版的基础上加一点逻辑比如改变密钥长度或密钥扩展方式改变循环代数的上下界对分组数据进行预处理或后处理对加密结果进行字符替换或位反转如果直接拿标准XXTEA解密得到的结果必然是乱的。所以识别为标准算法只是第一步更重要的在于——仔细对比当前实现和标准实现之间的差异。3. 加密细节拆解与参数还原3.1 关键差异字符表替换逻辑仔细阅读usualCrypt的伪代码我发现它确实比标准XXTEA多了几行操作。其中最显眼的是一个循环它遍历数据缓冲区对每个字节做某种条件字符替换。把替换逻辑提取出来大概是这样的for ( i 0; i len; i ) { if ( data[i] a data[i] z ) { if ( data[i] m ) data[i] - 13; else data[i] 13; } }这不就是ROT13吗但看起来不是全局ROT13而是对小写字母做的移位。我还发现在加密流程的更早位置可能还有一个对大小写同时处理的字符映射。需要进一步确认替换发生的时机是在XXTEA加密之前对明文做处理还是在加密之后对密文做处理这个顺序直接决定了解密时在哪一步做逆变换。这类“数据后处理”是usualCrypt这个名字背后的真正彩蛋。很多人识别出XXTEA就直接写脚本结果解出来乱码一堆就是忽略了这层字符表替换。遇见这种题必须养成习惯把所有变换都看成“数据管道”每一步都要定位清楚。3.2 密钥与分组处理细节再来分析密钥部分。函数中有一处把一串固定字符串设置为密钥并且传入XXTEA的密钥分组逻辑中。XXTEA的标准做法是把密钥按32位一组分成4组因为密钥长度固定为128位。但在这个实现里密钥初始化和标准版本几乎一致可以确认密钥就是程序内部写死的那串。这从逆向角度是个好消息——密钥不需要我们去爆破直接从程序里提取就行。还需要注意数据长度的问题。XXTEA的加密以32位为一个单位且要求数据长度至少为8字节如果不是8的倍数必须做填充。标准实现里填充的长度是用(n 1) * 4来计算的当前函数是否也按这个规则处理要看伪代码里的长度计算表达式。如果不一致比如长度计算有偏移解密脚本就很容易只解出前几个块后面全是乱码。我建议在分析时用IDA的交叉引用Xref把加密函数修改的数据长度来源追溯到上层调用确认输入长度最终是如何被计算和传递的。这一步确定不了后面解密脚本十有八九会挂。3.3 解密前必须确认的三件事在写解密脚本前我习惯列一个确认清单避免做一半发现方向错了项目确认内容确认方法算法类型是标准XXTEA还是变形版本找0x9E3779B9常数对比循环结构字符替换时机加密前还是加密后看伪代码中替换循环相对XXTEA调用的位置数据长度与分组填充方式是否符合常规看长度计算表达式的上下溢行为针对这份清单逐项在伪代码里落实。最终得出的结论是程序先用XXTEA加密明文加密完成后对结果中的小写字母做一次字符替换替换的结果作为最终密文与内置数据比对。所以正确的解密顺序应当是先对密文做逆字符替换再进行XXTEA解密。4. 解密脚本编写与验证4.1 还原XXTEA解密函数既然确定是XXTEA那就直接找一份标准实现改。Python写起来很顺手注意使用无符号32位运算否则Python的无限精度整数会在移位时产生诡异结果。下面是我用的解密核心函数import struct DELTA 0x9E3779B9 def xxtea_decrypt(data, key): n len(data) // 4 v list(struct.unpack(%dI % n, data)) k list(struct.unpack(4I, key)) rounds 6 52 // n total (rounds * DELTA) 0xFFFFFFFF p total 2 3 for _ in range(rounds): e (total 2) 3 for i in range(n - 1, 0, -1): z v[i - 1] v[i] (v[i] - (((z 5 ^ (v[(i 1) % n] 2)) (v[(i 1) % n] 3 ^ (z 4))) ^ ((total ^ v[(i 1) % n]) (k[(i 3) ^ e] ^ z)))) 0xFFFFFFFF z v[n - 1] v[0] (v[0] - (((z 5 ^ (v[1] 2)) (v[1] 3 ^ (z 4))) ^ ((total ^ v[1]) (k[(0 3) ^ e] ^ z)))) 0xFFFFFFFF total (total - DELTA) 0xFFFFFFFF return b.join(struct.pack(I, x) for x in v)这里有几个关键点需要特别说明。首先数据按小端序当作32位整数数组处理字节序错了解出来的内容会每个字节顺序颠倒。其次rounds由块数决定标准公式是6 52 / nn是32位字的分组数量。最后total的初始值是rounds * DELTA实现了与加密过程相反的累计。4.2 处理密文提取与字符替换解决了XXTEA部分现在来处理字符替换的逆过程。既然原加密在输出前把小写字母做了ROT13式的替换那我们在解密前就要把密文中的小写字母先转回来。同样是ROT13因为ROT13的逆操作就是再做一次ROT13——换13位是自逆运算。具体实现def reverse_substitution(cipher): arr bytearray(cipher) for i in range(len(arr)): if 0x61 arr[i] 0x7A: if arr[i] 0x6D: arr[i] - 13 else: arr[i] 13 return bytes(arr)在提取内置密文时注意从二进制中抠出来的数据不一定以\0结尾需要按字节数组看待。可以用objdump或IDA的导出数据功能直接拿到十六进制字节流。4.3 完整解密演示把两块合起来完整流程是这样key byour_extracted_key_here cipher bytes.fromhex(...) # 从二进制提取的密文 recovered reverse_substitution(cipher) plain xxtea_decrypt(recovered, key) print(plain)如果前面的分析全部正确这一步会直接打印出flag明文。我第一次跑的时候打印出的内容前面几个字符正常后面几段全是不可读的二进制垃圾这就暴露了一个问题数据长度填充不对。回到IDA检查长度计算逻辑发现原始加密长度并不是标准的全部输入长度而是在一定条件下会截断或补零。修正填充逻辑后再次运行脚本输出就整齐了。这一步也可以加入动态调试辅助验证在usualCrypt函数加密结束、字符替换开始前的指令处下断点观察此时内存中的值再在字符替换结束后、比较逻辑之前下断点观察变化。两个断点处的数据对比直接证实了替换发生在加密后、比较前。5. 常见坑点与同类题通用解法5.1 这题最容易踩的4个坑我把做这道题以及同类题时常见的坑整理在下面每一条都是实操中真实遇到过的问题不是理论推导。第一个坑是“被函数名带偏”。usualCrypt这个名字太像标准库函数导致很多人不去细究内部逻辑。逆向分析中函数名只是参考代码才是事实。遇到任何可疑函数都老老实实用伪代码通读一遍重点看是否存在自定义的额外变换。第二个坑是“忽略字符替换的时机”。如果替换发生在加密前解密就要先解XXTEA再逆替换如果发生在加密后就要先逆替换再解XXTEA。顺序反了结果一定是乱的。判断方法很简单跟踪数据在函数中的流向看替换循环到底操作的是明文还是密文。第三个坑是“字节序处理错误”。XXTEA基于32位整数运算而密文在内存中是连续字节序列。如果解密脚本里用大端序或者不做字节序转换整个结果就是乱序的。多数情况下用struct.unpack(I)即可但也要看程序所在平台的字节序习惯。第四个坑是“长度填充处理不当”。XXTEA要求分组数据最后一块不足时要补零。有些程序补零后会把补的零也参与比较如果你的脚本没有正确模拟解密结果末尾会出现异常字节。建议解密后用可见字符过滤加人工检查的方式确认。5.2 这类密码逆向题的通用套路经过这道题我把密码逆向题的通法再梳理一遍以后遇到类似题目可以直接套用信息收集先跑程序再查壳和字符串别急着看反汇编。跟踪数据流通过scanf/printf定位输入输出再用交叉引用找到校验函数。识别算法优先用findcrypt或其他常量扫描插件把标准加密算法的魔数找出来。对比改造拿伪代码和标准算法实现逐行对比找出额外插入的变换逻辑。确认操作顺序理清“原变换”和“额外变换”的执行顺序设计逆向流程。编写脚本使用Python等脚本语言快速实现用无符号整型模拟底层运算避免溢出问题。动态验证必要时候配合调试器下断点查看加密中间状态验证分析结论。这个流程不一定100%覆盖所有情况但可以覆盖绝大多数CTF密码逆向题。核心思想其实只有一句话先识别、再对比、最后还原。所有套路都是围绕这句话展开的。6. 复盘与心得6.1 从这个题目学到什么做完这道题我最大的感触是CTF里的“加密逆向题”重点从来不在加密数学本身而在于“如何识别并处理改造”。一个被改了多处细节的XXTEA难度远超一个原封不动的AES或RSA。出题人并不指望你会重新发明一种密码算法而是期望你能在逆向过程中准确捕捉到那些“奇怪的额外操作”。这也解释了为什么题目名字叫usualCrypt。它想告诉你的是使用的是一个“平常的加密算法”但你得把隐藏在平常外表下的不平常细节找出来。逆向工程和破案的相似之处就在这里犯罪手法往往是老套路真正决定成败的是对细节的观察是否足够敏锐。6.2 以后遇到类似命名该怎么处理如果以后再看到什么myCrypto、customEncrypt、usualEncrypt之类的函数名我的第一反应会是先假定它是某个已知算法的变体然后按照“扫描魔数 对比标准实现 定位差异”的思路快速处理。不要被函数的命名吓到更不要被它的命名骗到。还有一个小技巧不要把所有分析都堆在静态阶段。如果静态分析中某个变量的来源不清晰直接动态调试跑一遍在数据变换的关键节点打断点看看内存里到底发生了什么。眼见为实这比反复读伪代码快得多。最后想说的是这一类型题目非常适合用来练手。它的难度梯度处理得比较好识别算法需要一些密码学基础还原字符替换又需要细心和耐心。把这套流程练熟了下次遇到再隐蔽的“换皮算法”你也有底气在半小时内掀开它的真面目。