Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐍
🐍 PyPI
Not in CISA KEV
MEDIUM severity

Multidict: Reference leak in CIMultiDict/MultiDict items-view union and subtractionGHSA-54p9-h82j-f925

MEDIUMFix: aio-libs/multidict@350b4a0

GHSA-54p9-h82j-f925 is a medium-severity (CVSS 5.3) CWE-401 vulnerability in multidict. A fix is available for multidict — see the affected versions and patch details below.

Also known asCVE-2026-104874PYSEC-2026-4244
Published
Updated
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Oct 23, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

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-54p9-h82j-f925.

EPSS Exploitation Probability

via FIRST.org ↗
0.3%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs21th percentile — riskier than 21% of all scored CVEsHighest risk

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

How urgent is this, really

GHSA-54p9-h82j-f925 by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.

Where this sits among everything scored

Of 394,803 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.

Real-World Exposure

1 pkg affected
🐍multidict

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.

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:

// 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_DECREFs 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

ItemValue
RuntimeCPython 3.14.6
multidict6.9.0 (PyPI binary wheel, C extension)
OSmacOS (darwin 25.6.0, arm64)

Proof of Concept

POC Source Code

poc_refcount.py — leak proof with intersection control

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

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

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 (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 poc_dispatch.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 Py_CLEAR 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 or1_impl, which clears correctly and does not leak; a non-tuple operand element takes the parse_item 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 or2_impl (views.h:575) and sub1_impl (views.h:696), replace the lone Py_CLEAR(arg); with:

    Py_CLEAR(arg);
    Py_CLEAR(identity);
    Py_CLEAR(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/test_leaks.py style would lock it in.

Workaround. Avoid operand | view.items() and view.items() - operand on attacker-influenced operands, or install with MULTIDICT_NO_EXTENSIONS=1 to use the unaffected pure-Python build.


Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐍PyPImultidict≥ 6.7.0&&< 6.9.16.9.1pip install --upgrade 'multidict==6.9.1'

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for multidict, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update multidict to 6.9.1 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-54p9-h82j-f925 is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

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.

Red HatModerate

This vulnerability is rated as having Moderate impact for Red Hat products because triggering the memory leak requires an application to evaluate specific items-view set operations against untrusted user input, which is uncommon across standard deployments. While multiple Red Hat services bundle multidict,…

Workaround published by Red Hat
Mitigation for this issue is either not available or the currently available options do not meet the Red Hat Product Security criteria comprising ease of use and deployment, applicability to widespread installation base, or stability.
Source: Red Hat security advisory for GHSA-54p9-h82j-f925 (CC BY 4.0)
ProductFixed inAdvisory
Red Hat AI Inference Server 3.4rhaii/vllm-cpu-rhel9:1791857301RHSA-2026:83112
Red Hat AI Inference Server 3.4rhaii/vllm-spyre-rhel9:1791858592RHSA-2026:83114
Red Hat AI Inference Server 3.4rhaii/model-opt-cuda-rhel9:1791857110RHSA-2026:83116
Red Hat AI Inference Server 3.4rhaii/vllm-cuda-rhel9:1791857183RHSA-2026:83120
Red Hat AI Inference Server 3.4rhaii/vllm-rocm-rhel9:1791856725RHSA-2026:83121
Red Hat AI Inference Server 3.5rhaii/vllm-rocm-rhel9:1791425869RHSA-2026:82059
Red Hat AI Inference Server 3.5rhaii/vllm-gaudi-rhel9:1791427182RHSA-2026:82061
Red Hat AI Inference Server 3.5rhaii/vllm-cuda-rhel9:1791473631RHSA-2026:82368

Frequently Asked Questions

## 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 th
O3 Security · Impact-Aware SCA

Is GHSA-54p9-h82j-f925 in your dependencies?

Find it across PyPI, including transitive dependencies.

Multidict: Reference leak in CIMultiDict/MultiDict…