gxhash-64: seed-independent collisions

Hash. gxhash by Olivier Giniaux, https://github.com/ogxd/gxhash (Rust crate, v3 algorithm; the SMHasher3 port hashes/gxhash.cpp was made from ogxd/gxhash commit 55bde47 by Frank J. T. Wojcik, 2025-08-26, MIT). SMHasher3 registers two variants: gxhash (128-bit) and gxhash_64 (the low 64 bits, little-endian). The program implements the hash from that source (portable table AES; ARMv8 AES or x86 AES-NI are picked up automatically when the compiler enables them) and reports both outputs.

What the pairs exploit. gxhash computes state = compress_all(m) from the message alone, and only then mixes in the seed: gxhash(m, seed) = finalize(aesenc(state, seed‖seed)). Everything after compress_all is a bijection of the 128-bit state for every seed, so two messages with equal compress_all collide for every seed, on both the 64- and the 128-bit output. compress_all is easy to collide because each branch ends by XORing two independently processed message parts: for 17–32 bytes it is SR(SB(hv)) ^ P2(v0) with hv built from the leading bytes and P2 two public AES rounds of the trailing 16 bytes, so flipping a byte of hv is cancelled by setting v0' = P2^-1(P2(v0) ^ SR(SB(hv)) ^ SR(SB(hv'))) — one inverse AES round pair, no search (pair 1). For messages of at most 16 bytes compress_all is just zero-padding plus the length added to every byte, so a k-byte message and the 16-byte message (pad(m)[i] + k - 16) mod 256 are identical after compression (pair 2, a cross-length pair). The same weakness was reported publicly in ogxd/gxhash issue #83 (2024) with a 192-byte pair.

Pairs

# mechanism m m’ rate over random seeds
1 24 bytes, hv/v0 cancellation through the public rounds 000000000000000000000000000000000000000000000000 0100000000000000a803d3a0b6cb85eb1120e4f3a270c9a6 1 (2^0), all seeds
2 15 vs 16 bytes, length-padding twin 000000000000000000000000000000 ffffffffffffffffffffffffffffffff 1 (2^0), all seeds

Explicit colliding seeds (from the program): pair 1, seed 0xd73a9a3d941e7ec7gxhash_64 = 4c6ff29ce0549cdd for both (and seed 0x0123456789abcdef69ccb8a053812a7b, the published test value); pair 2, seed 0xf556ecbfcbfee3ad43ec3783791d0fb8 for both.

Build and run

cc -O2 -o gxhash64_verify gxhash64_verify.c -lm     # auto-detects ARMv8 AES; add -maes on x86 for AES-NI
./gxhash64_verify            # 2^24 random seeds (default), about 1 s with hardware AES, 4 s portable
./gxhash64_verify 26         # 2^26 seeds
./gxhash64_verify 24 0x1234  # different RNG master seed

-DGX_PORTABLE forces the table implementation (gxhash64_verify_portable below). The program aborts unless it reproduces the SMHasher3 verification values 0x48F84240 (gxhash_64) and 0x64A77B47 (gxhash) by the _ComputedVerifyImpl procedure (keys of length 0..255 with seed 256−i, concatenated outputs hashed with seed 0, first 4 bytes LE), cross-checks any hardware AES round against the table round, checks the published value 69ccb8a053812a7b, and asserts that the two example seeds reproduce their published gxhash_64 values (4c6ff29ce0549cdd, 43ec3783791d0fb8), not merely that the two messages collide there. Exit status 0 only if every check passes; a non-numeric argument is rejected with status 2.

Expected output

Apple M2 Pro, Apple clang 17, cc -O2, default arguments (2^24 seeds, 0.8 s):

gxhash / gxhash_64 key-free collision check  (impl = arm-neon-aes)
hash: ogxd/gxhash (v3 algorithm, commit 55bde47) as ported in SMHasher3 hashes/gxhash.cpp
validation: SMHasher3 verification gxhash_64 = 0x48F84240 (expect 0x48F84240) OK, gxhash = 0x64A77B47 (expect 0x64A77B47) OK
validation: arm-neon-aes round vs portable table round, and inverse round: 0 mismatches / 4096
validation: pair 1 at seed 0x0123456789abcdef: gxhash_64 = 69ccb8a053812a7b / 69ccb8a053812a7b (published 69ccb8a053812a7b) OK

