Like turning a paper straw into a firehose, tiny XML can coerce huge heap growth
CVE-2025-59375 is a resource-exhaustion / denial-of-service bug in libexpat affecting Expat before 2.7.2. A small attacker-supplied XML document can drive disproportionately large dynamic memory allocation inside the parser; upstream documented examples around ~250 KiB input causing ~800 MiB heap use, and the public fuzzing reproducer hit a 2 GiB libFuzzer limit. Upstream also notes that compression wrapped around XML can shrink the attack payload further, which matters for apps that accept compressed uploads, SOAP, SAML, XML APIs, feeds, or file imports.
The vendor/NVD 7.5 HIGH score is technically fair in a vacuum because the bug can be reached over a network with no auth and reliably causes availability loss. In real enterprise patch queues, though, this is not a universal network service flaw; it is a component-level parser DoS that only bites where an exposed application actually feeds attacker-controlled XML into Expat. That reachability friction, plus no KEV listing, very low EPSS, and availability-only impact, pushes this down to MEDIUM for most fleets.
4 steps from start to impact.
Find an XML ingestion path
curl, Burp Suite, or a custom HTTP client; the vulnerability is in the parser, not in a separate exploit primitive.- An internet- or tenant-reachable application accepts attacker-controlled XML
- That application actually uses
libexpatfor parsing - The vulnerable Expat build is present at runtime
- Many hosts with Expat installed never expose XML parsing to untrusted users
- Modern estates often terminate XML in higher-level frameworks or alternate parsers instead
- Asset inventories rarely map shared library presence to reachable XML endpoints, which cuts both ways
libexpat is a backend component, not a bannered service.Deliver a memory-amplifying XML document
- The target path passes the supplied XML to Expat largely unfiltered
- Request/body size limits are permissive enough for the crafted sample
- Reverse proxies, API gateways, and upload limits often reject or truncate oversized requests before the parser sees them
- Apps that pre-validate XML size/complexity or decompress in a constrained worker reduce reliability
Force disproportionate heap growth inside Expat
- The process is running an affected Expat build before 2.7.2 or an unfixed downstream backport state
- The service has enough memory headroom to attempt the allocations
- Container memory limits, cgroups,
ulimit, or service-level memory governors may kill only the worker/container rather than the host - Aggressive watchdogs and autoscaling can mask impact from one-off probes
Turn parser pressure into service disruption
- The service is business-relevant and exposed often enough for repeated requests to matter
- Restart or orchestration behavior does not fully absorb repeated crashes
- Process supervisors, container restarts, and horizontal scaling often convert this from outage to noisy degradation
- No confidentiality or integrity impact means attackers need repeated pressure for operational effect
The supporting signals.
| In-the-wild status | No public active exploitation evidence found in the reviewed primary sources, and no CISA KEV listing was identified. |
|---|---|
| Proof-of-concept availability | Public reproducer exists via OSS-Fuzz/ClusterFuzz issue #1018; this is enough for reliable DoS testing even without a turnkey exploit kit. |
| EPSS | 0.00102 (user-supplied), which is very low and consistent with a niche, availability-only parser bug rather than a high-velocity exploitation candidate. |
| KEV status | Not listed in the CISA Known Exploited Vulnerabilities Catalog. |
| CVSS vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H — unauthenticated network reachability and easy trigger, but availability only. |
| Affected versions | Expat before 2.7.2 / libexpat before that upstream release. |
| Fixed versions and backports | Upstream fix is 2.7.2. Distros may backport: Ubuntu lists fixes including 25.10 2.7.1-2ubuntu0.2, 22.04 2.4.7-1ubuntu0.7, and ESM builds for older releases; Debian marks unstable 2.7.2-1 fixed while some stable branches are still marked vulnerable or ignored as a minor issue. |
| Exposure reality | Inference: there is no clean internet census for this CVE because Expat is a shared library, not an identifiable network daemon. Exposure depends on whether your internet-facing apps actually hand untrusted XML to Expat. |
| Disclosure timeline | The public fuzzing issue was opened 2025-08-16; the fix PR merged 2025-09-15; the upstream security release was announced on 2025-09-16. NVD shows the record published on 2025-09-14. |
| Researcher / reporter | Discovery path points to OSS-Fuzz / ClusterFuzz; upstream security communication and release note came from Sebastian Pipping. |
noisgate verdict.
The decisive friction is reachability: this is only exploitable where an attacker can drive untrusted XML into an Expat-backed parser path, which is a much smaller population than 'every host with libexpat installed'. The bug is real and reliable for DoS once reached, but the lack of exploitation evidence and the availability-only, service-scoped blast radius keep it out of the urgent patch lane.
Why this verdict
- Downward pressure: requires a real XML parser path — unauthenticated network access only matters if the target application exposes an XML ingest feature that passes attacker content into Expat.
- Downward pressure: library bug, not service bug — presence of
libexpaton disk says little about exploitability; a large fraction of endpoints and servers carrying the package are not directly reachable through untrusted XML. - Downward pressure: blast radius is usually local to one service/container — modern orchestration, process supervision, and memory limits often convert this into a restart/noise problem rather than enterprise-wide failure.
- Upward pressure: public reproducer and easy trigger — the OSS-Fuzz testcase makes reliable validation straightforward, and upstream explicitly warns that compression can reduce the minimum payload size.
- Upward pressure: broad software embedding — Expat is common in the stack, so when you do have exposed SOAP/SAML/XML import surfaces, this bug can be surprisingly reachable.
Why not higher?
There is no code execution, auth bypass, or data exposure here; the impact is confined to availability. More importantly, the attack chain usually requires a specific exposed XML-handling feature, which implies a much narrower reachable population than the vendor CVSS suggests for a generic network bug.
Why not lower?
Once an attacker finds a reachable Expat-backed XML path, the DoS appears reliable and easy to trigger, and there is a public reproducer. For organizations still running SOAP, SAML, XML uploads, or XML-fed middleware on the edge, this is not theoretical background noise.
What to do — in priority order.
- Cap XML request size and decompression budget — Apply strict body-size, decompressed-size, and parser-complexity limits at the reverse proxy, API gateway, or upload handler. Because this verdict is MEDIUM, there is no mitigation SLA — go straight to the 365-day remediation window; still, do this early on any internet-facing XML endpoint because it reduces both this bug and adjacent parser abuse.
- Isolate XML parsing in memory-constrained workers — Run XML-handling services behind cgroup/container memory limits, systemd
MemoryMax, or equivalent resource governors so parser abuse kills a worker, not a shared node. For a MEDIUM finding there is no mitigation SLA, but this is the highest-value architectural guardrail if patching must wait. - Reduce exposed XML attack surface — Disable unused SOAP/XML upload/import features, require authentication where possible, and route partner-only XML paths behind VPN or allowlists. This is especially relevant for products that still expose legacy XML workflows externally; again, no mitigation SLA applies for MEDIUM, but exposed XML should not sit wide open.
- Prefer vendor backports over naive version greps — Update using your distro/vendor package stream and document approved fixed backports, because some patched builds carry upstream versions lower than
2.7.2. This avoids false positives and keeps verification sane during the 365-day remediation window.
- A generic WAF rule is not enough by itself; this bug is about parser-side allocation amplification, not a single stable exploit string.
- EDR alone will not prevent the issue; at best it will show OOM kills, crashes, or restarts after the fact.
- A file-size-only control can miss the case entirely because upstream notes that small XML, and especially compressed XML, can still trigger outsized memory use.
Crowdsourced verification payload.
Run this on the target host or container image that actually ships libexpat. Invoke it as python3 check_expat_cve_2025_59375.py; it needs no admin rights, but it benefits from access to local package-manager metadata. The script checks the runtime Expat version and a few known distro backport package versions, then prints VULNERABLE, PATCHED, or UNKNOWN.
#!/usr/bin/env python3
# CVE-2025-59375 verifier for Expat / libexpat
# Exit codes: 0=PATCHED, 1=VULNERABLE, 2=UNKNOWN
import ctypes
import ctypes.util
import os
import platform
import re
import shutil
import subprocess
import sys
FIXED_UPSTREAM = (2, 7, 2)
KNOWN_FIXED_PACKAGES = {
# Ubuntu USN-8022-1 backports
"2.7.1-2ubuntu0.2",
"2.4.7-1ubuntu0.7",
"2.2.9-1ubuntu0.8+esm1",
"2.2.5-3ubuntu0.9+esm3",
"2.1.0-7ubuntu0.16.04.5+esm11",
"2.1.0-4ubuntu1.4+esm11",
}
def run(cmd):
try:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if p.returncode == 0:
return p.stdout.strip()
except Exception:
pass
return ""
def parse_upstream_version(s):
if not s:
return None
m = re.search(r"(\d+)\.(\d+)\.(\d+)", s)
if not m:
return None
return tuple(int(x) for x in m.groups())
def version_lt(a, b):
return a < b
def get_runtime_expat_version():
names = []
found = ctypes.util.find_library("expat")
if found:
names.append(found)
names.extend([
"libexpat.so.1",
"libexpat.so",
"libexpat.1.dylib",
"libexpat.dylib",
"libexpat.dll",
"expat.dll",
])
tried = []
for name in names:
if name in tried:
continue
tried.append(name)
try:
lib = ctypes.CDLL(name)
fn = lib.XML_ExpatVersion
fn.restype = ctypes.c_char_p
raw = fn()
if raw:
text = raw.decode(errors="ignore")
return text, name
except Exception:
continue
return None, None
def get_package_version():
# Debian/Ubuntu
if shutil.which("dpkg-query"):
for pkg in ["libexpat1", "expat"]:
out = run(["dpkg-query", "-W", "-f=${Version}", pkg])
if out:
return pkg, out
# RPM-based
if shutil.which("rpm"):
for pkg in ["expat", "libexpat"]:
out = run(["rpm", "-q", "--qf", "%{VERSION}-%{RELEASE}", pkg])
if out and "is not installed" not in out:
return pkg, out
# Alpine
if shutil.which("apk"):
out = run(["apk", "info", "-v", "expat"])
if out:
line = out.splitlines()[0].strip()
return "expat", line.replace("expat-", "", 1)
# Arch
if shutil.which("pacman"):
out = run(["pacman", "-Q", "expat"])
if out:
parts = out.split()
if len(parts) >= 2:
return "expat", parts[1]
# Homebrew
if shutil.which("brew"):
out = run(["brew", "list", "--versions", "expat"])
if out:
parts = out.split()
if len(parts) >= 2:
return "expat", parts[1]
return None, None
def main():
runtime_text, runtime_lib = get_runtime_expat_version()
runtime_ver = parse_upstream_version(runtime_text or "")
pkg_name, pkg_ver = get_package_version()
print(f"platform={platform.platform()}")
print(f"runtime_lib={runtime_lib or 'not-found'}")
print(f"runtime_version={runtime_text or 'unknown'}")
print(f"package={pkg_name or 'unknown'}")
print(f"package_version={pkg_ver or 'unknown'}")
# Known distro backports first
if pkg_ver in KNOWN_FIXED_PACKAGES:
print("PATCHED")
sys.exit(0)
# Upstream runtime version check
if runtime_ver is not None:
if version_lt(runtime_ver, FIXED_UPSTREAM):
print("VULNERABLE")
sys.exit(1)
else:
print("PATCHED")
sys.exit(0)
# If only package metadata exists, infer cautiously from upstream-like versions
pkg_upstream = parse_upstream_version(pkg_ver or "")
if pkg_upstream is not None:
if version_lt(pkg_upstream, FIXED_UPSTREAM):
# Could still be backported on some distros; avoid false confidence
print("UNKNOWN")
sys.exit(2)
else:
print("PATCHED")
sys.exit(0)
print("UNKNOWN")
sys.exit(2)
if __name__ == "__main__":
main()
If you remember one thing.
Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.