AOB 搜索与“健壮”特征码
前言
在逆向工程、游戏安全或软件分析的领域中,我们都曾面临一个共同的敌人:软件更新。
辛辛苦苦通过反汇编找到的那个关键函数地址(比如 0x140012345),在下一次版本更新后,就变成了一串毫无意义的数字。开发者的一次小小改动,就能让我们所有的努力付之一炬。
这就是为什么我们不依赖静态地址,而是依赖AOB 搜索(Array of Bytes Searching),也就是“特征码扫描”。
AOB 搜索允许我们不再“写死”一个地址,而是去寻找一个“代码指纹”——一段独特的、在版本更迭中不会改变的指令序列。
但仅仅找到一段唯一的字节是不够的。如果这个“指纹”本身就很“脆弱”,比如包含了一个硬编码的偏移量,那么在更新中同样会失效。
本文将探讨 AOB 搜索的核心——通配(Wildcarding)——的原理。深入探索“为什么通配”、“如何正确通配”,以及 x86 和 ARM64 架构在通配策略上的巨大差异。
AOB 搜索的核心——“字节”与“掩码”
AOB 搜索的本质,不是在内存中搜索一个字符串,而是在搜索一个“模式”(Pattern)。
这个“模式”由两部分组成:
- 字节数组 (Array of Bytes): 希望匹配的原始字节,例如
E8 11 22 33 44。 - 掩码 (Mask): 一个平行的数组,告诉搜索引擎“这一位字节是否重要”。
在实践中,这通常有两种表现形式:
- 1. 代码/掩码风格 (Code/Mask Style):
- 字节 (Code):
\xE8\x11\x22\x33\x44 - 掩码 (Mask):
x???? x代表“必须匹配”,?代表“跳过,不重要”。
- 字节 (Code):
- 2. IDA 风格 (IDA Style):
- 特征码 (Sig):
E8 ?? ?? ?? ?? - 这是一种更直观的格式,
??(通配符)就是一个“不重要”的字节。
- 特征码 (Sig):
AOB 搜索算法的本质: 算法在内存中逐字节移动,在每一步都检查:
if ( ( 内存[i] & 掩码[i] ) == ( 字节[i] & 掩码[i] ) )
(或者在 IDA 风格中,if ( 掩码[i] == ‘??’ || 内存[i] == 字节[i] ))
通配的作用——为何通配?
我们为什么要使用 ???
答案是:我们在搜索的不是“字节”,而是“稳定的逻辑”。
通配符是我们用来“忽略”那些易变(Volatile)信息、只保留稳定(Stable)逻辑的工具。一个好的特征码必须在“唯一性”和“健壮性”之间找到完美的平衡。
- 太短或通配符太多(如
E8 ?? ?? ?? ??): 健壮性高,但唯一性差。你可能会在程序里找到 1000 个CALL指令。 - 太长或通配符太少(如
E8 11 22 33 44 5B 5F): 唯一性高,但健壮性极差。0x11223344这个地址哪怕只变动 1,特征码也会立刻失效。
我们通配什么?
- PC 相对地址 (PC-Relative Addresses)
这是最常见的通配符。
- x86/x64:
CALL 0x123456(编码:E8 11 22 33 44) - x86/x64:
JMP 0x123456(编码:E9 11 22 33 44) - ARM64:
BL 0x123456
这些指令的操作数(11 22 33 44)是一个相对偏移量。当开发者在源码中添加或删除一行代码时,这个偏移量一定会改变。但 CALL (E8) 和 BL 这个“调用”的行为是稳定的。
- 脆弱的签名:
E8 11 22 33 44 - 健壮的签名:
E8 ?? ?? ?? ??
2. 绝对地址 (Absolute Addresses)
- x86/x64:
MOV EAX, [0x14005000]
这个 0x14005000 很可能是一个全局变量的地址。在更新后几乎肯定会变。
- 脆弱的签名:
A1 00 50 00 14 - 健壮的签名:
A1 ?? ?? ?? ?? - 数据结构偏移 (Data Structure Offsets)
这是最棘手、也是最能体现“艺术”的地方。
- x86/x64:
MOV EAX, [ECX+0x10] - ARM64:
LDR X1, [X19, #0x10]
#0x10 (16 字节) 是什么?很可能是 Player 对象中 Health 字段的偏移量。如果在下个版本中,开发者在 Player 结构体的 Health 之前添加了一个 8 字节的 Mana 字段,这个偏移就会从 0x10 变成 0x18。
如果我们不通配,我们的特征码就会失效。
深入原理:x86 vs ARM64 的通配
这是我们通配策略的核心。为什么我们不能简单地像 x86 一样,把 LDR X1, [X19, #0x10] 变成一个“部分”通配符呢?
答案是:CPU 架构的编码方式完全不同。
1. x86/x64: “字节附加” (Byte Appended)
x86 的指令编码(大部分)是“理智”的。
MOV EAX, [ECX+0x10]
- 编码:
8B 41 10 - 操作码 (Opcode):
8B 41(代表MOV EAX, [ECX+...]) - 操作数 (Operand):
10(代表+0x10)
如果 0x10 变成了 0x18,编码就会变成 8B 41 18。
只有最后那个字节变了。8B 41 是稳定的。这使得 x86/x64 的部分通配非常简单和强大:
- 健壮的签名:
8B 41 ??
2. ARM64: “比特混合” (Bit Mixed)
ARM64 为了实现固定的 4 字节指令长度,采用了极其复杂的“比特混合”编码。操作数和操作码的比特位被“塞”进同一个 32 位整数的不同位置。
1 | LDR X1, [X19, #0x10] |
- 编码:
F9 40 0A 61(小端序存储)
LDR X1, [X19, #0x400] (只是把偏移量变大)
- 编码:
F9 41 02 61
请看这个灾难性的结果:
- 我们只是把
#0x10改成了#0x400。 Byte 0(61) 和Byte 3(F9) 没变。Byte 1(40->41) 变了!Byte 2(0A->02) 也变了!
为什么?
因为 imm12(12 位的立即数)跨越了 Byte 1 和 Byte 2。立即数的变化“溢出”到了相邻的字节。
这就是ARM64通配的陷阱:
你不能(在基于字节的AOB扫描中)安全地通配“立即数”部分,而不影响其他部分。
我们的策略是什么?
我们必须在“健壮性”和“唯一性”之间做出权衡:
- “懒惰”但安全的策略: 只要是
LDR [reg, #imm],就一概通配。- 签名:
?? ?? ?? ?? - 优点: 绝对健壮。
- 缺点: 唯一性极差。
- 签名:
- 健壮的策略: 通过阅读 ARM 手册发现,
imm12主要污染了Byte 1和Byte 2,而Byte 0(寄存器Rt和Rn的低位)与Byte 3(主要操作码)是相对稳定的。- 签名:
61 ?? ?? F9(按内存顺序) - 优点: 保留了大量稳定的信息 (
LDR X?, [X?9, ...]),同时通配了易变的字节。 - 缺点: 稍有风险(如果寄存器也变了,
61也会变),但这是一个远超?? ?? ?? ??的优秀策略。
- 签名:
AOB 搜索的幕后
既然我们有了“模式”(字节+掩码),搜索器是如何工作的?
1. 朴素算法 (Naive Algorithm)
这是最简单的方法,也是 SigMakerEx 中 FindSignature(备用)函数的工作方式:
1 | // 伪代码 |
这个算法的效率非常低,尤其是当特征码很长时。
2. AVX2 优化算法 (The Fast Way)
核心思想是:“一次性比较 32 个字节”。
只取特征码的第一个字节(
first)和最后一个字节(last)。使用 AVX2 指令(
_mm256_load_si256)一次性从内存中加载 32 个字节的数据块。并行地比较这 32 个字节是否等于 first:
eq_first = _mm256_cmpeq_epi8(first, block_first)
并行地比较这 32 个字节是否等于 last(在 pattern_size 的偏移处):
eq_last = _mm256_cmpeq_epi8(last, block_last)
将这两个结果“与”(
_mm256_and_si256)起来,得到一个掩码,显示在这 32 个字节中,哪些位置同时满足“第一个字节匹配”和“最后一个字节匹配”。只有当这个掩码不为零时,才停下来,对这个可疑的位置执行昂贵但精确的
memcmp_mask(带掩码的内存比较)。
这个算法的效率是朴素算法的数百倍,因为利用 SIMD(单指令多数据)一次性排除了 99.99% 的不匹配位置。