Pair 1: 24-byte same-length pair (L = 3 words), key-free
   mechanism: 17..32 bytes: C(m) = SR(SB(hv)) ^ P2(v0) with hv = get_partial(m[0:8]) and v0 = m[8:24];
   P2 = two public AES rounds (keys K0, K1) is invertible, so flip byte 0 of hv and set
   v0' = P2^-1( P2(v0) ^ SR(SB(hv)) ^ SR(SB(hv')) ).  The seed only enters after C.
  m  (24 bytes) = 000000000000000000000000000000000000000000000000
  m' (24 bytes) = 0100000000000000a803d3a0b6cb85eb1120e4f3a270c9a6
  re-deriving m' from m (constant time, no search): matches the published hex
  compress_all(m) = 953a6ffc8610b87f7f40c81e7dea7eaf
  compress_all(m')= 953a6ffc8610b87f7f40c81e7dea7eaf  -> EQUAL (the seed is applied after this point, so every seed collides)
  random seeds: N = 16777216 (2^24)
    gxhash_64 : collisions = 16777216, rate = 1.000000, log2 rate = 0.000
    gxhash 128: collisions = 16777216, rate = 1.000000, log2 rate = 0.000
  first random colliding seed 0xf1f5fbabc606b373: gxhash_64(m) = 2104e5d47c6801a1  gxhash_64(m') = 2104e5d47c6801a1
  published example seed 0xd73a9a3d941e7ec7:
    gxhash_64(m)  = 4c6ff29ce0549cdd   gxhash(m)  = dd9c54e09cf26f4c390150562e7344f4
    gxhash_64(m') = 4c6ff29ce0549cdd   gxhash(m') = dd9c54e09cf26f4c390150562e7344f4
    -> COLLIDE (64 and 128 bit); published gxhash_64 value 4c6ff29ce0549cdd reproduced

Pair 2: 15-byte vs 16-byte cross-length pair (L = 2 words), key-free
   mechanism: len <= 16: C(m) = zero-pad(m) + len, added to every byte mod 256, so
   0^15 -> 0x0f^16 and 0xff^16 -> 0x0f^16: every message shorter than 16 bytes has a 16-byte twin.
  m  (15 bytes) = 000000000000000000000000000000
  m' (16 bytes) = ffffffffffffffffffffffffffffffff
  re-deriving m' from m (constant time, no search): matches the published hex
  compress_all(m) = 0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f
  compress_all(m')= 0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f  -> EQUAL (the seed is applied after this point, so every seed collides)
  random seeds: N = 16777216 (2^24)
    gxhash_64 : collisions = 16777216, rate = 1.000000, log2 rate = 0.000
    gxhash 128: collisions = 16777216, rate = 1.000000, log2 rate = 0.000
  first random colliding seed 0xafd374b1146d3f23: gxhash_64(m) = 26732c2036b199ea  gxhash_64(m') = 26732c2036b199ea
  published example seed 0xf556ecbfcbfee3ad:
    gxhash_64(m)  = 43ec3783791d0fb8   gxhash(m)  = b80f1d798337ec43c5cdc136b3c57d0d
    gxhash_64(m') = 43ec3783791d0fb8   gxhash(m') = b80f1d798337ec43c5cdc136b3c57d0d
    -> COLLIDE (64 and 128 bit); published gxhash_64 value 43ec3783791d0fb8 reproduced

ALL CHECKS PASSED: both pairs collide for every sampled seed on both outputs.

The portable build (-DGX_PORTABLE) prints the same numbers (impl = portable, 4.3 s), and ./gxhash64_verify 26 gives 67108864/67108864 collisions on both outputs for both pairs (2.6 s). Raw logs of these three runs: run_2e24_neon.txt, run_2e24_portable.txt, run_2e26_neon.txt. The rate is exactly 1 by the argument above; the sampling only confirms that the implementation is the real gxhash.

Files

Upstream Rust check

rust_check_head.rs and rust_check_head.txt record the upstream crate check at 55bde47 (3.5.0). Create a Cargo binary with gxhash = "=3.5.0", use the supplied Rust file as src/main.rs, and build with RUSTFLAGS="-C target-feature=+aes,+sse2" on x86. The Xeon AES-NI and portable reruns are recorded in run_2e26_xeon_aesni.txt and run_2e24_xeon_portable.txt; their trailing timing blocks are retained.