Safeguard
Supply Chain Security•

libexpat CVE-2026-113351: Reset Parser Out of Memory, Fix Merged

CVE-2026-113351: libexpat 2.7.2 to 2.9.0 reset parsers reject every later XML document. Fix merged 23 Oct, no release yet. Who is affected, what to do.

Safeguard Security Team
38 min read

CVE-2026-113351 is a denial of service in libexpat 2.7.2 through 2.9.0. After one large XML document, a parser reused with XML_ParserReset() rejects every later document as "out of memory". The fix was merged on 23 October 2026, but no release ships it yet. Until one does, free and recreate parsers instead of resetting them.

This post is for C and C++ developers who embed expat, for people who maintain language bindings or long running XML services, and for anyone who saw "expat" on a scanner report this week and needs to know whether it applies. It covers the exact mechanism, the merged fix line by line, which versions and distribution packages are affected, how to check your code and your binaries for the risky call, a benign reproduction we ran on a real system library, and what to change today.

The short answer

CVE-2026-113351 affects libexpat 2.7.2 through 2.9.0. It only matters for code that reuses one parser across documents with XML_ParserReset(). After a single large document pushes the parser past its allocation tracking threshold (64 MiB by default), the reset parser refuses every later document with XML_ERROR_NO_MEMORY until it is freed. A fresh parser is not affected. The fix, pull request #1431, was merged into the libexpat main branch on 23 October 2026 at 13:42 UTC. As of 24 October, 2.9.0 is still the latest release, so there is no fixed version to upgrade to yet. Replace reset with free and create wherever untrusted input reaches a reused parser, and update when the next expat release or your distribution's backport arrives.

Two facts trip people up:

  • A version number below 2.7.2 does not prove you are safe. Debian's tracker lists its bookworm package, which is based on expat 2.5.0, as vulnerable, because distributions backported the 2.7.2 allocation tracker into older versions.
  • The common high level bindings we checked do not call XML_ParserReset() at all. Python's pyexpat, PHP's ext/xml, Perl's XML::Parser, Git, D-Bus, fontconfig and APR all create a new parser per document in their current sources. If your exposure runs only through those, this bug is not your path, though the library still needs its other updates.

What changed since 20 October

Four things have moved since this CVE was published on 20 October.

  1. The fix is merged. Pull request #1431, titled "[CVE-2026-113351] Fix parser reset", was merged by the expat maintainer on 23 October 2026 at 13:42:36 UTC. The merge commit is d8f31e647a4d1eda088e89354f4769b0dad303d3. The pull request carries six commits, 119 lines added and 15 removed, across expat/lib/xmlparse.c and two test files.
  2. There is still no release. The GitHub releases page lists 2.9.0, published on 5 October 2026 at 16:39 UTC, as the latest. The Changes file on the main branch has no new release section yet. Anyone who needs the fix today has to build from the main branch or carry the patch.
  3. Distribution trackers have picked it up. Debian's security tracker lists every expat package from bookworm to sid as vulnerable, with the source package "(unfixed)" in unstable and Debian bug 1151840 open. Debian marks the trixie entry "no-dsa (Minor issue)" and bookworm "postponed (Minor issue; can be fixed in next update)". Ubuntu's CVE API returned "does not exist" for this ID when we checked on 24 October.
  4. Exploitation status is on the record. CISA's ADP enrichment, added to the CVE record on 20 October at 15:10 UTC, scores it with SSVC as Exploitation "none", Automatable "no", Technical Impact "partial". The CVE is not in CISA's Known Exploited Vulnerabilities catalog (catalog version 2026.10.23, 1,756 entries, checked 24 October). NVD lists it as "Awaiting Analysis" with no NVD score of its own.

One thing has not changed: the reproduction. We re-ran our benign test on 24 October on the same Mac, now on macOS 27.0.1, and the system library, which reports expat_2.7.4, still fails exactly as the report describes.

Timeline

Times are UTC. Where a source gives only a date, the row says so.

When (UTC)EventSource
16 Sep 2025Expat 2.7.2 ships allocation tracking to fix CVE-2025-59375: 64 MiB activation threshold, maximum amplification factor 100.0expat Changes, Release 2.7.2
24 Sep 2025Expat 2.7.3 fixes the alignment of internal allocations, a follow up to the CVE-2025-59375 fixexpat Changes, Release 2.7.3
31 Jan 2026Expat 2.7.4 released (the version macOS 27 reports for its system library)expat Changes, Release 2.7.4
5 Oct 2026, 16:39Expat 2.9.0 released, the latest release as of 24 OctoberGitHub releases
15 Oct 2026 (date only)Reporter tests main branch commit 628f49ba13f7 and reproduces the bug; reports it privately under expat's SECURITY.mdReport quoted in PR #1431
17 Oct 2026, 18:43 to 18:59First three fix commits authored, including "Offset allocation tracking at XML_ParserReset time"PR #1431 commits
19 Oct 2026, 15:55 to 17:17Test commits and "Stop XML_ParserReset from resetting accounting settings" authoredPR #1431 commits
19 Oct 2026, 18:07:25PR #1431 opened publicly; the CVE record uses this as its public datePR #1431, CVE record datePublic
20 Oct 2026, 01:07:45CVE-2026-113351 reservedCVE record
20 Oct 2026, 10:41:37CVE-2026-113351 publishedCVE record, OSV
20 Oct 2026, 11:17:19NVD receives the recordNVD API
20 Oct 2026, 15:10:35CISA ADP adds SSVC: Exploitation none, Automatable no, Technical Impact partialCVE record ADP container
20 Oct 2026, 16:06:48CVE record last updatedCVE record
23 Oct 2026, 13:42:36PR #1431 merged into the main branch, merge commit d8f31e647a4dGitHub PR and commit history
23 Oct 2026, 19:30KEV catalog 2026.10.23 released; CVE-2026-113351 not listedCISA KEV JSON feed
24 Oct 2026No expat release after 2.9.0; Debian lists all suites vulnerable; Ubuntu has no recordGitHub releases, Debian and Ubuntu trackers

The reporter offered to keep the issue private "for at least 90 days". The maintainer had a fix public within days instead, which is why there is a public patch but no release yet.

How it works, in plain language

Expat counts two things while it parses a document.

The first is how many bytes of XML you have fed it. Expat calls these direct bytes. The second is how many bytes of heap memory it has allocated on your behalf. Before September 2025, according to the expat changelog, a document of about 250 KiB could make expat allocate about 800 MiB, an amplification of roughly 3,300 times. That was CVE-2025-59375. The fix in 2.7.2 added an allocation tracker: once a parser has allocated more than an activation threshold, 64 MiB by default, expat refuses any further allocation that would push the ratio of allocated memory to direct input bytes above a maximum factor, 100.0 by default. A document that tries to blow up memory gets rejected with an out of memory error instead of taking the process down.

XML_ParserReset() exists so a program can reuse one parser object for many documents without paying for a full teardown and rebuild each time. It clears handlers and state, and the parser is meant to behave as if new.

The bug is that reset cleared one counter and not the other. It set the direct byte count back to zero. It did not adjust the tracker's record of memory allocated, and some of that memory legitimately survives a reset, because the parser keeps buffers it already owns. So after a large document, the reset parser starts its next document believing it has already allocated more than 64 MiB while reading almost nothing. The ratio of a large number divided by a tiny one is enormous, the tracker is already past its activation threshold, and so every allocation the next document needs is refused. The parser reports out of memory on a document of a few bytes, and keeps doing so after every further reset, until the program frees it.

Nothing is corrupted. The report says AddressSanitizer and UndefinedBehaviorSanitizer were clean. The protection is doing exactly what it was built to do, on numbers that are wrong.

Technical details

The CVE record

FieldValue
CVE IDCVE-2026-113351
Titlelibexpat 2.7.2 through 2.9.0 Denial of Service via XML_ParserReset
StatePUBLISHED, record data version 5.2
Reserved2026-10-20T01:07:45.687Z
Published2026-10-20T10:41:37.468Z
Public date2026-10-19T18:07:25.000Z
Last updated2026-10-20T16:06:48.218Z
WeaknessCWE-909, Missing Initialization of Resource
Package URLpkg:github/libexpat/libexpat
CPEcpe:2.3:a:libexpat_project:libexpat:*:*:*:*:*:*:*:*, from 2.7.2 including, to 2.9.0 including
Affected rangeversion 2.7.2, lessThanOrEqual 2.9.0, semver; default status unaffected
CVSS 4.08.2 High, CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
CVSS 3.15.9 Medium, CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H
SSVC (CISA ADP)Exploitation none, Automatable no, Technical Impact partial
Finder creditPu Cheng
KEVNot listed (catalog 2026.10.23)
NVD statusAwaiting Analysis, published 2026-10-20T11:17:19

The scores disagree, and the reporter's is lower

There are three different readings of severity in the public material, and it is worth knowing why.

The CVE record's CVSS 4.0 score is 8.2 High. CVSS 4.0 has an "attack requirements" metric, set here to Present, which captures that the target must reuse parsers with reset. With network reach, low complexity and high availability impact, the 4.0 formula lands in High.

The same record's CVSS 3.1 score is 5.9 Medium, with attack complexity High. CVSS 3.1 has no separate attack requirements metric, so the reuse precondition is expressed as higher complexity.

The reporter's own estimate, in the report quoted in the pull request, was CVSS 3.1 5.3, AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L, with the note that AC:H at 3.7 is "also defensible". The difference from the record is the availability impact: the reporter rated it Low, the record rates it High. Debian's tracker calls it a "Minor issue".

For triage, the practical reading is this: if your service does not call XML_ParserReset() on parsers that see untrusted input, the score does not apply to you at all. If it does, the impact is a full stop of XML handling on that parser instance until it is recreated, which for a service with one long lived parser per worker can mean every request fails.

