逆向题里有一类特别有意思的:它不靠复杂的算法吓唬你,而是用一层"看起来像加密"的壳子,把真正的逻辑藏在字符变换的细节里。BUUCTF 上这道 [ACTF新生赛2020]rome 就是典型代表——名字叫 rome,乍一看让人联想到某种古典密码,实际动手跟下来会发现,它考的是你对字符编码、循环移位、以及逆向还原顺序的理解。我第一次做这道题的时候,卡在"加密方向搞反了"这个坑上整整一个下午,后来把思路捋顺,才发现它其实是一道非常适合入门逆向的练手题。
这篇内容适合两类人:一类是刚接触 CTF 逆向、想找一道难度适中、逻辑清晰的题来练手的同学;另一类是做过一些题、但总在"如何从加密逻辑反推输入"这一步犯迷糊的从业者。我会把这道题从静态分析、逻辑还原、脚本编写到最终验证的完整链路拆开讲,重点讲清楚每一步"为什么这么做",而不是只丢一个答案。文中涉及的二进制文件、函数名等均以通用代称描述,具体操作你可以对照自己手头的样本复现。
1. 拿到样本先别急着开调试器:rome 这道题的静态侦察思路
很多人做逆向题的习惯是"上来就扔进调试器单步",这个习惯在简单题上没问题,但遇到稍微绕一点的样本,盲目单步只会让你在汇编里迷路。我的做法是:先做静态侦察,把程序的骨架摸清楚,再决定要不要动态调试。rome 这道题尤其适合这个流程,因为它的核心逻辑几乎全在静态反编译里就能看明白。
1.1 文件类型与保护机制的快速判断
拿到样本的第一步,先确认它是什么类型的文件、有没有加壳、有没有反调试。用常见的文件识别工具看一眼,如果是一个 64 位的 ELF 可执行文件,且没有明显的加壳特征,那基本可以放心走静态分析路线。我一般会关注这几个点:
- 架构:32 位还是 64 位,决定了后面看汇编时寄存器命名(eax 还是 rax)和调用约定。
- 是否静态链接:静态链接的样本体积大,但函数调用关系清晰,不容易被动态库干扰。
- 符号信息:如果没 strip,函数名会直接暴露逻辑,比如 main、check、encrypt 这类名字,能省不少事。
rome 这道题的保护很"朴素",没有花指令、没有自修改代码,属于那种"把逻辑明明白白摆给你看,就看你能不能读懂"的类型。这种题的价值不在于对抗,而在于训练你读代码的耐心和还原逻辑的准确性。
提示:静态侦察阶段不要跳过字符串分析。很多题的关键线索(比如成功/失败的提示语、可疑的常量字符串)都藏在字符串表里,先扫一遍字符串,往往能快速定位到核心校验函数。
1.2 从 main 函数切入,锁定校验入口
静态分析的核心是找到"程序在哪里判断输入对不对"。对于 rome 这类题,入口通常在 main 函数里,表现为:读取用户输入 → 调用某个处理/校验函数 → 根据返回值打印成功或失败。我在反编译视图里会重点找这几类特征:
- 输入读取调用:比如读取字符串、读取一行的库函数调用,它的返回值或缓冲区地址会传给后续函数。
- 条件跳转:校验函数返回后紧跟一个 if 判断,成功走一条路、失败走另一条路。
- 可疑的循环结构:加密逻辑几乎必然包含循环,因为要逐字符处理。
rome 的 main 逻辑很干净:读入一串字符,调用一个处理函数,然后和一段硬编码的字节序列做比较。这个"硬编码字节序列"就是解题的钥匙——它是加密后的目标值,我们只要把加密过程逆过来,就能还原出正确的输入。
这里有个经验:看到硬编码的字节数组,先别急着抄下来,先确认它的长度和存储方式。长度决定了输入字符串的长度约束,存储方式(是按字节、按双字还是按字符串)决定了你后面写脚本时怎么读取。rome 里这段目标数据是以字节为单位存储的,长度直接对应输入的有效字符数,这一点在写还原脚本时必须对齐,否则会差位。
1.3 识别加密函数的输入输出契约
定位到处理函数后,别急着读它的内部实现,先搞清楚它的"契约":输入是什么、输出是什么、有没有副作用。这一步决定了你后面还原逻辑的框架。
- 输入:通常是用户输入的字符串指针,可能还带一个长度参数。
- 输出:可能是原地修改输入缓冲区,也可能是写到另一块内存再返回指针。
- 副作用:有没有全局变量被修改、有没有额外的状态需要跟踪。
rome 的处理函数是原地修改型的:它拿到输入缓冲区后,逐字符做变换,把结果写回原缓冲区,最后 main 拿这个缓冲区去和目标值比较。搞清楚这一点很关键,因为它意味着"加密是逐字符独立的"——每个字符的变换只依赖它自己和它的位置,不依赖其他字符。这个性质直接决定了我们能不能对每个字符单独求逆。
如果加密是链式的(比如后一个字符依赖前一个字符的结果),那还原就必须从头顺序推;而如果是逐字符独立的,理论上可以并行还原每个位置。rome 属于后者,这让还原脚本写起来非常直接。
2. 把加密逻辑读透:字符变换到底做了什么
这一节是整道题的核心。很多人做不出来,不是因为不会写脚本,而是因为没把加密逻辑读准确——差一个取模、差一个偏移,结果就全错。我会把 rome 的变换逻辑拆到最细,并且解释每一步为什么这么设计。
2.1 逐字符处理的主循环结构
加密函数的主体是一个循环,遍历输入字符串的每个字符。循环里对每个字符做一系列算术和位运算,然后写回。我把这类循环的通用读法总结成三步:
- 看循环变量:是索引 i 从 0 到长度,还是指针逐字节后移。这决定了"位置信息"怎么参与运算。
- 看每轮对字符做了什么:是加减常数、异或、移位,还是查表替换。
- 看写回的位置:是写回原位置,还是写到偏移后的位置(这会影响是否需要额外记录映射关系)。
rome 的循环是标准的索引遍历,每个字符独立处理,写回原位置。这种结构最友好,因为它保证了"第 i 个输出字符只由第 i 个输入字符决定",还原时逐个位置求解即可。
2.2 核心变换:移位与取模的组合
rome 的字符变换核心是循环移位。所谓循环移位,就是把字符的二进制位整体左移或右移若干位,溢出的位补到另一端。它和普通移位的区别在于:普通移位会丢位,循环移位不会,信息是守恒的——这正是它能被逆向的前提。
具体到这道题,变换大致是这样的逻辑(以字节为单位):把字符的 8 个二进制位按某个规则重新排列,相当于做了一次位级别的置换。这种置换有一个非常好的性质:它是可逆的,而且逆变换就是正向变换的某种对称形式。换句话说,如果你知道正向是怎么移的,逆向就是反着移。
我用一个简化的例子说明循环移位的可逆性。假设对一个 8 位值做"循环左移 3 位":
原始: 10110010 左移3: 10010101 (高3位 101 移到低位)要还原,只需对它做"循环右移 3 位",就能回到原始值。这个对称性是写还原脚本的关键——你不需要真的去"解方程",只需要把正向操作反过来执行一遍。
注意:循环移位的位数如果是变量(依赖位置 i),那还原时每个位置的移位量必须和正向严格对应。rome 里移位量和字符位置相关,所以还原脚本里必须带着索引一起算,不能当成固定常数。
2.3 为什么不能直接"反向异或"了事
有些同学看到加密里有异或,就条件反射地想"再异或一次就还原了"。这个思路在纯异或加密里成立,但 rome 不是纯异或——它是移位 + 算术运算的复合。复合运算的逆,必须按相反顺序逐个求逆,不能跳步。
举个生活化的类比:加密就像"先穿袜子再穿鞋",解密就得"先脱鞋再脱袜子"。顺序错了,结果就不对。rome 的变换里,如果正向是"先移位再加减常数",那逆向就必须"先减回去再反向移位"。我在第一次做的时候就是把顺序搞反了,导致还原出来的字符串全是乱码,排查了很久才发现是运算顺序的问题。
所以读加密逻辑时,我建议你在纸上把每一步操作按顺序列出来,标上序号,然后还原时从最后一个序号倒着写。这个笨办法看起来低效,但能避免 90% 的顺序错误。
2.4 用表格把变换规则固化下来
为了不让逻辑在脑子里打结,我习惯把每个位置的变换规则整理成表格。以 rome 的变换为例,假设它对第 i 个字符做"循环左移 k 位再异或某个常数"(具体参数以实际样本为准),表格大概长这样:
| 位置 i | 正向操作 | 逆向操作 |
|---|---|---|
| 0 | 循环左移 k0 位,异或 C0 | 异或 C0,循环右移 k0 位 |
| 1 | 循环左移 k1 位,异或 C1 | 异或 C1,循环右移 k1 位 |
| ... | ... | ... |
这张表的价值在于:写脚本时你只需要照着表逐行翻译成代码,不用再回忆逻辑。而且一旦结果不对,你可以逐位置对照表格排查,快速定位是哪一步的逆运算写错了。
3. 写还原脚本:从目标字节反推原始输入
逻辑读透之后,写脚本就是"翻译"工作。但翻译也有讲究——数据怎么读、循环怎么写、结果怎么验证,每一步都有坑。这一节我把脚本的编写过程和踩过的坑一起讲。
3.1 目标数据的正确提取方式
目标字节序列是硬编码在程序里的,提取时最容易犯的错是抄错字节或漏掉字节。我的做法是:不要手动抄,而是从反编译视图或十六进制视图里直接复制原始字节,然后核对长度。
提取时要注意几个细节:
- 字节序:如果目标数据是以多字节整数形式存储的,要注意大小端。rome 里是按字节存储的,不存在这个问题,但养成检查字节序的习惯没坏处。
- 结束符:字符串常量可能带结尾的
\0,但作为比较目标的字节数组通常不带,别把结束符也算进去。 - 长度对齐:目标字节数就是输入的有效长度,脚本里循环次数必须和它一致。
我一般会把提取到的字节写成一个列表,然后在脚本开头打印一下长度和十六进制表示,和反编译视图里的数据对一遍,确认无误再往下写。这个核对步骤只花几秒钟,却能避免后面大量的无效调试。
3.2 逆运算的代码骨架
有了变换规则表和目标字节,脚本骨架就很清晰了。以 Python 为例,核心结构是:遍历目标字节的每个位置,对每个字节执行对应的逆运算,拼成结果字符串。伪代码结构如下:
target = [0x??, 0x??, ...] # 从样本中提取的目标字节 result = [] for i, b in enumerate(target): # 按第 i 个位置的逆运算规则处理 b v = inverse_transform(b, i) result.append(chr(v)) print(''.join(result))这里的inverse_transform就是第 2 节表格里"逆向操作"那一列的代码化。写的时候要特别注意:索引 i 必须参与运算,因为移位量依赖位置。如果你把 i 当成常数或者干脆不用,结果一定不对。
我在写这类脚本时的习惯是:先只还原第一个字符,打印出来看是不是可打印字符。如果第一个字符就是乱码,说明逆运算逻辑有问题,先别往下跑;如果第一个字符正常,再放开整个循环。这种"小步验证"的方法能极大缩短排错时间。
3.3 循环移位的 Python 实现细节
Python 里没有内置的 8 位循环移位,需要自己实现。这里有个容易踩的坑:Python 的整数是无符号、无限精度的,移位后必须手动截断到 8 位,否则高位会残留,导致结果错误。
循环左移 n 位的标准写法是:
def rol8(v, n): n %= 8 return ((v << n) | (v >> (8 - n))) & 0xFF def ror8(v, n): n %= 8 return ((v >> n) | (v << (8 - n))) & 0xFF两个细节值得强调:第一,n %= 8是为了处理移位量大于等于 8 的情况,虽然本题里移位量不会那么大,但加上更稳妥;第二,最后的& 0xFF是必须的,它保证结果始终是一个字节。我第一次写的时候漏了这个掩码,导致还原出的字符值超出 0-255 范围,chr()直接报错,排查了半天才发现是掩码的问题。
提示:如果你不确定正向用的是左移还是右移,可以拿一个已知的简单值(比如 0x01)手动算一遍,和程序里的实际行为对照。这种"用最小例子验证方向"的方法,比盯着汇编猜要可靠得多。
3.4 结果验证:别急着交 flag
脚本跑出结果后,很多人看到一串像样的字符串就直接提交了。我的建议是多验证一步:把还原出的字符串重新按正向逻辑加密一遍,看是否等于目标字节。如果正向加密的结果和目标完全一致,那这个输入几乎可以确定是正确的。
这个"正向回代验证"的习惯非常重要,原因有两个:一是它能排除"碰巧看起来像 flag 但其实是错的"的情况;二是它能帮你确认自己对加密逻辑的理解是否完全正确。如果回代结果对不上,说明你的逆运算虽然凑出了可打印字符,但逻辑上有偏差,这时候要回去重新核对变换规则。
验证代码的结构就是把第 2 节的正向操作再实现一遍,输入是还原出的字符串,输出和target逐字节比较:
def forward_transform(s): out = [] for i, ch in enumerate(s): v = ord(ch) # 按第 i 个位置的正向规则处理 v out.append(v) return out assert forward_transform(flag) == target, "回代验证失败"跑通这个断言,心里就踏实了。
4. 这道题真正想教你的:逆向里的"顺序思维"与"位运算直觉"
做完 rome,如果只是拿到 flag 就翻篇,那有点浪费。这道题的设计其实很讲究,它在几个关键点上训练了逆向的核心能力。我把自己的体会拆开讲讲,这些经验对做其他题同样适用。
4.1 复合变换的求逆顺序为什么总被搞错
前面提过"穿袜子穿鞋"的类比,这里再深入一点。复合变换求逆之所以容易错,是因为人的直觉倾向于"对称地反向操作",但数学上正确的做法是严格按逆序。比如正向是f(g(x)),逆向必须是g⁻¹(f⁻¹(y)),而不是f⁻¹(g⁻¹(y))。
rome 的变换里,移位和算术运算的复合就属于这种情况。我在实际做题时养成了一个习惯:读加密代码时,每读到一个操作就在注释里标一个序号,比如// step1: 移位、// step2: 异或。写逆运算时从最大序号往回写。这个习惯看起来啰嗦,但它把"顺序"这个容易出错的东西显式化了,比靠脑子记可靠得多。
另外,如果变换里包含取模操作,要特别小心。取模是不可逆的(多个输入映射到同一个输出),但如果取模的范围覆盖了所有可能的字符值(比如对 256 取模),那它在字节层面就是可逆的,逆运算就是"再取一次模"。判断取模是否可逆,关键看模数和值域的关系。
4.2 位运算在逆向题里的高频套路
rome 用到了循环移位,这属于位运算家族。逆向题里常见的位运算套路我总结了几类,遇到时可以快速归类:
- 异或:自逆运算,
a ^ b ^ b = a。纯异或加密的还原就是再异或一次同样的密钥。 - 移位:普通移位丢位不可逆,循环移位保信息可逆。看到移位先判断是哪种。
- 与/或掩码:与运算会清零某些位(不可逆),或运算会置位某些位(不可逆)。如果加密里用了与/或,通常意味着信息有损,需要结合其他约束(比如字符必须是可打印的)来还原。
- 位反转/置换:把位的顺序打乱,逆运算就是按相反规则再打乱一次。
rome 的循环移位属于"保信息可逆"这一类,所以能干净地还原。如果哪天你遇到一道题,加密里用了与运算把某些位清零,那就不能纯靠逆运算了,得结合"输入是可打印 ASCII"这个约束去枚举可能的值——这是另一类题的思路,这里先埋个伏笔。
4.3 从"能跑通"到"讲得清":检验理解的标准
我判断自己是否真正理解一道题,标准不是"脚本跑出了 flag",而是"能不能不看代码,把加密逻辑和还原逻辑完整地讲给别人听"。rome 这道题我做完后,试着给一个刚入门的朋友讲了一遍,讲到"为什么逆运算要反序"的时候卡壳了,回头重新梳理才彻底想明白。
这个"讲得清"的标准很实用。如果你讲的时候发现某个环节说不清楚,那大概率是你对这个环节的理解还有模糊地带,只是脚本碰巧跑通了。逆向这行,碰巧跑通和真正理解之间的差距,在遇到变种题的时候会立刻暴露出来。
4.4 常见错误清单:我做这道题踩过的坑
把踩过的坑列出来,比讲一堆正确做法更有参考价值。以下是我在这道题(以及类似题)上真实犯过的错误:
| 错误类型 | 具体表现 | 根因 | 修正方法 |
|---|---|---|---|
| 逆序错误 | 还原结果全是乱码 | 复合变换求逆没按反序 | 给每步操作标序号,倒着写 |
| 掩码遗漏 | chr()报错或字符异常 | 移位后没截断到 8 位 | 每步位运算后& 0xFF |
| 索引未参与 | 部分字符对、部分错 | 移位量依赖位置但脚本没用 i | 检查变换是否含位置相关项 |
| 数据抄错 | 整体偏移一位 | 手动抄目标字节出错 | 直接复制原始字节并核对长度 |
| 方向搞反 | 结果接近但不对 | 左移右移弄混 | 用 0x01 最小例子验证方向 |
这张表我建议你保存下来,做其他逆向题时对照检查,能省不少时间。尤其是"掩码遗漏"和"索引未参与"这两条,在涉及位运算和位置相关变换的题里出现频率极高。
5. 把这道题的方法迁移到同类逆向题上
rome 做完了,但它背后的方法论可以迁移到一大类题上。我把自己总结的迁移思路分享出来,帮你在遇到新题时快速上手。
5.1 识别"逐字符独立加密"这一类题
rome 属于"逐字符独立加密"家族——每个字符的加密只依赖它自己和它的位置。这类题的共同特征是:加密函数里有一个遍历字符串的循环,循环体内对单个字符做变换,且变换不引用其他字符的结果。识别出这个特征,你就知道可以逐位置独立求逆,不用考虑字符间的依赖关系。
与之相对的是"链式加密",比如每个字符的加密依赖前一个字符的密文(类似分组密码的链接模式)。链式加密的还原必须从头顺序推,不能并行。判断方法很简单:看循环体内有没有引用"上一个结果"或"累积状态"。rome 没有,所以是独立的。
这个分类很重要,因为它决定了你还原脚本的框架。独立加密可以随便顺序还原,链式加密必须严格顺序,搞错了框架,后面全错。
5.2 硬编码目标值的题该怎么系统处理
rome 的目标值是硬编码的字节数组。这类题在 CTF 里非常常见,处理流程可以标准化:
- 定位目标数据:在比较逻辑附近找硬编码数组。
- 确认长度和类型:字节、双字还是字符串,长度多少。
- 提取并核对:直接复制原始字节,打印长度和十六进制,和反编译视图对照。
- 写逆运算:按变换规则表逐位置求逆。
- 正向回代验证:把结果重新加密,和目标比对。
这个流程我用了很多次,基本能覆盖大部分"硬编码目标值 + 可逆变换"的题。流程里最容易被跳过的是第 3 步和第 5 步,而这两步恰恰是保证正确性的关键。我的建议是把它固化成习惯,别图快省掉。
5.3 什么时候该放弃纯静态、转动态调试
rome 这道题纯静态就能搞定,但不是所有题都这么友好。遇到以下情况,我会果断转动态调试:
- 加密逻辑里有大量间接调用:函数指针、虚表调用,静态看不清调用目标。
- 有反静态分析手段:代码混淆、控制流平坦化,静态读起来极其痛苦。
- 关键数据是运行时生成的:比如密钥在运行时计算出来,静态看不到。
- 逻辑太复杂,静态读容易出错:这时候动态跑一遍、在关键点下断点看实际值,比硬读代码高效。
动态调试的核心技巧是在"输入之后、比较之前"下断点,直接看内存里加密后的结果,和你静态分析预测的对照。如果对得上,说明静态理解正确;对不上,就以动态的实际值为准,回头修正静态理解。这种"动静结合"的方法,是处理复杂样本的标配。
5.4 从做题到实战:这类技能在真实场景里的影子
最后说点题外话。CTF 逆向题是"人造"的,逻辑清晰、目标明确,但它的训练价值在于培养读二进制、还原逻辑、验证假设的能力。这些能力在真实场景里同样有用,比如分析一个没有源码的程序的输入校验逻辑、理解某个文件格式的解析过程、排查一个闭源库的行为异常。
我在实际工作中遇到过需要"从二进制反推数据格式"的情况,用的就是做逆向题时练出来的那套方法:先静态摸骨架,再动态验证关键点,最后写脚本批量处理。区别只在于真实场景的数据更脏、边界条件更多,但核心思路是一致的。所以别把 CTF 逆向当成纯粹的"解题游戏",它练的是真本事。
回到 rome 这道题,它最大的价值是让你把"复合变换求逆"这件事彻底想明白。这个点一旦通了,后面遇到各种变形的加密逻辑,你都能有条不紊地拆解。我个人在实际操作中的体会是:逆向的难点从来不是工具用得多溜,而是逻辑想得多清楚。工具只是帮你把逻辑验证一遍,真正决定成败的,是你脑子里那张清晰的变换规则表。