Immutable.js `List` 32-bit trie overflow → unrecoverable DoSGHSA-v56q-mh7h-f735
HIGHFix: immutable-js/immutable-js@a1a1ee4GHSA-v56q-mh7h-f735 is a high-severity (CVSS 7.5) CWE-190 vulnerability in immutable. A fix is available for immutable — see the affected versions and patch details below.
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
- CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.
Exploitation and automatability from CISA’s SSVC triage for GHSA-v56q-mh7h-f735.
EPSS Exploitation Probability
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
How urgent is this, really
GHSA-v56q-mh7h-f735 by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.
Where this sits among everything scored
Of 384,534 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.
Real-World Exposure
How broadly this vulnerability is actually deployed: weekly install volume shows current usage, and reverse-dependency count shows how many other packages break if it stays unpatched.
immutablenpmDescription
Summary
List#set, List#setSize, List#setIn, List#updateIn (and the functional set / setIn / updateIn) mishandle an index or size in the range [2 ** 30, 2 ** 31):
- On an empty
Listthe operation enters an uncatchable infinite loop (a tight CPU spin; a surroundingtry/catchnever regains control). Only killing the worker recovers it. - On a populated
List(≥ 32 elements — i.e. any array of ≥ 32 items turned into aListbyfromJS) the loop allocates without bound → heap exhaustion → the process aborts (SIGABRT, exit134, or kernel OOM-kill137). A real crash, not a recoverable error.
The index may be a numeric string, so it can come straight from a request body, URL, or key-path. A single small unauthenticated request is enough.
There is also a companion silent data-corruption issue in setSize:
List([1, 2, 3]).setSize(2 ** 31); // before fix => size 0 (silently cleared)
List([1, 2, 3]).setSize(2 ** 32 + 5); // before fix => size 5 (huge value wraps to 5)
Impact
Availability only. A reachable configuration is any endpoint that routes untrusted input into a List index or a setIn/updateIn key-path — which the extremely common state = fromJS(body); state.setIn(userPath, value) pattern does (config stores, document/collection editors, redux-immutable reducers, JSON-Patch endpoints, etc.).
No confidentiality or integrity impact, no RCE. The companion setSize bug can silently corrupt application state (wrong size) without crashing.
Reproduction (immutable 5.1.7)
import { fromJS, List } from 'immutable';
// 1) Populated List: OOM -> process abort (SIGABRT, exit 134) within ~2s
fromJS({ items: new Array(64).fill(0) }).setIn(['items', '1073741824'], 'x');
// 2) Empty List: hangs forever, uncatchable
List().set(2 ** 30, 'x');
// 3) Silent truncation
List([1, 2, 3]).setSize(2 ** 31); // => size 0
List([1, 2, 3]).setSize(2 ** 32 + 5); // => size 5
A remote 43-byte HTTP request ({"path":["items","1073741824"],"value":"x"}) is sufficient to abort a worker that applies it via state = state.setIn(path, value).
Any index in [2 ** 30, 2 ** 31) works (1073741824, 2000000000, …). An index in [2 ** 31, 2 ** 32) does not crash — it silently wraps (clearing the List) via the same root cause.
Root cause
List stores its values in a 32-wide trie (SHIFT = 5, so each level addresses 5 more bits) and uses signed 32-bit bitwise arithmetic throughout setListBounds() (src/List.js):
- Infinite loop (the hang / OOM). The level-raising loop
while (newTailOffset >= 1 << (newLevel + SHIFT)) {
newRoot = new VNode(
newRoot && newRoot.array.length ? [newRoot] : [],
owner
);
newLevel += SHIFT;
}
relies on 1 << (newLevel + SHIFT). A JavaScript shift count is taken mod 32, so once newLevel + SHIFT reaches 31 the term goes negative (1 << 31 === -2147483648) and at 32 wraps to 1 (1 << 35 === 8). The comparison then stays true forever and the loop never terminates. On a populated List, each iteration retains a new VNode ([newRoot]), so the heap fills and V8 aborts; on an empty List it spins on CPU without allocating.
- Silent wraparound (the
setSizecorruption). Thebegin |= 0/end |= 0coercion (ToInt32) silently wraps large finite values ((2 ** 31) | 0 === -2147483648,(2 ** 32 + 5) | 0 === 5), producing a wrong resulting size instead of an error.
The threshold is 2 ** 30: that is the largest size for which 1 << (newLevel + SHIFT) stays a valid positive 32-bit integer throughout the loops (newLevel + SHIFT stays ≤ 30).
Remediation
The fix is contained to setListBounds() in src/List.js:
-
Validate up front, before the lossy
| 0coercion. Compute the intended origin and capacity in full precision and throw a clear, catchableRangeErrorwhen they exceed the addressable range (MAX_LIST_SIZE = 2 ** 30).Infinity/NaNare left to the existing| 0 → 0behaviour (sosetSize(Infinity)stays0andslice(0, Infinity)still means "to the end"). -
Stop the shift from wrapping. Replace
1 << expin the level-raising loops with a helper that uses the cheap bitwise shift while it is exact (exp ≤ 30, the common path including everypush/setSize/slice) and falls back to the non-wrapping2 ** exponly for the rare deep trees reached when a negative origin (unshift/ negative index) is normalized to a large positive capacity (expcan reach 35 there, where1 << 35would wrap to 8).
This turns every hang, the misleading "Maximum call stack size exceeded", the OOM/SIGABRT, and the silent setSize truncation into one descriptive RangeError, preserves all behaviour for sizes < 2 ** 30, and keeps the hot push path on the fast bitwise shift (the 2 ** exp branch is never reached by non-negative operations).
Is the new limit a breaking change?
No working code is affected. A List could never actually hold ≥ 2 ** 30 values before — the attempt hung, crashed, or silently corrupted the size. The limit was already implicit in the 32-bit trie; the fix only makes it explicit and catchable, mirroring native JS arrays (new Array(2 ** 32) → RangeError: Invalid array length). The single observable behaviour change is that setSize(hugeValue), which used to return a silently wrong size, now throws. 2 ** 30 ≈ 1.07 billion entries (~8 GB of pointers alone), far beyond any practical use.
Mitigations (for users who cannot upgrade immediately)
- Validate/clamp any externally supplied
Listindex orsetIn/updateInkey-path segment against a sane maximum before passing it to immutable. - Reject numeric path segments
≥ 2 ** 30. - Run request handling in a worker that can be restarted, and cap the heap (
--max-old-space-size) so an abort is contained.
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 📦npm | immutable | ≥ 4.0.0-rc.1&&< 4.3.9 | 4.3.9npm install immutable@4.3.9 |
| 📦npm | immutable | ≥ 5.0.0-beta.1&&< 5.1.8 | 5.1.8npm install immutable@5.1.8 |
| 📦npm | immutable | all versions | 3.8.4npm install immutable@3.8.4 |
Affected Products
immutableimmutable-jsDetection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for immutable, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update immutable to 4.3.9 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-v56q-mh7h-f735 is resolved across your whole dependency graph.
Workarounds
Cap what an attacker can consume: apply request size, rate and timeout limits in front of the affected component, and run it with memory and CPU limits so exhaustion degrades one worker rather than the whole service.
Fixing This On Your OS
If you run this on a Linux distribution, patch through your package manager against the distro's own security advisory below — it tracks the exact backported fix for your release, which can ship on a different timeline (and sometimes a different severity) than the upstream project.
This Moderate impact denial of service vulnerability in Immutable.js affects Red Hat products that process untrusted input through List operations. An attacker can trigger an infinite loop or unbounded memory allocation by providing specially crafted large index values, leading to a service disruption. Additionally,…
To mitigate this issue, applications should implement input validation to reject or clamp any externally supplied List index or key-path segment that exceeds a sane maximum, specifically values greater than or equal to 2^30. Additionally, running request handling in isolated worker processes with capped heap sizes (e.g., using `--max-old-space-size`) can contain the impact of a potential process abort.Source: Red Hat security advisory for GHSA-v56q-mh7h-f735 (CC BY 4.0)
| Product | Fixed in | Advisory |
|---|---|---|
| Red Hat Satellite 6.19 for RHEL 9 | foreman-0:3.18.0.14-1.el9sat | RHSA-2026:74503 |
| multicluster engine for Kubernetes 2.6 | multicluster-engine/console-mce-rhel9:1790117683 | RHSA-2026:72850 |
| multicluster engine for Kubernetes 2.8 | multicluster-engine/console-mce-rhel9:1790121310 | RHSA-2026:72854 |
| multicluster engine for Kubernetes 2.9 | multicluster-engine/console-mce-rhel9:1789583887 | RHSA-2026:72851 |
| Red Hat Advanced Cluster Management for Kubernetes 2.14 | rhacm2/console-rhel9:1788563615 | RHSA-2026:67539 |
| Red Hat Ansible Automation Platform 2.1 | ansible-automation-platform/automation-portal:1790254963 | RHSA-2026:72712 |
| Red Hat Discovery 2 | discovery/discovery-ui-rhel9:1789677459 | RHSA-2026:69289 |
| Red Hat OpenShift Container Platform 4.21 | openshift4/nmstate-console-plugin-rhel9:1790778086 | RHSA-2026:74384 |
Frequently Asked Questions
Is GHSA-v56q-mh7h-f735 in your dependencies?
Find it across npm, including transitive dependencies.