
1. 从一道CTF逆向题说起Chellys identity到底在考什么如果你刷过BUUCTF的逆向分区大概率会对[GKCTF2020]这个比赛留下的几道题有印象。Chellys identity这道题在社区里的讨论热度一直不低原因很简单——它不像那种一眼就能看出是异或或者Base64的送分题也不像VM类题目那样动辄几百行解释器代码让人望而生畏。它的位置很微妙刚好卡在你觉得自己会逆向和你发现自己其实不会逆向之间。这道题的核心考点用一句话概括就是在二进制层面识别出编译器对特定数学恒等式的优化模式并逆向还原出原始逻辑。听起来有点绕但拆开来看就清楚了。出题人写了一段看起来像是某种校验逻辑的C代码编译之后丢给你一个ELF文件你需要从汇编层面理解它在干什么然后写出对应的逆向脚本拿到flag。适合谁来读这篇笔记如果你已经能熟练使用IDA或者Ghidra做静态分析能看懂基本的x86-64汇编但遇到编译器优化过的代码就有点发怵那这篇内容就是为你准备的。如果你是完全的新手建议先补一下汇编基础和ELF文件格式再回来看这道题会顺畅很多。我之所以想专门写这道题是因为它代表了一类非常典型的逆向场景源码逻辑并不复杂但编译器把它变得面目全非。很多人在刷题时习惯性地按F5看伪代码结果发现伪代码也看不懂就卡住了。这道题恰好能帮你建立一套应对这类情况的系统方法。2. 拿到二进制之后的第一轮信息收集2.1 文件类型与基础保护检查拿到一个未知的ELF文件第一步永远是做基础信息收集。这一步看起来简单但很多人会跳过或者做得不完整导致后面走弯路。file chellys_identity checksec --filechellys_identityfile命令告诉你这是一个64位的ELF可执行文件动态链接没有strip符号或者strip了但保留了部分符号。checksec的输出需要重点关注几个字段NX是否开启、PIE是否开启、Canary是否存在。对于这道题来说这些保护机制其实不太影响逆向分析本身但养成检查的习惯很重要——万一题目涉及栈溢出或者格式化字符串这些信息就是关键。我个人的习惯是拿到二进制先跑一遍看看行为chmod x chellys_identity ./chellys_identity输入一个测试字符串观察程序的反馈。这道题的表现是等待输入输入后要么输出Correct要么输出Wrong没有其他交互。这就明确了分析目标——找到那个判断输入是否正确的校验逻辑。2.2 字符串与符号表的快速筛查在打开IDA之前先用命令行工具做一轮快速筛查往往能省下不少时间。strings chellys_identity | head -50 readelf -s chellys_identity | grep FUNCstrings的输出里如果有明显的提示信息比如Correct、Wrong、flag{之类的可以帮你快速定位到关键函数。readelf看符号表如果函数名没有被strip掉那main函数、校验函数的名字可能直接暴露出来。这道题的情况是符号表里保留了main但核心校验逻辑被内联到了main里面或者被编译器优化成了一个独立的静态函数名字可能是sub_XXXX这种。这就意味着你不能指望通过函数名来猜逻辑必须老老实实读汇编。这里分享一个经验不要一上来就F5。对于编译器优化过的代码伪代码的可读性有时候还不如汇编。先看汇编理解控制流再用F5辅助理解语义这个顺序会高效很多。3. 静态分析在汇编层面追踪校验逻辑3.1 定位关键比较点打开IDA从main函数开始跟。main的结构通常很清晰打印提示、读取输入、调用校验函数、根据返回值打印结果。你需要找到那个根据返回值分支的地方然后反向追踪校验函数的入口。在这道题里main函数大致做了这么几件事调用__isoc99_scanf或者fgets读取用户输入对输入做某种变换把变换结果和某个硬编码的数组做比较根据比较结果输出Correct或Wrong关键点在于第2步和第3步。第3步的硬编码数组通常很容易找到——在汇编里表现为一串mov指令往栈上或者数据段写立即数。第2步的变换逻辑就是这道题的核心。3.2 识别编译器的优化模式当你看到类似这样的汇编片段时需要提高警惕mov eax, [rbpvar_4] imul eax, eax ; ... 一系列算术运算编译器在处理乘法、除法、取模等运算时会做大量优化。比如乘以一个常数可能被转换成移位和加法的组合除以常数可能被转换成乘法和移位。这些优化在汇编层面看起来毫无规律但如果你知道编译器在做什么就能反推回原始表达式。这道题的核心就是一个数学恒等式。出题人写了一个看起来复杂的表达式但编译器把它优化成了一个相对简单的形式。你需要做的是从优化后的汇编反推出原始表达式的语义。具体来说我在分析时注意到了几个特征有多次imul指令说明涉及乘法有shr或sar指令说明涉及除法或取模有xor或and指令说明涉及位运算最终结果被存入一个局部变量然后和硬编码值比较把这些线索串起来可以大致判断出原始逻辑是一个多项式或者某种哈希函数。3.3 用GDB动态验证静态分析的结论静态分析给出假设之后一定要用动态调试验证。GDB在这类题目里的价值极高因为你可以在关键比较指令处下断点输入已知的测试数据观察寄存器中的实际值验证你的逆向推导是否正确gdb ./chellys_identity b *0x401234 # 假设这是比较指令的地址 run # 输入测试字符串 info registers x/16xb $rsp通过对比输入和中间计算结果你可以逐步确认每一步变换的具体行为。比如你输入AAAA观察经过第一轮变换后某个寄存器的值是多少再输入BBBB看变化规律。这种输入-观察-推断的循环是逆向工程中最核心的工作方式。注意GDB的断点地址需要根据实际二进制确定不同编译环境下的地址会不同。建议先用objdump -d或者IDA找到比较指令的偏移再在GDB里下断。4. 逆向还原从汇编回到C代码4.1 逐段翻译汇编逻辑当你确认了核心变换逻辑之后下一步是把它翻译回可读的C代码。这个过程不需要100%还原原始源码只需要还原出功能等价的代码即可。我的做法是把汇编按基本块切分每个基本块对应C代码里的一个语句或一个表达式。然后逐步拼接形成一个完整的函数。这道题的变换逻辑大致是这样的以下为功能等价的伪代码// 假设输入字符串为input长度为len unsigned int result 0; for (int i 0; i len; i) { unsigned int c (unsigned char)input[i]; // 一系列算术和位运算 result (result * 0x1F) ^ c; // 示例实际常数需要根据汇编确定 result (result 3) | (result 29); // 循环移位 } // 最终与硬编码值比较实际的表达式可能更复杂但结构是类似的。关键在于不要试图理解每一步的数学意义而是关注它的计算效果。逆向工程很多时候就是纯粹的符号操作你不需要知道为什么出题人要这样设计只需要知道这样设计之后结果是什么。4.2 处理编译器优化带来的歧义编译器优化最让人头疼的地方在于同一个C表达式在不同优化级别下可能生成完全不同的汇编。反过来同一段汇编可能对应多种C表达式。比如x * 5可能被优化成(x 2) x而x * 9可能被优化成(x 3) x。如果你在汇编里看到lea eax, [rax rax*4]你需要知道这是乘以5而不是别的什么。这道题里有一个比较隐蔽的优化编译器把某个除法运算优化成了乘法和右移的组合。具体来说x / 3被优化成了(x * 0xAAAAAAAB) 33对于32位无符号数。如果你不知道这个模式可能会误以为是在做某种哈希运算。这里有一个实用技巧在IDA里你可以选中一段汇编然后按F5看伪代码。虽然伪代码可能不完全准确但它对算术运算的还原通常比人眼更可靠。把伪代码和汇编对照着看能大幅提高效率。4.3 写出逆向脚本当你还原出完整的变换逻辑之后最后一步是写出逆向脚本。因为这道题的校验逻辑是对输入做变换然后和硬编码值比较所以逆向脚本需要做的是从硬编码值反推输入。如果变换是可逆的比如异或、加法、循环移位那直接逆运算即可。如果变换不可逆比如涉及取模或者哈希那就需要暴力枚举或者利用约束求解器。这道题的变换是可逆的所以逆向脚本的结构是# 从硬编码值开始反向执行每一步变换 target [0x12, 0x34, 0x56, ...] # 从二进制中提取的硬编码值 for i in range(len(target)-1, -1, -1): # 逆向执行循环中的每一步 # ... flag_char ... flag chr(flag_char) flag print(flag)实际写脚本时最容易出错的地方是循环移位的方向和运算顺序。建议每逆向一步就用正向逻辑验证一下确保中间结果一致。5. 这道题真正值得记录的几个坑5.1 硬编码值的提取容易出错在IDA里看硬编码数组时要注意数据的宽度和字节序。有时候你看到的是dq8字节但实际逻辑是按dd4字节处理的。如果提取时搞错了宽度后面的逆向全错。我的做法是在GDB里直接查看内存确认每个值的实际宽度和排列方式。比如x/8xw 0x404060 # 以4字节为单位查看 x/4xg 0x404060 # 以8字节为单位查看对比两种查看方式的结果就能确定正确的宽度。5.2 循环边界的判断这道题的循环次数和输入长度有关但编译器可能会把循环展开或者做其他优化。在汇编里循环边界可能表现为一个比较指令也可能被优化成其他形式。我遇到的一个坑是汇编里有一个cmp指令我以为是循环条件结果发现它是用来判断输入长度的。真正的循环条件被优化成了一个指针比较。这种时候动态调试比静态分析更可靠——在循环体内下断点观察每次迭代时寄存器的变化就能确定循环的实际行为。5.3 字符集范围的假设写逆向脚本时我默认flag字符都是可打印ASCII。但实际上中间计算过程可能产生非打印字符只是最终结果恰好落在可打印范围内。如果你在逆向过程中发现某个中间值不在可打印范围不要慌继续往下算最后的结果可能是对的。一个实用的验证方法把逆向得到的flag输入程序看是否输出Correct。这是最终的验收标准比任何中间验证都可靠。6. 从这道题延伸出去如何系统应对编译器优化过的逆向题6.1 建立优化模式的直觉编译器优化虽然千变万化但常见的模式就那么几种。我整理了一个速查表在遇到类似题目时可以快速对照汇编模式对应的C语义注意事项lea eax, [rax rax*2]x * 3lea可以做乘法不访问内存imul eax, eax, 0xAAAAAAABshrx / 3魔数除法需要根据移位位数确定除数xor eax, eaxx 0比mov eax, 0更短test eax, eaxif (x 0)比cmp eax, 0更短shlshr组合位域提取或循环移位注意区分逻辑移位和算术移位这张表不是万能的但覆盖了大部分常见情况。遇到不认识的模式时用GDB动态观察是最可靠的方法。6.2 动态调试与静态分析的配合节奏我的习惯是静态分析定框架动态调试填细节。具体来说先用静态分析确定函数的大致结构和控制流找到关键的比较点和分支点用动态调试观察这些点的实际值根据观察结果修正静态分析的结论重复2-4直到逻辑完全清晰这个循环看起来繁琐但实际上比纯静态或者纯动态都要快。纯静态容易在细节上卡住纯动态容易迷失在大量的运行时数据里。6.3 写逆向脚本时的工程化思维很多人写逆向脚本时习惯一步到位结果调试起来非常痛苦。我的建议是分步验证逐步推进。具体做法是先写正向逻辑模拟程序的变换过程用已知输入验证确认正向逻辑正确后再写逆向逻辑逆向逻辑每完成一步就用正向逻辑验证中间结果最后用完整的逆向脚本生成flag输入程序验证这种工程化的做法看起来慢但实际上能避免大量的返工。我在做这道题时正向逻辑和逆向逻辑各写了一遍总共花了不到一个小时其中大部分时间是在验证中间结果。6.4 这类题目的变体与应对思路Chellys identity这类题目在CTF里有很多变体。常见的变体包括多轮变换输入经过多轮不同的变换每轮逻辑不同非线性变换涉及取模、查表等不可逆操作需要约束求解自修改代码程序在运行时修改自己的代码静态分析看不到完整逻辑反调试保护程序检测调试器需要先绕过保护再分析应对这些变体的核心思路是一样的理解程序在做什么而不是死记硬背汇编指令。只要你能够用自然语言描述程序的每一步行为你就能写出对应的逆向脚本。对于多轮变换建议把每一轮单独分析分别写出正向和逆向逻辑最后串联起来。对于非线性变换可以尝试用z3等约束求解器把问题转化成方程组求解。对于自修改代码和反调试需要结合动态调试在程序运行时观察内存和代码的变化。我在实际刷题时发现很多人在遇到优化过的代码时第一反应是这怎么看然后就卡住了。其实只要你愿意花时间在GDB里单步跟踪观察每一步寄存器的变化再复杂的逻辑也能逐步拆解。逆向工程本质上是一个耐心方法的游戏工具和技巧只是辅助真正重要的是你愿不愿意沉下心来一行一行地读汇编一个值一个值地验证。这道题给我的最大收获不是flag本身而是让我建立了一套应对编译器优化代码的方法论。后来遇到类似的题目我不再害怕看到满屏的imul和shr而是能够有条不紊地拆解、验证、还原。这种能力比记住某个具体的解题步骤有价值得多。