The mechanism in the source

The report, quoted in full in pull request #1431, reads the main branch source at commit 628f49ba13f7. The relevant structures are:

  • m_accounting, of type ACCOUNTING, which holds countBytesDirect, countBytesIndirect and the billion laughs protection settings.
  • m_alloc_tracker, of type MALLOC_TRACKER, which holds bytesAllocated, peakBytesAllocated, the debug level, maximumAmplificationFactor and activationThresholdBytes.

XML_ParserReset() calls the internal parserInit(). Before the fix, parserInit() ran a memset over the whole of m_accounting, so countBytesDirect started again from zero. It did not touch m_alloc_tracker, so bytesAllocated still counted memory the parser kept across the reset.

The allocation check lives in expat_heap_increase_tolerable(). It computes a new total as bytesAllocated plus the requested increase. If that total is at or above activationThresholdBytes, it computes the amplification as allocated bytes divided by direct bytes and refuses the allocation if the result exceeds maximumAmplificationFactor. After a reset that followed a large document, the total starts above the threshold and the divisor is close to zero, so every request fails.

With EXPAT_MALLOC_DEBUG=1, expat logs each refused allocation. The report gives this line from a static build of the main branch:

expat: Allocations(...): Direct 18, allocated + 104 to 134224020
(134224020 peak), amplification 7456890.00 (xmlparse.c:3754)

That is 18 direct bytes against 134,224,020 allocated bytes, an amplification of about 7.46 million, against a limit of 100. When we ran our check on macOS 27.0.1's system library with the same variable set, it logged:

expat: Allocations(0x7acb0d4008): Direct          4, allocated +        48 to  134223940 ( 134223940 peak), amplification 33555984.00 (xmlparse.c:7914)

Four direct bytes, because our second document is the four byte <r/>, and an amplification of about 33.6 million. The source line in the message differs because Apple's build is 2.7.4 and the reporter's was a newer main branch.

A second issue in the same report: settings across reset

The report raised a related inconsistency. Settings made with XML_SetAllocTrackerActivationThreshold() and XML_SetAllocTrackerMaximumAmplification() survived a reset, because they live in the tracker that reset did not touch. Settings made with the billion laughs functions, XML_SetBillionLaughsAttackProtectionActivationThreshold() and XML_SetBillionLaughsAttackProtectionMaximumAmplification(), were reset to their defaults, because they live in m_accounting, which reset wiped. The reporter's second test case showed a parser configured with a strict billion laughs limit rejecting a small entity document before reset and accepting it after.

The expat reference documentation says reset re-initializes the parser's external state except for the namespace settings, which arguably supports either reading. The merged fix chose consistency: both sets of limits now survive a reset. That is a behavior change you should know about before you pick up the next release, covered in the fix section below.

The merged fix, line by line

Pull request #1431 contains six commits:

CommitAuthored (UTC)Message
16b6b2d583f817 Oct 2026, 18:43lib: Extract variable bytesAllocated
a6cf8a6944b617 Oct 2026, 18:51lib: Offset allocation tracking at XML_ParserReset time
ba431e267e7517 Oct 2026, 18:59lib: Document the idea behind the parser->m_protocolEncodingName check
c400537c2df319 Oct 2026, 15:55tests/alloc_tests.c: Cover XML_ParserReset alloc tracker neutrality
f74501d61e0919 Oct 2026, 16:35tests/acc_tests.c: Cover XML_ParserReset accounting neutrality
8b722a1453b819 Oct 2026, 17:17lib: Stop XML_ParserReset from resetting accounting settings

The library changes in expat/lib/xmlparse.c do four things.

  1. A new field records the baseline. MALLOC_TRACKER gains size_t bytesAllocatedWhenLastReset.
  2. Reset sets the baseline. At the end of XML_ParserReset(), inside #if XML_GE == 1, the parser copies the current bytesAllocated into bytesAllocatedWhenLastReset.
  3. The check subtracts the baseline. expat_heap_increase_tolerable() now computes a local bytesAllocated as the tracker's total minus the baseline, clamped at zero if the baseline is larger, and uses that local value both for the integer overflow guard and for the new total. Memory the previous document left behind no longer counts against the next one.
  4. Reset keeps the limits. parserInit() gains an isReset argument. It still zeroes countBytesDirect and countBytesIndirect, and the entity counters countEverOpened, currentDepth and maximumDepthSeen. But when called from reset, it no longer overwrites the billion laughs maximumAmplificationFactor and activationThresholdBytes with their defaults, or the debug levels.

The approach answers a point the reporter made in the original message: simply zeroing bytesAllocated in parserInit() would be wrong, because memory kept across the reset is still allocated and is later freed through the tracker. Zeroing it would make the counter go negative in effect, or underflow, when that memory is released. Recording a baseline and subtracting it keeps the accounting correct for both the old memory and the new.

