liams.website

Seed-independent collisions on wyhash and rapidhash

How static secrets in wyhash and rapidhash enable HashDoS attacks regardless of seed randomisation, and why randomising secrets fixes it.

8 minute read

Randomising your hash function with a secret seed is the standard defence against HashDoS: if an attacker cannot predict your hashes, they cannot craft keys that collide. This article is about the fine print. Wyhash and rapidhash, two of the fastest hash functions in production use today, accepted a seed and then permitted collisions that hold for every possible value of it, and in 2025 Node.js shipped the consequences as CVE-2025-27209. We’ll walk through how the attack works and the fix that closes it, which involves randomising rather more than the seed. First, a quick refresher on what an engineered collision actually costs.

A refresher on hash tables

The relevant mechanics, briefly. A hash table turns a key into an array index: run the key through a hash function to get a wide integer, take it modulo the number of buckets, and that is the slot. hash("banana") % 16 might be 9, so “banana” and its value live in bucket 9, and a later lookup repeats the arithmetic to jump straight back there. The O(1) lookup is really the absence of a search.

The pressure point is that there are finitely many buckets and effectively infinitely many keys, so by the pigeonhole principle some keys must share a slot. When hash("banana") % 16 and hash("kiwi") % 16 both land on 9, that is a collision, and the table stores both: as a list per bucket (chaining) or by probing onward to the next free slot (open addressing). Either way, collisions cost lookup time, and the O(1) guarantee is really a promise that a decent hash function keeps the buckets small on average.

The interesting question is what happens when that promise breaks. If every key lands in the same bucket, that bucket is one list holding everything, each lookup walks all of it, and O(1) collapses to O(n): a linked list with worse cache behaviour. Insertion is worse, because it checks for an existing key first, so dropping n keys into one bucket costs O(n²). This never occurs with a sane hash function and ordinary keys, but what happens when the keys are not ordinary, and someone has chosen them on purpose?

From collisions to denial of service

When you run a web server, a great many of the keys flowing into your hash tables come from strangers. HTTP headers, JSON object fields, query parameters, form bodies: all routinely land in hash tables keyed by attacker-controlled strings. An attacker who knows your hash function can precompute a batch of keys that all collide into one bucket, pack them into a single request, and turn your constant-time table into a linear (or, on insert, quadratic) crawl. A small request buys an enormous amount of pointless CPU work. That is HashDoS: denial of service through deliberately chosen hash collisions.

In December 2011, researchers demonstrated at the Chaos Communication Congress that virtually every major web framework was vulnerable. A carefully crafted 2MB POST request containing colliding keys could trigger 40 billion string comparisons and lock up a server’s CPU. Minimal bandwidth, maximum damage. PHP, Java, Python, Ruby, ASP.NET, and V8 were all affected.

The fix seemed straightforward. Instead of using a predictable hash function, you randomise it. Generate a secret seed at startup, mix that same seed into every hash computation, and now the attacker can’t predict which keys will collide. They don’t know your seed, so they can’t craft adversarial inputs. SipHash was designed specifically for this purpose: a hash function fast enough for hash tables but cryptographically strong enough to resist differential attacks. Rust adopted randomised SipHash by default. Many other languages followed. The problem was solved.

But SipHash, while fast for a cryptographic construction, is still slower than non-cryptographic alternatives. For performance-critical applications, developers reached for faster options: MurmurHash, CityHash, wyhash, rapidhash. These hashes support random seeds too. Surely that’s good enough?

When seeds don’t help

A seed-independent collision is exactly what it sounds like: two inputs that collide regardless of what seed you use. The seed is supposed to make the hash function’s output unpredictable, but if you can find inputs that collide for every possible seed, the randomisation provides no protection at all.

This turns out to be more common than you might hope. Orson Peters has a detailed post explaining seed-independent collisions on CityHash64, MurmurHash2/3, and FarmHash64. The attacks exploit mathematical properties of the operations these hash functions use: fixed points in multiplication, invertible operations that let you work backwards from desired outputs.

Wyhash and rapidhash, two of the fastest non-cryptographic hash functions available today, have their own seed-independent collision vulnerability in the C++ implementations. I documented this in March 2025 while working on the Rust rapidhash crate, having missed an older issue that documents the same. A month later, CVE-2025-27209 announced that Node.js v24.0.0 was vulnerable to HashDoS through V8’s use of rapidhash. The attack is embarrassingly simple. And so is the fix.

The wyhash construction

To understand the vulnerability, we need to look at how wyhash handles the tail end of longer inputs. Like most fast hash functions, wyhash uses a folded multiply, multiplying two 64-bit values into a 128-bit result, then XORing the upper and lower halves together. This provides excellent bit diffusion in a single operation.

Wyhash also uses “secrets”, large constants mixed into the computation at various points. In theory, you could randomise these secrets to provide additional unpredictability. In practice, the C and Rust implementations use static, hardcoded secrets.

