Skip to content

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

Closed

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_seed β†’ MapValueDeserializer::deserialize_struct β†’ self.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.

Activity

  1. scadastrangelove commented on Jul 19, 2026

    @scadastrangelove
    ContributorAuthor

    Comment on #978 β€” the missing recursion-depth cap also needs to cover deserialize_seq and the enum path

    Ran a variant sweep for other recursion cycles in de/ that share this issue's "no depth cap" root cause
    (the Deserializer struct has no depth field; only event_buffer_size/limit, which bounds the
    overlapped-lists skip buffer, not call-stack depth). Two more cycles reproduce the same uncatchable
    stack overflow, aborting abort as the one already reported here, through different serde entry
    points β€” worth folding into whatever fix lands for this issue, since a cap added only to the
    deserialize_struct β†’ next_value_seed cycle this issue describes would not cover them.

    1. deserialize_seq cycle (a Vec<Self> field β€” the natural shape for a list of same-typed nested
    elements): deserialize_seq (de/map.rs:607) β†’ visit_seq(MapValueSeqAccess) β†’ next_element_seed
    (map.rs:954) β†’ ElementDeserializer::deserialize_struct (map.rs:1137) β†’
    visitor.visit_map(ElementMapAccess::new(..)) (map.rs:1146) β†’ back into next_value_seed β†’
    deserialize_seq. One frame per nesting level.

    #[derive(serde::Deserialize)]
    struct Node { #[serde(default, rename = "Node")] node: Vec<Node> }
    
    fn main() {
        let xml = "<Node>".repeat(10_000) + &"</Node>".repeat(10_000);
        let _: Result<Node, _> = quick_xml::de::from_str(&xml);
    }

    Depth 1,000 returns cleanly; depth 10,000 (~70 KB) β†’ thread 'main' has overflowed its stack / fatal runtime error: stack overflow, aborting.

    2. Enum newtype_variant/struct_variant/tuple_variant cycle: deserialize_enum
    (de/mod.rs:3295) β†’ EnumAccess::variant_seed (de/var.rs:39) β†’
    VariantAccess::newtype_variant_seed (de/var.rs:105, seed.deserialize(self.de)) β†’ re-enters
    deserialize_enum. deserialize_any (hence serde_json::Value-shaped targets) routes through the same
    enum/struct/seq machinery with no recursive user type required.

    #[derive(serde::Deserialize)]
    enum E { Leaf(bool), Wrap(Box<E>) }
    
    fn main() {
        let xml = "<Wrap>".repeat(10_000) + &"</Wrap>".repeat(10_000);
        let _: Result<E, _> = quick_xml::de::from_str(&xml);
    }

    Same abort as above.

    Both confirmed on master, release build. Whatever depth-tracking mechanism ends up fixing the struct/map
    cycle in this issue, it'll need to live somewhere shared (e.g. on the Deserializer itself, checked at
    the top of deserialize_struct, deserialize_seq, and deserialize_enum) so it catches all three
    re-entry points rather than just the one this issue was filed against.

    Found with the rust-in-peace security pipeline.

  2. dralley commented on Jul 29, 2026

    @dralley
    Collaborator

    I'm working on a replacement PR that avoids a new type

  3. added 3 commits that reference this issue on Jul 29, 2026
    1c2a689
    e0d6db2
    7fe06d3
  4. added a commit that references this issue on Jul 31, 2026
    caa734a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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