Two tests were added. test_alloc_tracker_reset_neutrality builds a document at 1.1 times the default activation threshold, parses it, resets the parser and asserts that <hello/> then parses. test_amplification_reset_neutrality sets a strict billion laughs limit with the new Properties API from 2.9.0 (XML_SetPropertyDouble and XML_SetPropertyUInt64), confirms a small entity document is rejected with XML_ERROR_AMPLIFICATION_LIMIT_BREACH, resets, and asserts it is still rejected, which proves the limit survived.

Where reset is and is not called

Because the bug only bites through reset, the most useful question is who calls it. We fetched the current sources of widely used expat consumers on 24 October and searched them for XML_ParserReset:

Project and fileCalls XML_ParserResetCalls XML_ParserCreate
CPython Modules/pyexpat.c (main)NoYes
PHP ext/xml/xml.c (master)NoYes
Perl XML::Parser Expat/Expat.xs (master)NoYes
Git http-push.c (master)NoYes
D-Bus bus/config-loader-expat.c (main)NoYes
fontconfig src/fcxml.c (main)NoYes
APR xml/apr_xml.c (trunk) and APR-util 1.7.xNoNot in this file

We also confirmed at runtime that a Python pyexpat parser object has no Reset method. CPython's main branch does expose the allocation tracker settings, as SetAllocTrackerActivationThreshold and SetAllocTrackerMaximumAmplification, alongside the billion laughs setters, so Python code can tune the protections, but it cannot reach the reset path.

This is not a survey of every expat user. The places to look are custom C and C++ services that parse many documents per connection, embedded firmware, and in-house bindings.

Affected and fixed versions

Upstream expat

VersionStatus for CVE-2026-113351Notes
Before 2.7.2Not affected upstreamNo allocation tracker exists, but exposed to CVE-2025-59375 and later fixes. Do not downgrade.
2.7.2 (16 Sep 2025)AffectedTracker introduced
2.7.3, 2.7.4Affected
2.8.0 to 2.8.5Affected
2.9.0 (5 Oct 2026)AffectedLatest release on 24 Oct 2026
Main branch after d8f31e647a4d (23 Oct 2026)FixedNot a release
Next releaseExpected to carry the fixNo date announced

Distribution packages

PlatformPackage versionStatusSource
Debian 12 bookworm2.5.0-1+deb12u2Vulnerable; postponed, "Minor issue"Debian security tracker
Debian 12 bookworm security2.5.0-1+deb12u5VulnerableDebian security tracker
Debian 13 trixie2.8.3-1~deb13u1Vulnerable; no-dsa, "Minor issue"Debian security tracker
Debian 13 trixie security2.9.0-1~deb13u1VulnerableDebian security tracker
Debian forky and sid2.9.0-1Vulnerable; unfixed in unstable; bug 1151840Debian security tracker
UbuntuNot listedNo CVE record on 24 OctUbuntu CVE API
macOS 27.0.1 system libraryreports expat_2.7.4Affected in our testOur reproduction, 24 Oct

The bookworm rows are the ones to read twice. Upstream says versions before 2.7.2 are unaffected, and bookworm's package is based on 2.5.0, yet Debian lists it as vulnerable. The reason is backports. Debian's tracker shows that it fixed CVE-2025-59375 in bookworm with 2.5.0-1+deb12u5, through DLA-4849-1, so that package carries the 2.7.2 allocation tracker inside a 2.5.0 version number, and with it this reset behavior. When the 2.7.2 fix shipped, the expat changelog told distributors who backport it to copy almost the whole pull request, which is what puts the tracker, and this bug, into older versions. Debian's tracker also lists the older bookworm point release package, 2.5.0-1+deb12u2, as vulnerable; that package predates the backport, and either way the path forward is the security update and then the future fix. The lesson generalizes: for distribution packages, trust the distribution's tracker over the upstream version range, in both directions.

Who is affected

  • Affected: C and C++ programs, and bindings for other languages, that call XML_ParserReset() to reuse a parser across documents, built against expat 2.7.2 or later or against a distribution package with the tracker backported, and that can receive large documents from sources you do not control. A long running service that resets one parser per connection or per request is the typical case. One document just over 64 MiB of allocations is enough.
  • Not affected by this bug: code that creates a new parser for each document. That includes Python's xml.parsers.expat, xml.etree.ElementTree, xml.dom.minidom and xml.sax on top of pyexpat, and the other projects in the table above, in their current sources.
  • Not affected: external entity parsers created with XML_ExternalEntityParserCreate(), because the documentation says reset may not be used on them and returns XML_FALSE.

Reproduce it safely

This script calls libexpat directly through Python's ctypes. It parses a 70 MiB document of a single attribute, resets the parser, parses a tiny document twice, then parses the tiny document on a fresh parser. Everything happens in your own process on a benign, well formed document. There is no network access and nothing malicious in the input. It needs only the Python standard library and a few hundred megabytes of free memory.

