Skip to content

NamespaceResolver::push — unbounded per-xmlns heap allocation inside NsReader, before the event is returned → OOM on untrusted XML #970

Description

@qifan-sailboat

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:H7.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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions