拿到“Not Bad”这道题的时候,我正在BUUCTF的逆向分类里一题一题地刷。名字起得很低调,甚至有点劝退的意思——Not Bad,不就是“还行”?可真正把文件拖进去开始分析之后,我发现这名字反而是个提醒:不要因为标题看着简单就轻视主流程里的坑。这道题很适合刚接触CTF逆向的新手,因为它把花指令、内存自解密、动态调试这几个高频考点压缩在一个很短的二进制里;同时对于刷过一段时间题目的老手,也能在很短时间里复盘一遍“静态分析—动态定位—脚本求解”的完整打法。
这篇文章不打算贴一堆无脑命令然后丢给你一个答案,而是想还原我当时从拿到文件到最终跑出flag的整个思考过程,包括中间走弯路、误判、以及最后怎么定位到关键比较逻辑。如果你也在做BUUCTF,或者正准备入门逆向,这篇可以作为一份实战笔记来看。
1. 拿到题目先别急着开IDA:先看这是什么文件
很多新手拿到一个二进制文件,第一反应就是扔进IDA按F5。这招不是不行,但碰到“Not Bad”这种带了一点手脚的题目,直接静态反编译往往会被花指令和自解密代码带偏。所以我习惯先做一些最基础的信息收集,把文件的基本属性、保护机制、以及有没有明显字符串泄露搞清楚,再决定用哪套打法。
1.1 file 与 readelf:识别文件格式
我拿到的版本是一个Linux下的ELF文件,不是Windows PE,也不是纯Python打包的ELF。第一步就是看文件头信息:
file not_bad输出大概是这样的:
not_bad: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=xxxx, not stripped这里有两个关键信息:一是“not stripped”,说明符号表还在,等会儿用GDB或者objdump看函数名会轻松很多;二是“dynamically linked”,说明它不是一个静态编译的大块头,里面可能用了glibc的函数,比如printf、scanf、strcmp之类的。
为了进一步确认程序入口和区段布局,我顺手跑了一下:
readelf -h not_bad readelf -S not_bad重点看.text段是否可写,以及有没有奇怪的段名。很多自解密题目会把一段代码在运行时解密,这就需要把代码段设置为可写,或者通过mmap重新映射一块内存来执行解密后的指令。“Not Bad”这家伙的.text段权限确实不是普通只读,这在后面分析SMC(Self-Modifying Code)的时候给了我一个提醒。
1.2 checksec:了解保护机制
接下来用pwntools自带的checksec看保护:
checksec --file=not_bad结果大致是:
Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled这里需要注意两个点。第一,PIE开启,说明程序加载基址是随机的,那么我们下断点时如果直接用硬编码的绝对地址就会失效。解决办法要么是每次运行都通过/proc/pid/maps算基址,要么直接用GDB的start命令停下来之后用$pc附近的符号名来下断。第二,没有canary,意味着如果这是一道堆栈相关的题,直接溢出是有可能利用的,但“Not Bad”不是pwn题,这个信息在这里更多是告诉我们它没有额外的栈保护干扰调试。
1.3 先跑一遍字符串扫描,再决定要不要直接逆
在静态分析之前,我喜欢先用strings扫一遍,尤其是PIE开启后,很多时候程序会把一些提示字符串放在只读数据段,虽然没有完整flag,但至少有逻辑提示:
strings not_bad | grep -E "flag|Not|Bad|input|wrong|right|key"我当时扫到的东西蛮有意思,有这么几条:
Not Bad! Guess my key? Input your flag: Wrong! Right!其中最显眼的其实是“Not Bad!”这句话。一开始我还以为这就是个普通的成功提示,但后来看了反汇编才发现,这段字符串所在的位置居然和主流程解密后的代码有关。换句话说,“Not Bad”不只是输出提示,它可能还是判断流程里的一个标志。
2. 静态逆向:主流程怎么读懂
信息收集做完后,我心里差不多有数了:这是一个裸的64位ELF,带PIE,有字符串提示,.text段还可写。这种情况下,盯着一个函数继续看反而容易钻牛角尖,不如先把整体调用关系理一遍。
2.1 定位主函数入口
因为程序没有strip,所以用GDB的info functions或者IDA打开后直接能看到main。不过在我实际用objdump看的时候,发现主函数很短,短到让人怀疑它只是个跳板:
objdump -d -M intel not_bad | grep -A 80 "<main>:"反汇编出来的主函数大概逻辑是先调用一个printf打印“Input your flag:”,然后调用scanf读入字符串,接着把这个字符串的指针传给一个名为check的函数。如果check返回0,就走“Wrong!”分支;返回1,就走“Right!”分支。
代码看起来很简单,但衍射出的问题在于check函数的内部逻辑。如果check是正常的函数,那这道题就是入门中的入门;可“Not Bad”显然没打算让你这么舒服。
2.2 分析关键check函数,发现异常跳转
我跳到check函数的反汇编时,第一眼看到的是开头几条指令还算正常:保存栈帧、分配局部变量、把第一个参数存起来。但再往下看,出现了一些莫名其妙的指令,比如:
push rax xor rax, rax add rax, 0x40 pop rax jmp loc_xxx而且重点不在指令本身,而在跳转目标。很多跳转不是跳到正常的指令边界,而是跳到某条指令的中间字节。这是一种典型的花指令布局:通过控制跳转地址,让静态反汇编器陷入错误解码,从而把真正的代码隐藏起来。
IDA在遇到这种情况时,F5经常会出来一团乱码,甚至直接提示“sp-analysis failed”。我当时没有急于F5,而是把这一段指令逐条看了一遍,发现在jmp之后的地址区域,看起来像是数据,实际上是一段被加密过的机器码。
2.3 “Not Bad”提示背后藏着的自解密逻辑
继续往下翻,我注意到在check函数中有一段比较奇怪的循环:
lea rdi, [rip + loc_xxx] mov ecx, 0x10 mov al, 0x66 xor byte ptr [rdi], al inc rdi dec ecx jnz ...这段循环的意思很直接:从某个地址开始,连续16个字节,每一个都和0x66做XOR。因为这段代码会修改自身所在区域的内容,所以属于SMC(Self-Modifying Code)。此时静态反汇编看到的是加密后的数据,只有程序运行起来,解密完成之后,才能看到真正的指令流。
这里还有一个很容易被忽略的细节:解密用的key是0x66,而循环次数是0x10,会不会和flag的长度有关系?我当时先记下来,后来动态调试的时候发现,这16个字节解密后对应了一段真正比较输入数据的代码,但比较逻辑不只在这16字节里,还有后续的一整段。
这让我想起很多混淆题的做法:把核心验证逻辑拆成几段,每段分别加密,再在main流程中按顺序解密执行。这样做的好处是,你单纯看静态反汇编根本拼不出完整的逻辑。
2.4 修复花指令与伪代码还原
为了看清楚解密后的指令,我做了个很土但有效的操作:用GDB在解密循环结束之后下一个断点,然后把该区域的机器码dump出来,再用objdump对这段二进制重新反汇编。具体做法是:
gdb -q ./not_bad b *主函数地址+偏移 run set $addr = 解密后代码地址 x/16bx $addr dump binary memory /tmp/dec.bin $addr $addr+0x40拿到/tmp/dec.bin之后,用ndisasm或者objdump单独反汇编这个文件,就能看到真实的比较逻辑。这样绕过了静态反汇编器的限制,也顺便验证了前面关于SMC的猜测。
解密后的核心逻辑大框架如下:
- 读入flag后,先检查长度是否为指定值。
- 对输入逐字节做一次基于索引的变换。
- 变换后的结果与内存中的密文逐个比较。
这和我们后面动态调试看到的内容吻合。
3. 动态调试:让程序亲口说出验证逻辑
静态分析只能告诉你“这里有花指令,这里有自解密,这里大概有个比较”。但真要还原flag,还是得在动态调试中确认每一步数据是怎么变化的,尤其是解密后代码和输入字符串之间的变换关系。
3.1 在关键解密循环后下断点
我当时的操作顺序是这样:
先用GDB启动程序,在main函数的scanf调用之后断下,输入一串测试flag,比如flag{aaaaaaaaaaaa},然后单步进入check。注意,因为PIE存在,你直接使用start后,GDB会自动停在入口点,可以通过set $base = 基址来计算偏移,也可以用b *($base+off)这种写法。
关键断点位置选在哪里?我选择在解密循环的jnz跳转指令之后,也就是解密完成之后的代码地址。断下后我检查寄存器:
x/10i $pc看到的指令已经变成了一段正常的cmp、movzx、xor序列。这印证了前面SMC的判断。此时再用x/bx看内存,能明显看到原来的字节从乱码变成了有序的指令。
3.2 跟踪输入数据在内存中的变换
为了搞懂变换逻辑,我选择用一个已知的输入来观察中间结果。假设输入是flag{abcdefghijkl},先记录它在内存中的原始字节,然后单步执行核心变换代码。
这里需要强调一个习惯:单步到cmp之前,把目标寄存器和内存中的值都记下来,不要急着继续跑,否则很容易跟丢。我当时把变换前后的数据列成了一张小表:
| 输入位置 | 输入字符ASCII | 变换后值 | 比较目标值 |
|---|---|---|---|
| 0 | 0x66 'f' | 0x?? | 0x?? |
| 1 | 0x6c 'l' | 0x?? | 0x?? |
| 2 | 0x61 'a' | 0x?? | 0x?? |
| 3 | 0x67 'g' | 0x?? | 0x?? |
通过几组数据对比,很快就能发现变换规律不是固定的异或常量,而是与索引有关。实际上,它是把输入的第i个字节先与索引i做异或,再和一个动态生成的字节做加法。只是这个动态生成字节来自前一个密文字节,所以看起来像是某种链式结构。
3.3 从比较函数反推flag
一旦搞清楚了变换规则,剩下的事情就简单了:从内存中把比较目标值逐个提取出来,反推输入即可。我在GDB里用以下命令提取密文:
x/32bx 密文地址得到一串十六进制数组,然后手写脚本反推出flag。这个反推过程和下一步的Z3脚本是等价的,但手推更容易理解题目的设计意图。
实际推出来之后会发现,最终flag是标准的flag{...}格式,长度是固定的。我当时推完后第一反应是:“这题确实Not Bad,关键是别被前面的SMC骗了。”
4. 脚本求解:用Python把最后的运算还原
到这一步,逻辑已经明确了,但还是建议把反推过程写成脚本。一方面是可以避免手算出错,另一方面是以后遇到同类题可以直接改脚本复用。
4.1 用Z3约束求解
我先把验证逻辑提取出来,写成Z3的约束求解脚本。核心思路是把输入的每个字节建模成BitVec(8),然后将题目里的变换操作逐条翻译为约束表达式,最后让求解器找出满足条件的一组值。
以下是我当时用的脚本框架(精简版):
from z3 import * flag_len = 32 s = Solver() flag = [BitVec(f'flag_{i}', 8) for i in range(flag_len)] # 约束:必须是可打印字符,且符合flag格式 for i in range(flag_len): s.add(flag[i] >= 0x20, flag[i] <= 0x7f) s.add(flag[0] == ord('f')) s.add(flag[1] == ord('l')) s.add(flag[2] == ord('a')) s.add(flag[3] == ord('g')) s.add(flag[4] == ord('{')) s.add(flag[flag_len - 1] == ord('}')) # 根据动态调试还原出的变换关系,添加约束 for i in range(flag_len): transformed = flag[i] ^ i # 这里假设之后的处理是单字节加法,具体以你调试为准 transformed = (transformed + key) & 0xff s.add(transformed == ciphertext[i]) if s.check() == sat: model = s.model() result = ''.join(chr(model[flag[i]].as_long()) for i in range(flag_len)) print(result) else: print("unsat")这段脚本很粗糙,但思路是对的。Z3求解器不用关心指令的具体顺序,只要把约束条件写对,它就能在毫秒级给出答案。
4.2 手动推导的数组置换
除了Z3,我还发现这个题的变换可以用纯Python手动实现,因为它本质上是逐字节异或加一个查表置换。下面是简化版的手动推导代码:
cipher = [0x??, 0x??, ...] # 从内存中提取的密文 flag = [] for i, c in enumerate(cipher): v = (c - key) & 0xff v ^= i flag.append(v) print(bytes(flag).decode())因为整个变换是可逆的,不需要跑爆破,复杂度只是O(n)。这也是很多CTF逆向题的特点:核心验证逻辑写得很短,但前面堆了好多花活让你找不到它在哪。找到之后,反推往往很简单。
4.3 脚本验证
拿到脚本算出的结果之后,记得回到程序里验证:
./not_bad Input your flag: flag{...} Right!这里有个小细节:如果输出是“Right!”不代表万事大吉,还要确认是不是标准的flag格式。BUUCTF平台提交时通常只需要flag{...}这一整段,不包括引号。我当时第一次跑出的结果前面几位是乱码,就是因为密文地址取错了一个字节,导致第一个字符变成了非f。手动核对格式之后,修正偏移,立刻正常。
5. 常见问题与避坑指南
做这道题的时候,我在好几个地方卡过壳,这些问题也很有代表性。这里整理一个速查表,方便你以后遇到类似“带SMC花指令”的题时快速排查。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
IDA F5出来全是一堆jmp和无意义指令 | 花指令干扰反汇编 | 别依赖F5,回到汇编窗口看跳转目标;或者动态调试后dump解密字节 |
| GDB断点下在函数开头但没断下 | 函数被编译器优化成跳到另一个地址 | 用start先跑到入口,再info functions确认符号地址 |
| 解密循环后的指令仍然看着像数据 | 解密key或循环次数判断错误 | 用x/20bx对比解密前后的内存,确认循环是否真正执行 |
脚本求解结果不符合flag{...}格式 | 密文地址偏移取错或变换规则补错 | 用已知输入单步调试,验证每一条变换指令 |
| 符号执行工具(如Angr)超时或路径爆炸 | 程序含有大量解密循环,路径爆炸严重 | 优先采用动态提取密文+手写逆向,不要迷信符号执行 |
5.1 为什么我静态反编译出来是乱码?
这是SMC题的经典问题。程序在执行过程中会修改自己的代码字节,因此静态反汇编器在文件加载阶段看到的是加密后的数据,自然解不出有效指令。解决办法就是让程序运行到解密完成点,把这段内存dump出来再分析。只要你能找到解密循环的位置,这个坑基本就算迈过去了。
5.2 动态调试时中断在奇怪地址怎么办?
我一开始在check函数内部用了ni单步执行,结果跳到一个看起来完全不相关的地址,一度以为是自己断错了。后来才意识到,这是因为花指令把正常指令拆断了,单步执行时CPU会“正常”地跳转到混淆代码里。出现这种情况不要慌,用x/5i $pc看看当前地址附近的指令,如果看起来还是垃圾,就继续单步几轮,直到碰到真正有意义的操作,比如cmp、movzx、xor这些。
5.3 符号执行超时怎么办?
很多从Web安全转过来的师傅喜欢拿到题就上Angr,但“Not Bad”这类带自解密和路径分支的题目,符号执行很容易因为路径爆炸而卡死。我的建议是先用动态调试获取密文,再用Z3或者纯Python求解。Angr这类工具适合flag格式简单、逻辑唯一的题;遇到SMC,还是人工分析更稳。
5.4 一个容易忽略的细节:比较函数不止一处
这里我再多嘴一句。check函数里可能存在多处cmp,其中一些是在比较长度,一些是比较字符串内容。如果你提取密文时取错了位置,算出来的结果会很诡异。正确做法是先把长度校验单独分析出来,再用已知长度的测试输入去定位内容比较段。我当时就是因为没先确认长度,脚本里flag_len写错,结果前几次求解全部失败。
6. 扩展思路:这道题还能怎么玩
从“Not Bad”这道题可以延伸出不少值得练手的点,尤其是如果你想系统性提高逆向能力,可以用同一种思路去拆解更多花样。
6.1 从手解到脚本化的思路迁移
SMC(自修改代码)本身并不神秘,它和反调试、反虚拟机一样,都属于“不让逆向工具正常发挥”的手段。但SMC的变体很多:有的用XOR,有的用加解密函数,有的直接在栈上生成机器码。你只要掌握“找到解密点—dump运行时内存—重新反汇编”这条链路,大部分SMC题都逃不出你的手心。
6.2 类似题型的对比与复盘
如果你刷BUUCTF,会发现不少逆向题表面上名字各不相同,但内核依然是“输入—变换—比较”三步走。比如有些题是纯逻辑运算,有些题是模拟指令,有些是VM保护。它们的共同点是:通过动态调试提取关键数据永远是最高效的路径。静态分析负责建立骨架,动态调试负责填充细节,脚本负责把最后一步计算自动化。
我个人的习惯是每做完一道题,就在本子上记下三个东西:程序用了什么混淆手段、我花了多长时间定位核心比较逻辑、以及脚本求解时踩了什么坑。这样刷题效率会高很多。
6.3 再把题目往深处卷一卷
“Not Bad”这个题如果作为模板,你完全可以自己给它加一层反调试,比如在解密前检查ptrace;也可以把加密key从常量改成动态从环境变量读取,增加人工分析难度。很多比赛题目就是这么干的:基础逻辑很简单,但外层套了一堆壳。理解了内核,再剥壳就不会太慌。
反过来,如果你是出题人,想考SMC,也可以从“Not Bad”这道题里找灵感:把真正的验证逻辑藏在一个看似无害的字符串输出后面,再用一个内存修改循环把它解密出来。这样既不会过分刁难新手,又能筛掉只会F5的人。
最后分享一个小技巧:遇到类似“Not Bad”这种名字带点调侃意味的题目,先别急着搜wp,自己动手把文件从里到外看一遍。哪怕卡住了,只要你把动态调试的断点打在了解密后的指令上,这道题的解题难度就已经降了一大半。逆向这个东西,很多时候不是比谁工具用得花哨,而是比谁更有耐心把流程理清楚。希望这篇记录能帮你少走一点弯路。