format: bound Storage reads that hostile size fields could stretch
On a backend without the file in memory, a structure read whose length comes from untrusted header fields was clamped only by the end of the file, so a crafted size made one read (and copy) of up to the rest of the file. Each such read now covers what the parser actually uses: - local heap names: read in growing pieces (64 bytes first, then 4x) up to the end of the data segment, instead of the rest of the segment per name (quadratic for a big symbol-table group); - fractal heap indirect blocks: the doubling-table geometry locates the entry covering the object, and the first read ends at that entry; only if it is unallocated does the walk read the rest of the block (it visits every entry then). One walk implementation serves both; - paged fixed/extensible array data blocks over 1 MiB: the prefix and page bitmap, then each page in use on its own (smaller blocks are still one read); - blocks under one checksum (non-paged array data blocks, extensible array index and super blocks): the bounds check that comes first (the checksum's; the page bitmap's for a super block) is made against the file length before reading (Window::check_extent), so a block claimed past the end of the file costs no read. With the checksum feature off the parser has no such first check and the old read stands. Other windows were already bounded (the superblock and object header prefixes, the fractal heap header by a u16, SOHM tables by u8/u16 counts) or are exact reads checked against the file length first. In memory nothing changes: the pieces are borrowed slices. Tests: CountingStorage over a crafted heap (16 MiB file, width and rows 0xFFFF: under 1 KiB read, 16.7 MB before), a heap segment claiming 64 MiB (one 64-byte read per short name), long names at every piece boundary, a fixed array block claimed past the end of a 16 MiB file (under 64 bytes read), and in the equivalence harness an h5py file with a 2.4 MB fixed array block and a >1 MiB extensible array block, whole and cut at 97 points: every chunk index agrees with the slice read and the largest takes 205 KB (2.4 MB and 1.2 MB when read whole). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
@@ -36,6 +36,10 @@ fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
|
||||
})
|
||||
}
|
||||
|
||||
/// First read of a name on a backend without the file in memory: most link
|
||||
/// names are shorter than this.
|
||||
const NAME_READ_START: usize = 64;
|
||||
|
||||
impl LocalHeap {
|
||||
/// Parse a local heap header at the given offset in the file data.
|
||||
pub fn parse(
|
||||
@@ -152,8 +156,9 @@ impl LocalHeap {
|
||||
self.read_string_in(file_data, string_offset)
|
||||
}
|
||||
|
||||
/// [`Self::read_string`] over any [`Storage`]: one read, from the
|
||||
/// string to the end of the data segment.
|
||||
/// [`Self::read_string`] over any [`Storage`]: one read of up to 64
|
||||
/// bytes for a short name, more (each four times the last) up to the end
|
||||
/// of the data segment for a longer one.
|
||||
pub fn read_string_in<S: Storage + ?Sized>(
|
||||
&self,
|
||||
file: &S,
|
||||
@@ -180,19 +185,33 @@ impl LocalHeap {
|
||||
});
|
||||
}
|
||||
|
||||
// Find null terminator
|
||||
// Find the null terminator, which lies before the end of the data
|
||||
// segment (or of the file). In memory that is one borrowed slice;
|
||||
// otherwise the bytes are read in growing pieces, so a name costs a
|
||||
// read of about its own length, not of the rest of the segment
|
||||
// (whose size is an untrusted header field).
|
||||
let search_end = seg_end.min(file_len);
|
||||
let rest = read_exact_at(file, str_start as u64, search_end - str_start)?;
|
||||
let Some(len) = rest.iter().position(|&b| b == 0) else {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: search_end + 1,
|
||||
available: search_end,
|
||||
});
|
||||
let total = search_end - str_start;
|
||||
let mut want = if file.as_contiguous().is_some() {
|
||||
total
|
||||
} else {
|
||||
total.min(NAME_READ_START)
|
||||
};
|
||||
|
||||
let s = core::str::from_utf8(&rest[..len])
|
||||
.map_err(|_| FormatError::InvalidLocalHeapSignature)?;
|
||||
Ok(String::from(s))
|
||||
loop {
|
||||
let rest = read_exact_at(file, str_start as u64, want)?;
|
||||
if let Some(len) = rest.iter().position(|&b| b == 0) {
|
||||
let s = core::str::from_utf8(&rest[..len])
|
||||
.map_err(|_| FormatError::InvalidLocalHeapSignature)?;
|
||||
return Ok(String::from(s));
|
||||
}
|
||||
if want == total {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: search_end + 1,
|
||||
available: search_end,
|
||||
});
|
||||
}
|
||||
want = want.saturating_mul(4).min(total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,4 +412,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Names of every length around the first read's size, and one with no
|
||||
/// terminator, read identically through a `read_at`-only storage; a
|
||||
/// short name in a heap whose header claims a huge data segment costs
|
||||
/// one small read, not a read of the rest of the file.
|
||||
#[test]
|
||||
fn long_names_and_hostile_segment_sizes() {
|
||||
use crate::storage::CountingStorage;
|
||||
let names: Vec<String> = [0usize, 1, 63, 64, 65, 255, 256, 257, 1000, 5000]
|
||||
.iter()
|
||||
.map(|&n| "n".repeat(n))
|
||||
.collect();
|
||||
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
|
||||
let mut file = build_heap_file(0, 64, &refs, 8, 8);
|
||||
let heap = LocalHeap::parse(&file, 0, 8, 8).unwrap();
|
||||
let storage = CountingStorage::new(file.clone());
|
||||
let mut off = 0u64;
|
||||
for name in &names {
|
||||
let got = heap.read_string_in(&storage, off);
|
||||
assert_eq!(got, heap.read_string(&file, off));
|
||||
assert_eq!(got.unwrap(), *name);
|
||||
off += name.len() as u64 + 1;
|
||||
}
|
||||
// The last name loses its terminator: both report the same error.
|
||||
let seg_end = 64 + heap.data_segment_size as usize;
|
||||
file[seg_end - 1] = b'n';
|
||||
let storage = CountingStorage::new(file.clone());
|
||||
let last = off - names[names.len() - 1].len() as u64 - 1;
|
||||
let want = heap.read_string(&file, last);
|
||||
assert!(want.is_err());
|
||||
assert_eq!(heap.read_string_in(&storage, last), want);
|
||||
|
||||
// A 64 MiB file whose heap claims a data segment reaching its end.
|
||||
let mut big = build_heap_file(0, 64, &["short", "names"], 8, 8);
|
||||
big.resize(64 << 20, 0);
|
||||
big[8..16].copy_from_slice(&((64u64 << 20) - 64).to_le_bytes());
|
||||
let heap = LocalHeap::parse(&big, 0, 8, 8).unwrap();
|
||||
let storage = CountingStorage::new(big.clone());
|
||||
assert_eq!(heap.read_string_in(&storage, 6).unwrap(), "names");
|
||||
assert_eq!((storage.reads(), storage.bytes_read()), (1, 64));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user