ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

逆向工程实战:黑盒映射还原与查表逆向技巧

逆向工程实战:黑盒映射还原与查表逆向技巧

逆向工程里有一类题目特别有意思,它不靠复杂的加密算法吓唬人,也不靠庞大的代码量拖垮你,而是用一个看似简单的“盒子”把你的思路锁死。reverse_box 就是这样一个典型代表——名字直白得近乎挑衅,仿佛在说“来吧,拆开我”。我第一次碰到这类题的时候,盯着那个黑盒函数看了半天,脑子里全是“它到底在干什么”。后来拆多了才发现,这类题的核心从来不是算法本身有多难,而是你能不能把“黑盒”变成“白盒”,把不可见的映射关系还原成一张看得见、摸得着的表。

这篇文章适合两类人:一类是刚接触逆向、想找个切入点练手的同学,另一类是做过一些题但总在“黑盒函数”面前卡壳、想系统梳理还原思路的从业者。我会围绕 reverse_box 这个主题,把黑盒映射的识别、还原、验证整条链路拆开讲清楚,包括我实际踩过的坑和几套可以直接抄作业的还原方案。全文不涉及任何具体平台或题目的敏感信息,只讲方法论和实操逻辑,你可以放心往下看。

1. 先搞清楚“盒子”到底在锁什么

1.1 黑盒函数的本质:输入到输出的映射关系

逆向题里所谓的“盒子”,本质上就是一个输入到输出的映射函数。你给它一个字节,它还你一个字节;你给它一串数据,它还你一串数据。问题在于,这个映射关系被藏在二进制里,你看不到源码,只能通过反汇编或者动态调试去猜。

我习惯把这类函数分成三个层次来看:

  • 第一层是接口层:函数接收什么参数、返回什么类型、有没有全局状态。这一层决定了你能不能“喂”数据进去。
  • 第二层是变换层:内部做了哪些运算,是查表、异或、移位,还是几者的组合。这一层决定了映射的复杂度。
  • 第三层是约束层:有没有长度限制、有没有校验和、有没有反调试。这一层决定了你能不能顺利地把数据喂进去再拿出来。

reverse_box 这类题,通常第一层很干净——就是一个字节进、一个字节出的函数,没有花哨的参数。第二层才是真正的战场,因为它往往用了一个预置的查找表或者动态生成的置换表,让你没法一眼看穿。第三层一般不会太重,毕竟题目目的是考还原能力,不是考反调试对抗。

理解这三层划分的好处是:你不会一上来就扎进汇编细节里出不来。先看接口,再看变换,最后处理约束,这个顺序能帮你省下大量时间。

1.2 为什么这类题偏爱“查表”而不是“算出来”

这里要讲一个设计上的逻辑。如果出题人用纯数学运算(比如异或一个固定值、做几次移位),那逆向的人只要把几条指令读明白,映射关系就出来了,难度太低。但如果用一张256字节的查找表,情况就完全不同了——你看到的是一堆数据,而不是一堆逻辑。

查表的好处在于:映射关系是任意的。任意意味着它可以是精心构造的置换,也可以是完全随机的对应,你没法用数学推导去“猜”下一个值,只能老老实实把整张表还原出来。这就是“盒子”锁人的地方——它把可推导的逻辑变成了不可推导的数据。

我见过不少新手在这里犯迷糊,总想着“这个表是不是有什么规律”,然后花大量时间去拟合公式。实测下来,绝大多数情况下这张表就是硬编码的,没有规律,你要做的就是把表 dump 出来,而不是去理解它为什么长这样。这个认知转变很关键,想通了之后效率会高很多。

1.3 还原思路的整体框架:从“能跑”到“能读”

我把整个还原流程分成四个阶段,后面几个章节会逐一展开:

  1. 定位:找到那个黑盒函数在哪儿,确认它的输入输出边界。
  2. 采样:构造输入,观察输出,收集足够的映射样本。
  3. 还原:把样本整理成完整的映射表,或者直接提取硬编码的表。
  4. 验证:用还原出来的表去预测新输入,和原函数对比,确认一致。

这个框架看起来简单,但每一步都有细节。比如“采样”阶段,如果你只测了几个输入就急着下结论,很可能漏掉某些分支;再比如“还原”阶段,提取硬编码表和动态采样是两条不同的路,选错了会绕远路。下面我按这个框架往下讲。

2. 定位黑盒:怎么在二进制里把它揪出来

2.1 静态分析:从字符串和调用点反推

拿到一个二进制,我第一件事不是打开反汇编器从头读,而是先看字符串和导入表。原因很实际:黑盒函数往往会被主流程调用,而主流程里通常有提示信息、输入输出相关的字符串。顺着这些字符串往上找交叉引用,很容易定位到关键调用点。

