前言

在逆向工程、游戏安全或软件分析的领域中,我们都曾面临一个共同的敌人:软件更新

辛辛苦苦通过反汇编找到的那个关键函数地址(比如 0x140012345),在下一次版本更新后,就变成了一串毫无意义的数字。开发者的一次小小改动,就能让我们所有的努力付之一炬。

这就是为什么我们不依赖静态地址,而是依赖AOB 搜索(Array of Bytes Searching),也就是“特征码扫描”。

AOB 搜索允许我们不再“写死”一个地址,而是去寻找一个“代码指纹”——一段独特的、在版本更迭中不会改变的指令序列。

但仅仅找到一段唯一的字节是不够的。如果这个“指纹”本身就很“脆弱”,比如包含了一个硬编码的偏移量,那么在更新中同样会失效。

本文将探讨 AOB 搜索的核心——通配(Wildcarding)——的原理。深入探索“为什么通配”、“如何正确通配”,以及 x86 和 ARM64 架构在通配策略上的巨大差异。

AOB 搜索的核心——“字节”与“掩码”

AOB 搜索的本质,不是在内存中搜索一个字符串,而是在搜索一个“模式”(Pattern)

这个“模式”由两部分组成:

  1. 字节数组 (Array of Bytes): 希望匹配的原始字节,例如 E8 11 22 33 44
  2. 掩码 (Mask): 一个平行的数组,告诉搜索引擎“这一位字节是否重要”。

在实践中,这通常有两种表现形式:

  • 1. 代码/掩码风格 (Code/Mask Style):
    • 字节 (Code): \xE8\x11\x22\x33\x44
    • 掩码 (Mask): x????
    • x 代表“必须匹配”,? 代表“跳过,不重要”。
  • 2. IDA 风格 (IDA Style):
    • 特征码 (Sig): E8 ?? ?? ?? ??
    • 这是一种更直观的格式,??(通配符)就是一个“不重要”的字节。

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,特征码也会立刻失效。

我们通配什么?

  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扫描中)安全地通配“立即数”部分,而不影响其他部分。

我们的策略是什么?

我们必须在“健壮性”和“唯一性”之间做出权衡:

  1. “懒惰”但安全的策略: 只要是 LDR [reg, #imm],就一概通配。
    • 签名: ?? ?? ?? ??
    • 优点: 绝对健壮。
    • 缺点: 唯一性极差。
  2. 健壮的策略: 通过阅读 ARM 手册发现,imm12 主要污染了 Byte 1Byte 2,而 Byte 0(寄存器 RtRn 的低位)与 Byte 3(主要操作码)是相对稳定的。
    • 签名: 61 ?? ?? F9 (按内存顺序)
    • 优点: 保留了大量稳定的信息 (LDR X?, [X?9, ...]),同时通配了易变的字节。
    • 缺点: 稍有风险(如果寄存器也变了,61 也会变),但这是一个远超 ?? ?? ?? ?? 的优秀策略。

AOB 搜索的幕后

既然我们有了“模式”(字节+掩码),搜索器是如何工作的?

1. 朴素算法 (Naive Algorithm)

这是最简单的方法,也是 SigMakerExFindSignature(备用)函数的工作方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 伪代码
for ( int i = 0; i < memory_size - pattern_size; i++ )
{
bool match = true;
for ( int j = 0; j < pattern_size; j++ )
{
// 关键!如果掩码是 0 (??), 就跳过这个字节的比较
if ( mask[j] == 0x00 )
continue;

// 如果掩码是 1 (x), 比较字节
if ( memory[i+j] != pattern[j] )
{
match = false;
break;
}
}

if (match)
return &memory[i]; // 找到了!
}

这个算法的效率非常低,尤其是当特征码很长时。

2. AVX2 优化算法 (The Fast Way)

核心思想是:“一次性比较 32 个字节”。

  1. 只取特征码的第一个字节first)和最后一个字节last)。

  2. 使用 AVX2 指令(_mm256_load_si256)一次性从内存中加载 32 个字节的数据块。

  3. 并行地比较这 32 个字节是否等于 first:

    eq_first = _mm256_cmpeq_epi8(first, block_first)

  4. 并行地比较这 32 个字节是否等于 last(在 pattern_size 的偏移处):

    eq_last = _mm256_cmpeq_epi8(last, block_last)

  5. 将这两个结果“与”(_mm256_and_si256)起来,得到一个掩码,显示在这 32 个字节中,哪些位置同时满足“第一个字节匹配”和“最后一个字节匹配”。

  6. 只有当这个掩码不为零时,才停下来,对这个可疑的位置执行昂贵但精确memcmp_mask(带掩码的内存比较)。

这个算法的效率是朴素算法的数百倍,因为利用 SIMD(单指令多数据)一次性排除了 99.99% 的不匹配位置。