Skip to content

Commit a0e5b81

Browse files
ros-crfolkertdev
authored andcommitted
Rework fuzz tests
Work done as part of an NLnet Security Evaluation for NLnet NGI Zero Core. Fix bugs in decompression and output buffer handling, extend differential fuzzing design, fuzz more compression parameters, remove now-unnecessary end-to-end test case, refactor and clean up code, add extensive code documentation to outline expectations and reasoning, rename fuzz tests, rename and disable corpus rejection feature by default, add README with fuzz resources, update lockfile.
1 parent e2f271b commit a0e5b81

File tree

11 files changed

+397
-286
lines changed

11 files changed

+397
-286
lines changed

.github/workflows/checks.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,9 @@ jobs:
234234
strategy:
235235
matrix:
236236
include:
237-
- fuzz_target: decompress
237+
- fuzz_target: decompress_chunked
238238
corpus: "bzip2-files/compressed"
239-
features: '--no-default-features --features="disable-checksum,keep-invalid-in-corpus"'
239+
features: '--no-default-features --features="disable-checksum"'
240240
- fuzz_target: compress
241241
corpus: ""
242242
features: ''

Cargo.lock

Lines changed: 2 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

fuzz/Cargo.toml

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ default = ["rust-allocator"]
1616
c-allocator = ["libbz2-rs-sys/c-allocator"]
1717
rust-allocator = ["libbz2-rs-sys/rust-allocator"]
1818
disable-checksum = ["libbz2-rs-sys/__internal-fuzz-disable-checksum"]
19-
keep-invalid-in-corpus = [] # For code coverage (on CI), we want to keep inputs that triggered the error branches
19+
# actively reject and ignore invalid fuzz inputs during processing
20+
# this can have negative effects
21+
reject-invalid-in-corpus = []
2022

2123
[dependencies.libfuzzer-sys]
2224
version = "0.4"
@@ -43,31 +45,25 @@ default-features = false
4345
members = ["."]
4446

4547
[[bin]]
46-
name = "decompress"
47-
path = "fuzz_targets/decompress.rs"
48-
test = false
49-
doc = false
50-
51-
[[bin]]
52-
name = "decompress_random_input"
53-
path = "fuzz_targets/decompress_random_input.rs"
48+
name = "decompress_chunked"
49+
path = "fuzz_targets/decompress_chunked.rs"
5450
test = false
5551
doc = false
5652

5753
[[bin]]
58-
name = "decompress_chunked"
59-
path = "fuzz_targets/decompress_chunked.rs"
54+
name = "decompress"
55+
path = "fuzz_targets/decompress.rs"
6056
test = false
6157
doc = false
6258

6359
[[bin]]
64-
name = "compress"
65-
path = "fuzz_targets/compress.rs"
60+
name = "compress_then_decompress_chunked"
61+
path = "fuzz_targets/compress_then_decompress_chunked.rs"
6662
test = false
6763
doc = false
6864

6965
[[bin]]
70-
name = "end_to_end"
71-
path = "fuzz_targets/end_to_end.rs"
66+
name = "compress_then_decompress"
67+
path = "fuzz_targets/compress_then_decompress.rs"
7268
test = false
73-
doc = false
69+
doc = false

fuzz/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Fuzz
2+
3+
## Seed corpus
4+
5+
* https://github.com/trifectatechfoundation/compression-corpus
6+
* https://gitlab.com/bzip2/bzip2-testfiles
7+
* See the GitHub workflow definitions for more information on seed corpus usage
8+
9+
## Fuzzer dictionary
10+
11+
* There is an existing bzip2 format dictionary: https://github.com/google/fuzzing/blob/master/dictionaries/bz2.dict
12+
* This could be useful for fuzz tests which consume compressed input and attempt to decompress it
13+
* However, there are only very few common input chunks that bzip2 streams share with each other, so the practical benefits of running the fuzzer with this dictionary is likely limited
14+
* See https://llvm.org/docs/LibFuzzer.html#dictionaries for more background

