这一讲的位置

上一讲把字节、位数、壳这些东西钉死了。这一讲开始真的拆代码:静态怎么读,动态怎么改。

配套的两个小工程(异或链的加密原版和逆推版)在 r4-reverse-course,那节课就是拿它们演示的。


一、先把"逆向"和"PWN"的边界说清楚

两个都是搞二进制的,为什么要分家?

区别在你能不能碰到那份代码。

  • 逆向:代码在你本地运行。你能读它、能改它、能单步它、能把它 dump 下来。所以 flag 一定藏在程序里面——你把整个代码分析透了,flag 就在你手里。
  • PWN:代码在服务器上,你碰不到。你把程序整个打穿、源代码分析得再透,也拿不到 flag;你得把服务打下来、拿到权限,去读服务器上的文件。

这个边界一旦立住,很多东西就顺了:为什么逆向要学调试器(因为程序在你手里)、为什么 PWN 不太需要静态读通业务逻辑(因为攻击面在输入而不是在算法)、为什么"能不能本地调试"是判断这两类题目的第一直觉。

顺带一个应用面上的差别:逆向的能力会直接用在注册机、勒索病毒解密工具这类事情上——因为那些东西的算法就在本地,是可以反推的。


二、异或的双向可逆,就是解题引擎

拿到一道"输入 flag → 变成看不懂的东西 → 比对"的题,第一件事不是看它加密得多复杂,而是确认它用的是不是异或。

异或有个性质,值得当场用计算器算一遍(251、123、108 这组数):

251 ^ 123 = 108
251 ^ 108 = 123

A ^ B = C,那么 C ^ B = A,C ^ A = B。 三个数知道任意两个就能推第三个,而且加密和解密是同一个操作。这就是为什么大量 CTF 逆向题的"加密"其实形同虚设——你只要把它的流程倒着走一遍,用的还是同一句异或。

还有一个歧义要提前拆掉:代码里到处是 flag 这个词。题目的 flag 和标志位的 flag 完全是两回事。后者常常是"某一位为 1 表示走 A 分支"的意思,比如约定某位为 1 用 TCP、为 0 用 UDP,那么把这一位与上一个掩码(0100)做或运算,就得到一个能同时表达多个开关状态的数。看到 flag 先判断它指的是哪一个,很多"看不懂"其实只是串了概念。


三、写逆函数时我的固定习惯

拿到加密流程,我不会直接写求解代码,而是这么干:

  1. 把原始 main 整段复制过来,改名成 main_origin(我习惯在后面标一个 o 表示 original)。它是我的验证器,不是要逆向的目标本身。
  2. 另写一个逆函数,把加密的每一步倒着做。
  3. 先调逆函数,再调 main_origin——逆函数算出来的东西喂回原版,原版打印 Success 就说明我推对了。

好处很直接:你不需要另外证明自己的推导是对的,程序自己会告诉你。 那节课的例子里,加密是"从第 1 个字符开始,每个字符和它前一个异或后赋给自己",那么逆过程就是从最后一个(第 32 个)往前,每一步用当前的 global[i] ^ global[i-1] 还原。方向反了就全错,而方向对不对,跑一遍原版就知道。

顺手几个 IDA 里的确认动作:

  • Shift + E 快速导出当前数据,切到 C 视图看它的结尾有没有 \x00,确认你截的是完整字符串。
  • 比对长度和数组长度不一致时,以比对的那个为准。 例子里 global 数组总长 34,但 strncmp 只比 0x21(也就是 33)。那第 34 个字节是留给 \0 的,真正参与判定的只有 33 个。这类"差一个"的地方不确认清楚,逆推就会多算或少算一位。

四、为什么 CTF 逆向题不会拿哈希当 flag 比对

这一条是我希望学生记住的判断题。

哈希本质不可逆。 一道题如果把"你输入的 flag"哈希之后和一个常量比对,那这道题就没有"逆推"这条路可走——你只能去撞。

所以:

  • 题目里出现哈希,要么它不是逆向题(可能是让你撞短口令、或者考点根本在别处);
  • 要么你分析错了——你看到的那个哈希只是整个流程里的一小段,真正的判定不在这里。

