从buuctf平台刷到这道题的时候,本来以为又是一道常规逆向签到题,结果在check函数里卡了小半天。题目叫not_the_same,名字起得很随意,但背后考的其实是逆向里最常见的两件事:静态分析找校验逻辑,然后用约束求解器把flag算出来。对刚接触CTF逆向、还没怎么用过Z3的人来说,这道题是个很好的练手样本。这篇文章就把我完整的分析过程和踩坑记录写下来,顺便把每一处关键判断的思路讲清楚,不光是给你一份能跑的脚本。
先说清楚这道题的整体情况。文件是个可执行程序,运行后让你输入一串字符,程序内部有个校验函数,对输入的每个字符做一系列算术变换,最后跟一组预设的常量比较。暴力穷举是不可能的,因为字符范围加上变换逻辑,直接把搜索空间撑爆。正确做法是把校验过程提取成方程组,交给Z3这种符号求解器去解。整个流程可以拆成三步:反编译程序读懂逻辑、把运算关系改写成约束条件、用Z3跑出唯一解。
1. 题型定位:一次搞懂not_the_same到底考什么
1.1 从题目名字看考察意图
not_the_same这个命名很容易让人迷惑,因为光看名字根本猜不到考点。但实际上这类题目在CTF逆向里属于“约束求解入门题”:它不考加壳、不考反调试、不考混淆,只考你有没有耐心把反编译出来的伪代码读明白,并且知道在运算复杂到没法手算时,用什么工具去处理。出题人想看到的,是你具备“把代码逻辑数学化”的意识。
这种题通常会在校验函数里故意制造大量的加减、异或、乘法、移位等混合运算,让你觉得手工逆推很麻烦。一旦运算步骤超过四五条,逐字符手动逆推就容易出错,而且程序里涉及的是有符号整型运算,还会伴随隐式的类型转换,直接手算非常容易翻车。这个时候,使用Z3这种求解器就变成最合理的路径。题目本身不需要多难的底层知识,但需要你理解一个关键点:程序校验flag的过程,本质就是一组布尔可满足性问题。
1.2 破解流程总览
我的分析主线是这样展开的:先加载二进制到静态分析工具里,通过字符串定位到输入提示,随后找到调用校验逻辑的main函数。接着点进校验函数,把里面的运算逐条记录,注意每个变量的类型是int还是char。最后打开Z3,把所有输入字符声明成未知量,把校验函数的运算用Z3的表达式复刻一遍,添加约束让校验结果等于目标值,调用求解器得到满足条件的向量,转成字符串就是flag。
这个流程不仅适用于这道题,几乎所有的“简单校验类逆向题”都能套用。如果你之前没接触过Z3,你会发现把代码逻辑转成约束条件这一步,比求解本身更容易出错,因为每一处强制类型转换都可能改变数值范围。后面我写脚本时,你会看到我在符号位和数据类型上反复折腾,那也是新手最常卡住的地方。
2. 工具准备:为什么这几件套就够用
2.1 静态分析工具选型
分析这类程序,IDA或Ghidra二选一就够了。我个人习惯先用Ghidra做反编译,因为它是免费开源的,安装省事,反编译出来的伪代码可读性也足够。当然你有IDA许可证用IDA更好,F5出来的代码有时候比Ghidra更贴近原始逻辑。不管用哪个工具,核心任务都一样:找到校验函数,看懂循环体里对每个字符做了什么运算。
刚上手的话,不一定非要追求反编译出的代码100%还原。很多情况下,甚至不需要理解每一行的语义,只要能把“输入值经过什么运算后与什么常量比较”提取出来,后面交给Z3就行。所以别在工具上纠结太久,能定位关键函数、能看清单个字符的处理链路,这个工具就够用了。
2.2 符号执行与Z3的选择
Z3是微软出品的约束求解器,支持位向量、整数、布尔等等多种理论。在CTF逆向里,它最常被用来解这类小规模校验题:输入未知,校验逻辑已知,求一个满足条件的具体值。你可以把它理解成一个超级强大的方程求解器,不仅能解小学那种单一方程,还能解包含成百上千个变量和复杂位运算的方程组。
为什么不直接用动态调试工具暴力跑?因为程序输入空间通常很大,而且变换是非线性的,逐字符爆破毫无效率可言。Z3的位向量求解能力在应对加减、异或、移位这类运算时非常成熟,它会在背后做大量的布尔逻辑推理,直接把符合条件的值抛给你。这也是这道题的核心技术点——不会用Z3的选手还在手算逆推,会用的选手几分钟出flag。
3. 静态分析实操:从入口到check函数
3.1 定位主函数与输入逻辑
用Ghidra加载文件后,先去查看导出函数列表,找到main。程序本身没有加壳,入口逻辑很直接,main函数里通常会调用一个print函数输出提示文字,比如“please input your flag”,然后调用读取函数获取你的输入,再把输入字符串传给校验函数。
我打开main反编译视图时,第一眼就看到一个容量很可疑的数组。程序里通常会在栈上开一个固定长度的缓冲区,比如64字节或者128字节,用来存放用户输入。既然题目标题叫not_the_same,而校验函数的名字可能会被保留为check之类的符号,我很快就锁定了关键位置。顺着main往下看,会发现一个if判断,把校验函数的返回值跟0做比较,返回值非0就提示正确,否则提示错误。
这里要注意,程序里读入的长度可能没有做严格限制,但校验函数内部往往会假设输入长度是固定的。所以你在构造约束时,也必须把长度这个前提放进去,不然求解器可能算出任意长度的解。实际分析里,我注意到校验函数里用了循环,循环次数是16,也就是说flag体长度被固定在16个字符。
3.2 读懂check函数里的运算关系
进入校验函数后,我截图记录了伪代码。核心结构是一个for循环,循环变量从0开始到15结束。在循环体内部,取输入字符串的第i个字符,对它进行若干步运算,然后把结果赋值给一个局部变量,再与一个常量数组的第i个元素做比较,如果任何一位不等,直接返回0。
具体的运算链大致长这样:先取字符,然后加上一个索引相关的值,再跟某个固定值做异或,然后对结果取反或者减一个数,最后可能再做一次异或。这类运算链在逆向题里很常见,目的就是让输入值和目标值之间的关系变得不那么直白,增加手工逆推的出错率。
同时要注意char类型的细节。C语言里char可能是signed或unsigned,取决于编译器。如果伪代码里把字符先转成int再参与运算,那么你在Z3里声明变量时也要用8位位向量,并且要明确是用有符号解释还是无符号解释。我第一次求解时就在这一步吃了亏,后面在避坑部分会详细讲。
3.3 提取约束条件的关键步骤
读完循环体后,我把运算关系整理成数学表达式。比如输入字符为x,经过运算后变成f(x),程序要求f(x)等于某个目标值target[i]。那么约束条件就是f(x) == target[i],其中i有16个,对应的x也有16个未知量。
这里有个很容易忽视的细节:目标常量数组到底是什么类型?
如果在反编译代码里看到的是int数组,那么目标值是32位有符号整数。而x只是8位字符,在运算过程中可能被隐式提升为int。这就会导致约束条件里x要与常数一致,同时要让x的取值范围落在0~255之间。Z3里我用BitVec(8)来声明x,运算时再通过ZeroExt或SignExt把它扩展成32位参与加法、异或、乘法。
我提取出的约束大致如下:对于每个x,计算t1 = (x + index) ^ constA,然后t2 = (t1 - constB) ^ constC,最后比较t2 == target[i],同时限定x的取值是合法可打印字符。你不用在乎我这里的常量具体是什么,因为不同版本题目细节会有差异。关键是你要学会把伪代码里的表达式原封不动翻译成Z3表达式,任何一步漏了或者写错符号,结果都会变成unsat。
4. Z3求解实战:写脚本一键解flag
4.1 安装与导入Z3
在Python环境里安装z3-solver非常直接,pip install z3-solver即可。装好之后,导入方式需要注意,这个库的顶层模块名是z3,常规用法是from z3 import *,同时也要导入相关的位向量类型,BitVec、BitVecVal、Solver、solve等。
我建议不要直接用solve()这种黑盒API,因为solve()在求得一个模型时会立即打印,难以做后续处理。更适合的做法是手写一个最简求解脚本,添加变量声明和约束条件,再单独调用solver.check()方法。这个流程能让你在unsat的时候快速定位是哪条约束出了问题。
4.2 建模核心代码
假设目标数组是target,长度16,运算规则是我上面提到的那个模式,可以把脚本写成下面这样。这里的x数组是未知量,类型统一用BitVec('x%d' % i, 8),运算时注意用零扩展转成32位:
from z3 import * # 目标数组,需要从反编译代码里提取 # 示例占位数据,实际以伪代码常量数组为准 target = [0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F, 0x7A, 0x8B, 0x9C, 0xAD, 0xBE, 0xCF, 0xD0, 0xE1, 0xF2, 0x03] s = Solver() X = [BitVec('x%d' % i, 8) for i in range(16)] for i in range(16): # 把8位字符零扩展为32位,避免位宽不匹配报错 x = ZeroExt(24, X[i]) t1 = (x + i) ^ 0x37 t2 = (t1 - 0x12) ^ 0xA5 s.add(t2 == target[i]) # 可打印字符范围约束,能极大缩小解空间 s.add(X[i] >= 0x20, X[i] <= 0x7E) if s.check() == sat: m = s.model() flag = ''.join(chr(m[X[i]].as_long()) for i in range(16)) print("flag:", flag) else: print("unsat, 检查约束条件")这段代码里最核心的是那一行ZeroExt(24, X[i])。因为X是8位,而常量target[0]在C语言里是int,如果你在Z3里直接拿8位向量去跟32位常量做相等比较,类型系统会直接报错,或者自动补位导致符号位扩展,结果全错。用ZeroExt补24个零位,才能保证数值逻辑跟C语言里“char提升为unsigned int”的部分情况一致。但要注意,如果伪代码里出现的是int类型的负常量,你可能需要改用SignExt,否则负数会被解释成另一个正数。
4.3 运行结果验证
脚本跑完如果返回sat,并且打印出一串长度16的可打印字符,拼上题目要求的格式就可以提交。我这里第一次跑出来的结果看起来是乱码,虽然能通过程序校验,但提交到平台却失败。后来检查发现,是因为我没有限定可打印字符的取值范围,导致Z3给出了一个包含控制字符的合法解。加上X[i] >= 0x20 && X[i] <= 0x7E这个约束之后,解就变成了正常的flag字符串。
这个现象在Z3求解flag时很常见。程序只检验运算结果,不检验输入是否是人类可读文本,所以方程存在多个解。你要主动添加常识性的约束,比如字符可打印、长度固定,才能筛选出预期答案。这一步极其关键,很多新手第一次跑出sat却交不上答案,原因就在这里。
5. 常见问题与排查技巧实录
5.1 符号位扩展与数据类型不匹配
最常见的报错是BitVec类型长度不匹配。你声明x是BitVec(8),然后去跟一个32位常量做运算或相等比较,Z3会直接抛异常。解决办法就是显式扩展,具体用ZeroExt还是SignExt,取决于反编译代码里参与运算的类型。怎么判断?
我个人的习惯是回Ghidra里看变量类型。如果伪代码显示变量类型是int且常量是正数,用ZeroExt;如果出现负数常量,比如-123,编译器通常是把char提升为int后做算术,这时用SignExt才能保持语义一致。如果不确定,可以把两种都试一遍,哪个能跑出可打印字符就用哪个。注意有的题目里校验函数使用了unsigned int,那么一律用ZeroExt。
5.2 Z3返回unsat的排查思路
unsat代表约束无解,这时候要回头检查三件事:第一,运算顺序是否跟伪代码一致,特别是异或和加减法的先后顺序,括号位置不同结果完全不同;第二,目标常量数组是否提取有误,我把一个十六进制常量看错过次数码位,导致某一位永远不可能满足;第三,输入长度约束是否与实际循环次数一致,比如循环16次但你在脚本里只写了15个变量。
更高效的排查方式是把约束拆成单条测试:每次只添加一个索引的约束,看求解器是否能单独满足。如果某一条单独就unsat,那这条约束对应的运算逻辑或目标常量肯定有问题。我实际操作时,用二分法定位,很快就能找到出错的某一位。
5.3 反编译工具识别不出校验函数
有的程序会去掉符号表,函数名变成FUN_00401xxx,这种情况下不要慌,直接通过main函数的调用关系找到它。Ghidra里对main反编译,点开被调用的函数列表,通常只有一两个函数是真正的校验逻辑,其他都是库函数。你只要注意看哪个函数内部引用了循环和常量数组,基本就是目标函数。
如果程序里用了动态地址或混淆,那在not_the_same这种入门题里很少见。真遇到的话,静态分析的路子会变得复杂,建议加一层动态调试辅助定位。但就这道题而言,静态分析已经足够,完全不需要上调试器。
5.4 输出flag格式不对
平台提交时一般要求flag{...}这种固定格式,但程序本身不校验前缀,只校验字符串体。这时候你要主动拼接。还有一种情况是长度不是16,而是更长或更短,那就需要根据循环次数调整脚本里的X数组长度。建议先查看main函数里缓冲区的大小和校验循环上界,不要靠猜。
6. 实操心得:这道题给我留下的三个提醒
6.1 做题前先看长度和类型
动手写Z3脚本之前,先花两分钟确认两个基础信息:循环遍历了多少个字符,参与运算的变量是什么类型。这两个信息决定了你整个脚本的骨架。我见过不少人在类型上翻车,直接卡在Z3的报错上,其实回到反编译界面看一眼变量声明就能解决。
6.2 脚本要留注释,方便复用
Z3脚本虽然短,但约束一多就会乱。每一段运算对应伪代码里的哪几行,最好用注释标出来,这样万一unsat,你能快速对照C代码检查。我给not_the_same写的脚本后来直接复用在另外几道类似的逆向题上,只需要改一改循环次数、常量数组和运算规则,省了重写的时间。
6.3 解出flag之后做个验证
拿到Z3解出的字符串后,别急着提交,先跑一下原始程序,把字符串输进去看看是否正确。这一步虽然看起来多余,但能有效预防“解出来的是另一组合法解”这种情况。Z3给的是满足条件的任意一个模型,不是唯一解,只有程序认可且格式正确的才是最终答案。多跑一次,心里踏实。
6.4 后续可以怎么扩展
如果你觉得Z3用得还不够熟练,可以再去刷几道同类简单逆向题,专门练运算链提取和类型处理。等你熟悉了这套流程,再遇到这类题目,基本上就是机械化操作:反编译、提取约束、写脚本、出flag。逆向学习前期最重要的事情,就是快速积累这种可复用的分析套路,而不是试图掌握所有花哨的奇技淫巧。
我个人在实际操作中最深的体会是,Z3这类工具本身并不神秘,真正决定你能否解出题目的,是你把程序逻辑转换成约束条件时的严谨程度。这道题把一个很典型的CTF逆向考法浓缩在一个小文件里,题目代码量不大但五脏俱全。把not_the_same完整做透,后面再遇到更大的逆向题,你就能更从容地把精力放在复杂的混淆对抗上,而不是卡在基础知识这里。