Skip to content

serde Deserializer has no recursion-depth limit → stack-overflow DoS on deeply nested untrusted XML (distinct from #819) #978

Description

@scadastrangelove

Relationship to #819: #819 is a correctness bug — a tiny recursive-newtype-enum document overflows even at depth ~3 (the struct-variant $value form works fine at the same depth). This report is different and orthogonal: the working deserialization path (struct variants, or serde_json::Value via deserialize_any) has no recursion-depth cap, so an attacker-controlled deeply nested document overflows the native call stack — a DoS, not a type-shape bug. A fix for #819 (make newtype recursion terminate) would not address this; a depth cap would.

Mechanism

deserialize_struct (src/de/mod.rs) consumes a Start and calls visit_map(ElementMapAccess::new(..)) while its own frame stays live; a nested child re-enters deserialize_struct via ElementMapAccess::next_value_seedMapValueDeserializer::deserialize_structself.map.de.deserialize_struct. Each XML nesting level adds several native stack frames. The only bound in de/ is event_buffer_size (the overlapped-lists skip buffer) — it does not bound call-stack depth. No recursive user type is even required: deserialize_any routes self-describing targets (e.g. serde_json::Value) through the same path.

The crash is an uncatchable abort (not a Result::Err), so a service that deserializes untrusted XML with the serialize feature has a pre-auth crash it cannot handle. Worker threads with smaller stacks fail far earlier than the 8 MB main thread.

How to reproduce

cargo new repro && cd repro, add quick-xml = { version = "0.41", features = ["serialize"] } and serde = { version = "1", features = ["derive"] }, drop this into src/main.rs, then cargo run --release:

use serde::Deserialize;

#[derive(Deserialize)]
struct S {
    #[serde(default, rename = "$value")]
    v: Vec<S>, // the struct-variant "$value" shape that #819 says WORKS
}

fn main() {
    for depth in [3usize, 1000, 20_000, 60_000] {
        let xml = "<a>".repeat(depth) + &"</a>".repeat(depth);
        let _: Result<S, _> = quick_xml::de::from_str(&xml);
        eprintln!("depth={depth} ({} bytes) -> returned", xml.len());
    }
}

Observed (release, default 8 MB main-thread stack):

depth=3 (21 bytes) -> returned
depth=1000 (7000 bytes) -> returned
thread 'main' has overflowed its stack
fatal runtime error: stack overflow, aborting

The key point vs #819: the struct-variant / $value shape #819 calls working deserializes correctly at shallow depth (3, 1000) but still aborts at attacker depth (~20 000, 140 KB) — so this is depth-driven resource exhaustion on the normal path, independent of the newtype-variant correctness bug.

Suggested direction

A configurable recursion-depth limit in the Deserializer that returns a clean Error past the cap — mirroring serde_json's Deserializer::recursion_limit and quick-xml's own event_buffer_size DoS guard.

Found by the rust-in-peace security pipeline.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementserdeIssues related to mapping from Rust types to XML

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions