ANT-2026-X3EB6DN3 · libexpat/libexpat
heap-buffer-overflow medium
CVE-2026-56132 GHSA-425r-vwq2-26qv
Severity Claude high · Security research firm high · Maintainer medium
Discovered by Claude Mythos Preview
Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Trail of Bits.
ANT-2026-X3EB6DN3: Heap-buffer-overflow in doProlog at xmlparse.c:5957 via deeply nested DTD with external entity interaction
The bug sits in libexpat's doProlog function in xmlparse.c, triggered when a crafted XML document combines an external entity containing a deeply nested ELEMENT content model (~32 levels) with a main DTD having even deeper nesting (~64+ levels). The external entity sub-parser reallocates m_groupConnector based on its own nesting level, but when control returns to the main parser with deeper nesting, the bounds check at line 5906 (m_prologState.level >= m_groupSize) fails to account for the cross-context buffer state. An attacker controls the XML document structure and nesting depths. The result is a 4-byte write of zero past the 264-byte heap buffer, potentially corrupting adjacent heap metadata or objects for code execution or denial of service.
Target
Project: expat
Location: xmlparse.c:5957, doProlog()
Technical Details
ASAN reports a WRITE of size 4 exactly at the boundary of the 264-byte heap region allocated via expat_realloc. The root cause is that parser->m_groupConnector[parser->m_prologState.level] = 0 at line 5957 writes past the buffer end because the size check at line 5906 doesn't correctly synchronize m_groupSize between the parent parser and the external entity sub-parser — the sub-parser's reallocation doesn't propagate properly when the main parser resumes with deeper nesting.
Crash trace (truncated — full trace in attached crash.log):
INFO: found LLVMFuzzerCustomMutator (0x5f8e8389b200). Disabling -len_control by default.
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 441415985
INFO: Loaded 1 modules (9447 inline 8-bit counters): 9447 [0x5f8e83c31538, 0x5f8e83c33a1f),
INFO: Loaded 1 PC tables (9447 PCs): 9447 [0x5f8e83c33a20,0x5f8e83c58890),
/out/xml_lpm_fuzzer: Running 1 inputs 1 time(s) each.
Running: /tmp/poc
EXIT_CODE:1
=== ASAN Report ===
=================================================================
==27==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6e7de3de1048 at pc 0x5f8e838c7592 bp 0x7ffe6d51ea30 sp 0x7ffe6d51ea28
WRITE of size 4 at 0x6e7de3de1048 thread T0
#0 0x5f8e838c7591 in doProlog /src/expat/expat/lib/xmlparse.c:5957:42
#1 0x5f8e838bc444 in prologProcessor /src/expat/expat/lib/xmlparse.c:5189:10
#2 0x5f8e838bc444 in prologInitProcessor /src/expat/expat/lib/xmlparse.c:4991:10
#3 0x5f8e838b9765 in callProcessor /src/expat/expat/lib/xmlparse.c:1293:11
#4 0x5f8e838b9046 in XML_ParseBuffer /src/expat/expat/lib/xmlparse.c:2494:25
#5 0x5f8e8389bed5 in Parse /src/expat/expat/fuzz/xml_lpm_fuzzer.cpp:117:28
#6 0x5f8e8389bed5 in TestOneProtoInput /src/expat/expat/fuzz/xml_lpm_fuzzer.cpp:437:14
#7 0x5f8e8389bed5 in LLVMFuzzerTestOneInput /src/expat/expat/fuzz/xml_lpm_fuzzer.cpp:414:1
[... 27 more frames — full trace in crash.log]
Reproduction
- Construct an external entity definition containing an ELEMENT content model with ~32 levels of nested parentheses
- Construct a main document DTD with ~64+ levels of nested parentheses that references the external entity via %e;
- Deliver the crafted XML to the target parser
- External entity sub-parser reallocates m_groupConnector to fit its nesting depth
- Control returns to main parser; deeper nesting bypasses the stale size check at line 5906
- Write at line 5957 overflows the 264-byte buffer by 4 bytes
[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]
Acknowledgement
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-X3EB6DN3.
Reference: ANT-2026-X3EB6DN3
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure
Triage and disclosure were performed by Trail of Bits.
- Verdict
- true positive
- Severity
- high
The change that resolved this finding.
diff --git a/expat/lib/xmlparse.c b/expat/lib/xmlparse.c
index 28d991b581..056c724305 100644
--- a/expat/lib/xmlparse.c
+++ b/expat/lib/xmlparse.c
@@ -424,6 +424,7 @@ typedef struct {
unsigned scaffCount;
int scaffLevel;
int *scaffIndex;
+ size_t scaffIndexSize;
} DTD;
enum EntityType {
@@ -5920,36 +5921,18 @@ doProlog(XML_Parser parser, const ENCODING *enc, const char *s, const char *end,
case XML_ROLE_GROUP_OPEN:
if (parser->m_prologState.level >= parser->m_groupSize) {
if (parser->m_groupSize) {
- {
- /* Detect and prevent integer overflow */
- if (parser->m_groupSize > SIZE_MAX / 2) {
- return XML_ERROR_NO_MEMORY;
- }
-
- char *const new_connector = REALLOC(
- parser, parser->m_groupConnector, parser->m_groupSize *= 2);
- if (new_connector == NULL) {
- parser->m_groupSize /= 2;
- return XML_ERROR_NO_MEMORY;
- }
- parser->m_groupConnector = new_connector;
+ /* Detect and prevent integer overflow */
+ if (parser->m_groupSize > SIZE_MAX / 2) {
+ return XML_ERROR_NO_MEMORY;
}
- if (dtd->scaffIndex) {
- /* Detect and prevent integer overflow. */
- if (parser->m_groupSize > SIZE_MAX / sizeof(int)) {
- parser->m_groupSize /= 2;
- return XML_ERROR_NO_MEMORY;
- }
-
- int *const new_scaff_index = REALLOC(
- parser, dtd->scaffIndex, parser->m_groupSize * sizeof(int));
- if (new_scaff_index == NULL) {
- parser->m_groupSize /= 2;
- return XML_ERROR_NO_MEMORY;
- }
- dtd->scaffIndex = new_scaff_index;
+ char *const new_connector = REALLOC(parser, parser->m_groupConnector,
+ parser->m_groupSize *= 2);
+ if (new_connector == NULL) {
+ parser->m_groupSize /= 2;
+ return XML_ERROR_NO_MEMORY;
}
+ parser->m_groupConnector = new_connector;
} else {
parser->m_groupConnector = MALLOC(parser, parser->m_groupSize = 32);
if (! parser->m_groupConnector) {
@@ -5964,6 +5947,21 @@ doProlog(XML_Parser parser, const ENCODING *enc, const char *s, const char *end,
if (myindex < 0)
return XML_ERROR_NO_MEMORY;
assert(dtd->scaffIndex != NULL);
+ if ((size_t)dtd->scaffLevel >= dtd->scaffIndexSize) {
+ /* Detect and prevent integer overflow */
+ if (dtd->scaffIndexSize > SIZE_MAX / 2 / sizeof(int)) {
+ return XML_ERROR_NO_MEMORY;
+ }
+ assert(dtd->scaffIndexSize > 0);
+ const size_t new_size = dtd->scaffIndexSize * 2;
+ int *const new_scaff_index
+ = REALLOC(parser, dtd->scaffIndex, new_size * sizeof(int));
+ if (new_scaff_index == NULL) {
+ return XML_ERROR_NO_MEMORY;
+ }
+ dtd->scaffIndex = new_scaff_index;
+ dtd->scaffIndexSize = new_size;
+ }
dtd->scaffIndex[dtd->scaffLevel] = myindex;
dtd->scaffLevel++;
dtd->scaffold[myindex].type = XML_CTYPE_SEQ;
@@ -7548,6 +7546,7 @@ dtdCreate(XML_Parser parser) {
p->in_eldecl = XML_FALSE;
p->scaffIndex = NULL;
+ p->scaffIndexSize = 0;
p->scaffold = NULL;
p->scaffLevel = 0;
p->scaffSize = 0;
@@ -7588,6 +7587,7 @@ dtdReset(DTD *p, XML_Parser parser) {
FREE(parser, p->scaffIndex);
p->scaffIndex = NULL;
+ p->scaffIndexSize = 0;
FREE(parser, p->scaffold);
p->scaffold = NULL;
@@ -7767,6 +7767,7 @@ dtdCopy(XML_Parser oldParser, DTD *newDtd, const DTD *oldDtd,
newDtd->scaffSize = oldDtd->scaffSize;
newDtd->scaffLevel = oldDtd->scaffLevel;
newDtd->scaffIndex = oldDtd->scaffIndex;
+ newDtd->scaffIndexSize = oldDtd->scaffIndexSize;
return 1;
} /* End dtdCopy */
@@ -8294,6 +8295,7 @@ nextScaffoldPart(XML_Parser parser) {
dtd->scaffIndex = MALLOC(parser, parser->m_groupSize * sizeof(int));
if (! dtd->scaffIndex)
return -1;
+ dtd->scaffIndexSize = parser->m_groupSize;
dtd->scaffIndex[0] = 0;
}
diff --git a/expat/tests/basic_tests.c b/expat/tests/basic_tests.c
index 03e6064f39..d4dd9adce1 100644
--- a/expat/tests/basic_tests.c
+++ b/expat/tests/basic_tests.c
@@ -4382,6 +4382,37 @@ START_TEST(test_skipped_external_entity) {
}
END_TEST
+START_TEST(test_scaff_index_shared_across_external_entity_parser) {
+ const char text[]
+ = "<!DOCTYPE doc [\n"
+ "<!ELEMENT a "
+ "((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((b))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))>\n"
+ "<!ENTITY % e SYSTEM 'ext'>\n"
+ "%e;\n"
+ "<!ELEMENT c "
+ "(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((d)))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))>\n"
+ "]>\n"
+ "<doc/>";
+ ExtOption options[]
+ = {{XCS("ext"),
+ "<!ELEMENT x "
+ "((((((((((((((((((((((((((((((((y))))))))))))))))))))))))))))))))>"},
+ {NULL, NULL}};
+
+ XML_Parser parser = XML_ParserCreate(NULL);
+ XML_SetParamEntityParsing(parser, XML_PARAM_ENTITY_PARSING_ALWAYS);
+ XML_SetUserData(parser, options);
+ XML_SetExternalEntityRefHandler(parser, external_entity_optioner);
+ XML_SetElementDeclHandler(parser, dummy_element_decl_handler);
+
+ if (_XML_Parse_SINGLE_BYTES(parser, text, (int)strlen(text), XML_TRUE)
+ == XML_STATUS_ERROR)
+ xml_failure(parser);
+
+ XML_ParserFree(parser);
+}
+END_TEST
+
/* Test a different form of unknown external entity */
START_TEST(test_skipped_null_loaded_ext_entity) {
const char *text = "<!DOCTYPE doc SYSTEM 'http://example.org/one.ent'>\n"
@@ -6749,6 +6780,8 @@ make_basic_test_case(Suite *s) {
tcase_add_test(tc_basic, test_trailing_cr_in_att_value);
tcase_add_test(tc_basic, test_standalone_internal_entity);
tcase_add_test(tc_basic, test_skipped_external_entity);
+ tcase_add_test__ifdef_xml_dtd(
+ tc_basic, test_scaff_index_shared_across_external_entity_parser);
tcase_add_test(tc_basic, test_skipped_null_loaded_ext_entity);
tcase_add_test(tc_basic, test_skipped_unloaded_ext_entity);
tcase_add_test__ifdef_xml_dtd(tc_basic, test_param_entity_with_trailing_cr);https://github.com/libexpat/libexpat/commit/db1a48091b40f6892e622a282eba20752e6860ad
https://github.com/libexpat/libexpat/pull/1272
Dates from discovery through public reveal.
- 2026-03-20 Reported to tracker
- 2026-05-21 Maintainer acknowledged
- 2026-05-28 Sent to maintainer
- 2026-06-19 Patch released
- 2026-08-17 Publicly revealed
SHA-3-512 hash:
bdf77e15fd29d60ffc0dcf00fd4d2b1f0bc1d862c694e8e5914a35f23e54ce99c7998642f61aa543090450bc1a91ce59ed8130f7b012838ec15bfec160129a02
Committed 2026-05-28 08:14 PT
Revealed 2026-08-17 10:47 PT
Verify (download preimage.json)
Show preimage JSON
{
"ant_id": "ANT-2026-X3EB6DN3",
"bug_class": "heap-buffer-overflow",
"claude_severity": "high",
"commit_sha": null,
"created_at": "2026-03-20T19:13:12+00:00",
"description": "The bug sits in libexpat's doProlog function in xmlparse.c, triggered when a crafted XML document combines an external entity containing a deeply nested ELEMENT content model (~32 levels) with a main DTD having even deeper nesting (~64+ levels). The external entity sub-parser reallocates m_groupConnector based on its own nesting level, but when control returns to the main parser with deeper nesting, the bounds check at line 5906 (m_prologState.level >= m_groupSize) fails to account for the cross-context buffer state. An attacker controls the XML document structure and nesting depths. The result is a 4-byte write of zero past the 264-byte heap buffer, potentially corrupting adjacent heap metadata or objects for code execution or denial of service.",
"discovered_at": null,
"location": "xmlparse.c:5957, doProlog()",
"poc_sha256": "351f79465c6d5b8827d4581c30d24cd52b65dcbcff71cbdb8559372a9ccd7c46",
"preimage_version": 1,
"project": "expat",
"reproduction": [
"Construct an external entity definition containing an ELEMENT content model with ~32 levels of nested parentheses",
"Construct a main document DTD with ~64+ levels of nested parentheses that references the external entity via %e;",
"Deliver the crafted XML to the target parser",
"External entity sub-parser reallocates m_groupConnector to fit its nesting depth",
"Control returns to main parser; deeper nesting bypasses the stale size check at line 5906",
"Write at line 5957 overflows the 264-byte buffer by 4 bytes"
],
"technical_details": "ASAN reports a WRITE of size 4 exactly at the boundary of the 264-byte heap region allocated via expat_realloc. The root cause is that parser->m_groupConnector[parser->m_prologState.level] = 0 at line 5957 writes past the buffer end because the size check at line 5906 doesn't correctly synchronize m_groupSize between the parent parser and the external entity sub-parser — the sub-parser's reallocation doesn't propagate properly when the main parser resumes with deeper nesting.",
"title": "Heap-buffer-overflow in doProlog at xmlparse.c:5957 via deeply nested DTD with external entity interaction",
"vendor_severity": "high"
}