The standard fix for HashDoS attacks has been understood since 2012. Seed your hash function with a per-process random value at startup, and an attacker who knows the algorithm still cannot predict which keys will collide. Python introduced hash randomization in version 3.3 and formalized it with SipHash-1-3 in 3.4 via PEP 456; Ruby and Perl adopted similar schemes around the same time. Node.js, through V8, had most of this covered, except for one specific class of string keys that carried an additional constraint making the standard solution inapplicable.
Node.js’s March 2026 security release addresses CVE-2026-21717, a HashDoS vulnerability in V8’s handling of array index strings. The patch is a three-round seeded xorshift-multiply hash, and the reason it has to be structured that way tells you something specific about V8’s internals that most coverage of the vulnerability skips over.
What HashDoS Requires
The attack was formally presented at 28C3 in December 2011 by Alexander Klink and Julian Wälde, though the underlying theory had been described in the literature earlier. It exploits hash tables operating in their worst case. For a well-distributed hash function, average lookup and insertion cost O(1). When an attacker crafts keys that all hash to the same bucket, the table degrades: each insertion becomes O(n), and n insertions become O(n²). A small HTTP payload with carefully chosen keys can produce seconds of server CPU consumption, blocking the event loop completely in single-threaded runtimes.
The defense is straightforward: introduce a secret seed into the hash computation so the attacker cannot predict which keys collide without knowing the seed. Because the seed changes on every process restart, static attack payloads do not work reliably across different server processes.
The Specific Vulnerability in V8
V8 stores string hashes in a hash_field embedded in each string object. For most strings, this field holds a seeded hash computed from the character content. Array index strings are treated differently. These are strings whose content is a valid non-negative decimal integer fitting within 24 bits, covering values 0 through 16,777,215. Rather than hashing the characters, V8 encodes the numeric value and the string length directly into the hash field:
hash_field = (string_length << 26) | (numeric_value << 2) | type_bits
For "1234" (length 4, value 1234), the hash field is 0x100004D8. The same value on every machine, every process, every run. An attacker who reads the V8 source code knows this formula exactly.
V8’s string internalization table, which deduplicates strings across the heap, uses quadratic probing. The probe sequence for a key with hash h in a table of size M follows (h + i*(i+1)/2) mod M. When many keys share the same hash modulo M, they walk the same probe sequence, and each lookup for a valid key buried at the end of a chain must traverse every prior collision.
The proof of concept in the advisory constructs roughly 2 MB of JSON containing 2^17 carefully chosen numeric strings followed by 2^17 repetitions of a target value. JSON.parse internalizes every string into the internalization table, forcing each lookup of the target value to traverse a chain of 131,072 entries 131,072 times. On a modern laptop, this payload hangs the process for around 30 seconds.
const payload = [];
const val = 1234;
const MOD = 2 ** 19;
const CHN = 2 ** 17;
let j = val + MOD;
for (let i = 1; i < CHN; i++) {
payload.push(`${j}`);
j = (j + i) % MOD;
}
for (let k = 0; k < CHN; k++) {
payload.push(`${val}`);
}
JSON.parse(JSON.stringify({ data: payload }));
Any Node.js server that parses user-controlled JSON is exposed. Express with its default 100 KB body limit substantially reduces the amplification but does not eliminate the vulnerability.
Why the Standard Fix Does Not Apply
Python’s solution was to replace its string hash with SipHash-1-3, a fast keyed hash function designed specifically for hash table use. The key is a 128-bit random seed generated at process startup; without it, computing colliding inputs is infeasible. Ruby and Perl adopted equivalent constructions.
This works cleanly when the hash is used purely for bucket selection. The hash determines where to look; the actual key content is always available to confirm a match. V8 cannot use this approach for array index strings because the hash field serves a second purpose: it stores the numeric value itself.
V8 needs to extract that integer from an array index string constantly, for parseInt, for direct element access on arrays (arr[42]), for for...in enumeration that discovers integer-keyed elements, and for JSON.parse producing numeric-keyed properties. If the hash were cryptographically one-way, V8 would have to re-parse the decimal string from its character content every time it needed the underlying integer. Decimal string parsing accesses the string’s character buffer and runs through each digit rather than reading a single cached 32-bit field. Across every array element access in every hot path, that overhead compounds.
The fix must be seeded, so attackers cannot predict hash values, and invertible, so V8 can recover the numeric value from the hash field without re-parsing the string.
A Bijective Hash Over 24 Bits
The chosen construction is a three-round seeded xorshift-multiply operating over 24-bit integers. Three multipliers are derived at process startup from V8’s existing rapidhash secrets: each is the lowest 24 bits of a 64-bit secret value, with the least-significant bit forced to 1 to ensure the value is odd. The multipliers differ on every run.
const uint32_t kMask = (1 << 24) - 1;
uint32_t SeedArrayIndexValue(uint32_t value, uint32_t m[3]) {
uint32_t x = value;
x ^= x >> 12; x = (x * m[0]) & kMask;
x ^= x >> 12; x = (x * m[1]) & kMask;
x ^= x >> 12; x = (x * m[2]) & kMask;
x ^= x >> 12;
return x;
}
Both primitive operations are bijective. A XOR-shift by half the bit width on a fixed-width integer is its own inverse: applying x ^= x >> 12 on a 24-bit value twice returns the original, because the top 12 bits are unchanged by the XOR and canceling them recovers the bottom 12 bits exactly. Multiplication by an odd number modulo a power of 2 is invertible because every odd integer has a unique multiplicative inverse mod 2^N. If m is odd, there exists exactly one m_inv satisfying m * m_inv ≡ 1 (mod 2^24), computable via the extended Euclidean algorithm. The precomputed inverse multipliers sit alongside the originals in V8’s hash seed structure.
Inversion runs the same sequence in reverse with the inverse 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;
x ^= x >> 12; x = (x * m_inv[1]) & kMask;
x ^= x >> 12; x = (x * m_inv[0]) & kMask;
x ^= x >> 12;
return x;
}
Because the construction is bijective over all 24-bit values, no two distinct inputs produce the same output. Hash table collisions can only arise when distinct hash outputs land in the same bucket modulo the table size, not because the hash function itself mapped two inputs identically. The previous scheme allowed an attacker to choose inputs producing identical hash outputs at will; the new scheme makes that impossible without knowing the per-process seed.
Statistical Quality and Round Count
The patch authors evaluated diffusion using the Strict Avalanche Criterion, which measures whether flipping any single input bit independently randomizes each output bit toward 50/50. Lower scores are better; the old deterministic encoding scores 1000, the worst possible result. Across 50 randomly generated secret sets:
| Rounds | Mean Bias | Max Bias |
|---|---|---|
| 2 rounds | 7.92 | 40.37 |
| 3 rounds | 0.50 | 1.68 |
Two rounds achieves reasonable mean diffusion but high variance. Some secret sets produce noticeably worse results than others, which matters because the runtime has no control over which specific multipliers it draws from its existing rapidhash secrets. Three rounds brings max bias below 2 with stable behavior across the full population of possible seeds. The analysis code and methodology are available in the rapid-xorshift-multiply repository referenced in the advisory.
Benchmark results across SunSpider, Kraken, Octane, and JetStream 3 show changes under 0.2% in any direction, within noise margins. The decode path adds four XORs, four shifts, three multiplies, and three reads from V8’s read-only roots per integer extraction, fast enough that standard benchmarks cannot distinguish it from baseline.
Scope and Threat Model
The advisory uses the phrase “minimally HashDoS resistant” deliberately. The construction assumes attackers cannot observe V8’s internal hash values, time individual hash table operations precisely through network jitter, or determine the per-process seed through timing side channels. These are reasonable assumptions for a server context: hash values never leave the process, network round-trip variation and GC pauses add substantial timing noise, and the string internalization table is cleaned between garbage collection cycles, limiting cross-request collision accumulation.
Chrome ships with this feature disabled. In a browser, a tab hanging is a user experience failure but not a cross-origin security boundary violation; the relevant concern is cross-origin data access, not event loop blocking. Node.js’s threat model differs structurally: a single blocked event loop denies service to all concurrent users sharing that process.
The multiplier derivation reuses V8’s existing rapidhash secrets, which are already generated at startup, so there is no additional entropy gathering or initialization cost. Extracting odd 24-bit multipliers from existing 64-bit secrets is arithmetic.
What This Required That Python’s Fix Did Not
Python’s SipHash adoption in 3.4 and Ruby’s equivalent move solved HashDoS for string hashes used purely for bucket selection. No additional constraint applied: hash the characters, key the hash with a random seed, done. V8’s array index strings carried a pre-existing optimization, encoding the numeric value directly into the hash field to avoid redundant decimal parsing on every array access. That optimization is load-bearing for V8’s performance on integer-keyed property and element operations.
Closing the vulnerability without removing that optimization required a construction with three properties simultaneously: seeded by a runtime-generated secret, bijective so inversion is exact, and fast enough that the additional decode cost does not register in standard workloads. The three-round xorshift-multiply satisfies all three. The Chromium code review implementing the change includes the full hash seed layout and the Torque macros used to implement encoding and decoding in JIT-compiled fast paths.
The March 2026 Node.js security releases for v20, v22, v24, and v25 include the fix. No configuration change is needed; the seeded hash is enabled by default in Node.js builds.