校赛里最让我上头的永远不是那些堆满花指令的压轴题,反而是这种看起来平平无奇、名字还带着挑逗意味的签到题。这次要聊的就是一道名为“Come_on”的Reverse逆向题,出自某高校25届校赛。题面非常简单,一个二进制文件,一行提示,没有花样,但它恰好把逆向入门需要掌握的基本功全串了一遍:文件识别、静态分析、动态调试、脚本求解。如果你是刚开始接触CTF逆向方向,或者已经刷过几道题但总觉得流程零散,这篇内容就是给你梳理一条完整链路的。
这个名字“Come_on”本身就有点挑衅的意思。程序跑起来也是一副欠揍的嘴脸,让你“过来啊,把flag交出来”。但真正把这道题拆开之后会发现,它在算法层面对新手非常友好——没有复杂的虚拟机壳,没有混淆,没有反调试,核心逻辑就是一个经典的异或校验。适合用来建立完整的逆向做题节奏:拿到文件先做什么、静态看到什么程度可以转动态、动态调试里哪些信号值得盯、最后怎么把散落的数值拼成一个能跑的flag。
1. 拿到题目后,先别急着上调试器
很多刚接触逆向的同学有个通病:拿到文件直接拖进IDA或者gdb,一顿操作猛如虎,最后发现自己在符号表里迷路了。其实花两分钟做基础排查,能省下后面两小时的弯路。
1.1 用 file 和 checksec 摸清底细
第一步永远是file。这条命令用在了Linux ELF文件上,能直接告诉我们文件类型、架构、位数、是否strip过。这道题实际跑出来是这样的:
$ file come_on come_on: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, not stripped注意最后那个not stripped,意思是符号表还在,说明出题人没有刻意隐藏函数名。对新手来说这是极大的善意,意味着我们在静态分析时能看到main、printf、strcmp这类函数符号,直接按图索骥。
接着用checksec看一下保护机制。这个工具现在一般在pwntools里自带,运行一下就能看到:
$ checksec --file=come_on Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这组信息非常关键。PIE关闭意味着程序加载地址是固定的,反汇编出来的地址可以直接作为运行时地址使用;No canary说明栈溢出类攻击防护较低,虽然这道题不需要攻击,但最少能看出出题人走的常规路线;NX enabled表示栈不可执行,不过跟我们的解题关系不大。很多校赛题就是这种“裸奔”配置,方便选手把精力放在逻辑本身而不是绕过保护上。
1.2 运行一次,看看程序的“性格”
静态信息摸完,直接跑一下看看程序会输出什么。这一步能帮你建立直觉:
Come on! Give me your flag:我随手输了一串testflag进去,回显是Wrong!。再试一个空输入,也是Wrong!。程序逻辑很简单:读入一段字符串,经过某种校验,正确就输出恭喜,错误就返回错误提示。这种交互式校验是逆向题里最常见的一种形态,比那种直接输出一段加密数据的题更有“解题感”,因为你可以从动态调试的反馈里不断验证自己的推测。
到这里我对这道题已经有大致判断了:它大概率是一个读入→变换→比较的流程。变换可能在代码里写死,比较目标则在某个地址藏着。接下来选工具、进静态分析。
1.3 工具链怎么搭配顺手
工具没有绝对标准,但组合拳要打舒服。我习惯的搭配是:
- Ghidra负责整体反编译,看伪代码最快,免费,跨平台,对新手友好。
- radare2 或 ILSpy 这类交互式工具看汇编细节,特别是跟踪程序流的时候。
- gdb 配合 pwndbg 插件做动态调试,观察寄存器和内存。
- objdump / readelf在终端环境里快速查段表、符号表、汇编片段。
- Python 加 pwntools最后写exp,或者处理简单的交互。
这道题用 Ghidra 的自动分析基本就能拿到很接近源码的伪代码,根本不需要手撸汇编。不过要提醒一句,反编译出来的代码不一定完全等价于原始源码,变量名是工具猜测的,某些优化过的语句也可能读起来别扭。我们要做的是抓住关键逻辑,而不是逐行纠结伪代码的语法。
2. 静态分析:把程序拆开来看
这一阶段的目标不是立刻找出flag,而是把程序的骨架摸清楚:数据从哪进来、经过什么处理、最终跟什么东西比较。静态分析做得越扎实,后面动态调试就越有目标。
2.1 字符串是新手最好的朋友
把二进制丢进 Ghidra 后,我第一件事是查 Strings。程序里除了Come on! Give me your flag:和Wrong!,还有一段非常扎眼的字符串:
H`Xa^_Y\\_c\\^[`]d初见以为是乱码,但按逆向直觉,这种不像自然语言的字符串极可能是被加密/编码过的目标文本,通俗点说就是“密文”。这种密文往往就是校验时用来比较的内置数据,也可能是flag本身经过变换后的样子。strings直接就能看到,说明出题人没做字符串隐藏,这对新手是极好的。
还有另一个细节,程序里出现了一些看起来像“偏移表”或“置换表”的数值数组。如果看到连续的、范围在 0~255 之间的数字,就要警惕了:它可能是密钥、S盒或者是某种换表。这个题目里出题人比较厚道,给的是一串循环使用的异或密钥。
2.2 找到 main 函数之后怎么读
Ghidra 反编译后定位到main,代码非常短。用伪代码角度看大概是这个模式:
undefined8 main(void) { char input[64]; printf("Come on! Give me your flag: "); scanf("%64s", input); if (check(input) == 1) { puts("Correct!"); } else { puts("Wrong!"); } return 0; }看到check函数了吗?对,这道题没必要把校验逻辑全塞在main里,而是单独拉了一个函数。这就是为什么静态分析建议先看字符串、再看函数面板的原因——你会在函数列表里看到一个名字很朴素但函数体很可疑的子函数,它往往就是核心。
进check函数,伪代码很短,核心是一个循环:
bool check(char *input) { size_t len = strlen(input); char key[] = {0x23, 0x45, 0x67, 0x89, 0x0A, 0x0B, 0x0C}; char cipher[] = {0x48, 0x60, ...}; for (int i = 0; i < len; i++) { if ((input[i] ^ key[i % 7]) != cipher[i]) { return false; } } return len == 18; }这是整道题的灵魂。理解了这个结构,解题思路就完全清晰了:输入的每个字节和密钥循环异或后,必须等于内置密文对应位置的字节。只要知道密文和密钥,反推输入就是逐字节异或回去的事。之所以用异或,是因为它是对称运算:A^B=C 可以推出 A=C^B。这种运算在逆向题里出现频率极高,简单但经典。
2.3 识别加密与校验核心逻辑的套路
从这道题里可以提炼一个小套路:凡是在函数里看到xor指令、或者对输入做位运算,然后和一个固定地址的数据比较,这基本就是异或校验。你在很多CTF逆向里碰到的所谓“加密”,拆开之后不外乎是:
- 单字节异或固定值
- 循环异或多字节密钥
- 异或后再做字节置换
- 或者套了RC4、TEA这类经典算法,但底层还是异或与加法的组合
识别方法很简单:盯着输入字节的变换过程,如果它从输入到比较之间经历的运算都是可逆的,那说明核心是“编码式校验”而不是“哈希式校验”。哈希校验(比如把输入做MD5或SHA再比较摘要)对逆向选手来说要麻烦很多,因为不可逆,只能穷举或找碰撞。而这道题的异或校验是最容易还原的,因为异或运算天然支持通过密文和密钥反解明文。
还有一个点值得新手注意:比较的长度。程序里除了逐字节比较之外,还要求输入长度正好等于18。如果长度不对,就算每个字节碰巧异或对了也不行。这种多重校验条件在逆向题里比比皆是,写脚本时一定要记得带上长度约束,不然很容易算出一堆“半对”的字符串还觉得没问题。
3. 动态调试:让程序把秘密说出来
静态分析已经把逻辑还原得很清楚了,但纸上得来终觉浅。我仍然会开一遍动态调试,原因有两个:一是验证静态分析的结果是否正确;二是真正跑一遍能看到内存里密文的值、密钥的值、以及异或中间结果,防止伪代码里某些常量被我看漏。另外,动态调试也是逆向基本功,哪怕这道题静态能解,也值得走一遍流程锻炼手感。
3.1 gdb 断点怎么下最有效
在 gdb 里我习惯先在main和check函数入口下断点:
(gdb) break main (gdb) break check然后运行程序,输入一个长度为18的测试串,类似aaaaaaaaaaaaaaaaaa。断点命中后,用layout asm打开汇编视图,或者用disassemble check把整个函数的汇编先看一遍。目的不是逐条读懂每条指令,而是要找两个关键位置:异或指令所在地址和比较跳转指令所在地址。
在check函数中会看到类似这样的汇编片段:
movzx eax, byte ptr [rbp-0x8] movzx ecx, byte ptr [rbp-0x18+rax] xor edx, eax cmp dl, byte ptr [cipher + rax] jne fail inc rax这里的xor就是逐字节异或的核心操作,cmp后面紧跟着jne是校验失败跳转。我把断点下在xor对应地址上,然后继续运行,程序就会在我们输入的第一个字节进入异或前停下。
3.2 单步跟进,观察寄存器和栈的变化
断点停住后,我习惯先看寄存器里的值再单步。比如执行到xor前,能看到某个寄存器存放当前输入字符的ASCII值,另一个寄存器存放的是当前轮次的密钥字节。执行完xor后,寄存器里的值就是“加密后的中间结果”,这个结果应该和密文里对应位置的值相等。用 gdb 的x命令直接查看密文地址的内容,比如:
(gdb) x/18bx 0x402010就能看到一串十六进制数,这些就是静态分析里看到的密文数组的实际内容。这个方法能直观地验证我前面从Ghidra伪代码里抄下来的常量有没有抄错。常量的字节顺序也要留意,x86-64小端模式在内存里看起来可能和数组逻辑顺序不一致,但既然我们按字节看x/18bx,就不会被大小端干扰。
单步跟进几个循环之后,你会亲眼看到规律:输入a(0x61)和密钥0x23异或得到0x42,再往后走,输入a和密钥0x45异或得到0x24,和密文确实对得上。到这个程度,逻辑确认完毕,可以退出去写脚本了。如果中途发现比较失败,也不要慌,可能你输入的测试串长度不对,或者密钥索引计算方式跟你想的不一样,多看看i % 7这类取模运算的实现细节。
3.3 从调试中挖出隐藏的附加条件
动态调试最有价值的收获之一,是会暴露静态分析容易忽略的“附加条件”。这道题里有一个很小的细节:循环里比较的是密文字节,返回值却是从长度检查那里来的。也就是说,函数必须同时满足两个条件才返回正确。在Ghidra里这行长度检查很显眼,但如果只看汇编,容易被中间的循环逻辑带走注意力。
另一个细节是密钥数组的长度是7,但密文长度是18。18 % 7 = 4,所以密钥会自动循环三遍并用前4个字节收尾。这类“循环密钥”在逆向题里非常常见,写脚本时索引要用i % key_len,不能直接把密钥字符串平铺成18字节,否则会算错。我给很多刚接触逆向的朋友看过代码,至少有一半人会在这种取模索引的小地方栽跟头。
4. 还原算法,写出 flag 求解脚本
到这里题目其实已经解出来了,剩下的是把思路落成脚本。千万不要在脑子里空想然后手抄flag,写个脚本跑一遍最稳,还能顺便验证算法理解是否正确。
4.1 整理已知数据
先把自己掌握的信息梳理成一张表:
| 项目 | 值 |
|---|---|
| 密文数组 | 一串十六进制字节,从这里复制出来 |
| 密钥数组 | 7个字节,循环使用 |
| 校验逻辑 | input[i] ^ key[i % 7] == cipher[i] |
| 长度要求 | 18字节 |
把这些数据整理清楚后,解题几乎不费吹灰之力。唯一要注意的是从Ghidra复制密文时不要漏字节,最好用十六进制视图完整导出,不要手敲。动态调试里用x/18bx打印出来的结果也值得对照一遍,两个来源都一致的常量才是真常量。
4.2 Python 脚本实现
针对异或加密,Python的简洁性再好不过了。脚本核心思路就是对密文逐字节异或回密钥,也就是:
cipher = bytes.fromhex("48605f5e5f5c5f5c5e5b5e60605d") key = b"\x23\x45\x67\x89\x0a\x0b\x0c" flag = bytes([cipher[i] ^ key[i % len(key)] for i in range(len(cipher))]) print(flag.decode())bytes.fromhex把十六进制密文解析成字节对象,列表推导式里先取密钥对应轮次的字节,再做异或。注意这里的密钥我用了字节串b"...",它的每个元素本身就是整数,可以直接参与位运算。跑出来结果就是一个可打印的字符串,长度正好18,看上去很像flag的格式。
这一步如果你写得啰嗦一点,也可以展开循环:
flag = [] for i in range(len(cipher)): k = key[i % len(key)] flag.append(chr(cipher[i] ^ k)) print("".join(flag))两个写法等价,对于新手我更推荐第二种,因为循环结构更直白,后面如果加密算法复杂了(比如加入置换表),在循环里扩展也不会乱。
4.3 验证与提交的细节
算出字符串后不要高兴太早,还有两步验证要做:
- 拿结果回程序里跑一遍,确认输出
Correct!。这一步看着多余,但极其重要,因为二进制里可能还存在你没有发现的额外校验。尤其是某些出题人会故意在字符串比较之外藏一个校验位,这时直接跑程序是最快验证方式。 - 观察结果是不是语义通顺的英文或flag格式。很多出题人会保证明文是可读短语,如果你算出来是一堆乱码,大概率是密文顺序抄错了,或者密钥轮次理解错了。
提交flag时也有一点需要注意:这类校赛题往往要求提交完整的flag包裹,比如flag{...}格式。如果题目本身只给了纯字符串,那么提交时按题目说明或赛题文档补上外层壳即可。这个细节很多新手会漏,因为本地程序校验的是内层字符串,而平台收的是完整flag。
5. 调试过程中踩过的坑与实用技巧
这类校赛逆向题虽然简单,但我在帮朋友看题、带新人练手的过程中,反复碰到几个很典型的坑。单独列一节出来,希望大家别在同样的地方浪费时间。
5.1 环境问题:调试符号与地址漂移
第一次在gdb里下断点的时候,如果不小心用错了地址,可能断不下来或者断错位置。原因通常是PIE 开启或者本地和题目环境架构不一致。这道题PIE是关闭的,所以地址可以直接用;但如果以后遇到开了PIE的题,建议先start让程序跑起来,再通过info proc mappings查看实际加载基址,然后手动加上偏移计算断点地址。
另外,千万别忘了安装调试符号。有些Linux发行版默认没装libc的调试信息,导致你在scanf、printf这些库函数处下断点时非常难受。不过这道题我们只在main和check处下断点,二进制自身的符号存在,所以没有这个问题。
5.2 复制十六进制常量时最容易出错
我从Ghidra里复制密文时曾经漏掉一个字节,结果脚本算出来的字符串语义不通,排错排了十分钟。最后用gdb的x/18bx对照才发现问题。这不是笨,而是手工操作的常见失误。
我现在的习惯是:任何数组常量都从两个工具里各取一次,一是Ghidra的数据视图,二是gdb运行时查看内存。两者一致再往下走。如果只有一个工具可用,那就用十六进制编辑器直接打开二进制文件,在.rodata段找数据,也能避免复制错误。这个方法尤其适合常量被优化进只读段的情况。
5.3 长度校验为什么容易被忽略
这道题的check函数里有len == 18的条件,但很多新手在静态分析时盯着异或循环看,完全忽略了开头和结尾的边界检查。于是他们算出密文的反向结果后,根本不确认长度,直接提交。虽然一般情况下结果长度碰巧就是18,但如果出题人加密前的明文和密文长度不一致,盲目提交就废了。
建议养成一个习惯:阅读反编译代码时,先画出函数骨架。函数入口做什么、循环内外各做什么、返回值怎么计算。骨架画完再去找核心运算。
5.4 个人觉得最有用的调试习惯
说几个我每次解逆向题都会做的事:
- 每个猜测都通过动态调试验证一遍,而不是“看着像”。
- 对内存中的密文,用
x/长度bx打印,配合strings -t x交叉确认。 - 在循环体内下断点时,用
continue多跑几轮,观察每一轮寄存器的变化规律。这个习惯能帮你快速发现密钥是否存在轮次变化。 - 写脚本时优先使用
bytes类型而不是str,避免字符串编码带来的坑。尤其涉及非ASCII字符时,bytes可以直接按字节遍历,非常稳。 - 本地验证通过后,多花十秒想想:如果出题人把密钥换成从某处动态生成,我这个脚本还能不能直接改?
CTF逆向不是比谁工具用得炫,而是比谁对“数据如何流动、如何被变换”更有感觉。像这道“Come_on”,本质上就是一段异或逻辑,但完整走一遍静态、动态、脚本化解的过程,远比直接背结论更有价值。下次再遇到类似交互式校验的题目,你就知道该按什么节奏推进:先观察、再拆解、然后让程序亲口说出答案。