3. CRC校验原理:从数学到代码

CRC校验,说白了就是给数据算一个“指纹”。这个指纹很小,但能帮你判断数据有没有被改过。我在做固件逆向分析时,十有八九的校验机制都是CRC。所以搞懂它,是基本功中的基本功。

3.1 CRC的数学本质

CRC的全称是循环冗余校验。它把数据看作一个巨大的二进制数,然后用一个固定的“除数”去除它。余数就是CRC值。

你可能会问:“这不就是除法吗?”没错,但这里的除法是模2除法。它和普通除法的区别在于:

  • 没有进位,也没有借位
  • 加减法就是异或(XOR)运算
  • 除法的“够不够减”只看最高位

举个例子。假设数据是 1101,多项式是 1011。模2除法是这样算的:

  1101 <- 数据
  1011 <- 多项式
  ----
  0110 <- 第一次异或结果
   1011 <- 多项式(对齐最高位)
   ----
  0101 <- 第二次异或结果
   1011 <- 多项式
   ----
  0110 <- 余数(CRC值)

嗯,这里要注意:余数位数永远比多项式少一位。如果多项式是4位,余数就是3位。这就是为什么CRC32算出来是32位,CRC16是16位。

核心概念:CRC校验的本质是多项式除法。数据是“被除数”,多项式是“除数”,余数就是CRC值。校验时,把数据和新算出的CRC值拼在一起,再用同一个多项式去除。如果余数为0,说明数据没被篡改。

3.2 常见多项式

多项式不是随便选的。好的多项式能检测出更多类型的错误。我在项目中见过最多的就是下面这几个:

名称 多项式(十六进制) 应用场景
CRC-32 0x04C11DB7 以太网、ZIP、PNG
CRC-32C 0x1EDC6F41 iSCSI、SSD校验
CRC-16-IBM 0x8005 Modbus、USB
CRC-16-CCITT 0x1021 XMODEM、蓝牙
CRC-8 0x07 1-Wire、SMBus

我个人习惯在逆向分析时,先试CRC-32和CRC-16-CCITT。这两个出现的概率最高。如果不对,再试其他的。

小技巧:在固件里搜索多项式对应的十六进制值,能快速定位CRC校验函数。比如CRC-32的0x04C11DB7,在二进制里是04 C1 1D B7。但注意,有些实现会把它反过来存,变成B7 1D C1 04。

3.3 软件实现:直接计算法

最直观的实现就是按位计算。每次处理一个bit,做异或和移位。代码长这样:

uint32_t crc32_bitwise(uint8_t *data, uint32_t len) {
    uint32_t crc = 0xFFFFFFFF;  // 初始值
    uint32_t poly = 0x04C11DB7; // 多项式

    for (uint32_t i = 0; i < len; i++) {
        crc ^= data[i];         // 把新字节异或到CRC的低8位
        for (int j = 0; j < 8; j++) {
            if (crc & 0x80000000) {
                crc = (crc << 1) ^ poly;
            } else {
                crc = crc << 1;
            }
        }
    }
    return crc ^ 0xFFFFFFFF;    // 输出异或值
}

这个实现能跑,但效率很低。每个字节要循环8次,处理1KB数据就要循环8192次。在MCU上,这速度没法忍。

注意:初始值和输出异或值不是固定的。有些实现用0x00000000做初始值,有些不用输出异或。逆向分析时,如果发现CRC值对不上,先检查这两个参数。

3.4 查表法:速度的飞跃

查表法是怎么来的?你想想看,直接计算法里,每个字节的处理逻辑是一样的。那为什么不把256种可能的字节结果提前算好,存成一张表?

查表法的核心思路是:

  • 预计算一个256项的查找表
  • 每个表项对应一个字节值(0x00~0xFF)的CRC贡献
  • 计算时,每次处理一个字节,查表+异或,4步搞定

代码实现:

// 生成CRC-32查找表
uint32_t crc32_table[256];

void crc32_init_table() {
    uint32_t poly = 0x04C11DB7;
    for (int i = 0; i < 256; i++) {
        uint32_t crc = i;
        for (int j = 0; j < 8; j++) {
            if (crc & 1) {
                crc = (crc >> 1) ^ poly;
            } else {
                crc = crc >> 1;
            }
        }
        crc32_table[i] = crc;
    }
}

// 查表计算CRC-32
uint32_t crc32_table_driven(uint8_t *data, uint32_t len) {
    uint32_t crc = 0xFFFFFFFF;
    for (uint32_t i = 0; i < len; i++) {
        uint8_t index = (crc ^ data[i]) & 0xFF;
        crc = (crc >> 8) ^ crc32_table[index];
    }
    return crc ^ 0xFFFFFFFF;
}

你看,每个字节只做一次查表、一次异或、一次移位。速度比按位计算快了8倍。在STM32这类MCU上,处理1MB数据也就几十毫秒。

我的经验:在逆向分析时,如果固件里有一张256×4字节的常量表(总共1024字节),那基本就是CRC查表法。你可以把这张表dump出来,反推出多项式。我曾经用这个方法,在半小时内就定位了一个Bootloader的CRC校验函数。

3.5 查表法的变种

查表法还有几个变种,我在项目中都遇到过:

  • 半字节查表:表只有16项,速度比按位快,但比全表慢。适合ROM空间紧张的MCU。
  • 反射查表:有些MCU是LSB-first的,数据要反转后再查表。CRC-16-CCITT常用这种。
  • 双字查表:一次处理4个字节,表有4×256项。速度最快,但表占4KB空间。

我个人习惯在空间允许时用全表查表法。代码简单,不容易出错。

3.6 CRC校验的常见陷阱

做逆向分析时,CRC校验有几个坑,我踩过不止一次:

  • 初始值不同:有的用0xFFFFFFFF,有的用0x00000000。算出来结果不一样。
  • 输出异或不同:有的在最后异或0xFFFFFFFF,有的不异或。
  • 字节序问题:MCU是小端还是大端?数据在内存里的排列方式会影响CRC值。
  • 多项式方向:有的多项式是正向的(左移),有的是反向的(右移)。

避坑指南:我曾经分析一个IAP升级固件,CRC值怎么算都对不上。折腾了两天,最后发现是MCU把CRC值的高低位反着存了。从那以后,我每次都会先检查字节序。

3.7 知识体系总览

下面这张图总结了CRC校验的核心知识点。你可以把它当作一个快速索引:

CRC校验知识体系 CRC校验 数学基础 常见多项式 软件实现 模2除法 多项式除法 CRC-32 CRC-16 直接计算 查表法 半字节/全字节/双字 常见陷阱 初始值 输出异或 字节序 多项式方向 逆向分析时重点关注

这张图把CRC校验拆成了四个维度:数学基础、多项式、实现方式和常见陷阱。你在做逆向分析时,可以按这个框架去排查问题。

3.8 小结

CRC校验看起来简单,但细节很多。多项式、初始值、输出异或、字节序,任何一个参数不对,结果就天差地别。我建议你在实际项目中,先写一个小工具验证参数,再集成到固件里。这样能省下不少调试时间。

嗯,这一章的内容就到这里。CRC的原理和实现你都清楚了,接下来就是动手实践。记住,纸上得来终觉浅,绝知此事要躬行。