fuzz/fuzz_targets/compress.rs

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#![no_main]
2+
use libbz2_rs_sys::BZ_OK;
3+
use libfuzzer_sys::fuzz_target;
4+
5+
fuzz_target!(|input: (&[u8], u8)| {
6+
let (fuzzed_data, compression_decider) = input;
7+
8+
// let the fuzzer pick a value from 1 to 9 (inclusive)
9+
// use modulo to ensure this always maps to a valid number
10+
let compression_level: u8 = (compression_decider % 9) + 1;
11+
12+
// compress the fuzzer-controlled data via the Rust implementation
13+
let (error, deflated) = unsafe {
14+
test_libbz2_rs_sys::compress_rs_with_capacity(
15+
4096,
16+
fuzzed_data.as_ptr().cast(),
17+
fuzzed_data.len() as _,
18+
compression_level.into(),
19+
)
20+
};
21+
22+
// compress the fuzzer-controlled data via the C implementation
23+
let (error_c, deflated_c) = unsafe {
24+
test_libbz2_rs_sys::compress_c_with_capacity(
25+
4096,
26+
fuzzed_data.as_ptr().cast(),
27+
fuzzed_data.len() as _,
28+
compression_level.into(),
29+
)
30+
};
31+
32+
// differential testing: ensure both implementations behave identically
33+
assert_eq!(error, error_c);
34+
assert_eq!(deflated, deflated_c);
35+
36+
// this compression step should always succeed
37+
assert_eq!(error, BZ_OK);
38+
39+
// decompress the previously compressed data again to test round-trip behavior
40+
let (error, decompressed_output) = unsafe {
41+
test_libbz2_rs_sys::decompress_rs_with_capacity(
42+
1 << 10,
43+
deflated.as_ptr(),
44+
deflated.len() as _,
45+
)
46+
};
47+
// this decompression of valid compressed data should always succeed
48+
assert_eq!(error, BZ_OK);
49+
50+
// after the round trip through compression + decompression, the result data
51+
// should be identical to the initial data
52+
assert_eq!(decompressed_output, fuzzed_data);
53+
});
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#![no_main]
2+
use libbz2_rs_sys::bz_stream;
3+
use libbz2_rs_sys::BZ2_bzDecompress;
4+
use libbz2_rs_sys::BZ2_bzDecompressEnd;
5+
use libbz2_rs_sys::BZ2_bzDecompressInit;
6+
use libbz2_rs_sys::{
7+
BZ_CONFIG_ERROR, BZ_DATA_ERROR, BZ_DATA_ERROR_MAGIC, BZ_FINISH, BZ_FINISH_OK, BZ_FLUSH_OK,
8+
BZ_IO_ERROR, BZ_MEM_ERROR, BZ_OK, BZ_OUTBUFF_FULL, BZ_PARAM_ERROR, BZ_RUN_OK,
9+
BZ_SEQUENCE_ERROR, BZ_STREAM_END, BZ_UNEXPECTED_EOF,
10+
};
11+
12+
use libfuzzer_sys::fuzz_target;
13+
14+
fn compress_c(data: &[u8], compression_level: u8, work_factor: u8) -> Vec<u8> {
15+
// compress the data with the stock C bzip2
16+
17+
// output buffer for compression, will get resized later if needed
18+
let mut output = vec![0u8; 1024];
19+
20+
let mut stream = libbz2_rs_sys::bz_stream {
21+
next_in: data.as_ptr() as *mut _,
22+
avail_in: data.len() as _,
23+
total_in_lo32: 0,
24+
total_in_hi32: 0,
25+
next_out: output.as_mut_ptr() as *mut _,
26+
avail_out: output.len() as _,
27+
total_out_lo32: 0,
28+
total_out_hi32: 0,
29+
state: std::ptr::null_mut(),
30+
bzalloc: None,
31+
bzfree: None,
32+
opaque: std::ptr::null_mut(),
33+
};
34+
35+
unsafe {
36+
let err = libbz2_rs_sys::BZ2_bzCompressInit(
37+
&mut stream,
38+
compression_level.into(),
39+
0,
40+
work_factor.into(),
41+
);
42+
// init should always succeed
43+
assert_eq!(err, BZ_OK);
44+
};
45+
46+
let error = loop {
47+
match unsafe { libbz2_rs_sys::BZ2_bzCompress(&mut stream, BZ_FINISH) } {
48+
BZ_FINISH_OK => {
49+
let used = output.len() - stream.avail_out as usize;
50+
// The output buffer is full, resize it
51+
let add_space: u32 = Ord::max(1024, output.len().try_into().unwrap());
52+
output.resize(output.len() + add_space as usize, 0);
53+
54+
// If resize() reallocates, it may have moved in memory
55+
stream.next_out = output.as_mut_ptr().cast::<i8>().wrapping_add(used);
56+
stream.avail_out += add_space;
57+
58+
continue;
59+
}
60+
BZ_STREAM_END => {
61+
break BZ_OK;
62+
}
63+
ret => {
64+
break ret;
65+
}
66+
}
67+
};
68+
69+
// compression should always succeed
70+
assert_eq!(error, BZ_OK);
71+
72+
// truncate the output buffer down to the actual number of compressed bytes
73+
output.truncate(
74+
((u64::from(stream.total_out_hi32) << 32) + u64::from(stream.total_out_lo32))
75+
.try_into()
76+
.unwrap(),
77+
);
78+
79+
unsafe {
80+
// cleanup, should always succeed
81+
let err = libbz2_rs_sys::BZ2_bzCompressEnd(&mut stream);
82+
assert_eq!(err, BZ_OK);
83+
}
84+
85+
output
86+
}
87+
88+
fuzz_target!(|input: (&[u8], usize, u8, u8)| {
89+
let (fuzzer_data, chunk_size, compression_decider, work_factor_decider) = input;
90+
91+
// let the fuzzer pick a value from 1 to 9 (inclusive)
92+
// use modulo to ensure this always maps to a valid number
93+
let compression_level: u8 = (compression_decider % 9) + 1;
94+
95+
// valid values 0 to 250 (inclusive)
96+
// use modulo to ensure this always maps to a valid number
97+
let work_factor = work_factor_decider % 251;
98+
99+
if chunk_size == 0 {
100+
// we can't iterate over chunks of size 0 byte, exit early
101+
return;
102+
}
103+
104+
let compressed_data = compress_c(fuzzer_data, compression_level, work_factor);
105+
106+
let mut stream = bz_stream::zeroed();
107+
108+
unsafe {
109+
let err = BZ2_bzDecompressInit(&mut stream, 0, 0);
110+
assert_eq!(err, BZ_OK);
111+
};
112+
113+
// output buffer for decompression, will get resized later if needed
114+
let mut output = vec![0u8; 1 << 10];
115+
stream.next_out = output.as_mut_ptr() as *mut _;
116+
stream.avail_out = output.len() as _;
117+
118+
// iterate over chunks of the compressed data
119+
'decompression: for chunk in compressed_data.as_slice().chunks(chunk_size) {
120+
// designate the current chunk as input
121+
stream.next_in = chunk.as_ptr() as *mut _;
122+
stream.avail_in = chunk.len() as _;
123+
124+
loop {
125+
// perform one round of decompression
126+
let err = unsafe { BZ2_bzDecompress(&mut stream) };
127+
match err {
128+
BZ_OK => {
129+
// the decompression mechanism still has data in the input buffer,
130+
// but no more space in the output buffer
131+
if stream.avail_in > 0 && stream.avail_out == 0 {
132+
let used = output.len() - stream.avail_out as usize;
133+
// The dest buffer is full, increase its size
134+
let add_space: u32 = Ord::max(1024, output.len().try_into().unwrap());
135+
output.resize(output.len() + add_space as usize, 0);
136+
137+
// If resize() reallocates, it may have moved in memory
138+
stream.next_out = output.as_mut_ptr().cast::<i8>().wrapping_add(used);
139+
stream.avail_out += add_space;
140+
continue;
141+
} else {
142+
// the decompression of this chunk step was successful, move on to the next
143+
break;
144+
}
145+
}
146+
BZ_STREAM_END => {
147+
// the decompression has completed, exit loops
148+
break 'decompression;
149+
}
150+
BZ_RUN_OK => panic!("BZ_RUN_OK"),
151+
BZ_FLUSH_OK => panic!("BZ_FLUSH_OK"),
152+
BZ_FINISH_OK => panic!("BZ_FINISH_OK"),
153+
BZ_SEQUENCE_ERROR => panic!("BZ_SEQUENCE_ERROR"),
154+
BZ_PARAM_ERROR => panic!("BZ_PARAM_ERROR"),
155+
BZ_MEM_ERROR => panic!("BZ_MEM_ERROR"),
156+
BZ_DATA_ERROR => {
157+
panic!("BZ_DATA_ERROR")
158+
}
159+
BZ_DATA_ERROR_MAGIC => panic!("BZ_DATA_ERROR_MAGIC"),
160+
BZ_IO_ERROR => panic!("BZ_IO_ERROR"),
161+
BZ_UNEXPECTED_EOF => panic!("BZ_UNEXPECTED_EOF"),
162+
BZ_OUTBUFF_FULL => panic!("BZ_OUTBUFF_FULL"),
163+
BZ_CONFIG_ERROR => panic!("BZ_CONFIG_ERROR"),
164+
_ => panic!("{err}"),
165+
}
166+
}
167+
}
168+
169+
// truncate the output buffer down to the actual number of decompressed bytes
170+
output.truncate(
171+
((u64::from(stream.total_out_hi32) << 32) + u64::from(stream.total_out_lo32))
172+
.try_into()
173+
.unwrap(),
174+
);
175+
176+
unsafe {
177+
// cleanup, should always succeed
178+
let err = BZ2_bzDecompressEnd(&mut stream);
179+
assert_eq!(err, BZ_OK);
180+
}
181+
182+
// round-trip check
183+
// compressing and then decompressing should lead back to the input data
184+
assert_eq!(output, fuzzer_data);
185+
});

0 commit comments

Comments
 (0)