举个常见的场景:程序提示你输入一串字符,然后输出“正确”或“错误”。你在字符串窗口里找到“错误”这个提示,看它被谁引用,通常就能看到一个比较分支,比较的两边分别是“你的输入经过黑盒处理后的结果”和“某个期望值”。这时候黑盒函数就在比较分支的旁边,顺着数据流往回追就能找到。

这里有个经验:不要一上来就追最深的调用链。先看主函数附近的直接调用,很多题的黑盒函数就在主流程里被调用一两次,追起来很快。如果发现调用层级很深,再考虑用动态方法。

2.2 动态调试:用断点把输入输出“夹”出来

静态分析有时候会被混淆或者间接调用干扰,这时候动态调试更直接。我的做法是:在疑似黑盒函数的入口和出口各下一个断点,然后喂一个已知输入,看寄存器或栈上的值怎么变。

具体操作上,入口断点看参数怎么传(是寄存器传参还是栈传参),出口断点看返回值是什么。两次断点之间,你可以单步跟,也可以直接放行,关键是记录下“输入 X 对应输出 Y”这个事实。多喂几个不同的 X,你就能快速判断这是不是一个逐字节的映射——如果输入长度变化时输出长度也跟着变,且每个字节独立对应,那基本可以确认是逐字节查表。

提示:动态调试时优先用“已知明文”做输入,比如全 0x00、全 0xFF、递增序列 0x00 0x01 0x02……,这些输入能帮你快速看出映射的边界行为,比如 0x00 是否被特殊处理、是否有字节被原样透传。

2.3 判断“逐字节”还是“整块”变换

这一步很关键,因为它决定了你后面采样的方式。判断方法不复杂:

  • 如果输入长度是 N,输出长度也是 N,且改变第 i 个输入字节只影响第 i 个输出字节,那就是逐字节独立映射,可以一个字节一个字节地还原。
  • 如果改变一个字节会影响多个输出字节,那就是整块变换(比如分组、扩散),还原难度会上升一个量级。

实测中 reverse_box 这类题大多是逐字节映射,因为这样出题和验证都方便。但你不能假设,必须验证。验证方法就是上面说的:固定其他字节,只改一个字节,看输出变化范围。这个测试花不了几分钟,但能帮你避免后面走弯路。

3. 采样与还原:两条路,选对省一半时间

3.1 路线一:直接提取硬编码表

如果静态分析发现黑盒函数内部有一块连续的 256 字节数据被反复引用,那大概率就是查找表本体。这时候最省事的做法不是采样,而是直接把这块内存 dump 出来。

操作上,我在反汇编器里找到那块数据的起始地址,选中 256 字节,导出为二进制或者直接复制成十六进制文本。然后写个小脚本把它转成数组,就得到了完整的映射表。这条路的好处是一次到位、零误差,缺点是要求你能准确识别出表的边界——有时候表前后还有别的数据,选多了选少了都会出错。

判断边界的方法:看引用这块数据的指令。如果是movzx eax, byte ptr [表基址 + 索引]这种形式,那索引范围就是 0 到 255,表长就是 256 字节。如果索引被掩码处理过(比如and eax, 0x3F),那表可能只有 64 字节。这个细节必须看清楚,否则 dump 出来的表长度不对,后面全错。

3.2 路线二:动态采样构造映射表

如果表是动态生成的(比如运行时根据某个种子算出来),或者静态提取有困难,那就走采样路线。思路很直接:把 0 到 255 全部喂一遍,记录每个输入对应的输出。

这里有个实操细节:不要一个一个手动喂,太慢。我的做法是写一个脚本,通过调试器的脚本接口或者直接构造输入文件,批量跑。如果程序是从标准输入读数据,那就构造一个包含 0x00 到 0xFF 全部字节的输入,让它一次性处理完,然后在输出端把结果截下来。这样一轮就能拿到完整的 256 项映射。

但要注意两个坑:

  • 输入过滤:有些程序会对输入做合法性检查,比如拒绝某些字节或者遇到特定字节就截断。这时候批量喂会失败,得先摸清过滤规则,必要时绕过检查再采样。
  • 状态依赖:如果黑盒函数依赖全局状态(比如一个计数器或者前一次的结果),那批量喂和单独喂的结果可能不一样。验证方法是:单独喂一个字节得到结果 A,再在批量输入里喂同一个字节得到结果 B,如果 A 不等于 B,说明有状态依赖,得改成逐个独立采样。

3.3 采样结果的整理与校验

拿到 256 项映射后,别急着用。先做两个校验:

  1. 完整性校验:确认 256 个输入都有对应的输出,没有遗漏。如果发现某些输入没有输出,说明采样过程被截断或者有分支没覆盖到。
  2. 单射性校验:检查输出是否有重复。如果这是一个置换(双射),那 256 个输出应该互不相同。如果有重复,说明它不是置换,而是普通映射,这在后续构造逆表时要注意——逆表可能不存在或者不唯一。

