The March 2026 Node.js security release fixed nine CVEs across active release lines. Eight of them follow recognizable patterns: a missing try/catch around a TLS callback, a header name that triggers a TypeError, a Permission Model bypass. CVE-2026-21717 is different. Its fix required designing a new integer hash function with a property that most hash functions are never asked to satisfy, and the engineering behind it traces through modular arithmetic, algebraic bijections, and a research project that exhaustively catalogs reversible hash constructions.
The attack class that keeps returning
HashDoS as a concept was formalized in 2003 by Scott Crosby and Dan Wallach in their USENIX Security paper on algorithmic complexity attacks. It reached mainstream attention in December 2011, when Alexander Klink and Julian Wälde presented at the 28th Chaos Communication Congress showing that PHP, Python, Ruby, Java, and Perl all used unseeded, deterministic hash functions for their built-in associative data structures. HTTP POST bodies, whose field names become hash table keys, provided a direct injection path. PHP received CVE-2011-4885, Java web containers received CVE-2011-4858, and a coordinated multi-language disclosure under oCERT-2011-003 produced a wave of fixes.
The standard remedy was consistent across languages: replace the unseeded hash with a keyed function, seed it at startup with fresh entropy, and ensure the seed never leaks. Python 3.4 adopted SipHash-1-3, Ruby 2.4 followed, and Rust’s standard HashMap has used SipHash-1-3 as its default since the beginning. V8 could not follow that template for the specific class of strings under attack in CVE-2026-21717.
The dual-use hash field
V8 stores JavaScript string objects in memory with a hash field that serves two purposes simultaneously. When a string has been hashed for table lookup, the computed hash value is cached in that field to avoid recomputation. For ordinary strings this is unambiguous.
Array index strings are treated differently. An array index string is a numeric string whose integer value fits in 24 bits, covering "0" through "16777215". V8 identifies these strings and stores the actual integer value directly in the hash field’s lower 24 bits, while packing the string length into the upper 6 bits. Before the fix, the formula was:
hash = (length << 24) | numeric_value
This allowed V8 to extract the integer from "42" by reading a single field, with no character-by-character re-parsing. Array indexing, numeric property lookups, and calls like parseInt on short numeric strings all benefited from this cached value. The performance justification is sound; the security implication is that the formula carries no secret. Anyone reading the V8 source can enumerate strings whose hashes collide in a table of any given capacity.
The attack surface is V8’s string internalization table, a process-wide hash table that deduplicates string objects. When JSON.parse() encounters the same key repeatedly across objects, V8 interns the string: subsequent occurrences find the existing entry and reuse the pointer. The internalization table uses open addressing with quadratic probing. With the original deterministic hash, an attacker who constructs a JSON payload with numeric keys that all map to the same bucket forces O(n) probe lengths per insertion, producing O(n²) total work for n keys.
The proof of concept in the advisory is direct: roughly 2 MB of crafted JSON causes JSON.parse() to block for approximately 30 seconds on a contemporary machine. Any Node.js HTTP endpoint that calls JSON.parse() on request body content without a tight size limit is reachable from the internet.
const MOD = 2 ** 19;
const CHN = 2 ** 17;
// Build a probing chain: values that sequentially fill quadratic probe positions
let j = 1234 + MOD;
const payload = [];
for (let i = 1; i < CHN; i++) {
payload.push(`${j}`);
j = (j + i) % MOD;
}
// This causes JSON.parse to spend ~30 seconds processing ~2 MB of input
JSON.parse(JSON.stringify({ data: payload }));
Why SipHash does not fit here
The standard response to HashDoS is to replace the deterministic hash with SipHash-1-3. It is fast, well-studied, and genuinely resistant to collision construction because the key is secret. The reason V8 cannot substitute it for array index string hashing is invertibility.
Once the hash field stores a seeded SipHash output, recovering the original integer requires re-parsing the string: iterating through characters, accumulating a digit sum, one multiply-and-add per digit. The overhead is modest for individual operations, but array indexing over numeric-keyed objects is a hot path in any JavaScript engine, and a regression that shows up in JetStream benchmarks is not acceptable. The fix had to produce a seeded hash that is also efficiently reversible: given the output and the seed, recover the original integer in O(1) arithmetic.
Bijective permutations over 24-bit integers
The solution developed by Joyee Cheung of Igalia, sponsored by Bloomberg, applies a three-round xorshift-multiply construction over the 24-bit integer domain. The forward transform is:
const uint32_t kMask = (1 << 24) - 1;
const uint32_t kShift = 12;
uint32_t SeedArrayIndexValue(uint32_t value, uint32_t m[3]) {
uint32_t x = value;
x ^= x >> kShift; x = (x * m[0]) & kMask; // round 1
x ^= x >> kShift; x = (x * m[1]) & kMask; // round 2
x ^= x >> kShift; x = (x * m[2]) & kMask; // round 3
x ^= x >> kShift;
return x;
}
Both primitive operations are bijective. The xorshift step x ^= x >> kShift with shift distance equal to exactly half the bit width is an involution: applying it twice returns the original value, because the high kShift bits are unmodified and the XOR is undone by repeating the same operation. The multiply step x = (x * m) & kMask is a bijection on the 24-bit domain if and only if m is odd. Odd integers are coprime to any power of 2, meaning they have multiplicative inverses modulo 2^24 by Bezout’s identity. Given m, the modular inverse m_inv satisfies (m * m_inv) mod 2^24 = 1. These inverses are precomputed at startup using Newton’s method, and the inverse transform simply applies the same steps in reverse order with inverse multipliers.
The multipliers are derived from rapidhash’s existing 64-bit startup secrets, which V8 already generates at process startup, contributed by Gus Caplan of Deno for a prior fix. No new entropy source is required; the seeding infrastructure was already present.
The three-round decision is quantified
The xorshift-multiply construction is in the same family as MurmurHash3’s fmix32 finalizer, xxHash’s mixing steps, and Java’s SplittableRandom internal state mixing. All use alternating xorshift and multiply operations because XOR operates over GF(2) while multiplication operates over Z/2^N, and neither operation can undo the mixing introduced by the other.
How many rounds are sufficient is not a matter of conservative safety margins; it is measurable. The Strict Avalanche Criterion scores diffusion quality, scaled so that 0 represents perfect avalanche and 1000 represents no diffusion:
| Construction | SAC Bias Score |
|---|---|
| Original V8 hash (no mixing) | 1000.000 |
| 1-round xorshift-multiply | 446.852 |
| 2-round xorshift-multiply | 3.447 mean, std dev 7.19 |
| 3-round xorshift-multiply | 0.50 mean, std dev 0.20 |
The two-round construction has an acceptable mean, but the standard deviation of 7.19 indicates that some multiplier combinations, depending on which rapidhash secrets happen to be generated at startup, produce noticeably weaker outputs. Three rounds reduces the standard deviation to 0.20, making diffusion quality consistent across the full space of possible startup seeds. This is the same style of argument found in Christopher Wellons’ hash-prospector project, which exhaustively searches and ranks bijective integer permutations by avalanche quality, and which served as a direct reference for the construction chosen here.
Performance measurements on SunSpider, Kraken, Octane, and JetStream 3 show overhead within benchmark noise, with the largest measured delta at -0.15% on JetStream 3. V8’s JIT CodeStubAssembler fast paths were updated to emit the permutation instructions inline, keeping hot paths from degrading to general-purpose integer extraction.
What “minimally HashDoS resistant” means in practice
The advisory uses that phrase deliberately. V8’s hash values are never exposed to JavaScript or transmitted over the network, so an attacker constructing collisions must work blind, without observing hash outputs. Timing side channels are possible in principle: an endpoint that parses many JSON payloads and returns response times provides indirect signal. In practice that signal is buried in network latency, garbage collection pauses, and concurrent request noise, requiring many measurements against a stable target. The CVE is rated medium severity with high attack complexity for these reasons.
This is a narrower threat model than the one that drove Python’s hash randomization, where dictionary iteration order was observable in Python 2 and provided indirect hash output leakage. The CVE-2026-21717 fix addresses specifically the case where an attacker sends crafted JSON with numeric keys to a server-side parsing endpoint.
Chrome received the patch but ships it with the feature flag v8_enable_seeded_array_index_hash disabled. Browser tabs run one origin per process under modern site isolation, eliminating the multi-tenant server threat model. The same V8 codebase serves both contexts; the flag is the mechanism that keeps them aligned.
The constraint that made this hard
Most HashDoS fixes are straightforward: substitute SipHash and update the seed at startup. V8’s case shows what happens when a hash function carries an additional semantic contract beyond uniform distribution. The hash field’s dual role as an integer cache created a constraint that ruled out every standard mitigation and required a construction with specific algebraic properties.
The solution is not novel cryptographic work. The bijectivity of odd multipliers modulo powers of two and the self-inverse property of the half-width xorshift are well-established results, and hash-prospector has been publishing ranked bijective integer permutations since 2018. The engineering contribution in CVE-2026-21717 was recognizing that the constraint existed, identifying the right prior art, and verifying through the SAC analysis that three rounds gave consistent diffusion across the full space of possible startup multipliers. That last step, the statistical validation across seeds rather than just testing one representative case, is what separates a solid fix from one that happens to work on the test machine.