Skip to content

inflate: add opt-in limit on Huffman table rebuilds (fixes #198)#199

Open
scadastrangelove wants to merge 1 commit into
Frommi:masterfrom
scadastrangelove:fix-init-tree-dos
Open

inflate: add opt-in limit on Huffman table rebuilds (fixes #198)#199
scadastrangelove wants to merge 1 commit into
Frommi:masterfrom
scadastrangelove:fix-init-tree-dos

Conversation

@scadastrangelove

@scadastrangelove scadastrangelove commented Jul 19, 2026

Copy link
Copy Markdown

Fixes #198.

Summary

Adds an opt-in max_huffman_table_rebuilds budget that bounds the number of times init_tree() (the O(1024+576) Huffman decode-table rebuild) can run during a single decompression call, closing the algorithmic-complexity DoS described in #198: a chain of minimal, degenerate DEFLATE blocks that each produce 0 bytes of output can force the expensive table rebuild arbitrarily many times, with the existing _with_limit output-size guard never engaging since nothing is ever written.

Design

  • No existing signatures change. The new counter lives on DecompressorOxide as opt-in state, defaulting to u32::MAX (unlimited) — fully backward compatible for flate2 and every other consumer.
  • Reachable from all three API layers:
    • Core: DecompressorOxide::set_max_huffman_table_rebuilds(&mut self, u32)
    • Streaming: InflateState::decompressor()
    • To-Vec: new decompress_to_vec_with_limits / decompress_to_vec_zlib_with_limits, bounding both output size and rebuild count alongside the existing _with_limit functions.
  • Counts table rebuilds (a static block costs 2, dynamic costs 3) rather than raw block count, since that's the literal, precise cost driver — documented so callers can translate to a "max blocks" mental model if preferred.
  • New additive TINFLStatus::HuffmanTableRebuildLimitExceeded variant (the enum is already #[non_exhaustive]) rather than reusing Failed, so callers can distinguish "hit your own budget" from "corrupt data."

Alternatives considered and rejected

  • Cap total block count / reject many-small-blocks structurally — risks rejecting legitimate, if unusual, streams.
  • Skip table rebuild when code lengths are unchanged from the previous block — legitimate dynamic-Huffman streams virtually always vary per block, so this buys little; and detecting "unchanged" itself requires O(n) comparison work, making this a larger, more invasive, correctness-risk-carrying change for comparatively little benefit. Preferred the smaller, safer opt-in-limit approach instead.

Testing

  • Full existing test suite: 53/53 passing before and after (29 unit + 2 flush + 20 test.rs + 2 doctest), across default features, --features block-boundary, and --features serde,std. cargo clippy --all-targets shows the same pre-existing warnings before/after, zero new ones.
  • 4 new regression tests in miniz_oxide/tests/test.rs:
    • Core API (DecompressorOxide) stops early once the rebuild budget is exceeded.
    • decompress_to_vec_with_limits rejects the attack payload.
    • The rebuild limit does not affect legitimate decompression (checked at compression levels 0/1/6/9).
    • An unbounded chain of empty blocks still decompresses successfully with no limit set (backward-compat sanity check).
  • Manual PoC verification (standalone harness, not part of the test suite): the 1,000,000-block/1.25 MB attack payload from Algorithmic-complexity DoS: chained degenerate blocks bypass decompress_to_vec_with_limit (init_tree cost decoupled from output size) #198 — 1.8-3.4s unbounded — now stops in ~300µs with max_huffman_table_rebuilds = 100. A 9 MB legitimate round-trip (levels 1/6/9) is byte-identical and timing-indistinguishable with a generous 200,000-rebuild budget engaged.

Open questions for maintainer judgment

  1. API naming/shape — I chose "table rebuilds" as the unit; "max blocks processed" is a simpler but slightly less precise alternative framing. Happy to rename if you have a preference.
  2. No default non-MAX value is set, deliberately — a "safe" value depends entirely on caller workload/hardware. Open to discussion if you think a conservative default makes sense here (though it would be a behavior change for existing callers).
  3. from_block_boundary_state() does not currently carry over a previously configured limit when resuming from a serialized BlockBoundaryState — noted in a doc comment, but flagging explicitly in case that gap matters for the block-boundary/streaming-resume use case.

Discovered by the rust-in-peace security pipeline.

🤖 Generated with Claude Code

…lexity DoS)

Every BTYPE=1/BTYPE=2 deflate block header forces a full O(FAST_LOOKUP_SIZE +
MAX_HUFF_TREE_SIZE) rebuild of the decoder's Huffman tables in init_tree(),
regardless of how many symbols the block actually encodes. Since DEFLATE
allows chaining an unbounded number of legal, minimal, near-empty blocks
(a static block containing only an end-of-block code is 10 bits), a
~1.25MB crafted input made of a million such blocks forces over a second
of CPU time while producing zero bytes of output. Because output never
grows, the existing output-size limit (decompress_to_vec_with_limit) never
triggers and provides no protection against this.

This is legal (if degenerate) DEFLATE, not malformed input, so it can't be
rejected outright without risking legitimate decompression. Add a second,
independent, opt-in limit instead:

- DecompressorOxide::set_max_huffman_table_rebuilds() bounds the number of
  table rebuilds a single stream is allowed to trigger before decompression
  aborts with the new TINFLStatus::HuffmanTableRebuildLimitExceeded status.
  Defaults to u32::MAX (unlimited), so existing callers are unaffected
  unless they opt in.
- decompress_to_vec_with_limits() / decompress_to_vec_zlib_with_limits()
  expose the same knob at the to-Vec convenience layer, alongside the
  existing *_with_limit() output-size-only variants.
- The budget is checked in init_tree() before doing any of the expensive
  table-clearing/construction work, so an exceeded budget is cheap to
  detect.

The counter lives on DecompressorOxide itself (reset per-stream in the
Start state, like the other transient decode fields) rather than as a new
decompress()/decompress_with_limit() parameter, so no existing function
signature changes and streaming users (via InflateState::decompressor())
can opt in too.

Adds regression tests reproducing the attack (a from-scratch DEFLATE bit
writer emitting N minimal static-Huffman blocks) at the core and to-Vec
API layers, confirming: unbounded decompression of such a stream remains
legal and unaffected (no existing behavior regressed), a configured limit
stops decompression after a tiny fraction of a large attack payload, and
the limit has no effect on legitimate round-trip decompression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Algorithmic-complexity DoS: chained degenerate blocks bypass decompress_to_vec_with_limit (init_tree cost decoupled from output size)

1 participant