Here’s the relevant code for inputs longer than 16 bytes:

cpp
// a = penultimate 8 bytes
// b = final 8 bytes
a = _wyr8(p + i - 16);
b = _wyr8(p + i - 8);

// XOR with secret[1] and the seed
a ^= secret[1];
b ^= seed;

// Folded multiply
_wymum(&a, &b);

// Final mix
return _wymix(a ^ secret[0] ^ len, b ^ secret[1]);

The seed only affects b; the secrets are fixed constants. That asymmetry is the whole vulnerability.

If we set the penultimate 8 bytes of our input to equal secret[1], then after the XOR a becomes zero. When you multiply zero by anything, you get zero. The folded multiply produces zero for both a and b, regardless of what b was, which means regardless of what the seed was.

The final mix then operates on secret[0] ^ len and secret[1]. Both are constants. For any fixed input length, we can generate infinitely many inputs that all hash to the same value by:

  1. Choosing any arbitrary bytes for the first part of the input
  2. Setting bytes at position len - 16 to len - 9 equal to secret[1]
  3. Choosing any arbitrary bytes for the final 8

Every such input will hash identically, no matter what seed the hash table is using.

Rapidhash inherits the problem

Rapidhash is derived from wyhash with performance optimisations. It uses a similar tail-end structure, the C++ implementation:

cpp
if (len <= 16) {
    // Short input handling...
} else {
    // Process chunks...

    // Handle tail, where i is the length of the input
    a = rapid_read64(p + i - 16) ^ i;
    b = rapid_read64(p + i - 8);
}
a ^= secret[1];
b ^= seed;
rapid_mum(&a, &b);
return rapid_mix(a ^ secret[7], b ^ secret[1] ^ i);

There’s a small difference: rapidhash XORs i (the length) into a before the secret XOR. But the attack still works: just set the penultimate 8 bytes to secret[1] ^ len instead of just secret[1].

Here’s a test demonstrating the attack on rapidhash v3:

rust
#[test]
fn seed_independent_hash_collisions() {
    const SECRET1: u64 = 0x8bb84b93962eacc9;
    const EXPECTED_HASH: u64 = 18446744073709551615;

    fn random_slice() -> Vec<u8> {
        // generate a random 32-byte input
        let mut data = vec![0; 32];
        let rng = &mut rand::rng();
        rng.fill_bytes(data.as_mut_slice());

        // set penultimate 8 bytes to secret[1], XORed with the input length 32
        let offset = data.len() - 16;
        let a = &mut data[offset .. offset + 8];
        a.copy_from_slice(&(SECRET1 ^ 32).to_le_bytes());

        data
    }

    // 10 hashes with different seeds and input data, all colliding
    for _ in 0..10 {
        let hash = rapidhashcc_v3(&random_slice(), rand::random::<u64>());
        assert_eq!(hash, EXPECTED_HASH, "Expected seed-independent hash collision.");
    }
}

We generate ten completely different byte sequences with random seeds, ensure only that bytes 16 to 23 equal secret[1] ^ len, and they all collide to the same hash value.

The fix

The attack relies on knowing the static secrets, so the fix is to randomise them along with the seed.

The Rust rapidhash crate now supports two approaches:

  1. Random secrets: Generate secrets randomly at startup for ephemeral hashing. Maximum unpredictability. RapidHashMap makes use of this by default with its initialisation through RandomState.
  2. Seed-derived secrets: Call RapidSecrets::seed(seed) to generate deterministic but unique secrets from your seed. Same seed, same secrets, reproducible hashing, but an attacker can’t exploit static constants.

Either approach defeats the attack. If the attacker doesn’t know secret[1], they can’t construct the bytes that force a to zero. Foldhash and other folded-multiply hashes avoid the same class of seed-independent collisions in the same way, by randomising their secrets.

An attacker who can observe raw hash outputs, or collect enough timing data to infer when collisions occur, might still find ways through, as non-cryptographic hash functions aren’t designed to resist that level of scrutiny. But for a typical hash table processing untrusted input, where the attacker only controls the keys and not the observation channel, randomising the secrets can provide a reasonable level of DoS-resistance for most use cases.

Rapidhash with randomised secrets is still very fast. It just has one more thing to randomise.

A sequel

When V8 closed the rapidhash gap, it did so by generating randomised rapidhash secrets at startup rather than baking them into the binary. That groundwork paid off in March 2026, when Node.js patched a second HashDoS, CVE-2026-21717, this time in V8’s hash for numeric array-index strings, which used a fully deterministic formula with no seeding at all. The remedy they describe is the same idea in miniature, a small seeded permutation whose multipliers are randomised at boot, and those multipliers are simply borrowed from the already-randomised rapidhash secrets. The randomisation introduced to fix the first HashDoS quietly became the raw material for fixing the second.