换句话说,哈希在逆向题里几乎总是个"此路不通"的路标,而不是终点。 看到它就退回去重新找主流程,比坐下来研究怎么反解 MD5 有用得多。


五、静态读不动的时候:反调试、自修改、内存里释放代码

三类常见干扰,处置方式完全不同,别一视同仁。

反调试(检查调试器在就自杀)。如果它不影响最终判定,直接 NOP 掉。它的存在只是为了恶心你,不是题目逻辑的一部分。

自修改代码 / 运行时才释放到内存的指令。这类是真要看的东西:代码在运行前根本不存在,IDA 静态打开时它还没被解密,所以静态什么都看不出来。这种就必须上动态调试器,等它自己解完密再去内存里看。

这里有个区分值得记:

  • 对代码的自解密 → 只能用动态调试器。静态分析器分析不到还没生成的指令。
  • 对数据的自解密 → 不一定要动态。你可以写一个一样的脚本跑一遍,或者在解密那一步后面下个断点,等它解完直接去内存里找。

所以顺序应该是:先判断这段干扰会不会改变最终判定。 不会的,NOP 掉走人;会的,再决定是静态硬啃还是转动态。IDA 很重要,但不能太依赖 IDA——它分析不出来不等于题目没做,有时候你得先把内存 dump 下来再丢回 IDA。


六、动态调试实操(这部分全是手熟)

我用 x64dbg(OD 只有 32 位,x64dbg 32/64 都能用;两个没高下,习惯问题)。

基本键位

  • F2 下断点 / 取消断点。条件断点也是 F2 系,但不好用,我基本不用。
  • F8 步过(跳过)。
  • 空格 汇编当前指令。
  • Ctrl + * 修改执行点——想让程序从哪跑就从哪跑,甚至往回跳。
  • Alt + 退格 撤销刚才的修改;Ctrl + 退格 恢复选区。

一个必踩的坑:NOP 掉一条指令之后,整个函数突然变乱

这是那节课现场撞出来的,也是最值得记的一条。

想把某个 call 废掉,于是把它前面那条压参数的指令 NOP 了。改完一看——整个函数面目全非,刚才那个 call 也不知所踪。

原因不在调试器,在指令长度。看原始字节就明白了:

C7 04 24 xx xx xx xx     ← mov dword ptr [esp], imm32,一共 7 个字节

你把它第一个字节换成 90(nop),只废掉了 1 个字节,剩下 6 个字节还躺在那儿。调试器从下一条开始重新解析,把那 6 个字节当成了新的指令流,于是后面全部错位——看起来就是"函数变乱了"。

正确做法:勾选"剩余空间填充"(NOP sled),把整条指令的 7 个字节全部填成 90。右下角会一直提示你「指令比原来小 6 字节」,那个提示就是在提醒你这个不对等。

这条的通用价值:在调试器里改指令,改的是字节,不是语义。 你替换的字节数必须和被替换的指令长度一致,否则破坏的是指令流的边界,而指令流一旦错位,后面所有反汇编都是垃圾。

补丁重跑会失效,断点却记得住

这是个会让人困惑的现象:你打的补丁,重新运行一遍程序就没了;但断点还在。

这是故意的。 自修改程序和自校验程序就是靠这个吃饭的——如果调试器的补丁能永久生效,这类保护就形同虚设。所以工具宁可让补丁是一次性的。

(有没有办法让补丁长期生效?大概有,我没去找,因为我用不到。)

找不到 main 的时候

两种办法,都很快:

  1. 断在 exit 上,或者勾上「线程结束」这个断点选项。程序结束前一定会走到这里,断下来之后顺着调用栈往回看,一层层就是你的主函数。
  2. 直接在 main 里找。

但有个顺序要注意:别断得太靠外。 如果断在系统级的退出点上,函数的临时变量可能早就销毁了,你想拿的东西已经没了。理想位置是 main 内部、返回之前——既能兜住退出,又还在自己的栈帧里。断在退出前主要是为了来得及看堆栈,不是为了看局部变量。

