Defending Against Decompression Bombs in Dart
A decompression bomb is a small compressed input that expands to an enormous size when decompressed. A 10 MB file might decompress to 10 GB — or more. If your application accepts compressed input from untrusted sources (file uploads, API payloads, user-generated content), this is a real attack vector.
This article walks through what decompression bombs are, why Dart applications are vulnerable, and how to defend against them — with code examples from dart_lz4, a pure-Dart LZ4 implementation.
What is a Decompression Bomb?
CWE-400 defines the vulnerability: "The software does not properly control the allocation and maintenance of a limited resource, thereby enabling an actor to influence the amount of resources consumed."
In the context of compression, the resource is memory. LZ4 — like all LZ-family algorithms — uses back-references to encode repeated data. A single byte in the compressed stream can reference a previously decompressed region and copy it forward. This means a compact compressed input can produce output many orders of magnitude larger than itself.
Consider this: LZ4's maximum block size is 4 MB. A well-crafted frame can chain hundreds of these blocks. A 1 MB compressed file could legitimately decompress to 4 GB if the blocks are independent, or even more with linked blocks referencing prior history.
Why Dart Applications Are Vulnerable
Dart's memory model makes this particularly dangerous:
Heap allocation is automatic.
Uint8Listallocations grow the Dart heap. There's nommapwithMAP_ANONYMOUSto get OS-level overcommit protection — the Dart VM allocates real memory.Web targets are worse. On Web (JS/WASM), a 4 GB allocation will crash the browser tab. There's no graceful recovery.
Streaming amplifies the risk. If you're decoding a compressed stream chunk by chunk, you might not know the total decompressed size until you've already allocated it. Without a bound, an attacker can keep feeding compressed chunks that each expand to the maximum block size.
The
dart:convertpattern hides the danger. If you use aCodec<List<int>, List<int>>wrapper, the decode call looks innocent:
final codec = Lz4Codec();
final decoded = codec.decode(compressedBytes); // How big is decoded?
Without a limit, decoded could be gigabytes.
The Defense: Bounded Decompression
The fundamental defense is simple: always set a maximum output size when decompressing untrusted input.
Setting maxOutputBytes on Frame Decode
The sync frame decoder accepts an explicit limit:
import 'package:dart_lz4/dart_lz4.dart';
// Set a reasonable upper bound for your use case
final decoded = lz4FrameDecode(
untrustedFrame,
maxOutputBytes: 64 * 1024 * 1024, // 64 MiB max
);
If the decompressed output would exceed this limit, the decoder throws an Lz4Exception before the allocation happens. No partial output, no memory exhaustion — just a clean error you can handle.
Streaming Decoder: The Higher-Risk Path
Streaming decoders are more dangerous because the input arrives in chunks. Without a bound, an attacker can keep feeding compressed data indefinitely. The streaming decoder also accepts maxOutputBytes:
final decodedChunks = byteChunksStream.transform(
lz4FrameDecoder(maxOutputBytes: 128 * 1024 * 1024), // 128 MiB max
);
This was a real vulnerability in dart_lz4. Before v1.4.0, the streaming decoder had no default output limit — only the sync decoder did. If you used the streaming decoder without explicitly setting maxOutputBytes, you were unprotected. The v1.4.0 release closed this gap by applying a 256 MiB default to both decoders.
The dart:convert Codec
The Lz4Codec wrapper enforces the same 256 MiB default:
final codec = Lz4Codec(); // 256 MiB default
final decoded = codec.decode(untrustedInput);
// Or set your own limit:
final codec = Lz4Codec(maxOutputBytes: 32 * 1024 * 1024); // 32 MiB
This is important because the Codec pattern doesn't naturally expose safety parameters — you'd expect codec.decode(input) to "just work." The default limit ensures it does, safely.
What Should the Limit Be?
There's no universal answer. The limit should be based on what your application actually expects:
| Use case | Suggested limit |
|---|---|
| API payload (JSON) | 8–16 MiB |
| File upload (images) | 64–128 MiB |
| Log stream | 256 MiB |
| Trusted internal pipeline | Unlimited (pass maxOutputBytes: -1 or a very large value) |
The key principle: the limit should be the largest size your application would legitimately accept, not the largest size the format can produce.
Beyond Output Limits: Buffer Zeroization
There's a second, subtler vulnerability: CWE-226, "Sensitive Information in Resource Not Cleared Before Reuse."
When you reuse buffers across decompression operations (via a buffer pool for performance), the buffers may contain residual data from previous operations. If a subsequent decompression reads past the logical end of its output — due to a bug, a truncated block, or a malicious input — it can observe bytes from a prior, possibly sensitive, decompression.
dart_lz4 provides two buffer pool implementations:
// Standard pool — fast, but does NOT zero buffers on return
final pool = SimpleLz4BufferPool(
maxTotalBuffers: 64,
maxBuffersPerBucket: 8,
);
// Secure pool — zeroes buffers on return (CWE-226 mitigation)
final securePool = SecureLz4BufferPool();
SecureLz4BufferPool calls buffer.fillRange(0, buffer.length, 0) on every buffer return, ensuring no residual data persists. The performance cost is small — a single fillRange call per buffer — but the security guarantee is significant for multi-tenant or security-sensitive workloads.
Use SecureLz4BufferPool when:
- Processing data from different users/tenants through the same pipeline
- Handling sensitive payloads (session tokens, PII, decrypted data)
- Operating in environments where memory isolation matters
Use SimpleLz4BufferPool when:
- Single-tenant, high-throughput pipelines
- Non-sensitive data
- Buffers are guaranteed to be fully overwritten
Checksums Are Not Authentication
LZ4 frames support block checksums and content checksums (xxHash32). These detect accidental corruption — bit flips, truncated files, transmission errors. They do not provide cryptographic authentication.
If an attacker can modify the compressed input, they can produce a valid frame with different decompressed output that still passes the checksum. For authentication, use a MAC (e.g., HMAC-SHA256) or a digital signature over the compressed frame.
Summary
| Threat | Mitigation | CWE |
|---|---|---|
| Decompression bomb (memory exhaustion) |
maxOutputBytes on all decoders |
CWE-400 |
| Residual memory leakage (buffer reuse) |
SecureLz4BufferPool with zeroization |
CWE-226 |
| Corrupted input | Block/content checksums | — |
| Tampered input | MAC or signature (not checksum) | — |
The core principle: treat compressed input like any other untrusted input. Set bounds, validate output, and don't assume the decompressed size matches the compressed size. A 10 MB upload should never produce 10 GB of output.
dart_lz4 is a pure-Dart LZ4/LZ4HC implementation with bounds-safe decoding, buffer pool architecture, frame fuzzing in CI, and an OpenSSF Best Practices Gold badge. It works on all Dart platforms including Web and WASM.
Top comments (0)