攻破 CRC32:从哈希值造出原始数据#

本章的内容#

CRC32 是用来检查数据完整性的校验和,并不是密码学意义上的哈希:知道一个输出值,就可以反推出能产生它的输入。

  • 把 CRC32 的计算用 Z3 的 BitVec 原样照抄

  • 把以指定的字符串开头、只含英文字母、CRC32 等于指定的值等条件写成约束

  • 让 Z3 造出满足条件的数据

用到的工具是 BitVec 的位运算,即 >>、&、^、~,与位宽扩展 ZeroExt。整章的思路只有一条:把校验和的计算当作普通函数,反过来把它的输入当作未知量。

什么是 CRC32#

CRC32 是 Cyclic Redundancy Check 32-bit 的缩写,即用于检测数据错误的 32 位校验和,广泛用于网络通信、文件传输与压缩包的完整性检查。它的计算只用到移位与 XOR,硬件与软件都能很快完成。

不过 CRC32 并不是密码学意义上安全的哈希,本章要体会的正是它不能用于保存口令、也不能用于签名验证的原因所在。

CRC32 的计算方法#

CRC32 的计算由下面的 C 代码给出。

static uint32_t crc32(uint32_t crc, unsigned char *buf, size_t len)
{
    int k;

    crc = ~crc;
    while (len--) {
        crc ^= *buf++;
        for (k = 0; k < 8; k++)
            crc = crc & 1 ? (crc >> 1) ^ 0xedb88320 : crc >> 1;
    }
    return ~crc;
}

这段代码做了四件事。

  1. 把初始值按位取反,即 crc = ~crc

  2. 依次把每个字节 XOR 进去

  3. 重复八次,每次右移一位,若最低位为 1 则 XOR 多项式 0xedb88320

  4. 最后再取反一次并返回

这四步都是按位运算,每一步都能原样写成 BitVec 的表达式,因此从 C 到 Z3 的翻译不需要理解算法,只需逐行替换。

这段计算在 Python 里也能原样写出,而换成 Z3 的 BitVec 之后,输入就可以保持为变量参与计算。输入一旦是变量,同一个式子就从计算变成了关于输入的方程。

from z3 import *


def z3_crc32(data, crc=0):
    """返回 data(BitVec(8) 的列表)的 CRC32,形式为 Z3 的表达式"""
    crc = ~crc                      # 反转初始值
    for c in data:
        crc ^= ZeroExt(24, c)       # 把 8 位字节扩展到 32 位再 XOR
        for _ in range(8):
            crc = If(crc & 1 == BitVecVal(1, 32),
                     LShR(crc, 1) ^ 0xedb88320,
                     LShR(crc, 1))
    return ~crc                     # 最后反转

用到的 Z3 函数#

函数与运算符

作用

ZeroExt(24, c)

把 8 位的值在高位补 0,扩展成 32 位

BitVecVal(1, 32)

造出 32 位的常数 1,位宽本身是类型的一部分

LShR(crc, 1)

无符号右移,空出的高位补 0

If(条件, A, B)

条件为真取 A,为假取 B

~crc

按位取反

^, &

XOR 与 AND

这里不用 >> 而用 LShR(),是因为 BitVec 区分有符号与无符号,要与 C 的 unsigned 行为一致,就必须用 LShR()。有符号右移会把最高位当作符号位补 1,而 C 里的 crc 声明为 uint32_t,只能按补 0 的方式移位。

另外 BitVecVal() 造出的是固定值的 BitVec。simplify() 是化简式子的函数,所有值都确定之后结果就是数值。输入仍是变量时,它只能把式子整理得更短,不会给出数值。

与 binascii.crc32() 是否一致#

与 Python 标准库的 binascii.crc32() 比较,确认这个实现是否正确。

from binascii import crc32

data = b'hello world!!'
print(crc32(data))

simplify(z3_crc32([BitVecVal(x, 8) for x in data]))
2909492980
2909492980

得到了相同的值,由于把 data 的每个字节都做成 BitVecVal(x, 8) 这样的常数来计算,Z3 的式子也会化简到数值。

从 CRC32 反算数据#

从这里开始是本章的重点,即让 Z3 找出满足下面条件的数据。与前面核对实现的步骤相反,这一回输入是未知量。

  • 长度为 n 字节

  • 以 head 开头,以 tail 结尾

  • 中间的字符是英文字母,即 a〜z、A〜Z

  • CRC32 等于 crc_code

把每个字节做成 BitVec(f'b{i}', 8) 这样的变量,CRC32 等于 crc_code 就直接成为约束。

def is_between(b, start, end):
    """b 在 start 到 end 的字符编码范围内"""
    return And(b >= ord(start), b <= ord(end))


def starts_with(data, string):
    """data 以 string 开头"""
    return And([b == ord(ch) for b, ch in zip(data, string)])


def ends_with(data, string):
    """data 以 string 结尾"""
    return And([b == ord(ch) for b, ch in zip(data[::-1], string[::-1])])


def break_crc32(n, head, tail, crc_code):
    """找出满足条件的 n 字节数据"""
    data = [BitVec(f'b{i:02d}', 8) for i in range(n)]

    solver = Solver()
    solver.add(starts_with(data, head))
    solver.add(ends_with(data, tail))

    # 中间的字符只能是英文字母
    for b in data[len(head):-len(tail)]:
        solver.add(Or(is_between(b, 'a', 'z'), is_between(b, 'A', 'Z')))

    solver.add(z3_crc32(data) == crc_code)

    if solver.check() != sat:
        return None

    model = solver.model()
    return bytes([model[d].as_long() for d in data])


def test(n, head, tail, crc_code):
    res = break_crc32(n, head, tail, crc_code)
    if res is None:
        print('unsat(不存在满足条件的数据)')
    else:
        print(res, hex(crc32(res)))

运行示例#

要找的是长度 20 的字符串:以 "hello " 开头、以 "!!" 结尾,并且 CRC32 为 0x12345678。

test(20, 'hello ', '!!', 0x12345678)
b'hello PWvJSuTYDVsZ!!' 0x12345678

输出末尾的 0x... 就是所得字符串的 CRC32,应当与指定的 0x12345678 一致,这里用 binascii.crc32() 又算了一遍,因此可以检验 Z3 的答案是否真的正确。

太短就解不出来#

字符数一减少,可以自由选择的字符就变少,若此时不存在满足条件的数据,结果就是 unsat。

test(13, 'hello ', '!!', 0x00000000)
unsat(不存在满足条件的数据)

head 有 6 个字符,tail 有 2 个字符,中间的字符只有 5 个。Z3 证明了用这 5 个字符造不出 CRC32 为 0 的数据,下面把字节数增加一个。这里的 unsat 是一个结论,而不是求解失败:约束本身已经证明无解。

test(14, 'hello ', '!!', 0x00000000)
b'hello zXforl!!' 0x0

只增加了一个自由字符,满足条件的数据就找到了。解是否存在取决于自由度的多少,与算法的强弱无关。

  • 可供搜索的字符越多,满足条件的数据就越容易找到

  • CRC32 是线性计算,把条件原样写成约束之后 Z3 就能高速求解

  • 因此 CRC32 可以用于检测篡改,却无法防止篡改

小结#

  • CRC32 的计算可以用 BitVec 的位运算原样照抄

  • 8 位的字节用 ZeroExt(24, c) 扩展到 32 位

  • 无符号右移用 LShR(),常数用 BitVecVal()

  • 把开头、末尾、字符种类与 CRC 值写成约束,Z3 就能造出满足条件的数据

  • 自由字符太少时会得到 unsat,即证明这样的数据不存在