ARTICLE DETAIL

资讯详情

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

先add再xor校验算法的逆向反推:进位处理与脚本实现

先add再xor校验算法的逆向反推:进位处理与脚本实现 简介面向数据恢复初学者与逆向爱好者的思路解析文档聚焦“先加再异或”二次加密后的反推方法。文档从固定字节中挑选含 00 的字节入手通过拆分十六进制高、低四位结合“同加同减差值不变”的数学原理逐步演示如何逆向还原原始数据并给出异或数值后再减固定数的具体结论。内容包含完整的推导过程和位运算技巧附带十六进制异或表适合需要理解异或性质、加法可逆性并上手实践的新手参考。全文以实例贯穿通过字节拆分对比、高低位进位处理与异或试错清晰展示从密文到明文的还原路径能有效帮助读者建立数据恢复中逆向分析的思路。包体为一个doc文档体积约四十二 KB内容精炼便于阅读和对照练习。目前已有六百八十七人学习下载可供数据恢复、文件修复场景下处理类似加密问题时借鉴。1. 先 add 再 xor 的反推思路二从单字节公式走向能落地的还原方法调试一个校验函数时经常能看到这样的处理读一个字节先 add 一个常量紧接着 xor 另一个常量最后拿去和某个 magic 值比较。你想通过修改输入让比较通过发现怎么试都差一点——低位对了高位错改了这字节那字节又崩。原因很直接add 会带进位xor 是按位翻转这两步叠在一起不是“试几次就能凑出来”的线性关系。这篇是二默认你已经理解了最基础的单字节逆推已知 ( Y ((X K) 0xFF) \oplus M )那么 ( X ((Y \oplus M) - K) 0xFF )。本文要解决的是真正落地时绕不开的部分多字节进位怎么处理、链式累加怎么从尾部反推、固件校验和与内存补丁里如何用这套思路少走弯路。适合正在调试序列号验证、分析固件校验、做内存补丁的开发者。2. 先拆运算本质add 与 xor 在二进制上差在哪反推时才不会逆错方向2.1 逐位独立与进位传播为什么 xor 的逆是自己add 的逆是减法xor 的本质是模 2 加法每一位只依赖当前这一位不关心低位有没有进位。所以它有一个很特殊的性质一个数连续 xor 同一个值两次会回到原来的值自身的逆运算就是自己。这也是“xor 加密”最常见的实现原理。但 add 完全不同普通整数加法是带进位的从最低位开始每位相加的结果可能向高位产生一个进位这个进位会一级一级传上去。于是 add 的逆运算必须是减法而减法同样有借位链方向正好和加法相反。这个差异直接决定了反推的顺序。对于 ( Y ((X K) 0xFF) \oplus M ) 这种“先 add 再 xor”的组合最后一步是 xor逆推时必须先处理最后一步先 ( Y \oplus M ) 得到中间值再做减法还原 ( X )。这个顺序不能换因为 xor 打散的只是每一位的最终结果而 add 产生的进位信息在 xor 之前就已经固定在中间值里了。我见过不少人在这一步翻车觉得反正两个运算都可逆直接写成 ( X ((Y - K) 0xFF) \oplus M )结果低位几个比特碰巧是对的高位全乱。低位对是因为低几位没有进位干扰看起来像“算法没问题”实际上只是错觉。2.2 组合变换的代数形态已知哪些量决定你用公式还是用枚举反推前先确认已知条件这决定了你用什么手段。最常见的三种情况第一种K 和 M 都已知比如从反汇编里直接看到了立即数。这时候直接用逆推公式单字节一次就算完复杂度是 O(1)。第二种K 和 M 未知但手里有几组输入输出样本。单字节场景下直接枚举K 有 256 种可能M 有 256 种可能组合起来 65536 种用 Python 暴力跑一遍也就几十毫秒。筛选条件是所有样本都满足 ( Y ((X K) 0xFF) \oplus M )。但如果换成 16 位甚至 32 位枚举空间变成 ( 65536^2 ) 或更大就不能硬来了得靠分析进位链或者交给约束求解器处理这是第 6 章的内容。第三种只有一组输入输出K 和 M 都未知。这种情况下单凭一组样本无法唯一确定 K 和 M因为不同的 (K, M) 组合可能映射到同一个输出解不唯一。这时候反推的意义就变了不是求出原始 X而是求“哪些 K、M 组合能满足当前这一组关系”或者换个思路直接枚举出所有可行解看有没有规律可循。还要提醒一点add 和 xor 之间不满足分配律( (A B) \oplus C ) 不等于 ( (A \oplus C) (B \oplus C) )更不能化简成 ( A (B \oplus C) ) 之类的形式。很多人在这一步想当然地合并常量最后推出来的公式在特定字节上碰巧正确换一组数据就崩。老老实实按“先逆 xor再逆 add”的顺序做是最不容易出错的方式。2.3 用十六进制看问题0xFF、0x100、溢出截断到底是什么add 和 xor 反推里最容易忽略的是“宽度”。程序里处理单字节时加法结果超过 0xFF 的部分会被丢掉也就是取模 256。这个截断动作发生在哪个位置直接决定你的反推公式长什么样。举个具体的例子假设 X 0xF0K 0x40M 0x0F。按 8 位运算先做加法得到 0x130截断成 0x30再 xor 0x0F 得到 0x3F。但如果你用 32 位整数去算加法结果是 0x130xor 之后是 0x13F和 8 位结果差了 0x100。这个差异在反推时会被放大你按 32 位反推出一个“正确”的 X但程序实际按 8 位比较结果永远对不上。所以动手写脚本前先确认程序里每个操作数的宽度以及掩码到底在哪一步生效。XKM8位过程8位结果32位过程32位结果差异0xF00x400x0F(0xF00x40)0xFF0x30^0x0F0x3F0xF00x400x130^0x0F0x13F0x1000x7F0x010x80(0x7F0x01)0xFF0x80^0x800x000x7F0x010x80^0x800x00无0xFE0x100x55(0xFE0x10)0xFF0x0E^0x550x5B0xFE0x100x10E^0x550x15B0x100从表格能看出来差值全是 0x100 的整数倍这正是第 8 位之上的进位被截断导致的。第二组数据里 0x7F 0x01 没有超过 0xFF8 位和 32 位结果一致这也是为什么有些人用 32 位脚本测几个字节“碰巧”全对换到边界值就翻车。血泪经验写反推代码时每一步计算都套上 ( mask (1 bits) - 1 )不要偷懒只在最后一步做掩码。3. 反推实现先 add 再 xor 的还原脚本与进位处理3.1 最小还原脚本单字节场景下 10 行 Python 出结果不知道你有没有试过直接在调试器里手算反推。我早期做这类校验分析时习惯拿计算器一步步算后来发现完全没必要单字节的逆推逻辑是固定的写成脚本一劳永逸。下面是最小可用的 Python 实现。def rev_add_xor(y: int, k: int, m: int, bits: int 8) - int: mask (1 bits) - 1 # 第一步对最后一步 xor 做逆运算xor 的逆就是它自己 t (y ^ m) mask # 第二步对 add 做逆运算减法结果用掩码保证落在 [0, 2^bits) 内 x (t - k) mask return x # 穷举验证bits8 时遍历全部 K、M、X确认公式无损 for k in range(256): for m in range(256): for x in range(256): y ((x k) 0xFF) ^ m assert rev_add_xor(y, k, m) x print(8-bit all ok)逻辑说明正向运算的顺序是先加后异或逆向就必须先异或后减。第一行t (y ^ m) mask还原出“加法完成后的中间值”第二行(t - k) mask把中间值里的常量 K 减掉得到原始 X。掩码的作用是模拟程序里无符号整数溢出后的截断行为保证结果落在合法范围内。参数说明y是程序最终读到的结果值k是 add 的立即数m是 xor 的立即数bits是运算宽度默认 8改成 16 或 32 就能处理更宽的数据。需要注意的是这个函数假设程序是“先 add 再 xor”的顺序如果实际代码是“先 xor 再 add”公式要对应调整成先减后异或顺序搞反是反推结果对不上的头号原因。3.2 多字节与链式累加进位从哪来、反推要从哪头推单字节公式只能处理“每个字节独立做 add 再 xor”的情况。但实际项目里更常见的是链式累加读一个字节和当前状态做一次((s b k) ^ m) 0xFF再把结果作为下一次的输入状态循环处理整个缓冲区。这种场景的典型代表是各种自研校验和算法。先看正向算法长什么样def forward(data: bytes, k: int, m: int, iv: int 0) - int: s iv for b in data: s ((s b k) ^ m) 0xFF return s这里的iv是初始状态很多程序里不是从 0 开始的而是从一个固定值或者上一个模块的计算结果接续。正向计算不难理解关键是反推如果我想让最终结果变成某个 target但只能修改 data 中某一个字节该改成什么值思路是从尾部往前推。最终结果已经知道程序在最后一个字节上做了一次 add 再 xor。先对 target 做 xor 的逆运算再减去最后一个字节和 K就得到处理最后一个字节之前的状态。这样一步一步往回倒一直倒到你想修改的那个字节之后算出“该字节处理完后续结果必须是什么”然后正向算出进入该字节时的状态两者相减就能得到新字节。def solve_byte_at(data: bytes, pos: int, target: int, k: int, m: int) - int: # 正向计算得到进入 pos 位置时的状态 s_in s_in 0 for b in data[:pos]: s_in ((s_in b k) ^ m) 0xFF # 反向计算从 target 往前倒推得到 pos 位置处理完后的期望状态 s_out s_out target for b in reversed(data[pos 1:]): # 反向越过 xor s_out ^ m # 反向越过 add减去当前字节和常量 k s_out (s_out - b - k) 0xFF # 根据 s_in 和期望的 s_out反推 pos 处的新字节 b_new (s_out - s_in - k) 0xFF return b_new # 示例修改第 5 个字节让最终校验值变成 0xA5 data bytearray.fromhex(01 02 03 04 05 06 07 08) data[5] solve_byte_at(data, 5, 0xA5, 0x10, 0x55) print(forward(data, 0x10, 0x55)) # 输出 0xA5逻辑说明这个函数把问题拆成两半。前半段s_in是修改前就确定的状态因为 pos 之前的字节没有改动后半段用反向循环从 target 一路解回s_out也就是 pos 位置处理完必须达到的状态。最后s_out - s_in - k得到的就是这个位置应该放的字节。关键点在于反向循环的顺序每一轮先^m再减b和k和正向的(s b k) ^ m严格互逆差一步都不行。参数说明pos是要修改的字节下标target是期望的最终校验值data是原始数据。这个函数只改一个字节其他字节保持不变。如果程序里的 K 或 M 不是常量而是数组那反向循环里就不能直接用同一个 k、m而要按下标取对应的值这属于脚本改造的范畴下一小节展开。3.3 脚本改造点初始状态、常量数组、截断位置都不一样怎么办实际逆向时遇到的算法很少像示例这么干净常见的改造点有三个。第一初始状态 iv 不为 0。正向代码里s的初始值是 0但程序可能从固定值开始甚至从某个外部输入开始。改造方法很简单把iv作为参数传进正向函数反向推s_in时同样先s_in iv再处理data[:pos]部分。第二每一轮的 K 和 M 是变量。有些校验算法会对不同字节使用不同的常量甚至用前一个字节的值作为后一轮的 xor 参数。这种情况下把k和m从单个 int 改成列表正向循环里用k[i]、m[i]反向循环里对应下标也要同步。注意反向循环的下标关系和正向不同for i in range(len(data)-1, pos, -1)这种写法更直观不容易错。第三掩码的位置变了。这个是最隐蔽的坑。前面所有代码都假设算法是((s b k) 0xFF) ^ m但有些程序写的是((s b k) ^ m) 0xFF也就是先异或再截断。两种写法在正向结果上可能碰巧一致但反推公式完全不同第一种逆推时要先掩码再异或第二种要先异或再掩码。遇到这类代码别急着套公式先在调试器里确认汇编指令的顺序或者用已知输入输出跑一遍正向函数验证你的理解是对的再动笔写反推。还要提一个不太起眼但很实际的点Python 里的负数。(t - k) mask这个写法在 Python 里可以正确处理负数和无符号回绕因为按位与运算会把结果约束在掩码范围内。如果你用 C 或 JavaScript 写同样的逻辑要格外注意负数右移和类型转换的问题否则算出来的结果可能是负的。4. 实战应用序列号校验、固件校验和与内存补丁里的反推思路4.1 序列号校验用反推构造一字节合法序列号先给你一个典型的代码形态。某个自研软件校验注册码时逐字节处理用户输入的序列号最后把状态值和一个固定 magic 比较KEY bABCD-1234-EFGH def check(serial: bytes) - bool: s 0 for b in serial: s ((s b 0x10) ^ 0x55) 0xFF return s 0xA5现在的情况是你已经有一把可以用的旧序列号但想改其中一位比如把版本号从 1 改成 2又不想重新走一遍完整的注册流程。直接改的话最终校验值大概率对不上程序会认为序列号非法。这时候用solve_byte_at就能精确算出改哪一位、改成什么值才能让最终结果恰好等于 0xA5。前面 3.2 节的函数直接用参数target0xA5k0x10m0x55。计算出新字节后替换原位置整个序列号依然能通过校验。这不是破解而是理解算法后的构造性修改常用于自己做工具软件时的离线授权验证开发或者 CTF 逆向题里“给定校验算法构造合法输入”的标准操作。实际使用中要注意序列号通常会包含校验位、长度位甚至对字符范围有约束比如只能是 0-9 或 A-F。如果solve_byte_at算出来的值不在合法字符范围内说明这个位置不能改或者需要同时改两个字节来凑。两个字节的情况不复杂固定一个字节用同样的函数算出另一个如果还不满足字符集约束就枚举第一个字节的合法字符集每个都算一遍第二个字节直到两个都在范围内。暴力组合 256 种情况对于脚本来说完全不是负担。4.2 固件校验和修改 bin 后重算校验字节还是反推固件文件和序列号场景不太一样。很多时候你需要修改 bin 里的某个配置参数但固件末尾附了一个校验字节程序启动时会重新计算整段数据的校验值并和末尾字节比较。这种场景下有两个选择。第一个选择是“正向重算”改完数据后重新跑一遍forward函数算出新的校验值写回末尾。这是常规做法大多数固件修改工具都是这么干的。前提是你能找到校验值存储的位置并且程序允许你重算后覆盖写入。但有个常见的限制固件末尾的校验区可能被单独签名锁定或者整段固件有 CRC 保护单独改一个字节会导致另一处校验失败。第二个选择是“反推构造”“校验算法是先 add 再 xor比较值是固定写死在程序里的”这种场景。如果程序不是用末尾字节做对比而是内部维护一个 magic 常量记录“数据完整时校验结果应该等于多少”那你重算校验字节就没有意义因为比较值不变。真正需要做的是找出你要改的那个参数在 bin 中的偏移用 3.2 节的solve_byte_at算出替换后应该填什么字节让整个校验链的结果仍然等于 magic。这样修改的文件从外观上看所有字节都“合法”。实操流程一般是先用 binwalk 或 010 Editor 找到固件里校验函数的算法参数用调试器或模拟器确认 K 和 M然后用 Python 打开 bin读入整段数据定位参数偏移调用solve_byte_at计算新字节写回文件。整个过程可以用下面的脚本骨架完成python3 - EOF data bytearray(open(firmware.bin, rb).read()) # 偏移由调试定位0x12A0 是要改的配置参数 pos 0x12A0 new_val solve_byte_at(data, pos, 0xA5, 0x10, 0x55) data[pos] new_val open(firmware_patched.bin, wb).write(data) EOF注意如果固件校验算法覆盖的是一段连续区域而你修改的字节正好在区域末尾反向循环处理空数据时reversed(data[pos1:])为空列表s_out就等于target公式依然成立不会出错。4.3 内存补丁里“改跳转”之外的选择反推字节让比较自然通过之前我在分析一个程序时也干过这种事定位到校验函数出口是一条cmp al, 0xA5加jz第一反应是直接把jz改成jmp爆破。但这样做动静太大程序有完整性自检或者调试器检测时改代码段的痕迹很容易被发现。更隐蔽的思路是让比较本身就成立也就是修改输入数据让 AL 寄存器自然等于 0xA5这样程序走正常分支代码段一个字都不用动。具体步骤分三步。第一步在cmp al, 0xA5下断点运行到断点时读取 AL 的当前值同时确认此时处理到的是输入数据的第几个字节、K 和 M 的值。第二步用rev_add_xor或solve_byte_at计算如果 AL 当前是 0x3F期望是 0xA5需要把当前处理的字节改成什么。第三步把计算出的新值写回内存继续运行观察程序是否走了正常分支。这是逆向调试里很常用的一招不碰代码只改数据。GDB 下的操作大概长这样# 假设校验函数的 cmp 指令地址是 0x4012A0 break *0x4012A0 run info registers al # 读取当前处理的字节地址假设在 rsi 指向的缓冲区 x/bx $rsi读出来之后本地 Python 算一遍再用set {char}$rsi 新值写回。这套做法的好处是程序自检只会扫描代码段是否被修改数据段的变化在多数情况下不会被检测到。前提是校验的输入缓冲区在内存中是可写的且程序后续不会重新从磁盘读取原始数据覆盖。血泪经验再强调一次修改之前务必确认程序比较的是 AL 还是 AX是 8 位比较还是 16 位比较。我遇到过一次GDB 里看的 AL 等于 0x85期望值是 0xA5用单字节公式算了半天怎么改都不对最后发现比较指令是cmp ax, 0xA5高位还有个 0x00 参与比较按单字节改当然永远不对。宽度和比较粒度是这类调试里最容易被忽略的两个细节。5. 避坑备忘add/xor 反推最常见的 5 个翻车点5.1 8 位算法用了 32 位反推结果差 0x100 的整数倍现象反推出来的 X 用正向算法验证前几个字节全对到某个字节开始差 256、512 这种整数倍偏差。原因正向程序是 8 位运算加法溢出自动截断你写脚本时用的是 Python 整数没有每一步都套掩码。解决确认正向算法中 0xFF 的位置在逆推代码的每一处减法、异或后都补上 mask不要只做最后一步。5.2 Python 里算出负数直接拿去用现象脚本输出类似-113这样的值写进内存或者存成字节时抛异常或者变成 0xFF。原因(t - k)的结果在 Python 里是负数Python 的整数是无界的不会自动回绕成无符号值。解决所有减法结果立即 mask把负数转换到无符号范围。这一步在 Python 里是在 C 语言里要显式转uint8_t在 JavaScript 里要用 0处理语言不同做法不同核心思想一致。5.3 逆推顺序搞反最后一步是 xor你却先做了减法现象低位字节反推正确高位字节全错看起来像是“局部正确”。原因正序是(X K) ^ M逆推必须先 xor 再减。有人习惯性地先算减法因为觉得“加法逆运算是减法”最先想到。低位正确只是巧合低位没有进位发生先减后异或和先异或后减在某些组合下等价。解决严格按正向操作的逆序处理最后一步的逆运算先做。5.4 掩码位置和程序不一致现象单字节验证全对多字节链式累加时从头错到尾。原因程序里可能是先0xFF再^M也可能先^M再0xFF两种写法在正向结果上偶尔一致但反推公式不同。解决反推前先用已知输入跑一遍你理解的正向算法确认输出和程序一致再动笔写逆推代码。多花 30 秒验证省下半小时排查。5.5 把“正向重算校验值”当成“反推构造合法输入”现象改了 bin 里的数据也用forward重算了末尾校验字节并写回程序仍然报校验失败。原因程序可能不是拿这个字节做比较而是内部固定了一个 magic 值每段数据算完之后和 magic 比较。重算校验字节只改了存储值没改变计算结果比较自然还是失败。解决先用调试器确认程序的比较方式。如果是对固定 magic 比较需要用solve_byte_at反推输入数据的某个字节如果是读取文件末尾的校验字节做比较才适合正向重算。6. 进阶遇到复杂层数时用 Z3 做交叉验证比手算更稳当反推链路变长比如连续做了三轮“先 add 再 xor”或者 K、M 不是常量而是跟位置相关的数组时手算公式容易错这时候我习惯用 Z3 做交叉验证。Z3 是微软开源的约束求解器适合处理这种位运算等式你给它描述约束它直接给出所有可行解。以本文的单字节公式为例Z3 的写法很直接from z3 import BitVec, Solver, sat x BitVec(x, 8) s Solver() s.add(((x 0x40) ^ 0x0F) 0x3F) while s.check() sat: model s.model() print(fx {model[x].as_long():#04x}) # 排除当前解继续求下一个 s.add(x ! model[x])逻辑说明BitVec(x, 8)声明一个 8 位位向量运算会自动按 8 位截断天然模拟了无符号溢出。s.add加入约束方程while循环逐个输出所有可行解。上面这个例子会打印多个 x 值因为 8 位空间下不同 x 可能映射到同一个 y多解是正常的。实际使用技巧我先用第 3 章的脚本算出一个候选值再用 Z3 验证这个值是否满足所有约束。两边结果一致才往下走不一致就说明我的逆推公式有漏洞回头查掩码和运算顺序。特别是遇到多字节进位的情况Z3 能帮你确认“期望状态是否真的可达”有些 target 在 8 位空间下根本没有对应输入字节属于无解这时候硬算只是浪费时间。我现在拿到这类校验算法已经养成一个固定习惯先写一小段正向函数拿程序实际输出验证正向理解没错再写逆推函数用随机数据做一轮“正推再反推”的往返测试最后才应用到实际补丁里。这个流程跑下来那些看起来很玄学的“反推行不通”问题最后基本都落在宽度、截断位置和运算顺序三个点上。希望这篇能帮你在调试这类校验时少走两步弯路。本文还有配套的精品资源点击获取
返回列表