第一次在Bugku上看到Reverse分类里的"love"这道题,我心里想的是:坏了,这名字起得这么甜,题目估计不会简单。结果拖进IDA一看,发现它几乎是给新手量身定做的入门教材——一个明显的main函数、一张Base64编码表、一个字符串比较,外加一个有点意思的位运算。这篇文章把从拿到文件到解出flag的整个过程重新走一遍,聊聊我是怎么定位关键函数、怎么判断加密逻辑、怎么写还原脚本的。同时也打算借这个例子,给刚开始玩逆向的朋友整理一套可以复用的分析思路。内容不涉及高深技巧,一个能装IDA和Python的Linux环境就够了。
1. 拿到love这道题,我第一步做了这几件事
1.1 先确认文件类型和基础信息
从平台下载的压缩包解开后,出现在眼前的不是一个带后缀的可执行程序,而是一个名称就叫love的普通文件。这种时候我的习惯是先用file命令确认格式,别靠后缀猜:
$ file love love: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32看到ELF 64-bit LSB executable这条信息,心里就有底了:这是Linux下的64位可执行文件。CTF的Reverse题喜欢出Linux ELF,原因很简单——环境统一,一个Linux容器或者WSL就能覆盖大部分题目的运行需求,而且这类题目结构简单,动态链接、静态链接都不影响我们找main和关键比较函数。
接下来跑一遍strings,看看有没有直接暴露的关键词:
$ strings love /lib64/ld-linux-x86-64.so.2 ... Input Your Flag: right wrong ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/上面这段输出信息量不小。前三行告诉我们程序的交互逻辑是:提示输入flag,然后给出right或wrong。最后一串字符乍看像乱码,实则是一个标准物——Base64编码表。一个程序不会平白无故把整张Base64表放在数据段里,出现它,基本可以断定程序内部有Base64编码或解码的操作。
1.2 先运行一遍,观察程序行为
在Linux环境里直接跑一下看看,这里我用的是WSL:
$ chmod +x love $ ./love Input Your Flag: 123456 wrong随便输入一串数字,程序只回了一个wrong。没有反调试、没有花指令、没有额外的提示信息。到这里,这道题的性质已经很清楚了:输入-处理-比较三段式结构。下一步就是用IDA打开,把"处理"和"比较"这两个环节找出来。
如果是在Windows上做题,看到love是个没有后缀的文件,不要试图改后缀双击运行。先
file确认类型,再放进WSL或虚拟机。这是很多新手踩的第一个坑。
1.3 这几条线索能得出什么结论
把前面观察到的信息汇总一下:
| 观察结果 | 推断 |
|---|---|
| 64位ELF,动态链接 | 需要在Linux环境运行 |
| 出现Input Your Flag、right、wrong | 交互式判定输入正确性 |
| 出现标准Base64编码表 | 后续要重点关注查表操作 |
| 运行时无复杂输出 | 代码量不大,适合静态分析 |
这三条线索加起来,基本可以放弃动态调试,直接用IDA做静态反编译。love这种题目不会触发IDA的自动分析问题,拖进去几秒钟就能出结果。
2. IDA里的主要矛盾:main函数和一个神秘的sub_400C8E
2.1 从main函数入口看起
用IDA打开love,左侧函数列表里能看到为数不多的几个函数,main就在其中。双击进入main,按F5生成伪代码。不同IDA版本反编译出来的代码在变量名和行号上有差异,但结构基本一致:
int __cdecl main(int argc, const char **argv, const char **envp) { char input[256]; unsigned int i; puts("Input Your Flag:"); scanf("%s", input); for (i = 0; i < strlen(input); ++i) input[i] = (input[i] << 4) | (input[i] >> 4); if ( sub_400C8E(input, "这里是一串Base64字符串") ) puts("right!"); else puts("wrong!"); return 0; }第一次看这段代码,我最容易跳过去的就是循环里那行input[i] = (input[i] << 4) | (input[i] >> 4);。很多新手会觉得这只是某种无所谓的运算,不影响大局。但实际上,它就是加密链路里的第一环,而且是理解整道题的关键。
2.2 逐行读出main的三段逻辑
main的整体逻辑分三段:
- 第一段,提示输入,用
scanf("%s", input)把用户字符串读进数组。 - 第二段,遍历input的每个字符,做位运算变换。
- 第三段,调用
sub_400C8E,传入处理后的input和一个目标字符串,根据返回值决定输出right还是wrong。
这里有个小细节:strlen(input)在循环条件里每轮都会重新计算,但因为scanf读入的字符串长度在输入后是固定的,循环次数就是输入长度,不会变来变去。出题人源码里经常这么写,分析时不用纠结。
sub_400C8E返回非零输出right,返回零输出wrong,可以确定它就是最终比较函数。两个参数分别是"处理后的输入"和"目标字符串"。换句话说,程序本质上是在做这样一件事:把你的输入加工成某个目标样子,再和目标字符串比较。相等就是对,不相等就是错。
2.3 核心运算:(input[i] << 4) | (input[i] >> 4) 在干嘛
拿一个具体字符手算一遍,就全明白了。假设输入字符是'A',ASCII码是0x41,二进制是0100 0001。
0x41 << 4:左移4位,变成0001 0000。原高4位0100被推到了低4位区域,高位补0。0x41 >> 4:右移4位,变成0000 0100。原低4位0001被甩到了高4位区域。- 两者做或运算,结果是
0001 0100,也就是0x14。
原来0x41变成了0x14。这个变换的精确定义是:一个字节的8个bit,高4位和低4位互换。用循环移位的视角看,就是对8位二进制数做4位循环移动,左移4位和右移4位效果一样。
2.4 这个变换是自逆的,所以解题反而省事
如果我们对0x14再做一次同样的操作:
0x14 << 4,得到0x400x14 >> 4,得到0x01- 或运算结果,0x41,又回到了'A'
这说明(c << 4) | (c >> 4)是一个自逆变换,连续做两次等于什么都没做。这对解题非常关键:如果加密侧的流程是"先做位变换,再做Base64编码",那解密侧的流程就是"先做Base64解码,再做完全相同的位变换"。脚本里不需要额外推导逆运算,直接照抄同一个式子即可。
有个类型问题必须提醒一下。C语言里
char参与运算会被提升为int,如果变量是带符号的char,移位过程中可能产生符号位干扰。实际出题用的是无符号字符,但为了稳妥,我写脚本时一律加& 0xFF把中间结果截断到8位,避免Python里整数无限扩展导致结果异常。
3. 顺着数据流识别Base64和比较目标
3.1 字符串窗口是逆向的"第一快照"
在IDA里按Shift+F12打开Strings窗口,刚才用strings命令见过的所有字符串都在这里。最扎眼的就是那行ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/。
双击它,IDA会跳到这串字符在数据段的位置。右键选择"列出交叉引用",就能看到哪些代码在引用它。顺着引用关系,Base64编码的调用点就藏不住了。
3.2 如何确认sub_400C8E内部就是标准Base64
从编码表跳转过去,最终会来到sub_400C8E。它的伪代码大概长这样:
bool __fastcall sub_400C8E(const char *data, const char *target) { // 计算data长度 // 申请输出缓冲区 // 3个字节一组,拆成4个6位索引 // 用编码表查字符 // 尾部补'=' // strcmp(output, target) }只靠伪代码还不够稳,我在汇编层面确认了Base64的特征。标准Base64编码在指令层有非常固定的套路:
- 三个连续字节被读取后,用移位指令拆成4个6位索引
- 每个索引用
movzx eax, byte ptr [rax+table]这样的指令查表 - 剩余不足3字节时,有补
=的尾部逻辑
另外,Base64的原理本身也不复杂:3个字节共24位,每6位一组,得到4个0到63之间的索引,再用索引去查表得到4个可见字符。所以编码后的长度必然是4的倍数,末尾可能出现=填充。
3.3 比较目标字符串就是我们的"已知密文"
在sub_400C8E的调用点,第二个参数就是比较目标。伪代码里它显示成字符串字面量,双击可以直接复制。
复制这个Base64字符串有几个雷区:
- 注意末尾的
=或==,少一个字符解码结果都不同 - IDA的字符串视图可能显示转义形式,从字符串窗口完整复制更保险
- 复制完后数一下长度,必须是4的倍数,不是的话说明复制少了
到这里,整个加密链路已经成型:
用户输入 → 每个字节高低4位互换 → Base64编码 → 与硬编码字符串比较比较目标字符串相当于已知密文。我们要求的是从密文倒推出用户输入,而这个用户输入就是flag。在动手写脚本之前,还要确认一件事:sub_400C8E用的是不是标准Base64表。love这道题经确认是标准表,但我在其他题里也见过打乱顺序的自定义表,遇到那种情况,解码脚本里的表字符要跟着换。
4. 写脚本还原flag:完整解密链路
4.1 解密思路
已知加密链:
输入 → 位变换T → Base64编码 → 密文那解密链就是:
密文 → Base64解码 → 位变换T → 输入(flag)为什么最后一步还是T而不是T的逆?因为T是自逆变换,前面已经验证过。如果这道题用的是异或固定密钥或者加法之类的操作,这里就要小心推导逆操作了,不能随手照抄加密函数。
4.2 Python脚本
import base64 # 在IDA中复制的目标字符串,注意保留末尾的=号 cipher = "在这里粘贴从IDA复制的Base64密文" # 第一步:Base64解码,得到位变换后的字节序列 tmp = base64.b64decode(cipher) # 第二步:对每个字节再做一次高低4位互换 flag = bytearray() for b in tmp: x = ((b << 4) | (b >> 4)) & 0xFF flag.append(x) print(flag.decode())跑完脚本,输出应该是一个以}结尾、整体可读的字符串,格式通常是flag{...}或者bugku{...}。把这串内容填回程序验证一下:
$ ./love Input Your Flag: flag{你得到的字符串} right看到right,整个闭环就完成了。我不在这里直接贴出题目里的实际flag,是为了保留你自己提取密文并解码的完整过程。如果只是想抄答案,CTF的乐趣就没了;但如果你是想学分析方法,上面这些已经足够。
4.3 脚本里的两个常见翻车点
第一个翻车点是Python3的字符串和字节类型。base64.b64decode返回的是bytes,你遍历它取出来的元素是0到255的整数,这符合预期。千万别提前.decode()成str再逐字符处理,那样chr和ord来回切换,极易把自己绕晕。
第二个翻车点是输出时的不可见字符。如果flag里碰巧有非ASCII字符,直接decode()可能报错。稳妥一点的写法是:
try: print(flag.decode()) except UnicodeDecodeError: print(bytes(flag).hex())不过love这种入门题的flag基本都是纯ASCII,正常情况下走不到这里。我一般会留着这段try,因为后面练更难的题时早晚用得上。
除Python之外,CyberChef也能解这道题:先用From Base64把密文还原成字节,再用Rotate Left按4位、8-bit模式旋转,输出的内容就是flag。这条路径适合不想写代码的人,但我还是建议自己写一遍脚本,因为脚本能帮你理解位运算的细节。
4.4 顺带说一句std::reverse的误传
写题过程中我在网上翻资料,看到有人问"C++11以下能用std::reverse吗",然后试图把这几个字往love上套。这里说明一下:std::reverse是C++98标准库<algorithm>里的模板算法,作用是把一段区间内的元素倒序,C++11以下完全可以用。love这道题的反编译结果里并没有std::reverse,它走的是C语言风格的位运算加Base64。如果以后在别的高难度逆向题里看到std::reverse相关符号,那才真是遇到了C++ STL代码,把它等价成一个"区间倒序"操作即可,和本题无关。
5. 从love说开去:这类入门逆向题的分析套路
5.1 从"最后比较"反推数据流
love给我最大的收获,是让我第一次体会到"从终点反推"的逆向思维。大部分Reverse题不管做多少层变换,最终都会落在一个比较函数上——可能是strcmp,可能是memcmp,也可能是一个逐字符比较的循环。分析方法永远是这几步:
- 找到最终比较点
- 看比较点的两个操作数分别是什么
- 顺着其中一个操作数向上追溯,直到追到用户输入
- 途中经过的每一次运算,就是加密链的每一环
然后在脚本里从已知密文出发,沿着反方向逐层还原。有些人一上来就想着动态调试,断点设在哪里都不知道。对这种静态逻辑清晰的题目,IDA的F5已经完全够用,动态调试反而多余。
5.2 识别加密原语的通用技巧
经过这道题,我总结出几个常见的"嗅探"方法,看到特征可以直接对号入座:
- 数据段有64个可见字符的表,先怀疑Base64,并对比表是否标准
- 看到固定值异或
^ 0x3F,或者异或值和数组下标相关,先怀疑XOR加密 - 看到大量加法和减法遍历字符串,先怀疑凯撒式移位或某种简单流密码
- 看到循环移位或高低位互换,记住这类操作大多是自逆的
- 看到复杂数组、迷宫寻路、奇怪的递归,多半是出题人在炫技,入门阶段可以先绕开
识别原语的过程很像看化验单:汇编指令特征指向加密算法类型,最后用脚本验证结论。love这道题就提供了一个很干净的示例:一次位变换加一次Base64,特征明显,验证简单。
5.3 新手容易卡住的三个点
我带人看这道题,大家最常卡的地方有这三个,我单独拎出来说。
一是环境。拿到了ELF文件,不知道Linux环境怎么来。我的建议是装好WSL或者一个Ubuntu虚拟机,比在Windows上做一堆兼容层操作省心得多。逆向题里有相当大比例是ELF,这个环境迟早要配。
二是IDA基本操作。F5只是生成伪代码,不等于替你做题。要学会看交叉引用(选中后按X键)、看字符串引用(在Strings窗口双击)、修改变量类型(按Y键)。这几个操作能提升分析速度一大截。
三是脚本里的位运算。<<和>>的优先级容易记混,Python的整数移位又没有固定位数上限。写脚本时先拿一个已知字符手算一遍,再跑全量,可以有效降低出错率。
5.4 下一站可以练什么
做完love,我建议按这个顺序往下练:
- 找一道用了自定义Base64表的题,练习提取自定义表和重建解码器
- 找一道XOR加密的题,练一练从密文和已知明文推断密钥
- 找一道带花指令混淆的题,感受一下修复函数边界的过程
- 换用Ghidra再做一遍love,体验不同工具对同一道题的反编译差异
别急着冲高难度的题。逆向是个"刷够一定量就突然通透"的领域,love就是那个让你第一次感受到数据流追踪乐趣的起点。我自己最深的感觉是,把这题的每个字节在纸上手算一遍,比收藏十个writeup都有用。算完之后,你再看别的Reverse题,会不自觉地拆成"输入、变换、比较"三段,这就是这道love留给你的最值钱的东西。