ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从花指令到Z3:CTF逆向题crazy的完整求解思路

从花指令到Z3:CTF逆向题crazy的完整求解思路 reverse这个词放到Web开发圈和CTF圈完全是两码事。做Django的人听到reverse第一反应是URL反向解析那个reverse()函数但混攻防世界的都知道reverse标签下全是逆向工程的活儿。GFSJ0810这道题的名字叫crazy我当初点进去的时候还在想题目名字都这么野估计有点东西。这题适合已经跑通了一两个简单逆向题、准备进阶的选手。整体难度不算特别高没加壳逻辑也能反编译出来但里面塞了不少混淆思路和花指令能帮你把逆向分析最核心的几条路完整走一遍静态分析、动态调试、算法还原。下面我就按我当时解题的顺序把完整过程捋一遍包括踩过的坑和一些不太会写进官方WriteUp的经验。1. 题目入手先摸清程序底细再动手1.1 拿到题目后第一步我做了什么题目编号GFSJ0810名字crazy。下载下来之后我没有急着丢进反编译器而是先在终端里把它跑了一遍。$ chmod x crazy $ ./crazy Input your flag:输入一串随便写的字符比如flag{test123}回车之后程序回了句Wrong!就退出了。别小看这一步它给了我两个关键信息程序是交互式的以行为单位读取输入同时存在成功和失败两个分支说明后面一定有一个关键的比较运算。然后我用file命令看了文件类型$ file crazy crazy: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, not stripped64位、x86-64架构、动态链接、not stripped。没有UPX那类壳的标记符号表也还在这意味着用Ghidra或IDA打开时函数名大概率是能直接看到的省掉了手工猜测函数边界的功夫。但这个级别也说明题目难度不在“找入口”而在“读逻辑”。我还顺手算了个哈希记到笔记里$ sha256sum crazy很多人觉得这步多余但平台下载的文件偶尔会和之前记录的不一致尤其是反复下载时需要确认是不是同一个文件。哈希记下来后面怎么折腾都不怕对不上版本。1.2 检查保护属性checksec的结果怎么看文件类型确认后第二个必做动作是看程序开启了哪些安全保护。我用的是pwntools自带的checksec$ checksec --file./crazy [*] crazy Arch: amd64-64-little RELRO: Partial RELRO Stack: 32-bit Canary found NX: NX enabled PIE: PIE enabled四行保护信息至少有两个会影响后面的分析方式PIE enabled程序每次启动时加载到内存的基址是随机的。gdb里不能直接下静态文件里的绝对地址断点要么用符号断点要么先通过vmmap拿到基址再把文件偏移加基址换算成运行态地址。Canary found栈上存在金丝雀值。这通常是栈溢出利用的核心障碍但对逆向题来说只要专注读代码和算法基本不影响分析别去改动栈数据就行。NX和RELRO对这类“验证flag”的程序影响也不大。看保护的作用是给整个解题定调这是逆向分析题不是pwn题所以安全机制基本可以抛到一边专心拆逻辑就好。1.3 为什么“先运行、再静态、后调试”这个顺序很重要很多新手拿到逆向题第一反应就是直接IDA里按F5伪代码一出来就盯着屏幕发呆。我自己也吃过同样的亏。所以这里我特别强调一下顺序先运行用最低成本收集程序的交互方式、输入格式、输出提示。哪怕只是输个test看它吐什么都是有效线索。再静态有了运行时的观察再用Ghidra/IDA读代码。字符串列表里的 Wrong! 会直接帮你定位到判断分支。后调试静态分析给出“应该在哪看”的判断动态调试用来验证“实际是不是这样”。如果一上来就调试很容易陷入单步地狱——每步都在跑但不知道自己从哪来、要到哪去。这道题叫crazy原因就是核心校验函数在静态反编译后会显得非常乱。如果跳过了运行观察和字符串定位直接扎进伪代码里很容易被带偏。反过来顺着提示字符串一路反向追踪混乱的控制流也能慢慢梳理出主线。2. 静态分析顺着字符和跳转把主逻辑捞出来2.1 字符串定位错误提示是最好的入口打开Ghidra以后我首先看的是Strings窗口。里面最显眼的三个字符串是Input your flag: Wrong! Congratulations!在 Wrong! 上右键选择引用查找直接跳到引用它的指令。通常这里就是校验失败分支的起点。往上翻几条指令能看到类似test al, al或jz ...的判断语句再往上就是一个函数调用的返回值判断。把这个函数调用的返回值和后续比较逻辑对齐后基本就能画出main函数里真正的验证代码了。需要注意在64位程序里字符串引用经常不是mov rdi, offset xxx那种直观形式而是通过lea rdi, [ripxxx]的方式寻址。Ghidra和IDA会自动做符号解析但如果你看的是纯汇编就要留个心眼别看到lea rdi, [rip0x2aa]就反应不过来它其实就是加载字符串地址。2.2 核心校验逻辑的初印象为什么会觉得“crazy”我用Ghidra的Decompiler打开主校验函数时第一反应是这代码疯了。伪代码里全是((byte)((uint)*(char *)(param index) 3) | ...这种位操作还有多层嵌套的函数调用以及看起来毫无意义的内存拷贝和重复赋值。冷静下来之后我做了三件事第一只看参数和返回值。这个函数接收的指针一定指向用户输入的字符串返回值一定是bool型决定最终输出的是Congratulations!还是Wrong!。第二找循环。验证逻辑极少是纯线性代码通常都有一个for或while循环内对输入逐位或逐分组处理。找到循环的上界就能知道要求的输入长度。第三找常量表。只要代码里出现了连续的字节序列比如长度为32的0x12, 0x34, 0x56...很大概率是后面比较用的期望值或者某种置换表。顺着这三步我把那个函数拆成了三段第一段校验输入长度必须等于某个值第二段对输入做多轮变换变换过程中访问了一个全局的置换表第三段把变换结果和另一个全局数组做逐字节比较。所谓的“crazy”其实不在于算法有多高级而在于出题人刻意在函数里塞了大量编译优化和混淆代码。整个函数几乎没用标准库函数全是手工内联的位运算导致反编译器输出了一堆看着像天书的长表达式。真正的解题思路反而是要把这些大表达式拆小找到它背后简单的数学关系。2.3 花指令的识别与绕过思路在这个函数里我确实遇见了花指令。花指令是出题人故意插入的永远不会被执行的垃圾指令或者利用反汇编器的线性扫描漏洞让错误数据被当成指令解析干扰静态分析。识别花指令有个很好用的经验如果在反汇编视图里看到某个函数中间出现了int3、call 0、pop 寄存器这类奇怪组合而且跳转关系特别诡异那八成就是花指令。对付它有两种主流做法。一种是静态patch在IDA里把垃圾字节直接改成nop或者用Edit - Patch Program - Change byte把误判的字节改成数据再重新分析。有时也需要手动修改控制流让反汇编器跳到真实代码地址。对crazy这道题花指令主要集中在两个分支的中间空隙patch掉之后再去看伪代码清晰很多。另一种是动态观察不用纠结机器码是不是被反汇编器误判直接上gdb单步执行到关键跳转看实际发生的跳转链。CPU执行的真相远比静态反汇编可靠。我实际是把两者结合起来先用动态调试确认真实执行流再回静态视图里把无关代码标注掉保证后续看伪代码不被干扰。花指令这道坎过了之后核心逻辑就不难读了。3. 动态调试让程序自己告诉我们校验过程3.1 用gdb搭好调试环境静态分析能给出大致结构但想拿到精确的校验数据和中间变换结果还得靠动态调试。我的调试环境是gdb pwndbg插件pwndbg的主要优势是能在断点处自动显示寄存器、栈内容、当前反汇编和函数调用关系省去大量手工命令。启动命令很简单$ gdb ./crazy pwndbg start因为开启了PIE直接break main也可以gdb会通过符号帮我们管理地址不需要手工算基址。如果确实想看加载基址可以这样pwndbg vmmap LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA 0x0000555555554000 0x0000555555555000 r-xp ... /home/user/crazy这里的0x0000555555554000就是代码段的运行时基址。假设静态分析里某个关键指令的文件偏移是0x1524那么运行态断点地址就是0x0000555555554000 0x1524。如果你不习惯手工加pwndbg里也可以直接写break *mainoffset或者用break *$base0x1524。我的调试习惯是先在main入口停住然后一次性设置好所有关键断点再继续运行。排查复杂校验逻辑时直接看中间结果往往比一步一停高效得多。3.2 在关键比较位置下断点读取寄存器现场我在校验函数的最后比较点下了一个断点也就是伪代码里if (transformed[i] ! target[i])对应到汇编的位置。用pwndbg运行到这里后我执行了几条常用命令x/s $rdi如果rdi指向字符串打印字符串内容。x/32bx $rax以十六进制打印rax指向的32个字节。x/i $rip查看当前指令确认断点是否落在预期位置。continue继续运行观察是否进入错误分支。在断点处我实际看到的是比较循环的每一次迭代里一个字节来自程序动态计算的缓冲区另一个字节来自固定的全局数组。把这两个字节分别打印出来对比就能直接确认我对算法的理解是否正确。这里有一个非常关键的实操经验不要只看一个断点的现场要在变换流程的前、中、后分别下断点。比如在置换表读取后下个断点打印取到的索引在异或操作后下个断点打印中间结果。这样一层层对比静态分析的推演才敢确认整个变换模型是正确版本。如果发现动态结果和静态分析的预期不一致优先怀疑两点一是PIE基址没加断点根本没落在真实目标指令上二是函数参数传参约定理解错了。64位SysV调用约定下前六个整数参数依次是rdi、rsi、rdx、rcx、r8、r9别把rsi当成rdi用。3.3 反调试手段的应对ptrace不是拦路虎有的逆向题会调用ptrace(PTRACE_TRACEME)来检测自己是否处于被调试状态。如果ptrace返回值不是0说明有调试器已经在跟踪当前进程程序就会走异常分支或直接退出。判断有没有反调试我用了一个很简单的办法在函数名列表里搜ptrace或者在disassemble main里看有没有call plt_ptrace的调用。如果存在就选中那条调用在gdb里用catch syscall ptrace观察然后在ptrace返回后修改rax为0pwndbg catch syscall ptrace pwndbg run pwndbg set $rax0 pwndbg continue这个操作的本质是让程序以为ptrace调用失败从而绕过整个安全检查逻辑。更彻底的方案是用LD_PRELOAD写一个假的ptrace函数让目标进程在正常模式下也认为没有被调试。回到crazy这道题我没有看到非常硬核的反调试主要难点还是在校验函数的混淆上。但把这个应对姿势练熟了后面做其他题目会很实用。4. 算法还原把“疯狂”的变换拆成数学公式4.1 数据流梳理输入到输出的每一步变换在确认关键比较位置之后我用笔记把变换流程记了下来写出来的结构大致是这样的第一步程序确认输入必须是指定长度。第二步对输入字符串做一次逐字节的置换操作置换规则来自一个全局表本质上是一个重排。第三步将重排后的每个字节与一个固定密钥流做异或。第四步对得到的数据再做一次类似TEA的循环变换循环轮数不多但里面涉及的右移和左移位数比较奇怪不是标准的值。第五步最终结果与目标数据逐字节比较。从逆向的角度看这个流程不是最难的但出题人为了让代码难以阅读把这五步全部内联展开了位运算表达式还在中间插了很多冗余变量。所以我在笔记里不是直接抄伪代码而是把每一步抽象成小函数input - P(input) - Xor(P(input), key) - M(Xor(...)) - compare with target把大表达式拆成小函数后整体逻辑清楚很多。这一步特别建议用纸笔走一遍哪怕只是在注释里写。不要高估自己面对两大屏位运算时的短期记忆写下来才能避免后面写脚本时候的混乱。4.2 用Z3约束求解直接反推flag像这种“输入经过一串可逆或半可逆变换后与目标比较”的题目Z3是非常顺手的工具。Z3是一个约束求解器你只需要把程序里的变换关系写成约束方程就能自动反推出满足所有约束的输入值。一个最简模板长这样from z3 import * # 从IDA/Ghidra里提取的目标数据替换成真实值 key [0x12, 0x34, 0x56, 0x78] target [0xAB, 0xCD, 0xEF, 0x01] s Solver() flag_len 32 # 从程序循环上界得到 chars [BitVec(fc{i}, 8) for i in range(flag_len)] # 限制在可打印ASCII范围内 for c in chars: s.add(c 32, c 126)然后把你分析出来的每一步变换翻译成等式。比如如果程序逻辑是对每个字节右移3位再和下标异或最后与目标比较for i in range(flag_len): s.add(LShR(chars[i], 3) ^ i target[i] ^ key[i])注意Z3里逻辑右移要写成LShR而不是直接用Python的否则求解时会把符号位也带进去。这个细节虽然小但经常导致解出来一堆负数。求解完成后遍历模型把BitVec转成字符串即可if s.check() sat: m s.model() flag .join(chr(m[chars[i]].as_long()) for i in range(flag_len)) print(flag)我习惯于在写约束时就顺手加一条flag格式的校验比如s.add(chars[0] ord(f))、s.add(chars[1] ord(l))、s.add(chars[2] ord(a))、s.add(chars[3] ord(g))这样能进一步过滤掉无意义解。4.3 手写逆向脚本的通用思路不是所有题都能用Z3一把梭。如果变换里出现了非线性查表操作或者轮数超过一定规模导致求解变慢手写逆向脚本会更直接。核心思想只有一个从最后的比较数据出发把每一步变换都求逆。逆运算的对应关系是异或的逆还是异或a ^ b的逆就是a ^ b只要密钥相同。加法的逆运算是减法a key的逆是a - key。置换的逆是反置换表如果b[i] a[perm[i]]那么a[j] b[perm^{-1}[j]]。移位操作按位处理有时需要手动恢复每一位。我把这个题目最后的比较目标数组保存成了一个Python列表然后在脚本里倒着执行变换最终还原出一串字节再做ASCII转换就是flag。写脚本时建议每写一步都打印一次中间结果和动态调试里的现场数据对照哪里不一致就说明模型哪里理解错了。手工逆向的好处是能真正理解题目到底做了什么而且面对自创算法时不会有Z3的兼容问题。坏处是容易在逆推某一步时把下标搞错所以脚本里最好全程用同样的变量命名别偷懒缩写否则排查起来很痛苦。5. 常见问题与排查技巧实录5.1 高频问题速查表我在做这道题以及帮朋友参谋其他逆向题时遇到过不少重复问题整理了一个速查表。现象常见原因解决方案反编译伪代码完全看不懂程序有花指令或开了高优化编译先看反汇编patch掉花指令后再回退看伪代码gdb下断点后程序直接跑完PIE基址未计算断点落空用vmmap拿基址计算运行态地址或用符号断点动态调试结果与静态分析不一致参数寄存器理解错或函数被多次调用在调用点看rdi/rsi实际值确认传入的是输入还是中间缓冲区Z3求解报错或超时约束过少或包含不可逆操作增加ASCII范围约束把非线性查表拆成等价位运算逆推脚本某一步结果对不上置换表反查方向弄反手动构造两个字节验证置换正反关系程序一进gdb就退出检测到ptrace反调试在ptrace调用点改返回值或用LD_PRELOAD替换这张表不一定覆盖所有情况但应对大多数入门进阶逆向题已经足够。值得注意的是表格里第一行的问题在crazy里最典型。很多人一见伪代码乱就放弃其实正确的姿势是先定位到关键函数然后用动态调试确认真正参与运算的代码最后才安心去读反编译结果。5.2 我踩过的三个坑第一个坑太依赖伪代码。在crazy这个函数里伪代码里有一大堆看起来是对输入做变换的表达式但实际上它们只是冗余赋值根本不参与后续计算。我被这些噪音带偏了将近半小时后来是动态断点加数据比对才发现真正主导计算的只是其中三行。从那以后我再不敢把反编译器的输出当作标准答案。第二个坑把PIE地址算错了。第一次在gdb里按静态地址下断点程序根本没停下来。后来查了vmmap才发现符号断点break main是能用的但靠文件偏移算出来的地址必须加上基址。这个错误属于常见操作失误但每次踩到还是挺费时间。第三个坑Z3约束写少了。我一开始只拿比较等式做约束结果Z3给了一堆满足条件但完全不是flag的字节序列。加上可打印字符约束和flag{前缀约束以后解才变得正常。这说明工具只是辅助模型对了还不够约束边界也要写全。6. 解题心得做这类混淆逆向题的通用套路我个人做这类题有几点体会可能比具体解法更值得记住。第一攻防世界的reverse题难度曲线比较友好从简单的base64变种、字符串逆序到TEA、RC4再到带花指令的混淆是一个很完整的阶梯。crazy这道题的价值在于它把所有常用考点缝在了一起。做完一遍静态分析、动态调试、算法还原这三条腿就都能站起来。第二逆向题的时间分配建议前20%时间用来跑程序、看文件、看保护中间50%用来静态分析和动态调试确认算法模型最后30%用来写脚本解flag。很多新手把80%的时间耗在反编译界面里这是不划算的。一旦看得太久没有进展就立刻切到动态调试或者回去看字符串引用让程序自身的执行轨迹来替你找主线。第三遇到太乱的代码记得退一步。如果你盯着伪代码超过二十分钟还没有头绪最好的操作是合上伪代码窗口去跑一遍动态调试。反过来如果调试过程完全不知道在哪下断点就回去静态分析看字符串引用。两道工序来回切换才是分析花指令题目的正确节奏。这题本身难度不算高但crazy这个名字起得很贴切。它能让你在混乱中学会抓主线把看似疯狂的代码顺成一个可解的数学问题。以后再碰到同类题目心里就有底了。
返回列表