前阵子内部训练的时候,一个同学在群里丢了个文件上来,文件名很简单:reverse_re3。他说这是最近一次练习赛里卡得最久的一道Reverse题,跑起来就一句"Input: ",输错就回"Wrong",既没有那种夸张的壳,也没有反调试,但就是让人在几千行编译优化后的伪代码里找不到北。我当时拿到手,第一反应是这题很适合拿来写一篇完整复盘:它不考偏门技巧,考的是最核心的"如何系统定位、还原、验证一个未知变换"的能力。这篇文章就按我实际解这道题的顺序来写,包含第一轮侦察、静态分析、动态调试、算法还原、Python脚本求解,最后再聊聊这类题通用的方法论,给想走一遍完整逆向流程的朋友做个参考。
1. 拿到文件后的第一轮侦察:file、checksec与运行行为
1.1 file命令:一分钟判断文件身份
拿到一个未知文件,我几乎不会先扔进反编译器,而是先跑一条最基础的命令:
$ file reverse_re3 reverse_re3: ELF 64-bit LSB executable, x86-64, dynamically linked, stripped几个关键信息:
- 64位ELF:这是Linux下的可执行文件,x86-64架构,在做题时对应的寄存器宽度、调用约定(System V AMD64)都要按64位来处理。
- LSB小端序:多字节数据在内存中是低字节在前。后面提取密文、密钥时,如果遇到整数常量,要记得按小端解释。
- dynamically linked:动态链接,意味着程序依赖libc等共享库。很多库函数在反编译工具里能被自动识别出来,这能帮我们快速划清"库函数"和"自定义函数"的边界。
- stripped:符号表被剥离了。没有main、没有函数名,这是最直接影响入手速度的一点。后面我会专门说无符号表时怎么找main。
很多朋友容易忽略file的输出,直接打开IDA,结果面对一个没有main的入口函数愣半天。其实file的输出已经告诉你了:后面的工具选择、分析策略,都该按照"64位C程序、动态链接、无符号表"这个前提来定。
1.2 checksec:摸清题目有哪些"护城河"
对纯算法型Reverse题,保护机制不会真的拦住你,但它决定了调试时的操作方式,最好第一时间摸清楚:
$ checksec --file=reverse_re3 Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled我当时看到这个输出,心里大概有了底:
- NX + Canary + Full RELRO:这对漏洞利用来说是堵了栈执行、栈溢出和GOT篡改的路,但Reverse题根本不碰这些,所以可以直接无视。
- PIE enabled:程序加载基址是随机的。静态分析时看到的地址(比如IDA里显示的
0x1150)是相对偏移,动态调试时必须先算一次"基址偏移"。这个会在后面GDB部分详细说。
还有一点容易被忽略:stripped在checksec里看不出来,但它的影响比PIE更实在。PIE只是让你多算一次基址,stripped则直接把你定位代码入口的难度拉高了一个档次。
1.3 先跑一次,再依靠strings找线索
工具看完了,先别急着分析。直接跑一次,观察程序行为和字符串,是最快建立直觉的方式:
$ ./reverse_re3 Input: hello Wrong程序是典型的"输入-校验-输出"结构,没有菜单交互,没有多次输入,说明核心逻辑在一条很短的路径上。
接着用strings扫一遍:
$ strings -n 8 reverse_re3 | head -50 ... Input: Wrong Correct!这次的字符串信息很少,没有那种可以交叉引用的明显提示。不过"Correct!"这个字符串非常关键:**如果程序存在一个"作对之后的输出",那就必然存在一个比较点,而且比较点附近一定引用着期望密文。**这就是我后续静态分析的第一个锚点。大多数Reverse题,即使故意把提示字符串藏得很深,也总会留下这种"成功/失败"的分支,这就是程序逻辑里最脆弱、最值得下手的地方。
2. 静态分析阶段:从main函数切入主逻辑
2.1 没有符号表时如何快速锁定main
拿到一个stripped的ELF,第一件事不是从entry开始一路瞎看,而是利用一个固定事实:**在x86-64的Linux程序里,__libc_start_main是被动态链接进来的,而它接收的第一个参数就是main函数的地址。**所以只要找到__libc_start_main的调用点,它的rdi寄存器就是main的地址。
我的做法通常是先用Ghidra做一次自动分析,它会把entry附近的代码反编译出来,通常长这样:
void entry(void) { ... __libc_start_main(main_addr, argc, argv, init, fini); ... }如果反编译工具没自动识别,也可以用objdump看启动代码:
$ objdump -d reverse_re3 | grep -A 20 '<_start>'在<__libc_start_main@plt>调用位置往上找,把lea rdi, [rip + addr]或mov rdi, addr里的那个地址记下来,那个就是main的真实偏移。有了入口,再让Ghidra或IDA以这个地址为起点重新分析,函数边界就清楚了。
这个步骤我没有跳过,因为如果直接从entry往水里跳,很容易一头扎进__do_global_dtors_aux、frame_dummy这类初始化代码里,白费半小时。
2.2 函数调用图里圈出"重点嫌疑函数"
从main的反编译结果看,逻辑非常干净:
int main(int argc, char **argv) { char buf[64]; print_string("Input: "); read_input(buf, 64); if (check(buf, strlen(buf))) { print_string("Correct!"); } else { print_string("Wrong"); } return 0; }整个程序的自定义函数没有想象中那么多,重点很快圈到一个名为check的调用上(实际二进制里当然没有名字,是我根据行为起的)。main的伪代码里,它接收两个参数:输入缓冲区指针buf和长度strlen(buf)。
这一步最值得说的地方在于:**不要一上来就钻进check内部看每一行,而是先确认这个函数的输入输出特征。**它接收的是"输入字符串的地址和长度",返回的是bool值,几乎可以肯定程序的核心算法就在这。类似的结构,比如memcmp、strncmp出现的位置,也都是高嫌疑点,值得优先下断点。
2.3 优化代码的伪代码怎么看才不容易晕
用默认O2优化编译出来的C程序,反编译出来的代码会有一点劝退:循环展开、常量传播、地址计算替换。我见过很多朋友在这一步开始放弃,然后跑去硬调,效率反而更低。
我的经验是记住一句话:**看数据流,不要被控制流吓到。**具体来说:
- 优先关注输入字节被读了之后写到了哪里、和哪些常量发生了异或/加/减/移位。
- 如果看到一大段几乎重复的代码,尝试猜测这是编译器把一个循环展开了,比如
for (i = 0; i < 8; i++)被优化成了8段几乎一样的手写体。 - 遇到
((x << 3) | (x >> 5))这类组合,第一反应应该是"循环移位",别被两个shift加or搞晕。
在我这个例子里,check函数一进来看起来就是一大块的test、xor、movzx操作。但稍微整理一下就能发现:它实际上是在对每个输入字节做一组固定的数学变换,然后把结果和一个固定数组比较。这时候,我已经有足够信心进入动态调试阶段,用GDB去记录实际内存变化了。
3. 动态调试:关键断点与内存追踪
3.1 在输入和比较附近下断点
静态分析给了地图,动态调试负责验证地图是不是对的。我的调试流程一般是这样:
$ gdb ./reverse_re3 (gdb) starti (gdb) info proc mappings因为开启了PIE,首先要拿到运行时基址。info proc mappings输出里,第一行那个0x555555554000开头、包含/home/.../reverse_re3的映射,就是程序的加载基址。假设它是0x555555554000,那么IDA/Ghidra里看到的偏移0x13a5对应的运行时地址就是:
0x555555554000 + 0x13a5 = 0x555555556dA5我通常会在三个位置下断:
read或scanf返回后,检查输入缓冲区内容。check函数入口,看传入参数。memcmp/strcmp调用点前后,直接看"实际计算结果"和"期望密文"。
对这道题,最重要的是第3个。GDB里在比较指令处停下之后,用x/32bx $rdi看左边、用x/32bx $rsi看右边,一眼就能获取两样东西:变换后的结果长什么样,密文长什么样。有了这两条数据,静态分析的正确性基本可以在30秒内确认。
3.2 用watchpoint记录输入字节的"旅行"
如果断点还不够,就上watchpoint。watchpoint的本质是"数据被改写时停下来",非常适合追踪输入字节在内存里的流动:
(gdb) watch *(unsigned char *) ($rsp + 0x20) # 假设输入缓冲区在栈上这个位置输入字节一旦被改变,GDB就会中断,并打印类似"Old value = 0x41, New value = 0x52"的记录。这样一次次记录,就能把"原始输入 -> 第一次变换结果 -> 第二次变换结果 -> ... -> 最终比较值"整条链拼出来。
我当时跟踪到的变化大概是这样的:
0x41 -> input = 'A' 0x52 -> 与密钥异或后 0x9e -> 循环左移3位后 0x7c -> 与S盒表异或后 ...继续跟踪下一个字节...把这些数据记录下来,和静态分析里的伪代码逐行对照,就能非常扎实地确认"我理解的算法"和"程序实际运行的算法"是一致的。这一步做得越细,后面写脚本时的底气就越足。
3.3 把程序当加密oracle来试探
还有一个特别实用的小技巧:**如果核心变换函数是"逐字节、状态无关"的,直接把程序当作加密oracle用。**比如你输入"AAAA",程序会用你的4个A分别算出一个变换后的字节,虽然最终结果是Wrong,但那个变换后的字节你可以在GDB里从断点处截获。
于是就可以批量做这样的事:
- 输入
"AAAA",从GDB记录下check函数内的变换结果。 - 输入
"BBBB",再记录一次。 - 输入
"CCCC",再记录一次。
如果变换确实是逐字节独立的,那相当于拿到了一个从输入字节到输出字节的映射表。不需要完全逆向算法,直接用映射表反查密文,也能把每个位置的输入字节解出来。
这个方法在遇到"查表类"算法时特别省力。但注意:它只适用于状态无关的变换。如果算法里存在类似"基于前一个字节的反馈"(比如CBC模式的加密),那就不能用这种简单映射,只能回到完整算法还原。
4. 核心算法还原:拆开魔改编码器
4.1 先画一条数据流:输入字节到输出字节
把静态分析和动态调试结果汇总后,我得到了整条数据流:
输入字节 input[i] -> 与密钥 key[i & 7] 异或 -> 8位循环左移3位 -> 以当前值作为下标查S盒表,再异或 -> 得到输出字节 out[i] 最终: out[0..len-1] 与常量密文 expected[0..len-1] 比较这个结构是Reverse题里非常经典的三件套:**异或、移位、带表替换。**它可能看起来像某种加密算法的雏形,但实际上往往只是题目作者随手拼起来的简单变换,并没有真正的密码学强度。解题的关键不是研究它有多"密码学",而是用程序化的方式把它还原出来。
我当时在伪代码里拿到的不完全是上面这么干净的形式,期间还夹杂着很多movzx、lea、shr之类的细节。我的建议是:**先用纸笔或者文档写出一版朴素的数学表达式,再去代码里验证每个操作符。**伪代码里一个((x << 3) | (x >> 5)) & 0xFF很可能是循环左移,但如果没写& 0xFF这种掩码,就要小心它是64位寄存器上的操作,只是后面只取低8位。
4.2 逐个操作还原:异或、循环移位与查表
逐个来看,这三个操作都有容易踩的坑。
异或密钥,这步最直接。密钥是一个8字节的数组,key[i & 7]说明密钥按输入位置循环使用。我在二进制里看到这个数组长这样(示意):
unsigned char key[8] = { 0x3C, 0xA5, 0x7E, 0x1B, 0x4D, 0x92, 0x0F, 0x66 };提取这类常量的办法很简单:在反编译工具的数据窗口里,从十字交叉引用处跟到.rodata或.data段,按数组长度连续复制就行了。唯一要留意的是有些工具会把连续数据当作long long显示,记得切回byte数组视图。
循环左移3位,也就是:
x = ((x << 3) | (x >> 5)) & 0xFF;这里容易犯的错是忘了& 0xFF。C语言的整型提升规则会让x << 3变成int类型,如果不在最后加掩码,它和(x >> 5)做完或之后可能就不是一个8位字节了。我在还原时索性每次都写成& 0xFF,心里才踏实。
查表异或,这步是很多人的困惑点。伪代码里出现了一个256字节的数组,下标却是"当前值自己":
x = x ^ table[x];这种“用自己当索引再异或自己”的模式,本质上是一个自定义替换表。它不像异或密钥那样“一对一公式化”,但可以通过构造一个逆表来求解。我后面会写到。
4.3 提取密文与密钥,锁定输入长度
整个check函数的最后环节,是把变换后的输出和一个常量数组比较。这个常量数组就是期望密文expected,长度直接决定了flag长度。
我在GDB里看到比较长度是42字节,这意味着flag就是42个可见字符。注意一个细节:如果用户输入的长度不等于42,程序可能直接走Wrong分支,也可能在check函数内部先判断len。这道题是比较前先strlen(buf),所以长度不同就直接失败。于是求解时对长度的假设很重要,42这个数字不是拍脑袋定的,而是从比较指令的立即数、密文数组大小里拿到的。
为了稳妥,我用脚本提取了密文,转成十六进制:
expected = bytes.fromhex( "3C 7B 4E 1A 5F 8D 2E A1 67 90 0C 7F ... 42字节..." )密钥和S盒也都分别转成Python的bytes和list。到这一步,算法的所有成分都齐了,下一步就是写脚本。
5. 写Python脚本还原flag:从伪代码到解算器
5.1 先写正向加密脚本做"对拍"
逆向还原算法最容易犯的错误是"自认为理解了,结果还原了个寂寞"。所以我的习惯永远是:先写一个正向变换脚本,拿已知输入和程序实际输出做对拍,确认理解的算法没问题,再写逆推脚本。
正向脚本长这样:
def rol3(v: int) -> int: return ((v << 3) | (v >> 5)) & 0xFF def transform(data: bytes) -> bytes: key = bytes([0x3C, 0xA5, 0x7E, 0x1B, 0x4D, 0x92, 0x0F, 0x66]) sbox = [ 0x52, 0x09, 0x6A, 0xD5, 0x30, 0x36, 0xA5, 0x38, # ... 从二进制中提取的256字节 ] out = bytearray() for i, b in enumerate(data): x = b ^ key[i & 7] x = rol3(x) x ^= sbox[x] out.append(x & 0xFF) return bytes(out) if __name__ == "__main__": test = b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" # 42个A print(transform(test).hex())对拍方式很简单:用GDB在check函数的计算循环结束后、比较之前下断点,截取程序对输入"AAA..."算出的结果,然后和脚本输出对比。如果一致,说明算法理解完全正确;如果不一致,就回到伪代码重新找错位。这一步值得花时间,它省掉的是后面逆推失败时的无限纠结。
5.2 按逆序逐字节反推输入
正向算法确认无误之后,求flag就变成一个纯数学问题了。逆推顺序是从结果倒回去:
def ror3(v: int) -> int: return ((v >> 3) | (v << 5)) & 0xFF def solve(expected: bytes) -> bytes: key = bytes([0x3C, 0xA5, 0x7E, 0x1B, 0x4D, 0x92, 0x0F, 0x66]) sbox = [ ... ] # 逆表:对每个输出值,找到对应的输入值x,使得 (x ^ sbox[x]) == 输出值 inv = {} for x in range(256): y = x ^ sbox[x] if y not in inv: inv[y] = x flag = bytearray() for i, out in enumerate(expected): if out not in inv: raise ValueError(f"output 0x{out:02X} has no preimage") x_after_rol = inv[out] x_before_rol = ror3(x_after_rol) flag.append(x_before_rol ^ key[i & 7]) return bytes(flag) print(solve(expected))这里最值得展开的是**inv逆表的思想**:因为x ^ sbox[x]这一操作的下标是x自身,逆推时不能直接out ^ sbox[out],而要让x遍历0到255,找出所有满足x ^ sbox[x] == out的x。好在x -> (x ^ sbox[x])通常是个一一映射(至少在这道题里是),所以逆表构造完之后每个out只有一个原像。
如果你发现某个out有多个原像,那就说明算法在查表那一步有信息损失,这时单靠逆推是得不到唯一解的,需要借助约束求解器,处理方式我放在下一节。
输出结果确实是一段能读的英文句子,首尾带着题目要求的格式,看起来就是flag。
5.3 当逆向操作遇到麻烦时,用z3兜底
不是每道题都能像reverse_re3这么温和,逆推表构造得干干净净。有些题会在运算里加入条件分支、与位置相关的查表下标、或者乘法模运算,手动逆推会很痛苦。这时候我习惯用z3做兜底。
一个典型的z3写法长这样:
from z3 import * solver = Solver() Inp = [BitVec(f"inp_{i}", 8) for i in range(len(expected))] # 把S盒声明成数组 sbox_arr = Array("sbox", BitVecSort(8), BitVecSort(8)) for i, v in enumerate(sbox): solver.add(sbox_arr[i] == v) for i, out in enumerate(expected): x = Inp[i] ^ key[i & 7] x = RotateLeft(x, 3) solver.add(x ^ Select(sbox_arr, x) == out) if solver.check() == sat: m = solver.model() flag = bytes([m.eval(Inp[i]).as_long() for i in range(len(expected))]) print(flag)这段代码和前面的正推脚本几乎一一对应,只是把“执行过程”变成了“约束条件”。z3的好处是它能自动处理多解、无解、带复杂条件的问题,代价是你得花点时间理解BitVec、Array、RotateLeft这些API。我的建议是:简单逆推能搞定的题目,直接逆推;逆推遇到反人类操作,再上z3,不要刚拿到题就上求解器。
6. 这类Reverse题的通用套路与我的踩坑记录
6.1 定位核心逻辑的四个抓手
复盘整个reverse_re3的解题过程,我觉得最核心的能力其实不是“算法还原”,而是“快速定位”。定位手段汇总下来有四类:
- 字符串交叉引用:
Correct!、Wrong这类胜利/失败字符串,背后一定挂着关键比较逻辑。顺着字符串引用往上找调用者,通常能直达main或核心校验函数。 - 输入缓冲区的流向:不管程序交互多复杂,用户的输入总会被某个函数读走,那么这个缓冲区指针被传递到的每个函数都值得检查。追踪指针比追踪代码本身清晰得多。
- 比较指令特征:
memcmp、strncmp、strcmp,以及手写的repz cmpsb,都是核心校验的终点。在GDB里对这类调用下断点,直接看两个比较操作数,往往就拿到了“变换结果”和“期望密文”。 - 循环边界与长度:比较长度实际就是flag长度。这个数字可以从
strlen结果、循环计数器、或立即数里找,它也是后面约束求解时最关键的常量。
另一个很实用的习惯是:先花10分钟画一张程序行为草图,把“输入从哪进去、输出从哪出来、比较在哪发生”标出来,再开始逐行看伪代码。很多人卡住是因为一开始就掉进细节里,等回过神来已经忘了主路径在哪。
6.2 保护机制对做题的真实影响
每次有人问我“PIE开了怎么办,是不是要下断点都下不了”,我都想说:Reverse题里保护机制的影响远没有大家想得那么可怕。
- stripped:影响最大,因为它消灭了函数名。应对方法是利用
__libc_start_main定位main,再靠字符串和调用关系补全函数角色。 - PIE:只是让你下断点时多做一次加法
runtime_addr = base + offset。一次info proc mappings就能拿到底。 - Canary:对分析算法型题目毫无影响,只在你想故意触发栈溢出时才需要考虑。做Reverse题基本可以无视。
- NX / RELRO:本来是用来防漏洞利用的,在分析型题目里可以当作不存在。
换句话说,这些保护机制对“做题”本身没有本质杀伤力,它们更多是在提醒你:这道题不是让你利用漏洞,而是让你读懂程序。
6.3 我的工具组合与踩坑经验
最后说说工具和踩坑。我这次用的组合是Ghidra做第一轮自动分析 + IDA做精细确认 + GDB验证数据流 + Python脚本求解。Ghidra免费、批量分析能力强,能快速给出函数调用关系;IDA的伪代码质量和数据交叉引用体验更好,适合在确定核心函数后精读;GDB务必备好,因为很多静态反编译结果必须靠动态数据来确认。
踩过的坑主要有三个:
第一个是忘了& 0xFF。我在写脚本时,一开始没在移位操作后加掩码,对拍结果全错。后来追了半天才发现是Python的整数无限位宽和C的unsigned char截断行为不一样。凡是模拟C语言里的8位运算,务必在每一步后都做& 0xFF。
第二个是逆表构造时没有考虑多值冲突。起初我以为每个out都应该有唯一原像,直到发现某个输出值对应两个x时脚本崩了。实际检查后发现是我S盒数据提取多了4个字节,导致表错位。逆表一旦异常,优先回去检查数据提取对不对,不要急着改算法。
第三个是太早依赖z3。有一版我嫌手动逆推麻烦,直接上约束求解器,结果因为没限定每个字节是0到255之间,约束条件里隐含了符号扩展问题,求解器跑出负值。后来还是老老实实先把正推逻辑验证清楚。对这道题来说,手动逆推又简单又可靠,z3留到更复杂的题里再上更合适。
说实话,reverse_re3并不是一道难到离谱的逆向题,但它把“细节”和“耐心”这两个词体现得淋漓尽致。唯一让我觉得值得反复咀嚼的,是它在编译优化的干扰下,依然保留了一条非常清晰的“输入-变换-比较”主线。只要能稳住心态,按“文件侦察-静态分析-动态验证-脚本求解”这个流程一步步走下来,绝大多数同类题都能拿下。如果非要再分享一个小技巧:拿到题先别盯着奇怪算法看,找失败和成功的那两个输出分支,永远是最快的突破口。