CVE-2026-104874: Multidict: Reference leak in CIMultiDict/MultiDict items-view union and subtraction
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(), multidictitemsviewor2impl) and the subtraction path (d.items() - operand, multidictitemsviewsub1impl) 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
multidictitemsviewparseitem() returns new references: a fresh identity via mdcalcidentity() and a fresh value via PyNewRef(). The or2impl first parse loop requests both but clears only arg:
c // views.h:565 — or2impl first parse loop while ((st = PyIterNextItem(iter, &arg)) > 0) { int tmp = multidictitemsviewparseitem( self, arg, &identity, NULL, &value); // identity + value: new refs if (tmp < 0) goto fail; else if (tmp > 0) { if (setadd(tmpset, identity, value) < 0) goto fail; } PyCLEAR(arg); // :575 clears arg ONLY }
setadd() builds its own tuple with PyTuplePack and PyDECREFs 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 sub1impl 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, and1impl (:304-306), and2impl (:389-391), or1impl (:506-508), and the second (mdnext) loops of both functions (:605-607, :726-728). PR #1413 added a PyDECREF(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 mdcalcidentity() 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
pocrefcount.py — leak proof with intersection control
python import sys, gc from multidict import CIMultiDict
def probe(name, runop): 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) runop(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
pocdispatch.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)
pocrss.py — availability consequence
python import sys, gc, resource from multidict import CIMultiDict
def rssmb(): r = resource.getrusage(resource.RUSAGESELF).rumaxrss return r / (1024 1024) if sys.platform == "darwin" else r / 1024
d = CIMultiDict(); d["seed"] = "x" gc.collect(); print(f"start RSS = {rssmb():.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 = {rssmb():.1f} MB")
Execution Steps
1. python3 -m venv venv (CPython 3.10+). 2. ./venv/bin/pip install "multidict==6.9.0" (installs the C-extension wheel). 3. From a directory that is not a multidict checkout, run each script with ./venv/bin/python.
Actual Execution Evidence
[or2 operand | items] leaked strong refs = 100000 [sub1 items - operand] leaked strong refs = 100000 [and2 items & operand] leaked strong refs = 0 <- control
operand | d.items() (or2) leaked=50000 d.items() | operand (or1) leaked=0 d.items() - operand (sub1) leaked=50000 operand - d.items() (rsub) leaked=0 d.items() & operand (and) leaked=0
start RSS = 15.6 MB after 2,000,000 elements: RSS = 288.1 MB after 6,000,000 elements: RSS = 776.8 MB after 10,000,000 elements: RSS = 1266.8 MB (gc.collect() every iteration)
Version-boundary evidence
Same pocdispatch.py, PyPI wheels, identical environment. Last-clean is 6.6.4; first-affected is 6.7.0:
6.6.4 : or2=0 sub1=0 others=0 (clean, last 6.6.x) 6.7.0 : or2=50000 sub1=50000 others=0 (AFFECTED, first) 6.9.0 : or2=50000 sub1=50000 others=0 (affected)
The clean 6.6.x releases return correct set-operation results, so the zero leak reflects correct memory management, not a broken path.
Analysis of Results
The operand holds exactly N references to one sentinel value; after the union its refcount rises by another N and stays there post-GC, so each element leaked one strong reference. Subtraction shows the identical delta. The intersection control, which clears identity and value, leaks zero; the only code difference is the two missing PyCLEAR calls, so the leak is caused by that omission and not the harness. RSS climbs from 15.6 MB to 1266.8 MB (~250 MB per 2M elements) despite per-iteration GC, confirming the objects are unreachable by the cyclic collector.
---
Impact
A process that evaluates items-view unions or subtractions over remote-influenced operands leaks one identity plus one value object per element, permanently. In the aio-libs stack multidict backs HTTP headers and query strings, so an attacker who enlarges the operand (for example, many repeated header items compared against a fixed allow/deny set) forces steady, unrecoverable heap growth and can eventually exhaust memory in a long-lived server. This is an availability defect only; results stay correct and no data is exposed.
Reachability depends on the application evaluating operand | view.items() (reflected union) or view.items() - operand (subtraction) over a sequence of 2-tuples whose count is remote-influenced. The forward union view.items() | operand routes to or1impl, which clears correctly and does not leak; a non-tuple operand element takes the parseitem early-return and does not leak. Set algebra over items views is not the most common multidict usage, which bounds exposure and is why this is Medium, not High. This is distinct from PR #1413, which fixed a temporary-tuple leak in the second loop and did not release these per-element objects.
---
Remediation
Recommended fix. Clear the two per-element references at the end of the first parse loop in both functions, exactly as every sibling path does. In or2impl (views.h:575) and sub1impl (views.h:696), replace the lone PyCLEAR(arg); with:
c PyCLEAR(arg); PyCLEAR(identity); PyCLEAR(value);
This is an in-repo change to a static internal function; it alters no public API, type, or signature, and asks callers to change nothing. A leak test in the existing tests/testleaks.py style would lock it in.
Workaround. Avoid operand | view.items() and view.items() - operand on attacker-influenced operands, or install with MULTIDICTNOEXTENSIONS=1 to use the unaffected pure-Python build.
---
Other sources
Multidict is an implementation of a multidict data structure. From 6.7.0 until 6.9.1, the C extension's items-view reflected union operation, operand | d.items(), in multidictitemsviewor2impl and subtraction operation, d.items() - operand, in multidictitemsviewsub1impl fail to release new key-identity and value references returned for each operand element. Applications that perform these operations over attacker-influenced sequences can leak two strong references per element, and garbage collection cannot reclaim them, so repeated operations can cause unbounded process memory growth and denial of service. Forward union, intersection, non-tuple operand elements, and pure-Python builds are not affected by this reference leak. This issue is fixed in version 6.9.1.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/multidictto a version that resolves this vulnerability.Fixed in 6.9.1 - Upgrade
Upgrade
multidictto a version that resolves this vulnerability.Fixed in 6.9.1 - Configuration
Set MULTIDICT_NO_EXTENSIONS=1 to install and use the unaffected pure-Python build.
multidict MULTIDICT_NO_EXTENSIONS = 1 - Compensating control
Avoid evaluating operand | view.items() and view.items() - operand when operands are attacker-influenced.
Event History
Frequently Asked Questions
Which deployments are exposed to the memory leak?
Deployments using Multidict versions from 6.7.0 through 6.9.1 with the C extension are affected when they perform the vulnerable items-view operations. Pure-Python builds are not affected.
What attacker influence is needed to trigger denial of service?
An attacker needs to influence sequences processed by the reflected union operation, operand | d.items(), or the subtraction operation, d.items(). Repeating these operations can leak two strong references per operand element and cause unbounded process memory growth.
Which similar operations are not affected?
Forward union, intersection, and operations involving non-tuple operand elements are not affected by this reference leak.
What is the available remediation?
Update Multidict to version 6.9.1, which fixes the issue. If updating is not immediately possible, avoid applying reflected union or subtraction items-view operations to attacker-influenced sequences.