我用一个简单的表格来记录采样结果,格式如下:

输入字节输出字节备注
0x000x??边界值,重点观察
0x010x??
......
0xFF0x??边界值,重点观察

这个表看起来朴素,但它是后面所有工作的基础。我建议把它存成脚本能直接读的格式(比如 CSV 或者 Python 列表),方便后续自动化验证。

4. 逆向验证:用还原的表去“预测”原函数

4.1 构造逆映射:从输出反推输入

很多题目的最终目标不是“正向映射”,而是“给定输出,求输入”。这时候你需要构造逆表。如果正向映射是双射,逆表很好构造:遍历正向表,把逆表[输出] = 输入填进去就行。如果不是双射,就得特殊处理——某些输出可能对应多个输入,你得根据题目上下文判断该选哪个。

构造逆表的代码很短,但容易写错下标。我习惯用 Python 写,因为直观:

# 假设 forward 是长度为 256 的列表,forward[i] 表示输入 i 对应的输出 forward = [...] # 从采样或 dump 得到 inverse = [0] * 256 for i in range(256): inverse[forward[i]] = i

这段代码的前提是 forward 是一个置换。如果不是,inverse里会有未被赋值的位置,用之前必须检查。

4.2 用已知样本做回归测试

还原完表之后,我一定会做一轮回归测试:拿几个没有参与采样的输入(如果采样是全量的,那就重新构造几个组合输入),走一遍原函数,再走一遍我还原的表,对比结果。全部一致才算过关。

这一步不能省。我踩过的坑里,有一次是采样时漏掉了某个分支,导致表里有一项是错的,但因为我没做回归测试,直到最后提交才发现结果对不上,回头排查花了很久。从那以后,回归测试成了我的固定动作。

4.3 处理“表被二次加工”的情况

有些题不会让你直接拿到原始表,而是在表的基础上再做一层加工,比如:

  • 表 + 异或:查完表后再异或一个固定值。
  • 表 + 移位:查表结果做循环移位。
  • 双表交替:根据索引奇偶用不同的表。

遇到这种情况,采样阶段拿到的其实是“加工后的结果”,你需要把加工逻辑也还原出来。方法是:先假设加工是线性的(异或、移位这类),然后用少量样本反推参数。比如怀疑是异或,就取两组样本,看输出1 ^ 输出2和表值1 ^ 表值2的关系,如果能对上,说明异或值固定。

这个环节最考验耐心,但也最能体现逆向的乐趣——你是在和出题人“对话”,猜他的设计意图。

5. 实操中的常见坑与排查速查表

5.1 采样不全导致的“幽灵错误”

最常见的坑就是采样不全。表现是:大部分输入结果正确,但个别输入结果对不上。原因可能是某个分支只在特定输入下触发,而你采样时没覆盖到。

排查方法:把出错的输入单独拿出来,下断点跟一遍,看它走了哪条路径,和正常输入有什么不同。通常差异就在某个条件判断上,比如“输入大于 0x7F 时走另一条路”。找到差异后,针对性地补采样或者补逻辑。

5.2 字节序和符号扩展的陷阱

处理字节数据时,字节序和符号扩展是两个高频错误源。比如你把一个字节从内存读进 32 位寄存器,如果用了带符号的指令(如movsx),0x80 以上的值会被扩展成负数,后续运算全乱。正确做法是用movzx做零扩展。

字节序的问题多出现在多字节数据上。如果黑盒函数处理的是 4 字节整数,你得确认它是小端还是大端读入,否则采样出来的映射关系是错的。验证方法很简单:喂一个0x01020304,看它按什么顺序处理,就能判断字节序。

5.3 反调试与输入校验的绕行思路

有些题会加轻量级的反调试,比如检测调试器存在就改变行为。表现是:静态分析看着没问题,动态调试时结果却不对。遇到这种情况,先确认是不是反调试在作怪——方法是对比“调试器附加”和“不附加”两种情况下同一输入的结果,如果不同,基本可以确认。

绕行思路不是硬刚反调试,而是换采样方式。比如改成纯静态提取表,或者用脚本模拟执行而不是真调试。能绕就绕,没必要在反调试对抗上耗时间,除非题目明确要求。

下面这张速查表是我整理的常见问题与应对:

现象可能原因排查动作
个别输入结果不符采样漏分支单独跟踪该输入,对比路径差异
全部结果偏移固定值表后有异或/加法加工取两组样本反推加工参数
高位字节异常符号扩展错误检查是否误用 movsx,改 movzx
多字节数据错乱字节序判断错误喂 0x01020304 观察处理顺序
调试时结果与静态不符反调试干扰对比附加/不附加调试器的结果

