· 8 min read ·

V8 Had a HashDoS Problem That SipHash Could Not Solve

Source: nodejs

The March 2026 Node.js security release addressed CVE-2026-21717, a HashDoS vulnerability in V8’s array index string hashing. The technical challenge of the fix is worth examining closely, because the obvious solution, seeding with SipHash, was unavailable. A load-bearing performance optimization inside V8 imposed a constraint that most runtimes never face when solving this class of problem.

What HashDoS Attacks Exploit

Hash flooding was formalized as an attack class by Crosby and Wallach in 2003, but reached widespread attention in December 2011 when Alexander Klink and Julian Wälde demonstrated at 28C3 that PHP, Python, Perl, Ruby, Java, and ASP.NET all used deterministic hash functions for their default hash tables. The attack exploits hash table worst-case behavior: if an attacker can craft input keys that all hash to the same bucket, O(1) average inserts degrade to O(n) per insert, making n inserts cost O(n²) total.

The standard fix across nearly every affected language was immediate and nearly uniform: seed the hash function with a random value at process startup. Python added per-process randomization in 3.3 and later switched to SipHash-1-3 in 3.4; Rust adopted SipHash from its first stable release; Ruby moved to SipHash in version 2.4. SipHash is a keyed pseudorandom function designed explicitly for this use case: fast, non-cryptographic, and computationally opaque without the key.

V8 had already applied randomization to ordinary strings years before 2026, using rapidhash seeded randomly at startup. The vulnerability in CVE-2026-21717 was narrower: it affected only array index strings, a specific category that V8 encoded differently from all other strings.

The Integer Cache That Made Randomization Hard

V8 encodes every string’s precomputed hash in a 32-bit field in the string object header. For ordinary strings, those 30 bits hold a rapidhash value. For array index strings, which are decimal strings whose numeric value fits in 24 bits (roughly "0" through "16777215"), V8 used a fully deterministic encoding:

hash = (length << 24) | numeric_value

This field is dual-purpose. It serves as a hash table slot selector and as a cached parsed integer. When JIT-compiled code encounters arr["42"] or parseInt("42") on a hot path, it reads the integer directly from the hash field with one memory access and one mask operation. No character-by-character string parsing, no branch on string content. This optimization runs throughout V8’s runtime C++, Torque macros, and CodeStubAssembler-generated JIT code.

Because the original encoding was deterministic (identical on every machine and every process), the full formula was public knowledge. An attacker who knew it could enumerate integer strings that all land in the same bucket and follow the same quadratic probing chain. The proof-of-concept from the advisory shows a roughly 2 MB JSON payload that hangs a Node.js server for about 30 seconds by exploiting this determinism:

const payload = [];
const val = 1234;
const MOD = 2 ** 19;
const CHN = 2 ** 17; // probing chain length

let j = val + MOD;
for (let i = 1; i < CHN; i++) {
  payload.push(`${j}`);
  j = (j + i) % MOD;
}
for (let k = 0; k < 2 ** 17; k++) payload.push(`${val}`);

JSON.parse(JSON.stringify({ data: payload }));

Why SipHash Is Ruled Out

SipHash is a compression function. It takes a key and an input and produces output that cannot be reversed to recover the input. That non-invertibility is desirable in most hash table contexts; here it breaks the integer cache.

If V8 stored a SipHash output in the hash field, every piece of code that reads the integer from the hash field would need to fall back to re-parsing the string’s character data. That means additional memory accesses, cache misses on string character data that was otherwise never touched, and significantly more work on array indexing hot paths. The performance cost of those fallbacks on typical JavaScript workloads would be measurable in ways that a slightly more expensive hash computation is not.

Simpler seeded constructions also fail, but for different reasons. XOR with a secret (hash = value ^ secret) has poor diffusion: each output bit depends on exactly one input bit, so an attacker can construct collisions by targeting only the low-order bits that determine bucket placement, without knowing the full secret. Linear congruential mixing (hash = a * value + b mod 2^24) preserves linear relationships between inputs and outputs, meaning differences between inputs map predictably to differences between outputs regardless of the secret constants.

Bijective Mixing as the Solution

The fix, developed by Joyee Cheung (Igalia, Bloomberg) with design review from the V8 team at Google, uses a construction from Christopher Wellons’ hash-prospector project: alternating xorshift and multiply operations over the 24-bit integer space.

// kMask = (1 << 24) - 1, kShift = 12 (half of 24)
uint32_t SeedArrayIndexValue(uint32_t value, uint32_t m[3]) {
  uint32_t x = value;
  x ^= x >> 12; x = (x * m[0]) & kMask;  // round 1
  x ^= x >> 12; x = (x * m[1]) & kMask;  // round 2
  x ^= x >> 12; x = (x * m[2]) & kMask;  // round 3
  x ^= x >> 12;
  return x;
}

