GHSA-V56Q-MH7H-F735

Vulnerability from github – Published: 2026-07-21 18:36 – Updated: 2026-09-16 18:47
VLAI
Summary
Immutable.js `List` 32-bit trie overflow → unrecoverable DoS
Details

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 List the operation enters an uncatchable infinite loop (a tight CPU spin; a surrounding try/catch never regains control). Only killing the worker recovers it.
  • On a populated List (≥ 32 elements — i.e. any array of ≥ 32 items turned into a List by fromJS) the loop allocates without bound → heap exhaustion → the process aborts (SIGABRT, exit 134, or kernel OOM-kill 137). 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):

  1. 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.

  1. Silent wraparound (the setSize corruption). The begin |= 0 / end |= 0 coercion (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:

  1. Validate up front, before the lossy | 0 coercion. Compute the intended origin and capacity in full precision and throw a clear, catchable RangeError when they exceed the addressable range (MAX_LIST_SIZE = 2 ** 30). Infinity/NaN are left to the existing | 0 → 0 behaviour (so setSize(Infinity) stays 0 and slice(0, Infinity) still means "to the end").

  2. Stop the shift from wrapping. Replace 1 << exp in the level-raising loops with a helper that uses the cheap bitwise shift while it is exact (exp ≤ 30, the common path including every push/setSize/slice) and falls back to the non-wrapping 2 ** exp only for the rare deep trees reached when a negative origin (unshift / negative index) is normalized to a large positive capacity (exp can reach 35 there, where 1 << 35 would 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 List index or setIn/updateIn key-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.
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "immutable"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.0.0-rc.1"
            },
            {
              "fixed": "4.3.9"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "npm",
        "name": "immutable"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "5.0.0-beta.1"
            },
            {
              "fixed": "5.1.8"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "npm",
        "name": "immutable"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.8.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-59879"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-1284",
      "CWE-190",
      "CWE-400",
      "CWE-835"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-21T18:36:27Z",
    "nvd_published_at": "2026-07-08T17:17:26Z",
    "severity": "HIGH"
  },
  "details": "## Summary\n\n`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)`:\n\n- On an **empty** `List` the operation enters an **uncatchable infinite loop** (a tight CPU spin; a surrounding `try/catch` never regains control). Only killing the worker recovers it.\n- On a **populated** `List` (\u2265 32 elements \u2014 i.e. any array of \u2265 32 items turned into a `List` by `fromJS`) the loop allocates without bound \u2192 heap exhaustion \u2192 the **process aborts** (`SIGABRT`, exit `134`, or kernel OOM-kill `137`). A real crash, not a recoverable error.\n\nThe 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.\n\nThere is also a companion **silent data-corruption** issue in `setSize`:\n\n```js\nList([1, 2, 3]).setSize(2 ** 31); // before fix =\u003e size 0  (silently cleared)\nList([1, 2, 3]).setSize(2 ** 32 + 5); // before fix =\u003e size 5  (huge value wraps to 5)\n```\n\n## Impact\n\nAvailability only. A reachable configuration is any endpoint that routes untrusted input into a `List` index or a `setIn`/`updateIn` key-path \u2014 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.).\n\nNo confidentiality or integrity impact, no RCE. The companion `setSize` bug can silently corrupt application state (wrong size) without crashing.\n\n## Reproduction (immutable 5.1.7)\n\n```ts\nimport { fromJS, List } from \u0027immutable\u0027;\n\n// 1) Populated List: OOM -\u003e process abort (SIGABRT, exit 134) within ~2s\nfromJS({ items: new Array(64).fill(0) }).setIn([\u0027items\u0027, \u00271073741824\u0027], \u0027x\u0027);\n\n// 2) Empty List: hangs forever, uncatchable\nList().set(2 ** 30, \u0027x\u0027);\n\n// 3) Silent truncation\nList([1, 2, 3]).setSize(2 ** 31); // =\u003e size 0\nList([1, 2, 3]).setSize(2 ** 32 + 5); // =\u003e size 5\n```\n\nA 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)`.\n\nAny index in `[2 ** 30, 2 ** 31)` works (`1073741824`, `2000000000`, \u2026). An index in `[2 ** 31, 2 ** 32)` does not crash \u2014 it silently wraps (clearing the List) via the same root cause.\n\n## Root cause\n\n`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`):\n\n1. **Infinite loop (the hang / OOM).** The level-raising loop\n\n```js\nwhile (newTailOffset \u003e= 1 \u003c\u003c (newLevel + SHIFT)) {\n  newRoot = new VNode(\n    newRoot \u0026\u0026 newRoot.array.length ? [newRoot] : [],\n    owner\n  );\n  newLevel += SHIFT;\n}\n```\n\nrelies on `1 \u003c\u003c (newLevel + SHIFT)`. A JavaScript shift count is taken **mod 32**, so once `newLevel + SHIFT` reaches `31` the term goes **negative** (`1 \u003c\u003c 31 === -2147483648`) and at `32` wraps to `1` (`1 \u003c\u003c 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.\n\n2. **Silent wraparound (the `setSize` corruption).** The `begin |= 0` / `end |= 0` coercion (`ToInt32`) silently wraps large finite values (`(2 ** 31) | 0 === -2147483648`, `(2 ** 32 + 5) | 0 === 5`), producing a wrong resulting size instead of an error.\n\nThe threshold is `2 ** 30`: that is the largest size for which `1 \u003c\u003c (newLevel + SHIFT)` stays a valid positive 32-bit integer throughout the loops (`newLevel + SHIFT` stays \u2264 30).\n\n## Remediation\n\nThe fix is contained to `setListBounds()` in `src/List.js`:\n\n1. **Validate up front, before the lossy `| 0` coercion.** Compute the intended origin and capacity in full precision and throw a clear, catchable `RangeError` when they exceed the addressable range (`MAX_LIST_SIZE = 2 ** 30`). `Infinity`/`NaN` are left to the existing `| 0 \u2192 0` behaviour (so `setSize(Infinity)` stays `0` and `slice(0, Infinity)` still means \"to the end\").\n\n2. **Stop the shift from wrapping.** Replace `1 \u003c\u003c exp` in the level-raising loops with a helper that uses the cheap bitwise shift while it is exact (`exp \u2264 30`, the common path including every `push`/`setSize`/`slice`) and falls back to the non-wrapping `2 ** exp` only for the rare deep trees reached when a negative origin (`unshift` / negative index) is normalized to a large positive capacity (`exp` can reach 35 there, where `1 \u003c\u003c 35` would wrap to 8).\n\nThis 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 `\u003c 2 ** 30`, and keeps the hot `push` path on the fast bitwise shift (the `2 ** exp` branch is never reached by non-negative operations).\n\n### Is the new limit a breaking change?\n\nNo working code is affected. A `List` could never actually hold `\u2265 2 ** 30` values before \u2014 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)` \u2192 `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` \u2248 1.07 billion entries (~8 GB of pointers alone), far beyond any practical use.\n\n## Mitigations (for users who cannot upgrade immediately)\n\n- Validate/clamp any externally supplied `List` index or `setIn`/`updateIn` key-path segment against a sane maximum before passing it to immutable.\n- Reject numeric path segments `\u2265 2 ** 30`.\n- Run request handling in a worker that can be restarted, and cap the heap (`--max-old-space-size`) so an abort is contained.",
  "id": "GHSA-v56q-mh7h-f735",
  "modified": "2026-09-16T18:47:10Z",
  "published": "2026-07-21T18:36:27Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/immutable-js/immutable-js/security/advisories/GHSA-v56q-mh7h-f735"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-59879"
    },
    {
      "type": "WEB",
      "url": "https://github.com/immutable-js/immutable-js/commit/a1a1ee412dcaa380ab325196283d06594ffe4b84"
    },
    {
      "type": "WEB",
      "url": "https://github.com/immutable-js/immutable-js/commit/f0bc997d8eb9886aff2236635aa210a95a04304a"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/immutable-js/immutable-js"
    },
    {
      "type": "WEB",
      "url": "https://github.com/immutable-js/immutable-js/releases/tag/v3.8.4"
    },
    {
      "type": "WEB",
      "url": "https://github.com/immutable-js/immutable-js/releases/tag/v4.3.9"
    },
    {
      "type": "WEB",
      "url": "https://github.com/immutable-js/immutable-js/releases/tag/v5.1.8"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
      "type": "CVSS_V3"
    },
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Immutable.js `List` 32-bit trie overflow \u2192 unrecoverable DoS"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…