Description
XMLParser.feed(), flush(), close(), and _parse_whole() access one Expat parser and its handler, target, name-cache, and error state without a per-instance critical section. Concurrent calls can alter parser state while another thread is inside XML_Parse() or reading the resulting error fields. The observed path passes a null or stale Expat error string into strlen() through PyUnicode_FromFormat().
Observed Behavior
Eight threads fed complete self-closing child fragments to one parser after an opening root tag. Every possible serialized fragment order is valid XML. The free-threaded ASan build segfaulted in strlen() from expat_set_error(), reached through expat_parse() and XMLParser.feed(). The same executable parsed and closed successfully with the GIL enabled.
Affected Version
CPython 3.14.7 at commit 823f0323ee6ec1402088b73bce1a38473cac36dc, built with --disable-gil --with-address-sanitizer.
Reproduction
ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 PYTHON_GIL=0 python3.14 poc/reproduce.py --seconds 8 --feeders 8
Setting PYTHON_GIL=1 provides the GIL-enabled control.
PoC Source Code
poc/reproduce.py
#!/usr/bin/env python3
"""Concurrently feed serialization-independent fragments to one XMLParser."""
import argparse
import threading
import time
import xml.etree.ElementTree as ET
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--seconds", type=float, default=8.0)
parser.add_argument("--feeders", type=int, default=8)
args = parser.parse_args()
xml_parser = ET.XMLParser()
xml_parser.feed("<root>")
stop = threading.Event()
errors: list[str] = []
def feed(slot: int) -> None:
sequence = 0
try:
while not stop.is_set():
xml_parser.feed(f"<tag{slot}_{sequence}/>")
sequence += 1
except BaseException as exc:
errors.append(repr(exc))
stop.set()
threads = [
threading.Thread(target=feed, args=(slot,)) for slot in range(args.feeders)
]
for thread in threads:
thread.start()
time.sleep(args.seconds)
stop.set()
for thread in threads:
thread.join()
if not errors:
xml_parser.feed("</root>")
xml_parser.close()
print({"errors": errors})
if __name__ == "__main__":
main()
Description
XMLParser.feed(),flush(),close(), and_parse_whole()access one Expat parser and its handler, target, name-cache, and error state without a per-instance critical section. Concurrent calls can alter parser state while another thread is insideXML_Parse()or reading the resulting error fields. The observed path passes a null or stale Expat error string intostrlen()throughPyUnicode_FromFormat().Observed Behavior
Eight threads fed complete self-closing child fragments to one parser after an opening root tag. Every possible serialized fragment order is valid XML. The free-threaded ASan build segfaulted in
strlen()fromexpat_set_error(), reached throughexpat_parse()andXMLParser.feed(). The same executable parsed and closed successfully with the GIL enabled.Affected Version
CPython 3.14.7 at commit
823f0323ee6ec1402088b73bce1a38473cac36dc, built with--disable-gil --with-address-sanitizer.Reproduction
Setting
PYTHON_GIL=1provides the GIL-enabled control.PoC Source Code
poc/reproduce.py