Both operations are individually bijective over the 24-bit integer space. The xorshift (x ^= x >> 12, where 12 is exactly half of 24) is an involution: applying it twice returns the original value. Multiplying by an odd constant modulo 2^24 is a bijection because odd integers are coprime to any power of two, so their modular multiplicative inverse always exists and can be computed using Newton’s method once at startup.

The algebraic argument for why alternating them provides diffusion: xorshift is linear over GF(2) (the XOR group) but nonlinear over integers modulo 2^N; multiplication is linear over integers modulo 2^N but nonlinear over GF(2). Each operation destroys the structure that would allow the other to be inverted predictably. The interplay between these incompatible algebraic groups propagates bit influence across the full output width. This same pattern appears in MurmurHash3’s finalizer, xxHash’s mixing steps, and Java’s SplittableRandom state mixing.

Inversion follows the same sequence in reverse, using the modular inverses of the multipliers:

uint32_t UnseedArrayIndexValue(uint32_t hash, uint32_t m_inv[3]) {
  uint32_t x = hash;
  x ^= x >> 12; x = (x * m_inv[2]) & kMask;  // undo round 3
  x ^= x >> 12; x = (x * m_inv[1]) & kMask;  // undo round 2
  x ^= x >> 12; x = (x * m_inv[0]) & kMask;  // undo round 1
  x ^= x >> 12;
  return x;
}

The secret multipliers are derived from rapidhash’s existing 64-bit startup secrets, already present in V8’s hash seed storage. The low 24 bits of each secret are taken and OR’d with 1 to guarantee oddness. No new entropy infrastructure was needed; modular inverses are precomputed once at process startup.

Three Rounds vs. Two, and Why Variance Matters

The choice of three rounds over two is grounded in variance, not mean performance. Evaluating diffusion using the Strict Avalanche Criterion, which measures the probability that any single input bit flip also flips each output bit (ideal: 50% for every output bit regardless of which input bit changed), across 50 randomly generated multiplier sets:

RoundsMean SAC BiasStd DevMax
2 rounds7.927.1940.37
3 rounds0.500.201.68

The two-round construction performs well on average but produces an outlier maximum bias of 40.37 for some randomly generated multiplier combinations. Since each production server draws one multiplier set at startup and runs with it for the process lifetime, a deployment that draws an unlucky set has persistently weak diffusion. Three rounds collapse that maximum to 1.68 with a standard deviation of 0.20. The additional round costs roughly one multiply and one xorshift at runtime, sitting within measurement noise on standard benchmarks: SunSpider, Kraken, Octane, and JetStream 3 all show deltas of 0.2% or less, at the boundary of statistical significance.

This is a reasonable engineering tradeoff. The mean performance of two rounds is acceptable, but the long tail of bad multiplier draws represents a fraction of real-world deployments with genuinely weaker protection. Three rounds eliminates the tail without a measurable throughput cost.

”Minimal” as a Deliberate Scope Decision

The advisory describes this as “minimal HashDoS resistance,” and the framing is intentional. The construction’s security depends on the secrecy of the multipliers, not on any cryptographic hardness property of the algorithm itself. The design argument is that a blind attacker working over a network, with garbage collection latency, a shared hash table containing both regular and array index strings, and typical production rate limits, cannot exploit structural weaknesses in the function even if those weaknesses exist. No cryptographic strength is claimed or needed.

This is a meaningful contrast with how most runtimes answered HashDoS. Python, Rust, and Ruby reached for SipHash because their hash functions were pure hash functions; invertibility was never a constraint. V8 had a cached integer encoded in its hash layout, and that cache is load-bearing for array indexing performance across the JIT. The security argument had to fit within that constraint, so the team designed for the realistic threat model rather than the strongest possible hash function.

Knowing the inversion algorithm provides no advantage to an attacker without the secret multipliers. If those multipliers are compromised, the hash table is not the primary concern. The design is public; the security is in the process-local secret, which is Kerckhoffs’s principle applied to a non-cryptographic context.

Deployment and Scope

The fix shipped in Node.js v20, v22, v24, and v25 in the March 2026 security release, controlled by the build flag v8_enable_seeded_array_index_hash. It is intentionally disabled in Chrome: a browser tab-level denial of service affects only that tab, and the threat model does not warrant the code complexity. V8 embedders including Deno and Cloudflare Workers were notified separately to evaluate whether to enable the flag in their own builds.

The implementation touches V8’s Torque macros, CodeStubAssembler fast paths, and runtime C++ slow paths. The hash seed ByteArray in read-only roots was extended from 32 bytes to 56 bytes to store the three multipliers and three modular inverses alongside the existing rapidhash secrets. The overall change is surgical: the integer cache optimization is preserved, the hash layout bits are unchanged, and the only visible difference is that array index string hashes are no longer predictable without the process-local multiplier secrets.

Was this interesting?