Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/arsenic/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ use crate::error::Error;
/// Hard cap on a single block (FORMAT-SPEC §3: blockbits ≤ 24 → 16 MiB).
const MAX_BLOCK_BITS: u32 = 24;

/// Hard cap on total one-shot decode output. The block loop (`while end_flag
/// == 0`) is unbounded and the final-RLE layer can expand ~51× per block, so a
/// small crafted stream could otherwise drive `out` to unbounded size. Matches
/// the sibling sit13 decoder's `DEFAULT_OUTPUT_CAP`.
const DEFAULT_OUTPUT_CAP: usize = 256 * 1024 * 1024;

/// Outcome of a full-stream decode attempt.
pub(crate) enum DecodeOutcome {
/// The stream was decoded to completion (CRC verified).
Expand Down Expand Up @@ -299,6 +305,12 @@ pub(crate) fn decode_stream(data: &[u8]) -> Result<DecodeOutcome, Error> {
// The final-RLE layer emits one logical output byte per loop turn,
// consuming `numbytes` inverse-BWT bytes total.
while byte_count < numbytes as u64 || rle_repeat > 0 {
// Bound total output across all blocks. The final-RLE layer can
// expand a 16 MiB block ~51×, so the check must live inside the
// emit loop, not merely per-block.
if out.len() >= DEFAULT_OUTPUT_CAP {
return Err(Error::Corrupt);
}
if rle_repeat > 0 {
out.push(rle_last);
crc = crc32_update(crc, rle_last);
Expand Down
31 changes: 28 additions & 3 deletions src/bzip2/decoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,20 @@ impl Decoder {

// Now decode symbols 50 at a time, switching tables per group
// per selector. Stop when EOB (= alpha_size - 1) is seen.
//
// Anti-bomb bound: a single bzip2 block decodes to at most the
// declared block size = level * 100_000 bytes (level 1..=9). The
// RLE-2 stream we are reconstructing here (`mtf_indices`) is the
// pre-BWT/pre-MTF symbol stream and must not exceed that. We keep
// a tiny constant of slack (1024) aligned with the original
// per-run headroom, but the bound stays within a couple KB of the
// real block size — NOT a multiple of it. Without a *cumulative*
// cap a malicious stream can flush a fresh ~8 MB zero-run after
// every one of ~900_100 non-zero symbols and inflate this
// intermediate buffer to hundreds of GB before any output is
// produced (so LimitedDecoder, which only sees output bytes, can't
// stop it). Mirrors arsenic's `block.len() + count > block_size`.
let max_block_bytes: u64 = self.level as u64 * 100_000 + 1024;
let eob = (alpha_size - 1) as u16;
let mut mtf_indices: Vec<u8> = Vec::new();
// RLE-2 accumulator: each time we see RUNA/RUNB we extend the
Expand Down Expand Up @@ -332,9 +346,12 @@ impl Decoder {
let contrib = if s == 0 { 1 } else { 2 };
zero_run = zero_run.saturating_add(contrib * zero_weight);
zero_weight = zero_weight.saturating_mul(2);
if zero_run as usize > 900_000 * 9 + 1024 {
// Anti-bomb: a zero run that exceeds the maximum
// possible block content is malformed.
// Anti-bomb (cumulative): the already-materialised indices
// plus the in-flight zero-run must not exceed the declared
// block size. This catches both a single oversized run and
// the death-by-a-thousand-runs attack where each non-zero
// symbol flushes and resets a fresh run.
if mtf_indices.len() as u64 + zero_run as u64 > max_block_bytes {
return Err(Error::Corrupt);
}
} else {
Expand All @@ -348,10 +365,18 @@ impl Decoder {
if idx >= num_used {
return Err(Error::Corrupt);
}
// Anti-bomb (cumulative): bound the literal pushes too.
if mtf_indices.len() as u64 + 1 > max_block_bytes {
return Err(Error::Corrupt);
}
mtf_indices.push(idx as u8);
}
}
if zero_run > 0 {
// Anti-bomb (cumulative): final flush must also stay in bounds.
if mtf_indices.len() as u64 + zero_run as u64 > max_block_bytes {
return Err(Error::Corrupt);
}
mtf_indices.extend(core::iter::repeat_n(0u8, zero_run as usize));
}

Expand Down
15 changes: 15 additions & 0 deletions src/gzip/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,13 @@ impl RawDecoder for Decoder {
}
if self.aux_remaining == 0 {
self.phase = self.next_after(DecPhase::ExtraData);
// ExtraLen leaves `aux_idx == 2`; HeaderCrc reuses the
// same counter, so reset it on the transition into the
// CRC-skip phase or the loop there would consume 0 bytes
// and feed the FHCRC field into the deflate decoder.
if self.phase == DecPhase::HeaderCrc {
self.aux_idx = 0;
}
} else {
return Ok(RawProgress {
consumed,
Expand All @@ -301,6 +308,10 @@ impl RawDecoder for Decoder {
}
if found_nul {
self.phase = self.next_after(DecPhase::Name);
// See ExtraData arm: HeaderCrc shares `aux_idx`.
if self.phase == DecPhase::HeaderCrc {
self.aux_idx = 0;
}
} else {
return Ok(RawProgress {
consumed,
Expand All @@ -321,6 +332,10 @@ impl RawDecoder for Decoder {
}
if found_nul {
self.phase = self.next_after(DecPhase::Comment);
// See ExtraData arm: HeaderCrc shares `aux_idx`.
if self.phase == DecPhase::HeaderCrc {
self.aux_idx = 0;
}
} else {
return Ok(RawProgress {
consumed,
Expand Down
10 changes: 8 additions & 2 deletions src/lz5/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,14 +612,20 @@ impl RawDecoder for Decoder {
return Err(Error::Corrupt);
}
self.decoded.clear();
self.decoded.reserve(payload_len);
// `payload_len` is attacker-declared and validated only
// against max_block_raw, not bytes remaining, so reserve
// a bounded floor and let extend_from_slice grow as real
// bytes arrive (mirrors lz4's frame-decoder reserve cap).
self.decoded.reserve(payload_len.min(64 * 1024));
self.decoded_idx = 0;
self.phase = DecPhase::RawBlock {
remaining: payload_len,
};
} else {
self.block_buf.clear();
self.block_buf.reserve(payload_len);
// Bounded floor; the CompressedBlock phase grows
// `block_buf` incrementally via extend_from_slice.
self.block_buf.reserve(payload_len.min(64 * 1024));
self.phase = DecPhase::CompressedBlock {
block_len: payload_len,
gathered: 0,
Expand Down
7 changes: 6 additions & 1 deletion src/lzah/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,12 @@ fn preseed_window() -> [u8; WINDOW] {

/// Decode a raw method-5 payload of exactly `expected_len` bytes.
fn decode_payload(payload: &[u8], expected_len: usize) -> Result<Vec<u8>, Error> {
let mut out = Vec::with_capacity(expected_len);
// Cap the eager reservation: `expected_len` is an attacker-controlled
// out-of-band length, so a crafted entry could declare multiple GiB with a
// tiny payload. The `while out.len() < expected_len` loop below enforces the
// true bound, so capping the initial reservation changes only allocation
// behaviour, not correctness. Mirrors the LHA codecs (static_huff/lzhuf).
let mut out = Vec::with_capacity(expected_len.min(1 << 20));
if expected_len == 0 {
return Ok(out);
}
Expand Down
9 changes: 9 additions & 0 deletions src/lzx/decoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,15 @@ fn step_huff(ctx: &mut RunCtx) -> Result<bool, Error> {
if src != ctx.window_pos
&& src + chunk <= win_size
&& ctx.window_pos + chunk <= win_size
// Ensure the two sub-ranges are non-overlapping so
// the split_at_mut slices stay in bounds. In the
// wrap case (src > window_pos) this guarantees
// dst_start + chunk <= lo_start; without it the
// a[dst_start..dst_start + chunk] slice runs off the
// end of the lower half (len == src) and panics.
// When this fails we fall through to the correct
// byte-by-byte copy loop below.
&& chunk + dist <= win_size
{
let (lo_start, dst_start) = (src, ctx.window_pos);
let (lo, hi) = if lo_start < dst_start {
Expand Down
51 changes: 44 additions & 7 deletions src/rar5/decoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -765,14 +765,20 @@ fn decode_distance(bits: &mut BitBuf, dist_slot: u16, ldc: &Huffman) -> Result<u
if dist_slot as usize >= HUFF_DC {
return Err(Error::Corrupt);
}
let mut dist: u32;
// Accumulate in u64. For slot 63 the base is 0xC0000001 and the
// attacker-supplied high/low bits can push the running total past
// u32::MAX (e.g. 0xC0000001 + 0x3FFFFFF0 + 0xF == 0x1_0000_0000),
// which would overflow a u32 — a debug-build panic and a release-build
// wrap. Working in u64 keeps every intermediate exact; we bound-check
// before narrowing back to the u32 that `emit_match` consumes.
let mut dist: u64;
let dbits: u32;
if dist_slot < 4 {
dbits = 0;
dist = 1 + dist_slot as u32;
dist = 1 + dist_slot as u64;
} else {
dbits = (dist_slot as u32 / 2) - 1;
dist = 1 + ((2 | (dist_slot as u32 & 1)) << dbits);
dist = 1 + ((2 | (dist_slot as u64 & 1)) << dbits);
}
if dbits > 0 {
if dbits >= 4 {
Expand All @@ -791,16 +797,23 @@ fn decode_distance(bits: &mut BitBuf, dist_slot: u16, ldc: &Huffman) -> Result<u
high = (high << chunk) | part;
remaining -= chunk;
}
dist += high << 4;
dist += (high as u64) << 4;
}
let low = ldc.decode(bits)? as u32;
let low = ldc.decode(bits)? as u64;
dist += low;
} else {
let extra = bits.read(dbits)?;
let extra = bits.read(dbits)? as u64;
dist += extra;
}
}
Ok(dist)
// A distance above u32::MAX cannot be a valid back-reference (the window
// is at most 4 GiB and `emit_match` works in u32), so reject it here
// before the narrowing cast rather than letting it wrap. `emit_match`
// applies the precise `> window_size` bound on the in-range value.
if dist > u32::MAX as u64 {
return Err(Error::InvalidDistance);
}
Ok(dist as u32)
}

/// Read a filter descriptor immediately after the main code emits 256.
Expand Down Expand Up @@ -875,4 +888,28 @@ mod tests {
assert_eq!(adjust_length(3, 0x2001), 5);
assert_eq!(adjust_length(3, 0x4_0001), 6);
}

/// Regression: slot 63 with maximal attacker-supplied extra/low bits used
/// to overflow the u32 distance accumulator. base = 0xC0000001, the 26
/// high bits add 0x3FFFFFF0 (→ 0xFFFFFFF1), and the LDC low (15) would
/// push it to 0x1_0000_0000 — an `attempt to add with overflow` panic in
/// the dev profile (overflow-checks on) and a silent wrap in release.
/// The fix accumulates in u64 and rejects distances above u32::MAX with
/// `InvalidDistance` before narrowing. This test panics if unfixed.
#[test]
fn slot_63_max_bits_does_not_overflow() {
// Complete 16-symbol LDC code, every symbol length 4. Canonical
// assignment gives symbol 15 the all-ones code 0b1111, so an
// all-ones bitstream decodes the low nibble to 15 (the maximum).
let ldc = Huffman::from_lengths(&[4u8; HUFF_LDC]).unwrap();
let mut br = BitBuf::new();
// 26 high bits + 4 LDC bits = 30 bits; supply 8 bytes of 1s so the
// 16-bit peeks never run dry.
br.reset(&[0xFF; 8], 8);
// Must return an error rather than panicking or wrapping.
assert_eq!(
decode_distance(&mut br, 63, &ldc),
Err(Error::InvalidDistance)
);
}
}
15 changes: 14 additions & 1 deletion src/zstd/decoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,20 @@ impl Decoder {
let seq_data = &block[after_lit..];
let seqs = decode_sequences(seq_data, &mut self.seq_state)?;
// 3. LZ77 reconstruction. Append to history.
execute_sequences(&seqs, &lit.literals, &mut self.history)?;
//
// Per RFC 8478 §3.1.1.2 a single Compressed_Block decodes to at most
// Block_Maximum_Size = min(Window_Size, 128 KiB). Enforce that bound on
// the bytes this block appends so a malicious block (e.g. RLE_Mode FSE
// tables emitting huge match-lengths from cheap sequences) can't expand
// `history` to multiple GiB before any output is drained. When no
// Window_Descriptor was present (window_size == 0) fall back to the
// 128 KiB block cap.
let block_max = if self.window_size == 0 {
128 * 1024
} else {
core::cmp::min(self.window_size, 128 * 1024)
} as usize;
execute_sequences(&seqs, &lit.literals, &mut self.history, block_max)?;
// Return ownership of comp_buf for reuse.
self.comp_buf = block;
self.comp_buf.clear();
Expand Down
23 changes: 16 additions & 7 deletions src/zstd/literals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,17 @@ use alloc::vec::Vec;

use crate::error::Error;
use crate::zstd::bitreader::RevBitReader;
use crate::zstd::decoder::MAX_WINDOW_SIZE;
use crate::zstd::huffman::{HuffTable, decode_huffman_tree};

/// Per-block upper bound on a literals section's `Regenerated_Size`. Per RFC
/// 8478 §3.1.1.3.1.1 a block's literals decode to at most Block_Maximum_Size =
/// min(Window_Size, 128 KiB) bytes, which is itself capped at 128 KiB. We don't
/// thread the frame's window size into this module, so we use the unconditional
/// 128 KiB ceiling — far tighter than MAX_WINDOW_SIZE (128 MiB) and enough to
/// stop a few header bytes from eagerly allocating ~1 MiB (RLE) / large Huffman
/// outputs.
const MAX_BLOCK_REGEN_SIZE: u64 = 128 * 1024;

/// State carried across blocks: the most recently seen Huffman tree (used by
/// `Treeless_Literals_Block`).
#[derive(Default)]
Expand Down Expand Up @@ -86,9 +94,10 @@ fn decode_raw_or_rle(block: &[u8], is_rle: bool, sf: u8) -> Result<LiteralsResul

// Guard against decompression-bomb literal blocks: the 20-bit RLE
// `Regenerated_Size` can request up to ~1 MiB from a few input bytes, and
// we materialize it eagerly below. Reject sizes above the conventional
// window cap before allocating/resizing.
if regen_size as u64 > MAX_WINDOW_SIZE {
// we materialize it eagerly below. The per-block Regenerated_Size is
// spec-capped at Block_Maximum_Size (<= 128 KiB), so reject anything above
// that before allocating/resizing.
if regen_size as u64 > MAX_BLOCK_REGEN_SIZE {
return Err(Error::Corrupt);
}

Expand Down Expand Up @@ -176,9 +185,9 @@ fn decode_compressed_literals(
};

// Same decompression-bomb guard as the Raw/RLE path: reject a
// `Regenerated_Size` above the conventional window cap before we allocate
// an output buffer sized to it.
if regen_size as u64 > MAX_WINDOW_SIZE {
// `Regenerated_Size` above Block_Maximum_Size (<= 128 KiB) before we
// allocate an output buffer sized to it.
if regen_size as u64 > MAX_BLOCK_REGEN_SIZE {
return Err(Error::Corrupt);
}

Expand Down
3 changes: 2 additions & 1 deletion src/zstd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ pub mod _internal_test_api {
let seq_data = &body[lit.consumed..];
let seqs = super::sequences::decode_sequences(seq_data, &mut seq_state)?;
let mut out: Vec<u8> = Vec::new();
super::sequences::execute_sequences(&seqs, &lit.literals, &mut out)?;
// Block_Maximum_Size with an unknown window defaults to the 128 KiB cap.
super::sequences::execute_sequences(&seqs, &lit.literals, &mut out, 128 * 1024)?;
Ok(out)
}

Expand Down
35 changes: 33 additions & 2 deletions src/zstd/sequences.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,21 +413,46 @@ fn apply_offset(offset_value: u32, literal_length: u32, prev: &mut [u32; 3]) ->
///
/// `history` is the previously-decoded output (so back-references can read
/// from it); decoded bytes are appended to `history`.
///
/// `max_block_output` is the per-block decoded-output bound. Per RFC 8478
/// §3.1.1.2 a single Compressed_Block may decode to at most
/// `Block_Maximum_Size = min(Window_Size, 128 KiB)` bytes. Without this cap a
/// malicious block using RLE_Mode FSE tables (e.g. match-length RLE symbol 52,
/// `ml_base = 65539`, consuming no state bits) emits ~65 KiB per cheap
/// sequence, letting a ~128 KiB input block expand `history` to multiple GiB
/// before any output is drained — a decompression-bomb OOM that bypasses the
/// drained-bytes metering in [`crate::limit::LimitedDecoder`]. We track the
/// bytes this block appends (literals **and** match copies, plus the trailing
/// literals) and abort as soon as the running total would exceed the bound.
pub fn execute_sequences(
sequences: &[Sequence],
literals: &[u8],
history: &mut Vec<u8>,
max_block_output: usize,
) -> Result<(), Error> {
// Bytes appended to `history` by *this* block so far. `history` itself
// carries earlier blocks' output, so we meter against this running counter
// rather than `history.len()`.
let mut block_output = 0usize;
let mut lit_pos = 0usize;
for seq in sequences {
let ll = seq.literal_length as usize;
if lit_pos + ll > literals.len() {
return Err(Error::Corrupt);
}
let ml = seq.match_length as usize;
// Reject before allocating: a literal-run + match-length that would
// push this block past Block_Maximum_Size is a decompression bomb.
block_output = block_output
.checked_add(ll)
.and_then(|n| n.checked_add(ml))
.ok_or(Error::Corrupt)?;
if block_output > max_block_output {
return Err(Error::Corrupt);
}
history.extend_from_slice(&literals[lit_pos..lit_pos + ll]);
lit_pos += ll;
let offset = seq.offset as usize;
let ml = seq.match_length as usize;
if offset == 0 || offset > history.len() {
return Err(Error::Corrupt);
}
Expand All @@ -447,8 +472,14 @@ pub fn execute_sequences(
}
}
}
// Trailing literals: leftover bytes copied verbatim.
// Trailing literals: leftover bytes copied verbatim. They also count
// toward the per-block output bound.
if lit_pos < literals.len() {
let trailing = literals.len() - lit_pos;
let total = block_output.checked_add(trailing).ok_or(Error::Corrupt)?;
if total > max_block_output {
return Err(Error::Corrupt);
}
history.extend_from_slice(&literals[lit_pos..]);
}
Ok(())
Expand Down
Loading
Loading