5.4 一个容易被忽略的细节:表的初始化时机

如果表是动态生成的,要注意它什么时候被初始化。有些程序在main之前(比如全局构造函数里)就把表建好了,有些则在第一次调用时才懒加载。如果你在初始化之前就去 dump 表,拿到的全是零或者垃圾数据。

判断方法:在表所在的内存区域下内存写断点,看它什么时候被写入。写入完成后再 dump,才是有效数据。这个细节我在早期做题时忽略过,dump 出来一张全零的表,还以为是加密了,折腾半天才发现是时机不对。

6. 把还原结果用起来:从表到解题

6.1 正向查表与逆向查表的组合使用

还原出表之后,解题往往需要正向和逆向配合。比如题目给你一个目标输出,让你求输入,你就用逆表;如果题目让你验证某个输入是否正确,你就用正表。两者结合,基本能覆盖所有变体。

我习惯把正表和逆表都封装成函数,调用时一目了然:

def forward_map(b): return forward[b] def inverse_map(b): return inverse[b]

别小看这种封装,它能让你在写解题脚本时少犯下标错误,尤其是当题目需要多次转换时。

6.2 多轮映射的展开技巧

有些题不是一轮映射就结束,而是多轮串联:第一轮的输出作为第二轮的输入,跑若干轮。这种情况下,你可以把多轮展开成一张复合表,也可以逐轮调用。展开的好处是查询快,缺点是如果轮数多,展开表会很大(256 项还是 256 项,但计算复合表需要多一轮遍历)。

我的建议是:轮数少(比如 2 到 3 轮)就直接展开成复合表,轮数多就逐轮调用,避免展开时的计算开销和出错概率。展开的代码也很简单,就是循环套循环,但要注意每一轮的输入输出类型一致。

6.3 验证闭环:从结果反推确认无误

最后一步永远是验证。不管你用哪种方式还原,最后都要拿题目的预期结果去反推,确认能对上。对不上就回到采样阶段查,别在解题脚本上反复改——问题大概率在表本身,而不是脚本逻辑。

我个人的习惯是:还原完表,先写一个最小的验证脚本,只做“输入→输出”的对比,确认表没问题,再去写完整的解题逻辑。这个顺序能帮你把问题隔离在最小范围内,排查效率高很多。

7. 我在这类题上踩过的几个真实坑

第一个坑是过度相信静态分析。有一次我看到一段查表代码,直接 dump 了表,结果解题一直不对。后来动态跟了一遍才发现,程序在查表之前对索引做了一次变换(加了个偏移再取模),我 dump 的是原始表,但实际用的是变换后的索引。教训是:dump 表之前,一定要把索引的计算逻辑看清楚,别只看表本身。

第二个坑是采样时没控制好输入格式。程序要求输入十六进制字符串,我直接喂了原始字节,结果它把字节当字符解析,映射全乱。后来改成按它的格式构造输入,才拿到正确的采样结果。这个坑很基础,但新手特别容易中招——永远先确认输入格式,别急着采样。

第三个坑是忽略了表的复用。有些程序里同一个表被多个函数共用,我一开始只还原了其中一个函数的用法,漏掉了另一个函数对同一张表的反向使用。后来把表的引用点全部找出来,才发现有正向和反向两种用法。这个提醒我:定位到表之后,要把所有引用它的地方都过一遍,别只盯着一个调用点。

这些坑说起来都不复杂,但每一个都实打实花了我不少时间。写出来是希望你能跳过它们,把时间花在更有价值的地方——比如理解出题人的设计思路,而不是在基础错误上反复打转。

8. 给不同阶段读者的上手建议

如果你是刚接触逆向的新手,我建议从动态采样入手,先别碰静态提取。原因很实际:动态采样有明确的反馈——你喂一个输入,看一个输出,对错立判,学习曲线平缓。等你能熟练地把一个黑盒函数的映射表采出来,再去学静态提取,会顺很多。

如果你已经做过一些题,但总在“表被加工”的环节卡住,那重点练参数反推。方法就是拿两组样本,假设加工形式(异或、加法、移位),反推参数,再用第三组样本验证。这个技能练熟了,大部分变体题都能拿下。

如果你是从业者、想把这套方法用到实际工作中(比如分析某个闭源组件的映射逻辑),那重点放在自动化上。手动采样一次两次可以,次数多了必须脚本化。把“构造输入→运行→截取输出→整理成表→回归验证”整条链路写成脚本,以后遇到同类问题直接跑,效率提升非常明显。

最后分享一个小技巧:还原出来的表,不管题目要不要,都存一份到本地,按题目特征命名归档。时间长了你会发现,有些表是复用的,或者至少结构相似,下次遇到能直接参考,省下重新采样的时间。这个习惯我从早期坚持到现在,帮我省过不少事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进