#!/usr/bin/env python3
"""Check whether a libexpat build is affected by CVE-2026-113351.

Parses one large, well-formed document with a parser, resets it with
XML_ParserReset, then parses a tiny document on the same parser. On affected
builds (2.7.2 through 2.9.0, plus distribution packages with the 2.7.2
allocation tracker backported) the tiny document fails with "out of memory";
a fresh parser accepts it. Runs in this process only. Standard library only.

Usage: python3 -I expat_reset_check.py [path/to/libexpat]
"""
import ctypes
import ctypes.util
import sys

BIG_MIB = 70  # size of the attribute value in the first document


def load(path):
    lib = ctypes.CDLL(path or ctypes.util.find_library("expat"))
    lib.XML_ExpatVersion.restype = ctypes.c_char_p
    lib.XML_ParserCreate.restype = ctypes.c_void_p
    lib.XML_ParserCreate.argtypes = [ctypes.c_char_p]
    lib.XML_Parse.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_int]
    lib.XML_ParserReset.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
    lib.XML_ParserReset.restype = ctypes.c_int
    lib.XML_GetErrorCode.argtypes = [ctypes.c_void_p]
    lib.XML_ErrorString.restype = ctypes.c_char_p
    lib.XML_ParserFree.argtypes = [ctypes.c_void_p]
    return lib


def parse(lib, p, doc):
    ok = lib.XML_Parse(p, doc, len(doc), 1)
    return "ok" if ok else lib.XML_ErrorString(lib.XML_GetErrorCode(p)).decode()


def main(path=None):
    lib = load(path)
    print("libexpat", lib.XML_ExpatVersion().decode())
    big = b'<r a="' + b"x" * (BIG_MIB * 1024 * 1024) + b'"/>'
    small = b"<r/>"

    p = lib.XML_ParserCreate(None)
    print(f"  {BIG_MIB} MiB document, new parser :", parse(lib, p, big))
    lib.XML_ParserReset(p, None)
    print("  tiny document after reset    :", parse(lib, p, small))
    lib.XML_ParserReset(p, None)
    print("  tiny document after 2nd reset:", parse(lib, p, small))
    lib.XML_ParserFree(p)

    q = lib.XML_ParserCreate(None)
    print("  tiny document, fresh parser  :", parse(lib, q, small))
    lib.XML_ParserFree(q)


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else None)

On our test Mac, running macOS 27.0.1, on 24 October 2026:

$ python3 -I expat_reset_check.py /usr/lib/libexpat.1.dylib
libexpat expat_2.7.4
  70 MiB document, new parser : ok
  tiny document after reset    : out of memory
  tiny document after 2nd reset: out of memory
  tiny document, fresh parser  : ok

That matches the report exactly: the big document is accepted, every reset reuse fails, and a fresh parser works. Set EXPAT_MALLOC_DEBUG=1 in the environment to see the refused allocation lines shown in the technical details.

On Linux, pass the shared library path, for example /usr/lib/x86_64-linux-gnu/libexpat.so.1 on Debian and Ubuntu, /usr/lib64/libexpat.so.1 on Fedora and RHEL, or /usr/lib/libexpat.so.1 on Alpine. Inside a container image, run it in a throwaway container from that image, which tests the exact library your service loads. A fixed build prints "ok" on all four lines.

What to do now

Step 1: find every libexpat you ship

# Debian and Ubuntu, host or container
dpkg-query -W -f='${Package} ${Version}\n' 'libexpat1*' 'expat' 2>/dev/null
apt-cache policy libexpat1

# Fedora, RHEL, Rocky, Alma, Amazon Linux
rpm -q expat expat-devel

# Alpine
apk info -v 2>/dev/null | grep -E '^(expat|libexpat)'

# Any system with pkg-config and development headers
pkg-config --modversion expat

# macOS: the system library reports its own version
python3 -c "import ctypes;l=ctypes.CDLL('/usr/lib/libexpat.1.dylib');l.XML_ExpatVersion.restype=ctypes.c_char_p;print(l.XML_ExpatVersion().decode())"

# What Python is linked against, on any platform
python3 -c "import pyexpat; print(pyexpat.EXPAT_VERSION)"

Then look for copies the package manager does not know about. Expat is often vendored into C and C++ trees, and statically linked into binaries.

# Vendored expat sources: the version is in expat.h
grep -rn --include=expat.h -E 'define XML_(MAJOR|MINOR|MICRO)_VERSION' .

# Statically linked copies: the version string is embedded in the binary
strings -a ./path/to/binary | grep -E '^expat_[0-9]+\.[0-9]+\.[0-9]+$'

For container images, run the package queries inside a throwaway container from each image, or scan the image to produce an SBOM and search it for the expat or libexpat1 component.

Step 2: find who calls reset

Source first:

grep -rn "XML_ParserReset" \
  --include=*.c --include=*.cc --include=*.cpp --include=*.cxx \
  --include=*.h --include=*.hpp --include=*.m --include=*.mm .

Then binaries, which catches dependencies you link but did not write. A dynamically linked program that calls reset imports the symbol by name:

