Skip to content

Commit b183e26

Browse files
committed
Implement safety around zip-bomb-style attacks in replay files.
It will still be up to the library user to decide what reasonable limits are for their usecase, although the defaults, while conservative, should catch the really malicious cases.
1 parent 2978b9c commit b183e26

File tree

4 files changed

+255
-16
lines changed

4 files changed

+255
-16
lines changed

.gitattributes

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
* text=auto eol=lf
2-
32
*.rep filter=lfs diff=lfs merge=lfs -text
3+
broodrep/testdata/** filter=lfs diff=lfs merge=lfs -text

broodrep/src/compression.rs

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
use std::{
2+
io::{Read, Take},
3+
time::{Duration, Instant},
4+
};
5+
6+
use thiserror::Error;
7+
8+
#[derive(Debug, Copy, Clone)]
9+
pub struct DecompressionConfig {
10+
/// Maximum bytes to decompress (default: 100MB)
11+
pub max_decompressed_size: u64,
12+
/// Maximum compression ratio allowed (default: 1000:1)
13+
pub max_compression_ratio: f64,
14+
/// Maximum time to spend decompressing (default: 30 seconds)
15+
pub max_decompression_time: Duration,
16+
}
17+
18+
impl Default for DecompressionConfig {
19+
fn default() -> Self {
20+
Self {
21+
max_decompressed_size: 100 * 1024 * 1024, // 100MB
22+
max_compression_ratio: 1000.0,
23+
max_decompression_time: Duration::from_secs(30),
24+
}
25+
}
26+
}
27+
28+
#[derive(Debug, Error)]
29+
pub enum DecompressionError {
30+
#[error("Decompressed size limit exceeded")]
31+
SizeLimitExceeded,
32+
#[error("Compression ratio too high")]
33+
CompressionRatioExceeded,
34+
#[error("Decompression timeout")]
35+
TimeoutExceeded,
36+
#[error("IO error: {0}")]
37+
Io(#[from] std::io::Error),
38+
}
39+
40+
/// A wrapper around decompression implementations that implement [Read], providing various
41+
/// mechanisms for limiting decompression to avoid things like zip bombs. For best results, a config
42+
/// should be used that takes into account the characteristics of the data being decompressed.
43+
pub struct SafeDecompressor<R: Read> {
44+
inner: Take<R>,
45+
max_decompressed_size: u64,
46+
max_ratio: f64,
47+
max_time: Duration,
48+
input_size: Option<u64>,
49+
50+
start_time: Option<Instant>,
51+
bytes_read: u64,
52+
}
53+
54+
impl<R: Read> SafeDecompressor<R> {
55+
/// Constructs a new SafeDecompressor wrapping the given [Read] implementation. `input_size` is
56+
/// the size of the compressed input data in bytes, if known. If not specified, compression
57+
/// ratio limits will not apply.
58+
pub fn new(reader: R, config: DecompressionConfig, input_size: Option<u64>) -> Self {
59+
Self {
60+
inner: reader.take(config.max_decompressed_size),
61+
max_decompressed_size: config.max_decompressed_size,
62+
max_ratio: config.max_compression_ratio,
63+
max_time: config.max_decompression_time,
64+
input_size,
65+
66+
start_time: None,
67+
bytes_read: 0,
68+
}
69+
}
70+
}
71+
72+
impl<R: Read> Read for SafeDecompressor<R> {
73+
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
74+
if self.start_time.is_none() {
75+
self.start_time = Some(Instant::now());
76+
}
77+
78+
if self.start_time.map(|t| t.elapsed()).unwrap_or_default() > self.max_time {
79+
return Err(std::io::Error::new(
80+
std::io::ErrorKind::TimedOut,
81+
DecompressionError::TimeoutExceeded,
82+
));
83+
}
84+
85+
let bytes_read = self.inner.read(buf)?;
86+
self.bytes_read = self.bytes_read.saturating_add(bytes_read as u64);
87+
88+
if bytes_read == 0 && self.bytes_read == self.max_decompressed_size {
89+
// EOF and we've reached the max the Take will allow, try to read 1 more byte to see if
90+
// there was more data
91+
self.inner.set_limit(1);
92+
let mut buf = [0; 1];
93+
if let Ok(1) = self.read(&mut buf) {
94+
return Err(std::io::Error::new(
95+
std::io::ErrorKind::InvalidData,
96+
DecompressionError::SizeLimitExceeded,
97+
));
98+
}
99+
}
100+
101+
if let Some(input_size) = self.input_size {
102+
let ratio = self.bytes_read as f64 / input_size as f64;
103+
if ratio > self.max_ratio {
104+
return Err(std::io::Error::new(
105+
std::io::ErrorKind::InvalidData,
106+
DecompressionError::CompressionRatioExceeded,
107+
));
108+
}
109+
}
110+
111+
Ok(bytes_read)
112+
}
113+
}
114+
115+
#[cfg(test)]
116+
mod tests {
117+
use std::io::Write as _;
118+
119+
use explode::ExplodeReader;
120+
use flate2::bufread::ZlibDecoder;
121+
122+
use super::*;
123+
124+
const IMPLODE_BOMB: &[u8] = include_bytes!("../testdata/all_zeroes_1MB.impode");
125+
126+
#[test]
127+
fn implode_bomb_size() {
128+
let config = DecompressionConfig {
129+
max_decompressed_size: 1000 * 1024, // slightly less than 1MB
130+
..Default::default()
131+
};
132+
let mut safe_reader = SafeDecompressor::new(
133+
ExplodeReader::new(IMPLODE_BOMB),
134+
config,
135+
Some(IMPLODE_BOMB.len() as u64),
136+
);
137+
let mut out = Vec::new();
138+
let result = safe_reader.read_to_end(&mut out);
139+
assert!(result.is_err());
140+
let err = result.unwrap_err();
141+
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
142+
let err = err.downcast::<DecompressionError>().unwrap();
143+
assert!(matches!(err, DecompressionError::SizeLimitExceeded));
144+
}
145+
146+
#[test]
147+
fn implode_bomb_ratio() {
148+
let config = DecompressionConfig {
149+
max_compression_ratio: 100.0,
150+
..Default::default()
151+
};
152+
let mut safe_reader = SafeDecompressor::new(
153+
ExplodeReader::new(IMPLODE_BOMB),
154+
config,
155+
Some(IMPLODE_BOMB.len() as u64),
156+
);
157+
let mut out = Vec::new();
158+
let result = safe_reader.read_to_end(&mut out);
159+
assert!(result.is_err());
160+
let err = result.unwrap_err();
161+
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
162+
let err = err.downcast::<DecompressionError>().unwrap();
163+
assert!(matches!(err, DecompressionError::CompressionRatioExceeded));
164+
}
165+
166+
fn create_zlib_bomb() -> Vec<u8> {
167+
let mut encoder =
168+
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
169+
let data = vec![0u8; 1024 * 1024];
170+
encoder.write_all(&data).unwrap();
171+
encoder.finish().unwrap()
172+
}
173+
174+
#[test]
175+
fn zlib_bomb_size() {
176+
let config = DecompressionConfig {
177+
max_decompressed_size: 1000 * 1024, // slightly less than 1MB
178+
..Default::default()
179+
};
180+
let data = create_zlib_bomb();
181+
let mut safe_reader =
182+
SafeDecompressor::new(ZlibDecoder::new(&data[..]), config, Some(data.len() as u64));
183+
let mut out = Vec::new();
184+
let result = safe_reader.read_to_end(&mut out);
185+
assert!(result.is_err());
186+
let err = result.unwrap_err();
187+
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
188+
let err = err.downcast::<DecompressionError>().unwrap();
189+
assert!(matches!(err, DecompressionError::SizeLimitExceeded));
190+
}
191+
192+
#[test]
193+
fn zlib_bomb_ratio() {
194+
let config = DecompressionConfig {
195+
max_compression_ratio: 1000.0,
196+
..Default::default()
197+
};
198+
let data = create_zlib_bomb();
199+
let mut safe_reader =
200+
SafeDecompressor::new(ZlibDecoder::new(&data[..]), config, Some(data.len() as u64));
201+
let mut out = Vec::new();
202+
let result = safe_reader.read_to_end(&mut out);
203+
assert!(result.is_err());
204+
let err = result.unwrap_err();
205+
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
206+
let err = err.downcast::<DecompressionError>().unwrap();
207+
assert!(matches!(err, DecompressionError::CompressionRatioExceeded));
208+
}
209+
}

broodrep/src/lib.rs

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,23 @@ use std::{
44
};
55

66
use byteorder::{LittleEndian as LE, ReadBytesExt as _};
7+
use explode::ExplodeReader;
78
use flate2::bufread::ZlibDecoder;
89
use thiserror::Error;
910

11+
use crate::compression::SafeDecompressor;
12+
pub use crate::compression::{DecompressionConfig, DecompressionError};
13+
14+
mod compression;
15+
1016
#[derive(Error, Debug)]
1117
pub enum BroodrepError {
1218
#[error(transparent)]
1319
IoError(#[from] std::io::Error),
1420
#[error("malformed header: {0}")]
1521
MalformedHeader(&'static str),
16-
#[error("problem decompressing legacy compressed data: {0}")]
17-
LegacyCompressionError(explode::Error),
22+
#[error("problem decompressing data: {0}")]
23+
Decompression(#[from] DecompressionError),
1824
}
1925

2026
pub struct Replay<R: Read + Seek> {
@@ -24,7 +30,21 @@ pub struct Replay<R: Read + Seek> {
2430
}
2531

2632
impl<R: Read + Seek> Replay<R> {
27-
pub fn new(mut reader: R) -> Result<Self, BroodrepError> {
33+
/// Creates a new Replay by parsing data from a [Read] implementation with default settings for
34+
/// reading.
35+
pub fn new(reader: R) -> Result<Self, BroodrepError> {
36+
Self::new_with_decompression_config(reader, DecompressionConfig::default())
37+
}
38+
39+
// TODO(tec27): Would probably be nice to be able to specify limits for the file as a whole as
40+
// well
41+
/// Creates a new Replay by parsing data from a [Read] implementation with specified settings
42+
/// for reading. Note that the limits specified will apply to each chunk individually, rather
43+
/// than to the entire replay collectively.
44+
pub fn new_with_decompression_config(
45+
mut reader: R,
46+
config: DecompressionConfig,
47+
) -> Result<Self, BroodrepError> {
2848
let format = Self::detect_format(&mut reader)?;
2949

3050
reader.seek(SeekFrom::Start(0))?;
@@ -49,7 +69,7 @@ impl<R: Read + Seek> Replay<R> {
4969
}
5070

5171
// Replay header section
52-
let replay_header = Self::read_legacy_section(&mut reader, format)?;
72+
let replay_header = Self::read_legacy_section(&mut reader, format, config)?;
5373
let replay_header = Self::parse_replay_header(&replay_header)?;
5474

5575
Ok(Replay {
@@ -100,12 +120,17 @@ impl<R: Read + Seek> Replay<R> {
100120
})
101121
}
102122

103-
fn read_legacy_section(reader: &mut R, format: ReplayFormat) -> Result<Vec<u8>, BroodrepError> {
123+
fn read_legacy_section(
124+
reader: &mut R,
125+
format: ReplayFormat,
126+
config: DecompressionConfig,
127+
) -> Result<Vec<u8>, BroodrepError> {
104128
let header = Self::read_section_header(reader)?;
105129
// TODO(tec27): Pass a size hint for known sections to avoid reallocations?
106-
let mut data = vec![];
130+
let mut data = Vec::new();
107131
for _ in 0..header.num_chunks {
108132
let size = reader.read_u32::<LE>()?;
133+
data.reserve(size as usize);
109134
// TODO(tec27): Keep a working buffer around to avoid needing to reallocate buffers
110135
// frequently? Peek the first byte and seek back to avoid needing this allocation at
111136
// all?
@@ -114,20 +139,23 @@ impl<R: Read + Seek> Replay<R> {
114139

115140
match format {
116141
ReplayFormat::Legacy => {
117-
// TODO(tec27): Often our data will be much smaller than the default buffer
118-
// size, so maybe we could use whatever size hint based on the section to
119-
// provide a smaller buffer (and use explode_with_buffer)
120-
let decompressed = explode::explode(&compressed[..])
121-
.map_err(BroodrepError::LegacyCompressionError)?;
122-
data.extend(decompressed);
142+
let mut decoder = SafeDecompressor::new(
143+
ExplodeReader::new(&compressed[..]),
144+
config,
145+
Some(size as u64),
146+
);
147+
decoder.read_to_end(&mut data)?;
123148
}
124149
ReplayFormat::Modern | ReplayFormat::Modern121 => {
125150
if size <= 4 || compressed[0] != 0x78 {
126-
// FIXME: implement implode decoding
127151
// Not compressed, we can return it directly
128152
data.extend(compressed);
129153
} else {
130-
let mut decoder = ZlibDecoder::new(&compressed[..]);
154+
let mut decoder = SafeDecompressor::new(
155+
ZlibDecoder::new(&compressed[..]),
156+
config,
157+
Some(size as u64),
158+
);
131159
decoder.read_to_end(&mut data)?;
132160
}
133161
}
@@ -167,7 +195,6 @@ impl<R: Read + Seek> Replay<R> {
167195
let game_sub_type = cursor.read_u16::<LE>()?;
168196

169197
cursor.seek(SeekFrom::Current(8))?; // unknown
170-
eprint!("unknown cursor position: {:#x}", cursor.position());
171198

172199
let mut host_name = vec![0u8; 25];
173200
cursor.read_exact(&mut host_name[..24])?;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:232b7b9b284f0b4f3eb3c30f74cfc279b0baf4975c22d0499bc32901ed87b3d1
3+
size 7121

0 commit comments

Comments
 (0)