CRC32を破る ― ハッシュ値から元のデータを作る#

この章で学ぶこと#

CRC32 はデータの整合性チェックに使われるハッシュですが、暗号学的なハッシュではありません。出力が分かれば、条件を満たす入力を計算で作れてしまいます。

  • CRC32 の計算を、Z3 の BitVec でそのまま書き写す

  • 「この文字列で始まる」「英字だけ」「CRC32 がこの値」という条件を制約にする

  • Z3 に条件を満たすデータを作らせる

BitVec のビット演算(>>, &, ^, ~)と、ZeroExt(ビット幅の拡張)が道具です。

CRC32 とは#

CRC32(Cyclic Redundancy Check 32-bit)は、データの誤り検出に使われる32ビットのチェックサムです。ネットワーク通信やファイルの整合性チェックで広く使われています。

ただし、CRC32 は暗号学的に安全なハッシュではありません。出力が1つ分かれば、その出力になる入力を逆算できます。パスワードの保存などに使ってはいけない理由が、この章で体験できます。

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;
}

やっていることは、次の4つです。

  1. 初期値をビット反転する(crc = ~crc)

  2. 各バイトを順番に XOR する

  3. 8回、1ビットずつ右にずらしながら、いちばん下のビットが 1 なら多項式 0xedb88320 を XOR する

  4. 最後に、もう一度ビット反転して返す

この計算は 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() を使います。

また 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)))

実行例#

"hello " で始まり "!!" で終わる長さ20の文字列で、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文字しかありません。この5文字では、CRC32 が 0 になるデータは存在しない、と Z3 が証明しました。

1バイト増やしてみます。

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

自由な文字が1つ増えるだけで、条件を満たすデータが見つかりました。

  • 探索する文字が増えるほど、条件を満たすデータは見つけやすくなる

  • CRC32 は線形な計算なので、条件をそのまま制約にすれば Z3 が高速に解ける

  • だから CRC32 は「改ざん検出」には使えても、「改ざん防止」には使えない

まとめ#

  • CRC32 の計算は BitVec のビット演算でそのまま書き写せる

  • 8ビットのバイトは ZeroExt(24, c) で32ビットに広げる

  • 符号なしの右シフトは LShR()、定数は BitVecVal()

  • 条件(先頭・末尾・文字種・CRC値)を制約にすれば、条件を満たすデータを Z3 が作れる

  • 自由な文字が少なすぎると unsat になる(存在しないことが証明される)