# Linux: undefined (imported) dynamic symbols
nm -D --undefined-only ./your-service | grep XML_ParserReset
objdump -T ./your-service | grep XML_ParserReset

# Every shared library the process loads, not just the main binary
for lib in $(ldd ./your-service | awk '/=>/ {print $3}'); do
  nm -D --undefined-only "$lib" 2>/dev/null | grep -q XML_ParserReset && echo "$lib"
done

# macOS
nm -u ./your-service | grep XML_ParserReset

A statically linked binary will not show an import. For those, the vendored source search in step 1 is the check, and the next one is to test the binary's behavior with a large document in a staging environment.

Step 3: replace reset with free and create

The safe change is small, because reset already clears every handler and user data pointer. Code that resets a parser has to set its handlers again anyway. Moving that setup into one function makes the swap a two line change:

#include <expat.h>

static XML_Parser make_parser(void *ctx) {
  XML_Parser p = XML_ParserCreate(NULL);
  if (p == NULL)
    return NULL;
  XML_SetUserData(p, ctx);
  XML_SetElementHandler(p, on_start, on_end);
  XML_SetCharacterDataHandler(p, on_text);
  /* Re-apply any non-default protection settings here too. */
  return p;
}

/* Before: reuse one parser per connection
 *   XML_ParserReset(conn->parser, NULL);
 *   set_handlers(conn->parser, conn);
 *
 * After: a fresh parser per document
 */
static int next_document(struct conn *conn) {
  XML_ParserFree(conn->parser);
  conn->parser = make_parser(conn);
  return conn->parser != NULL;
}

The cost is one parser setup per document, which is small next to parsing itself for most workloads.

If you set non-default tracker or billion laughs limits, re-apply them in the same function. Today, billion laughs settings are lost on reset; after the fix ships, they persist. A fresh parser with explicit settings behaves the same under both.

Step 4: decide whether to carry the patch

If you build expat yourself and cannot change the calling code quickly, you can apply the merged fix to 2.9.0. The pull request diff is available by appending .diff to the pull request URL, which is the method the expat changelog recommended to distributors for the 2.7.2 fix. Apply the whole pull request, including the tests, and run expat's test suite:

git clone https://github.com/libexpat/libexpat.git
cd libexpat
git checkout -b r290-reset-fix R_2_9_0
curl -sL https://github.com/libexpat/libexpat/pull/1431.diff -o pr1431.diff
git apply --check pr1431.diff && git apply pr1431.diff
cd expat
cmake -S . -B build -DEXPAT_BUILD_TESTS=ON
cmake --build build -j
ctest --test-dir build --output-on-failure

We checked this on 24 October: the pull request diff applies cleanly to the R_2_9_0 release tag. We then built two minimal copies of the library from the 2.9.0 sources, one unpatched and one with the diff applied, and ran the reproduction script against each:

== 2.9.0, unpatched
libexpat expat_2.9.0
  70 MiB document, new parser : ok
  tiny document after reset    : out of memory
  tiny document after 2nd reset: out of memory
  tiny document, fresh parser  : ok
== 2.9.0 with PR #1431 applied
libexpat expat_2.9.0
  70 MiB document, new parser : ok
  tiny document after reset    : ok
  tiny document after 2nd reset: ok
  tiny document, fresh parser  : ok

The patched build still reports expat_2.9.0, because the version string only changes with a release. Keep that in mind for inventory: a patched private build and an affected stock build look identical to a version scanner, so record the patch in your SBOM or build metadata.

Two cautions. Our minimal build was a functional check, not expat's full test suite, so run the suite in your own build before shipping. And the next official release may carry this fix together with other changes, so a local patch is a stopgap until that release or your distribution's update.

Step 5: watch for the release and the backports

  • Watch the libexpat releases page and the Changes file for a release section that lists CVE-2026-113351.
  • Watch the Debian tracker entry and bug 1151840 for a fixed version per suite. For other distributions, watch their own trackers: none had a fixed package on 24 October.
  • When the release lands, re-run the reproduction against the new library. All four lines should print "ok".

Step 6: do not raise the limits

It is tempting to make the error go away by raising the activation threshold or the amplification factor with XML_SetAllocTrackerActivationThreshold() or XML_SetAllocTrackerMaximumAmplification(). That trades a narrow denial of service through reset for a broader denial of service through memory amplification, the exact attack CVE-2025-59375 describes. The expat changelog asks anyone who needs higher limits for real, non-attack payloads to file a bug report instead.

Step 7: add an alert for the symptom

A parser hit by this bug fails documents that are small and well formed with error code XML_ERROR_NO_MEMORY, which XML_ErrorString() renders as "out of memory". In most services, genuine out of memory parse errors on small inputs are rare. Log the error code and input size on every parse failure, and alert when "out of memory" appears for documents under a few kilobytes. That turns a silent outage into a page.

