Last month V8 shipped a fix for CVE-2026-21717, a HashDoS vulnerability that affected Node.js v20, v22, v24, and v25. The short version: strings that look like decimal integers (“1”, “42”, “65535”) used a fully deterministic, unseeded hash, and an attacker who knew the hash function could craft a payload of colliding keys that degraded hash table lookups from O(1) to O(n²). A roughly 2MB JSON body was enough to hang a Node.js server for over 30 seconds.
I build Discord bots and most of them have HTTP endpoints that parse JSON from webhooks. This one got my attention.
The interesting part is not the vulnerability itself, though. It is the constraint that shaped the fix.
Why Array Index Strings Are Special
In V8, strings that represent valid array indices get special treatment. A string like "1234" that fits in 24 bits gets its integer value encoded directly into the string’s hash field, storing both the string length and the numeric value in a single word. That encoding means V8 can skip re-parsing the string when you do something like parseInt("42") or access arr["42"]: it reads the hash field directly instead of walking the characters again.
This is a real optimization on hot paths. Arrays are indexed by strings in the spec; V8 turns that into an integer operation wherever possible.
The old hash was therefore essentially the identity function on the integer value. If your key was the string "1234", the hash was a deterministic encoding of 1234. Fully predictable. An attacker who understands the hash table’s internal probe sequence can calculate which integer values map to the same bucket, generate those strings, and watch the server drown.
The Node.js security advisory includes a concrete demonstration: roughly 2^17 crafted keys in a JSON payload, around 2MB. Server response time goes from milliseconds to over 30 seconds. This is not a theoretical concern.
Why SipHash Does Not Fit Here
The standard answer to hash table DoS is SipHash, the keyed hash used by Python, Ruby, Rust’s standard HashMap, and others since the vulnerability class was publicly documented at the 28C3 conference in 2011. SipHash is fast, resistant to differential cryptanalysis, and non-invertible. Seed it with a secret 128-bit key at startup, and an attacker cannot precompute collisions without knowing that key.
V8 cannot use SipHash for array index strings, because of the optimization described above. The hash field for these strings serves double duty: it is both a hash for bucket selection and a cache of the parsed integer value. The hash function must be a bijection, a reversible mapping, so that the original integer can be recovered from the hash. SipHash is a compression function; it maps variable-length input to a fixed-size output with no general inverse. You cannot recover the integer from a SipHash output without the key.
So the requirement is: the hash must be a permutation over the 24-bit integer space, seeded with a secret value, and efficiently invertible.
The Construction
The fix uses a 3-round xorshift-multiply permutation:
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; // finalize
return x;
}
Each round mixes the current value using a right shift by 12, which is half the 24-bit width. That split is deliberate: x ^= x >> 12 over a 24-bit domain is an involution (its own inverse), and it folds information between the upper 12 bits and the lower 12 bits. The multiply step then diffuses that mixed state further, computed modulo 2^24.
For the whole construction to be invertible, the multipliers must be odd. Odd integers are coprime to any power of two, which means their modular inverse exists. If m * m_inv ≡ 1 (mod 2^24), the multiply step can be undone. Inversion runs the same structure in reverse round order with each multiplier replaced by its modular inverse:
uint32_t UnseedArrayIndexValue(uint32_t hash, uint32_t m_inv[3]) {
uint32_t x = hash;
x ^= x >> kShift; x = (x * m_inv[2]) & kMask; // undo round 3
x ^= x >> kShift; x = (x * m_inv[1]) & kMask; // undo round 2
x ^= x >> kShift; x = (x * m_inv[0]) & kMask; // undo round 1
x ^= x >> kShift;
return x;
}
The multipliers are derived from the rapidhash secrets already present in V8, masked to 24 bits and OR’d with 1 to guarantee oddness. No new entropy source is required; the existing infrastructure is reused. Modular inverses are precomputed at startup via Newton’s method.
The approach draws on hash-prospector by Christopher Wellons, a project that systematically measures the avalanche properties of integer hash constructions using the Strict Avalanche Criterion. SAC bias quantifies how far a function deviates from the ideal where flipping any input bit flips each output bit with 50% probability. Lower bias means better diffusion:
| Construction | SAC Bias |
|---|---|
| Identity | 1000.000 |
| XOR only | 1000.000 |
| 1-round xorshift-multiply | 446.852 |
| 2-round xorshift-multiply | 3.447 |
| 3-round xorshift-multiply | 0.50 (mean) |
Three rounds drops bias from 1000 down to 0.50. Two rounds gets to 3.4, which is good, but the marginal cost of a third round is small enough that the additional diffusion is worth it. Crucially, the 3-round construction is stable across all randomly generated multiplier sets: testing 50 random seeds gave a range of 0.37 to 1.68 and a standard deviation of 0.20.
Why Reversibility Does Not Help Attackers
The function is published in the advisory and the inversion algorithm is straightforward. If an attacker knows the construction, can they reverse-engineer collisions anyway?
No, because the security is in the seed, not the construction. The multipliers are derived from randomly generated values at V8 startup. The construction being public is not a problem; this is Kerckhoffs’s principle applied to hash functions. Without the secret multipliers, the attacker cannot compute which input values map to the same bucket. The function is a family of permutations, and the startup secret selects which member of the family is active.
This is different from a standard keyed hash like SipHash only in that knowing the key also reveals the inverse function. Given m1, m2, m3, you can recover any input from its hash output. In a server threat model this does not matter: the attacker cannot exploit invertibility without first obtaining the secret multipliers, which requires compromising the process itself. At that point, hash tables are not the problem.
Performance Impact
Adding three rounds of multiply and xorshift to every array index string hash could matter on hot paths. The benchmark results across V8’s standard suites on x64 Linux:
| Suite | Baseline | Seeded | Change |
|---|---|---|---|
| SunSpider | 86.9 ms | 86.9 ms | 0.0% |
| Kraken | 470.3 ms | 469.2 ms | +0.2% |
| Octane | 72848 | 72742 | -0.1% |
| JetStream 3 | 203.20 | 202.90 | -0.15% |
All results fall within measurement noise. The operations involved, shifts, multiplies, and masks on 32-bit values, are cheap enough that three additional rounds do not register at this granularity.
The fix is not limited to the C++ runtime path. The JIT compiler paths implemented in Torque macros and CodeStubAssembler were also updated. Optimizing-compiler-inlined hash computations would have remained vulnerable without those changes.
Deployment
The fix ships enabled in Node.js but disabled in Chrome. The distinction reflects different threat models: Chrome does not run untrusted code in contexts where an attacker can observe server-side hash table timing. The server-side denial-of-service risk that motivated the fix does not apply to the browser.
Deno and Cloudflare Workers were notified as V8 embedders. If you embed V8 outside Node.js, verify that your version includes the patch and that v8_enable_seeded_array_index_hash = true is set at build time.
What This Means
For Node.js servers that parse untrusted JSON with integer-like keys, the patch removes a denial-of-service attack that required no authentication and only 2MB of payload. That sits well within the default body size limits of most HTTP frameworks.
For anyone interested in hash function design, the construction is a clean example of working inside a hard constraint. The standard solution for hash DoS resistance, a non-invertible keyed hash, was ruled out by a performance optimization built into V8’s string representation. The team found a bijective construction with strong diffusion properties, derived the seed from existing entropy, and drove avalanche bias down to 0.50 in three rounds. The performance cost was negligible. The constraint made the problem more interesting, and the solution is worth understanding even if you never open a V8 source file.