为什么一定要在 CMD 里跑程序

有些程序双击执行完窗口就消失了,你连输出都没看到。原因很简单:它的 main 里没有暂停。

所以养成在 cmd 里启动的习惯。 就算程序自己退出了,cmd 还在,输出还留在屏幕上。这一条不起眼,但它是"你能不能看到现场"的区别。


七、现场补课:大端序和小端序

讲内存的时候冒出一个 66 30 40 00,才想起来这个第一课漏讲了,现场补。

同样四个字节,读法有两种:

  • 按字节看:66 30 40 00
  • 按一个 dword 看:0x00403066

为什么会反过来?因为我们用的是小端序:低位存低地址。数值 0x1234 在内存里躺的是 34 12——小的那半放前面。

大端序就是按人类书写习惯来:12 34。大端序也叫网络序,网络传输统一用它。

直觉上人类习惯的大端序更省心,但小端序被机器选中是有理由的:它更高效。判断一个数是正是负、看低位运算结果,都只需要看最低地址那一个字节,不用先跳到末尾。Windows 上就是小端序;一些单片机用大端序,具体看平台。

顺带把上一讲那条再钉一次:逗号/空格的分隔是显示风格,不是数据边界。一个字节 = 两个十六进制字符。 这句话在两个不同的场合各救你一次。


八、还有一类题:迷宫

不是所有逆向题都在做加解密。有一类是地图藏在程序里但你看不见:你在某个起点,输入方向指令(上/下/左/右)来走,走到终点时,你走过的方向序列本身就是 flag。

它的判定通常是:踩到墙或者越界就失败,走到终点就输出 flag。所以这类题的解法不是逆推,是在程序里把那张地图找出来,然后自己走一遍。

实操上:短迷宫(比如 4×4)可以把字符抄下来手推;长迷宫不能这么干,得把地图在内存里找到、导出来再算。

那节课我当场走了一遍,走错了几次才对。事后补一句实话:这题我以前做过,所以当场能解出来不代表分析能力,纯粹是见过。 现场解题翻车很正常,别把"做过"当成"会了"。


九、学习路径:先看懂伪代码,汇编慢慢来

给刚入门的人一个明确的优先级:

  1. C 语言必须先掌握。 逆向绕不开它——IDA 反编译出来的是 C 风格的伪代码,你看不懂 C 就等于看不懂结论。写验证脚本也得用它(当然不限语言,但伪代码是 C,所以 C 是共同语言)。
  2. 汇编可以慢慢学。 不用一开始就啃。先能看懂 IDA 的伪代码就够解题了。真遇到"汇编题"(伪代码出不来或者被故意扰乱),再对着汇编看关键几行,不要全看——全看会耗掉你大量精力还不一定有结果,其余交给 IDA。
  3. 看不懂的那几行可以丢给 AI。 工具就是工具。
  4. 常见指令先认几个就够用:mov、push/pop、call、jmp、加减乘除、左移右移。

顺带回答"为什么总是 Windows 逆向":因为逆向的代码在本地、能改能调,Windows 上的目标最多、工具链最顺手。但逆向不等于 Windows——Linux 的 ELF 照样有逆向题,静态分析一模一样,只是动态调试的工具要换。


收尾:这一讲的四条

  1. 异或的双向可逆就是引擎:A^B=C ⇒ C^B=A。看到"加密"先怀疑它是不是异或,是的话倒着走一遍就完。
  2. 哈希在逆向题里是路标不是终点:出现哈希说明你走错方向了,退回去重找主流程。
  3. 改指令改的是字节,不是语义:NOP 一条 7 字节指令就要填满 7 字节,否则错位的是整条指令流。补丁重跑失效是设计,不是 bug。
  4. 干扰先分类:不影响判定的直接 NOP;影响判定的,代码自解密只能动态、数据自解密可以断点后去内存里捡。

课件和那节课的完整录像我一起放在 r4-reverse-course 里(录像和转写稿是加密存的,需要对应密钥才打得开)。有问题随时问。