Open questions

  • When is the next release? No date has been announced. The main branch shows active work after the merge, including commits on 21 and 22 October that precompute entity expanded sizes to detect amplification attacks earlier, so the next release may carry more than this fix.
  • Will distributions backport, and when? Debian has marked it minor and postponed for bookworm. Other distributions had not published records when we checked.
  • What else is pending? The maintainer's public issue #1160 lists unfixed non-public reports by date only. On 24 October several from September and October were still "yet to be validated", so plan for more expat releases, not one.
  • Who else calls reset? We checked a handful of major consumers. Custom servers and embedded code are where the answer lives, and only their owners can check.

The fair view

This is a narrow bug in a protection that worked. The allocation tracker added in 2.7.2 closed a real, easy memory amplification attack, and this flaw only appears on a code path many embedders never use. The report came privately with a clear reproduction, the maintainer had a fix public within days and merged it four days after that, and the merged fix also cleaned up an inconsistency in which settings survive a reset.

It is also a familiar shape of bug. Reset and reuse paths have to restore every piece of state the fresh path sets up, and they are tested far less often than the fresh path. Any security limit that keeps running totals deserves a test that runs one document, resets, and runs another. The expat fix added exactly those tests.

What would have helped

Practices any team can use

  • Know where expat lives. Most teams have expat in more places than they think: the OS package, a copy inside a Python or other runtime, a vendored copy in a C++ tree, and a statically linked copy inside a third party binary. An SBOM per image and per release build turns "do we use expat 2.7.2 or later" into a query instead of a week of grepping.
  • Record how a library is used, not just which version. Whether this CVE matters depends on a single function call. Code search across repositories for XML_ParserReset, plus the binary import check above in the build pipeline, gives a durable answer.
  • Prefer fresh state for untrusted input. Reusing parsers, decoders and decompressors across requests carries state from one client to the next. If you do reuse them, test the reset path: a large input, a reset, a small input.
  • Rebuild and redeploy on a schedule. When the fixed release or distribution package lands, the risk stays until every image that bundles it is rebuilt. A routine weekly rebuild means the fix reaches production without a special project.

Where Safeguard fits

Safeguard can help with the inventory and prioritization parts of this, and it cannot change how expat behaves.

  • Finding every copy. safeguard scan --dir . on a repository, safeguard scan --image <name> on a container image, or safeguard scan --sbom <file> on an existing SBOM produces a dependency inventory and vulnerability matches, written as JSON under ./output. In the platform, you can see which projects contain a given component and version, so "where is expat 2.7.2 through 2.9.0" is one search across the estate.
  • Matching against vulnerability data. Safeguard matches components against vulnerability databases, so this CVE shows up against the projects that carry an affected expat. For distribution packages, check the distribution's own fixed version as described above.
  • Ranking by reachability. Reachability analysis is designed to rank vulnerable code paths your application actually calls first. For a library bug that depends on one function, that is the question that matters, and it is why a reuse check belongs next to the version match.
  • Doing the code change with you. The Safeguard CLI's assistant can edit code and run commands with your approval. Asking it to find XML_ParserReset calls, refactor them to free and create, rebuild and run the tests is the kind of task it handles, with every command and edit approved by you under the /permissions mode you choose.

Where Safeguard does not help: it cannot ship an expat release, and for the system expat inside an operating system you do not build, the fix has to come from that distribution.

Common mistakes

  • Treating "below 2.7.2" as safe on a distribution package. Debian lists bookworm's 2.5.0 based package as vulnerable because of the backported tracker. Use the distribution's tracker.
  • Downgrading to dodge the bug. Going back before 2.7.2 brings back CVE-2025-59375 and every expat fix since. The workaround is in your code, not in the version.
  • Raising the allocation limits. That reopens the memory amplification attack the limits exist to stop.
  • Assuming Python, PHP or Perl services are affected. Their standard expat bindings, in the sources we checked, never call reset. Keep expat updated for its other fixes, but this CVE is not their path.
  • Assuming a request size cap makes you safe. The threshold counts heap allocations, which can exceed input size. Test with the reproduction.
  • Searching only your own source. A library you link may call reset. Check imported symbols in every shared object your service loads, and check vendored sources for statically linked copies.
  • Forgetting to re-apply settings after switching to free and create. Custom billion laughs or tracker limits must be set on each new parser.

FAQ

Is there a fixed version of libexpat for CVE-2026-113351?

Not yet as a release. The fix was merged into the main branch on 23 October 2026 in pull request #1431, merge commit d8f31e647a4d. As of 24 October, 2.9.0 is the latest release and is affected. Expect the fix in the next release.

Is this remote code execution or memory corruption?

No. It is availability only. The reporter states that AddressSanitizer and UndefinedBehaviorSanitizer were clean. The parser refuses allocations because its accounting is wrong; nothing is overwritten.

Is it being exploited?

There is no public report of exploitation. CISA's SSVC enrichment on the CVE record says Exploitation "none" and Automatable "no", and the CVE is not in the KEV catalog as of the 23 October catalog.

