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校验拆成了四个维度:数学基础、多项式、实现方式和常见陷阱。你在做逆向分析时,可以按这个框架去排查问题。
3.8 小结
CRC校验看起来简单,但细节很多。多项式、初始值、输出异或、字节序,任何一个参数不对,结果就天差地别。我建议你在实际项目中,先写一个小工具验证参数,再集成到固件里。这样能省下不少调试时间。
嗯,这一章的内容就到这里。CRC的原理和实现你都清楚了,接下来就是动手实践。记住,纸上得来终觉浅,绝知此事要躬行。