CTF逆向实战:花指令与SMC自解密,破解Not Bad

发布时间:2026/9/25 13:19:45
CTF逆向实战:花指令与SMC自解密,破解Not Bad 拿到“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段权限确实不是普通只读这在后面分析SMCSelf-Modifying Code的时候给了我一个提醒。1.2 checksec了解保护机制接下来用pwntools自带的checksec看保护checksec --filenot_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。因为这段代码会修改自身所在区域的内容所以属于SMCSelf-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 $addr0x40拿到/tmp/dec.bin之后用ndisasm或者objdump单独反汇编这个文件就能看到真实的比较逻辑。这样绕过了静态反汇编器的限制也顺便验证了前面关于SMC的猜测。解密后的核心逻辑大框架如下读入flag后先检查长度是否为指定值。对输入逐字节做一次基于索引的变换。变换后的结果与内存中的密文逐个比较。这和我们后面动态调试看到的内容吻合。3. 动态调试让程序亲口说出验证逻辑静态分析只能告诉你“这里有花指令这里有自解密这里大概有个比较”。但真要还原flag还是得在动态调试中确认每一步数据是怎么变化的尤其是解密后代码和输入字符串之间的变换关系。3.1 在关键解密循环后下断点我当时的操作顺序是这样先用GDB启动程序在main函数的scanf调用之后断下输入一串测试flag比如flag{aaaaaaaaaaaa}然后单步进入check。注意因为PIE存在你直接使用start后GDB会自动停在入口点可以通过set $base 基址来计算偏移也可以用b *($baseoff)这种写法。关键断点位置选在哪里我选择在解密循环的jnz跳转指令之后也就是解密完成之后的代码地址。断下后我检查寄存器x/10i $pc看到的指令已经变成了一段正常的cmp、movzx、xor序列。这印证了前面SMC的判断。此时再用x/bx看内存能明显看到原来的字节从乱码变成了有序的指令。3.2 跟踪输入数据在内存中的变换为了搞懂变换逻辑我选择用一个已知的输入来观察中间结果。假设输入是flag{abcdefghijkl}先记录它在内存中的原始字节然后单步执行核心变换代码。这里需要强调一个习惯单步到cmp之前把目标寄存器和内存中的值都记下来不要急着继续跑否则很容易跟丢。我当时把变换前后的数据列成了一张小表输入位置输入字符ASCII变换后值比较目标值00x66 f0x??0x??10x6c l0x??0x??20x61 a0x??0x??30x67 g0x??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(fflag_{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自己动手把文件从里到外看一遍。哪怕卡住了只要你把动态调试的断点打在了解密后的指令上这道题的解题难度就已经降了一大半。逆向这个东西很多时候不是比谁工具用得花哨而是比谁更有耐心把流程理清楚。希望这篇记录能帮你少走一点弯路。