Why does the CVE have two very different scores?

The record scores 8.2 under CVSS 4.0, where the reuse precondition is an "attack requirement" and availability impact is High, and 5.9 under CVSS 3.1, where the precondition raises attack complexity. The reporter estimated 5.3 with Low availability impact. Debian calls it minor. Whether it matters to you depends on whether you reset parsers.

Is my Python application affected?

Not through this bug. pyexpat, which xml.etree, xml.sax and xml.dom.minidom use, does not call XML_ParserReset(), and a pyexpat parser has no reset method. Python still links expat, so keep it updated for its other fixes.

Does the fix change any behavior I rely on?

Possibly. After the fix, billion laughs protection settings survive XML_ParserReset(), where before they returned to defaults. If your code relied on reset restoring defaults, set them explicitly after reset or, better, use fresh parsers.

Has my distribution shipped a fix?

Not as of 24 October. Debian lists every suite as vulnerable and unfixed, and Ubuntu had no record for the CVE. Watch your distribution's tracker.

How big does the malicious document have to be?

Big enough to push the parser's heap allocations past the activation threshold, 64 MiB by default. The report used a 70 MiB attribute value. The document is ordinary well formed XML and is accepted; the damage is to the documents after it.

For earlier expat issues this year, see our posts on CVE-2026-76956, the getentropy hash flooding bug, CVE-2026-76957, the encoding callback use after free, and expat 2.9.0 and Debian's DSA-6566-1.

If you want to try the scan and fix flow on your own code, the Safeguard apps are at safeguard.sh/download and the CLI guide is at docs.safeguard.sh/docs/cli.

Fix it without waiting for upstream

For CVE-2026-113351 the upstream fix exists but no release carries it yet, which is exactly the second gap described here. The most direct fix for most teams is in their own code, free and create instead of reset, and the steps below cover the dependency side.

Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.

1. Autonomous remediation: pull requests, not tickets

When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation covers eight categories, including dependencies, licenses, security posture and findings. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.

2. Gold Open Source: the safe version in seconds, free

gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds. The safeguard-sh/gold-scan-action GitHub Action can fail a build on a known exploited vulnerability, on any known vulnerability, or never, so a version you have just removed cannot quietly come back in next week's merge.

3. Gold Registry: start from components that are already fixed

registry.safeguard.sh provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs, zero malware and a completed license check, with SLSA L3 or higher provenance, a Sigstore signature and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.

When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. This fork and patch flow is in early access today, so ask which packages it covers before you plan around it.

Your situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingThe Gold scan GitHub Action in CI, plus Safeguard policies and gates
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch (early access)
You want to start clean on new projectsPull hardened components and images from Gold Registry

One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.

See how Safeguard would have helped

These demos are recordings of real sessions on other fixes. None of them shows this CVE. Each one shows a flow you would use the same way here.

The CLI demo shows the assistant fixing a lodash CVE in a JavaScript repository: it reads the finding and the advisory, asks before each command, edits package.json, runs the install and the tests with your approval, and pushes nothing. For CVE-2026-113351 you would ask the same assistant to find every XML_ParserReset call in your C service, replace each with free and create through one helper, rebuild and run the test suite, approving each edit and command as it goes.

Click through it at your own pace, or open it full screen.

The local runner demo shows setting up the runner on your own machine, approving it with its device code, and scanning from the terminal. Here, that is how you would scan each of your container images and build outputs for expat 2.7.2 through 2.9.0, including copies hidden in base images, without sending source code anywhere first.

Click through it at your own pace, or open it full screen.

The desktop app demo shows a CLI session appearing in the conversation list, asking for work on your computer, picking the folder, and approving or denying each command and file edit until the upgrade is done and the tests pass. When the next expat release ships, the same flow covers bumping the vendored copy or the base image tag and re-running the reproduction script, with you approving each step.

Click through it at your own pace, or open it full screen.

Quick check

Your Python service parses untrusted XML with xml.etree.ElementTree on a system with expat 2.9.0. Is it affected by CVE-2026-113351?

Not by this bug. Python's pyexpat module does not expose or call XML_ParserReset, and ElementTree creates new parsers. Keep expat updated for its other fixes.

A Debian 12 server runs expat 2.5.0-1+deb12u5. Upstream says versions before 2.7.2 are unaffected. Are you safe?

No, not by version alone. Debian's tracker lists that package as vulnerable, because the 2.7.2 allocation tracker was backported into it. If your code on that server resets parsers, apply the free and create workaround until Debian ships a fix.

Your C service resets one expat parser per request, and the fix is merged upstream. What is the quickest safe change today?

Free and recreate the parser for each request instead of resetting it, re-applying handlers and any custom limits in one helper. The merge is not a release, so your library is still affected until you update to a release or distribution package that carries it.

Sources

Last reviewed: 24 October 2026. The fix is merged but unreleased; check the libexpat releases page and your distribution's tracker for the latest status.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.