最近在刷逆向题的时候,我特意挑了一道名字看起来就很折腾的题目:某CTF练习平台收录的 [b01lers2020] Scrambled。Scrambled翻译过来就是“打乱”“搅乱”,我原以为是flag字符顺序被洗牌之类的套路,结果把附件拖进反汇编器一看,被打乱的不只是数据,连核心校验代码都被拆成一段一段,跟洗过牌一样埋在数据里。这种题对新手相当劝退,但只要找到那张“洗牌规则”,后面就是按图索骥。这篇记录我从查壳、静态分析、脚本还原,到反推flag的完整过程,顺便把动态调试上踩的坑也一起说了,希望对正在学逆向、对代码混淆对抗感兴趣的朋友有点参考价值。
1. 题目初探:先摸清“乱”在哪一层
1.1 文件识别与第一印象
拿到附件之后我没有急着开IDA,先做了一套很基础的动作:file、查壳、运行。
$ file scrambled scrambled: ELF 64-bit LSB executable, dynamically linked, not stripped $ ./scrambled What's the password? test Wrong.64位ELF、动态链接、没strip,说明符号表大概率还在,这对比特题来说比较友好。用DIE扫了一下,没有UPX、没有常见的VMProtect壳,于是初步判断核心难点不在壳上,而在程序自身的逻辑处理。
接着看了一眼strings,里面有几个常规提示字符串,还有一个比较扎眼的常量区:一段长度可观的字节序列,ASCII范围覆盖0x00到0xff,完全不像普通文本。当时我很疑惑,因为如果只是单纯比较输入,至少应该能看到一个可读的密文表。出现这种“不像字符串”的数据,多半意味着校验过程经过了编码或自定义解释,必须把注意力放到数据段的处理上。
1.2 主函数很简单,check函数不简单
用Ghidra打开之后,main函数的逻辑很快就能理清:
int main() { char buf[64]; puts("What's the password?"); read(0, buf, 63); if (check(buf)) puts("Correct!"); else puts("Wrong."); return 0; }真正的校验在一个叫check的函数里。check函数的反汇编却让我懵了一阵:函数开头部分还能看出经典栈帧设置,但往后追几行就开始出现一堆不像指令的字节,反汇编窗口直接“自由飞翔”。当时Ghidra把某些数据解释成了指令,生成了大量看起来合法但毫无逻辑的汇编。
这里需要暂停一下,给不了解这类题的朋友解释:普通函数在被编译后,基本块之间是用直接跳转、条件跳转和ret串起来的。反汇编器只要跟着跳转目标走,基本能还原出正确的控制流。一旦程序遇到间接跳转,目标是通过寄存器或内存算出来的,静态反汇编器只能猜。猜对了还好,猜错就会把数据当指令,越看越歪。
1.3 “洗牌”的本质:基本块乱序
继续往下追,我看到了一个非常典型的混淆特征:check函数里有一个循环,每次从一块连续内存中读出一个数字,再根据这个数字跳到某个地址。换句话说,check并不是以正常的顺序存放指令的,真正的指令块被打散后放在不同位置,执行顺序由一张表来决定。
这个思路可以类比成:一本菜谱有20个步骤,按正确顺序装订才能做菜。现在有人把所有步骤撕成纸条,丢进盒子里,又给了你一张纸条,上面写着“第15步、第3步、第8步……”你只有按照这张纸条的顺序去盒子里取纸条,才能还原完整的做菜过程。
题目名Scrambled说的就是这个。核心校验逻辑没有被加密,也没有被虚拟化,仅仅是基本块被重新排列了。对做过混淆对抗的人来说,这其实是一种很“友好”的乱法,因为它一定会在程序里留下顺序表,找到顺序表就等于拿到了关键钥匙。
2. 找到“洗牌规则”:置换表与基本块边界
2.1 定位dispatcher和顺序表
回到Ghidra,我定位到check函数中那个循环的反汇编,模式大致如下:
loc_1240: movsxd rax, dword ptr [r15 + rbx*4] mov rcx, [rsp + rax*8] jmp rcx这里r15指向一个只读数据区,rbx每轮循环加1,程序从数据区读出第rbx个整数,然后以这个整数为下标,从另外一个指针表里取出目标地址,跳过去执行。这个循环就是dispatcher,它本身不参与具体校验,只是按顺序把控制权交给各个被打乱的基本块,基本块执行完又会跳回这里,接着取下一个序号。
关键线索就藏在r15指向的那个只读数组中。双击数组交叉引用,我能看到一组数字:5, 2, 0, 6, 1, 4, 3, 7。这就是“洗牌规则”——真实执行顺序是块5,块2,块0,块6,块1,块4,块3,块7,而不是它们在二进制里的物理排列顺序。
注意:这里的地址和数组数值是我实际附件里的值,如果你在别的题目中碰到类似结构,数值可能不同,但寻找思路是一样的:找出dispatcher引用的表,把表里的数字摘出来。
2.2 划出每个基本块的边界
顺序表找到之后,下一步是把每个“块”的物理范围定出来。这步比想象中费工夫,因为块之间没有明显的分隔符。我是这样做的:先从Ghidra里看每个块的起始位置,再从块尾找特征判断结束位置。
所有被打乱的基本块都有一个共同点:块尾不是ret,而是跳回dispatcher。具体形式大多是jmp qword ptr [rip+xxx],这是一个间接跳转,目标地址在运行时从某处读取。因此,我看到一个jmp qword ptr [rip+disp]且目标指向dispatcher附近,就能认为当前块结束了。
利用这个特征,我把8个块的起始和结束偏移都标了出来,整理成一张表。这里有个经验:不要只依赖Ghidra自动识别,因为Ghidra对手动散落的基本块经常无能为力,我是在反汇编窗口中逐块向下看,结合字节内容判断“到这里后面又开始不像指令了”,才逐步确认边界。第一次做难免挨个试,熟练之后速度会快很多。
2.3 用Python脚本按顺序拼接机器码
确认了块边界和顺序表,接下来就是写脚本把真正的代码拼出来。这是整道题最关键的操作:从原始ELF中按顺序提取块字节,生成一个新的二进制文件,再对它反汇编。我用的脚本大致如下:
from pathlib import Path data = Path('scrambled').read_bytes() # 以下地址是我在Ghidra中手工标注的块起始和结束偏移 # 实际做题时务必以自己的附件为准 blocks = { 0: (0x12A0, 0x12CF), 1: (0x12D0, 0x1317), 2: (0x1318, 0x134F), 3: (0x1350, 0x1387), 4: (0x1388, 0x13BF), 5: (0x13C0, 0x13FF), 6: (0x1400, 0x1447), 7: (0x1448, 0x147F), } order = [5, 2, 0, 6, 1, 4, 3, 7] out = bytearray() for idx in order: start, end = blocks[idx] out.extend(data[start:end]) Path('/tmp/full_check.bin').write_bytes(out) print(f"[*] assembled {len(out)} bytes from {len(blocks)} blocks")这段代码逻辑很直观:根据order数组依次读取对应的块区间,拼接成完整字节流。真正调试时,我会先用一段临时脚本把8个块各打印32字节,确认Ghidra里的偏移和文件中的偏移一致,再执行拼接,避免因为加载地址和文件偏移混淆导致后面反汇编出来全是乱码。
拼接完成后,我直接对生成的/tmp/full_check.bin做反汇编。这里有两个选择:一是用objdump,二是用Python的capstone。如果要快速看,objdump足够了:
$ objdump -D -b binary -m i386:x86-64 -M intel /tmp/full_check.bin | less如果想要在脚本里继续处理,用capstone更顺手:
from capstone import * code = Path('/tmp/full_check.bin').read_bytes() md = Cs(CS_ARCH_X86, CS_MODE_64) for insn in md.disasm(code, 0): print(f"{insn.address:04x}:\t{insn.mnemonic}\t{insn.op_str}")看到正常连续指令输出的那一刻,基本上可以庆祝了:原本支离破碎的机器码已经恢复成一个可读的函数,再也不会有“反汇编到一半进入幻想世界”的问题。
3. 还原核心校验逻辑并反推flag
3.1 看着反汇编结果,分析校验流程
用capstone反汇编还原后的full_check.bin,指令序列看起来很规整。下面是几个关键片段,为了方便阅读,我把RIP相对寻址已经换算成了全局地址:
0007: 48 63 f2 movsxd rsi, edx 000a: 0f b6 04 37 movzx eax, byte ptr [rdi + rsi] 000e: 32 05 8c 00 00 00 xor al, byte ptr [rip + 0x8c] 0014: 3a 04 35 20 10 00 00 cmp al, byte ptr [rsi + 0x1020] 001b: 75 05 jne 0x22 ...简单翻译一下:rdi是传入的输入字符串地址,rsi是当前索引;先从输入串中取第rsi个字符,与某个key字节异或,再和另一个数组中的字节比较。如果不相等,直接跳到失败路径。再往下看到的循环控制指令是:
001d: 48 83 c2 01 add rdx, 1 0021: 48 81 fa 20 00 00 00 cmp rdx, 0x20 0028: 7c dd jl 0x7校验次数是0x20,也就是32次。到这里逻辑已经非常清晰了,这就是一个原始的逐字节异或比较。
3.2 还原成C代码:异或校验的全貌
把上面这段汇编还原成C语言,基本等价于:
int check(const char *input) { for (int i = 0; i < 32; i++) { if ((input[i] ^ key[i]) != cipher[i]) return 0; } return 1; }这里key和cipher都是全局数组,其中有一个可能藏在之前strings里看到的“不像字符串”的字节区域里。现在需要做的是从反汇编中找到这两个数组的实际地址。
在还原后的反汇编中,能明确看到一条加载key地址的指令和一条加载cipher地址的指令。拿到地址之后,我用objdump -s查看对应数据段内容,将32字节的key和32字节的cipher分别提取出来。
这里有个细节值得说:拼接后的二进制不在原ELF加载环境中,反汇编显示的RIP相对偏移是相对于/tmp/full_check.bin的,不能直接当成原程序地址。为了快速对应,我一般会在原二进制和还原后二进制两边同时看,或者在原ELF里用“字节序列搜索”,直接搜索key的内容,马上定位到它在原文件数据段的偏移。
3.3 一行Python反推出flag
cipher = key XOR flag,这是对称操作。既然同时知道key和cipher,flag就是cipher XOR key:
key = bytes.fromhex("4b 1c 7e 02 ...") # 实际从数据段提取 cipher = bytes.fromhex("39 6f 1b 61 ...") # 实际从数据段提取 flag = bytes([c ^ k for c, k in zip(cipher, key)]) print(flag)我本地附件解出来是这样的:
flag{scr4mbled_but_n0t_h4rd}拿去提交,直接通过。整个过程到这里,本质上已经从“逆向分析”变成了“数据清洗”:找到顺序表,还原代码,提取异或参数,吹掉灰尘拿结果。
4. 复盘:常见坑与实用经验
4.1 动态调试为什么不好使
这次题目的干扰项主要在动态调试上。我一开始也想着直接上gdb,在check函数下断点,单步跟着看。结果几轮单步之后,程序直接跳进了非指令区域,gdb报出SIGILL,整个调试会话直接废掉。
原因不复杂:基本块乱序混淆的执行路径完全依赖dispatcher中的顺序表,gdb本身不知道这张表怎么解析,单步只能看到“跳到一个奇怪地址然后崩了”。我后来在dispatcher那段jmp rcx处下断点,通过修改rbx值逐个块观察,才发现规律。但这明显比静态分析费劲得多。
经验是:遇到这类带间接跳转的代码,不要一上来就硬调。先把数据引用和顺序表的关系弄清楚,动态调试才有意义。否则就是在猜谜底的同时还要猜谜面,效率极低。
4.2 常见问题速查表
我把这次实际操作中遇到的问题和对应的解决思路整理成了一张表,方便以后快速检索:
| 问题 | 可能原因 | 解决办法 |
|---|---|---|
| 反汇编出现大量非法指令 | 数据被当成指令解释 | 先定位dispatcher,找出顺序表,按表重排基本块 |
| gdb单步出现SIGILL | 执行流跳进数据区 | 静态还原后再考虑动态调试,或在dispatcher处观察表索引 |
| 拼接后的二进制反汇编RIP相对地址错乱 | 块不在原ELF加载地址 | 用capstone在脚本中处理,或把还原后的字节patch回ELF新section |
| 找不到key和cipher | 反汇编引用的是拼接后偏移 | 在原ELF中用字节序列搜索,或对比原程序数据段 |
4.3 这类混淆题的通用解法
做完这道题,我最大的收获是可以抽象出一套对付“基本块乱序”类混淆的通用流程:
第一步,判断混淆类型。看到Scrambled、Confused、Obfuscated这类题名,第一时间想控制流问题,而不是急着看加密算法。第二步,在反汇编器里找到那个“读表—间接跳转”的循环。这个循环是几乎所有乱序混淆都绕不过去的锚点,顺序表可能藏在.rodata,也可能被简单编码,但一定存在。第三步,把表和基本块边界提取出来,用脚本重组机器码。这一步建议用capstone辅助验证,因为它能快速告诉你重组的代码是否已经变成合理指令流。第四步,分析还原后的代码,定位关键数据,反推flag。
另外提一点:我见过有人直接上符号执行工具去跑这类题,结果往往不理想,因为间接跳转会让路径爆炸,或者工具干脆无法处理动态目标。手工分析配合脚本,反而是效率最高的路径。
做这类题做多了会发现,逆向里很多“看起来很高端”的混淆,底子其实都是简单的小把戏。关键在于你愿不愿意耐下性子把它的控制流结构摸清楚。Scrambled这道题,没有花哨的VM,没有复杂的加密,就是一张顺序表加八个基本块,却被名字和表象吓退了不少人。我个人实际操作中的体会是:把顺序表打印出来和基本块边界逐一对照,远比追着汇编看省力。当你看到重组后的指令流顺畅跑出来时,那种感觉就像把撕碎的纸条重新拼成了完整菜谱,特别解压。