
1. 从一道CTF逆向题看字节变换的典型套路第一次拿到[MRCTF2020]Transform这道题的时候我盯着IDA里那几十行反汇编看了大概十分钟心里只有一个念头这题出得真够“规矩”的。它没有花哨的反调试没有复杂的虚拟机保护甚至连常见的花指令都没塞几处就是老老实实地把输入读进来做一轮字节变换然后跟一个硬编码的数组比对。但恰恰是这种“规矩”让它成为理解逆向工程中字节级变换这一类问题的绝佳样本。这道题的核心逻辑用一句话概括就是程序接收一个长度为33的字符串输入对每个字符做一次基于下标的位运算变换再按照一张固定的置换表重新排列最后与目标数组逐字节比较。听起来不复杂但里面藏着好几个容易翻车的细节——比如变换顺序不能搞反置换表的索引方向容易弄错还有那个看似多余的异或操作到底起什么作用。我写这篇文章的目的很直接把这道题从分析到还原的完整链路拆开揉碎讲清楚。不管你是刚接触逆向的新手还是已经刷过几十道CTF题的老手都能从中找到一些值得参考的思路。尤其是那些“看懂了伪代码但写不出正确脚本”的朋友这篇文章会重点解决从理解到复现之间的那道坎。提示本文涉及的所有分析均基于公开的CTF题目学习目的所有代码和思路仅供技术研究参考。2. 静态分析阶段定位关键比较逻辑2.1 从入口函数顺藤摸瓜用IDA加载题目附件后直接看main函数的反汇编。程序结构非常清晰先打印一句提示信息然后调用scanf或类似的输入函数读取用户输入接着进入一个循环对输入做处理最后调用strcmp或逐字节比较函数进行验证。这里有个小技巧值得分享当你面对一个没有符号信息的二进制文件时优先找字符串引用。这道题里有一句类似“input your flag:”的提示通过交叉引用就能直接跳到核心逻辑附近。比起从main入口一步步跟这种方式能省下不少时间。定位到关键区域后你会发现两个重要的数据段一个是长度为33的全局数组后面称为target另一个是长度为33的置换表后面称为table。程序的处理流程大致如下读取33字节输入到缓冲区对缓冲区每个字节执行一次变换按照置换表重新排列字节顺序将结果与target数组比较2.2 识别变换的具体形式在反汇编中变换部分的代码通常长这样for (i 0; i 33; i) { buffer[i] ^ table[i]; buffer[i] ((buffer[i] 4) | (buffer[i] 4)) 0xFF; }或者类似的变体。这道题的具体操作是先将输入字节与置换表中对应位置的字节做异或然后进行高低四位的交换也就是循环左移4位。这两个操作组合在一起构成了一个可逆的字节变换。为什么出题人要这样设计因为单独的异或或者单独的位移都太容易被识别和逆向但组合起来之后如果不仔细看每一步的操作数来源很容易把顺序搞反。我见过不少人在这一步翻车——他们知道要做逆运算但把“先异或再移位”的逆过程写成了“先移位再异或”结果脚本跑出来全是乱码。2.3 置换表的读取与理解置换表在数据段中通常以字节数组的形式存在长度与输入长度一致。这道题的置换表内容是一串看似随机的数字范围在0到32之间。它的作用是在变换之后按照table[i]的值重新排列字节顺序。这里有一个非常关键的细节置换的方向。到底是output[i] buffer[table[i]]还是output[table[i]] buffer[i]这两种写法在反汇编里看起来很像但逆向时如果搞错方向还原出来的字符串顺序就是完全错的。我的经验是看目标数组的索引方式。如果比较时是target[i] result[i]那么你就需要确保还原时按照同样的索引顺序来重建。具体到这道题通过观察比较循环的汇编代码可以确定置换操作是result[i] buffer[table[i]]也就是说table[i]给出了“第i个输出字节来自输入缓冲区的哪个位置”。3. 逆向变换的数学推导与脚本实现3.1 逐步拆解可逆性要写出正确的逆向脚本第一步是确认每个操作都是可逆的。这道题涉及两个操作异或操作a ^ b c已知b和c则a c ^ b。异或的逆运算就是它本身这是最友好的可逆操作。高低四位交换将一个字节的左4位和右4位互换。这个操作也是自逆的——做两次就回到原值。比如0xAB交换后变成0xBA再交换一次又变回0xAB。所以整个变换的逆过程就是先对目标值做高低四位交换再与置换表中对应位置的值异或。注意顺序必须完全反过来。用伪代码表示就是# 正向变换 for i in range(33): buf[i] ^ table[i] buf[i] swap_nibbles(buf[i]) # 逆向还原 for i in range(33): buf[i] swap_nibbles(target[i]) buf[i] ^ table[i]3.2 置换表的逆向应用正向过程中的置换是result[i] buffer[table[i]]那么逆向时我们需要根据result恢复buffer。由于置换只是重新排列位置不改变字节值所以逆向置换就是for i in range(33): buffer[table[i]] result[i]这里要特别注意table[i]的值可能重复吗在这道题里不会因为置换表是一个双射每个位置恰好被映射一次。但如果遇到有重复的置换表就需要额外判断了。这也是CTF中常见的坑点之一。3.3 完整还原脚本把上面的分析整合起来完整的Python脚本如下target [0x67, 0x79, 0x7B, ...] # 从二进制中提取的33字节目标数组 table [0x09, 0x0A, 0x0F, ...] # 从二进制中提取的33字节置换表 def swap_nibbles(b): return ((b 4) | (b 4)) 0xFF # 第一步逆向置换 buffer [0] * 33 for i in range(33): buffer[table[i]] target[i] # 第二步逆向字节变换 for i in range(33): buffer[i] swap_nibbles(buffer[i]) buffer[i] ^ table[i] # 输出结果 flag .join(chr(b) for b in buffer) print(flag)跑完这个脚本就能得到正确的flag。我第一次写的时候在置换那一步卡了将近二十分钟原因就是把buffer[table[i]] target[i]写成了buffer[i] target[table[i]]结果输出的字符串虽然看起来像那么回事但字符顺序完全是乱的。注意提取target和table数组时务必确认字节序和数组长度。IDA中双击数组名可以查看完整内容右键选择“Export data”或者手动复制都可以。建议用Python的列表推导式直接从十六进制字符串转换减少手抄错误。4. 那些容易翻车的细节与排查思路4.1 变换顺序搞反导致的“看起来对但就是不对”这是这道题最高频的翻车点。很多人看完伪代码后脑子里记住的是“先异或再交换”写逆向脚本时顺手就写成了“先异或再交换”完全忘了逆过程需要反过来。更麻烦的是这种错误有时候不会导致输出完全乱码而是输出一串“看起来像flag但读不通”的字符让人误以为是其他环节出了问题。我的排查方法是先单独验证每一步。比如先只做逆向置换看看得到的数组是否和目标数组有某种对应关系再单独做逆向字节变换观察结果是否符合预期。把两个步骤分开验证比一次性跑完整脚本更容易定位问题。4.2 置换表索引方向的判断依据前面提到过置换方向搞反是另一个常见错误。怎么判断正确的方向我的经验是看比较循环的汇编实现。如果比较时是mov al, [result i] cmp al, [target i]那么result[i]就是最终的第i个字节而result[i]来自buffer[table[i]]。所以逆向时要把target[i]放回buffer[table[i]]的位置。如果比较循环用的是寄存器间接寻址或者其他形式就需要更仔细地跟踪数据流。但核心原则不变正向的“从哪来”逆向就是“回哪去”。4.3 数组长度与边界条件这道题的输入长度是33但有些类似的题目会在长度上做文章——比如实际有效长度是32最后一个字节是校验位或者结束符。如果脚本跑出来的结果最后多一个或几个奇怪的字符先检查一下数组长度是否完全匹配。另外swap_nibbles函数里的 0xFF不能省。Python的整数没有固定位宽如果不做掩码左移操作可能产生超过8位的值导致后续异或结果错误。这个细节在C语言里不需要考虑但在Python脚本里必须手动处理。4.4 从伪代码到脚本的“最后一公里”很多人能看懂伪代码但写脚本时总是差那么一点。我的建议是先把伪代码逐行翻译成Python不做任何优化。比如# 伪代码 # for i in range(33): # buf[i] ^ table[i] # buf[i] swap(buf[i]) # 逐行翻译 for i in range(33): buf[i] buf[i] ^ table[i] buf[i] swap_nibbles(buf[i])然后再把正向逻辑改成逆向逻辑。这种“笨办法”看起来效率低但能最大程度避免思维跳跃带来的错误。等你熟练了之后自然就能一眼看出逆过程该怎么写。5. 从这道题延伸出的通用逆向思维5.1 字节变换类题目的通用解题框架刷过一定数量的CTF逆向题之后你会发现字节变换类题目有一套相对固定的解题框架步骤操作内容关键注意点1定位输入读取和比较逻辑通过字符串引用快速定位2提取目标数组和辅助表确认长度和字节序3识别每一步变换操作区分可逆与不可逆操作4确定变换顺序正向顺序的完全逆序5编写逆向脚本逐行翻译分步验证6验证结果检查flag格式和可读性这个框架适用于大多数没有复杂混淆的字节变换题。当然如果遇到带有加密算法、虚拟机保护或者动态代码生成的题目就需要额外的分析手段了。5.2 静态分析与动态调试的配合这道题用纯静态分析就能解决但有些题目光看反汇编很难理解逻辑这时候就需要上动态调试。我常用的组合是IDA负责静态理解结构GDB或者x64dbg负责动态验证假设。比如你不确定某个变换的顺序可以在动态调试时在关键位置下断点观察寄存器和内存的变化。这种“静态看逻辑动态验行为”的方式能大幅提高分析效率。5.3 脚本编写的工程化习惯虽然CTF比赛时间紧张但我还是建议在写还原脚本时保持一些工程化习惯把target和table数组单独放在脚本开头方便修改和核对每个步骤之间打印中间结果方便定位错误用函数封装可复用的操作如swap_nibbles脚本写完后用注释标明每一步对应的正向操作这些习惯在比赛时可能看起来“浪费时间”但实际上能帮你省下大量调试时间。我见过太多人因为数组抄错一位或者索引写反在最后关头功亏一篑。5.4 同类题目的变体与应对Transform这道题的变体在CTF中非常常见常见的变形包括多轮变换不是一轮异或加交换而是循环多轮每轮使用不同的表非线性变换用S盒或者查表代替简单的位运算依赖前一个字节当前字节的变换依赖于前一个字节的结果形成链式依赖混合置换与运算置换和运算交替进行增加逆向难度面对这些变体核心思路不变找到每一步的逆操作然后按相反顺序执行。如果遇到链式依赖可能需要从最后一个字节开始倒推或者建立方程组求解。6. 实操复盘我踩过的那些坑第一次做这道题的时候我犯了一个很典型的错误把置换表的索引方向搞反了。当时我写的逆向脚本是这样的for i in range(33): buffer[i] target[table[i]] # 错误写法跑出来的结果是一串完全看不懂的字符。我一开始以为是异或那一步出了问题反复检查了好几遍异或逻辑甚至怀疑自己提取的table数组有误。后来把中间结果打印出来对比才发现是置换方向反了。正确的写法应该是for i in range(33): buffer[table[i]] target[i] # 正确写法这两个写法的区别在于错误写法是“从target中按table的索引取值”正确写法是“把target的值放到table指定的位置”。虽然只是索引位置换了一下但结果完全不同。另一个坑是swap_nibbles的实现。我一开始写的是def swap_nibbles(b): return (b 4) | (b 4) # 缺少掩码对于某些字节值这个函数会返回超过8位的整数导致后续异或结果错误。加上 0xFF之后就正常了。这个坑在Python里特别常见因为Python的整数是任意精度的不会自动截断。还有一个不算坑但值得注意的点这道题的flag格式是flag{...}长度33正好对应。如果你跑出来的结果长度不对或者开头不是flag{那基本可以确定是某个环节出了问题。利用flag的已知格式作为校验条件是逆向中非常实用的技巧。提示在CTF逆向中flag的已知格式如flag{开头、}结尾是极好的验证工具。如果还原结果不符合格式优先检查变换顺序和索引方向。7. 写在最后的一些个人体会这道题给我的最大启发不是某个具体的逆向技巧而是一种系统化的分析习惯。面对一个陌生的二进制文件不要一上来就想着写脚本而是先把程序的整体流程理清楚输入是什么、经过哪些处理、输出是什么、比较逻辑在哪里。这个框架建立起来之后剩下的就是填充细节。另外逆向工程中“手算”和“脚本”要结合起来。有些步骤适合手算验证比如取前几个字节手动推一遍有些步骤必须靠脚本批量处理。我通常的做法是先用前3到5个字节手动推演一遍确认自己的理解正确然后再写脚本处理全部数据。这样即使脚本出错也能快速定位是逻辑问题还是实现问题。最后说一句关于工具选择的事。这道题用IDA加Python脚本就能完美解决不需要上GDB或者任何动态调试工具。但如果你遇到的是带有反调试或者代码自修改的题目动态调试就是必须的了。工具没有优劣之分关键看场景。把静态分析和动态调试都练熟面对不同类型的题目时才能游刃有余。