Files
clawhdf5/crates/clawhdf5-format/src/local_heap.rs
T
osobhandClaude Opus 5.5 90e050944f fix(format): refuse a local heap whose free list leaves the heap
libhdf5 walks a local heap's free list when it loads the heap's data and
refuses the heap ("bad heap free list") when a free block starts or ends
outside the data segment, or links to offset 0. We never looked at the
free list, so a damaged old-style group listed names read from the broken
heap: once the user block of cve-2021-36977.h5 was applied, its root
listed eight garbage names where libhdf5 fails.

LocalHeap::validate_free_list (new) mirrors H5HL__fl_deserialize, with a
cycle bound, and accepts H5HL_FREE_NULL (1) or an all-ones head as the
end of the list. Like libhdf5 it runs when the first name is needed, not
on parse, so an empty group with a damaged heap still lists as empty
(cve-2018-13871.h5, cve-2024-29166.h5, gh-4431-poc-03.h5 keep matching
h5py).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:07:50 -05:00

349 lines
12 KiB
Rust

//! HDF5 Local Heap parsing.
#[cfg(not(feature = "std"))]
use alloc::string::String;
use crate::error::FormatError;
/// Parsed HDF5 Local Heap header.
#[derive(Debug, Clone)]
pub struct LocalHeap {
/// Size of the data segment in bytes.
pub data_segment_size: u64,
/// Offset of the free list head within the data segment.
pub free_list_head_offset: u64,
/// File address of the data segment.
pub data_segment_address: u64,
}
/// Checks that `[offset, offset + needed)` fits within `data`, guarding the
/// addition against `usize` overflow from a crafted near-`usize::MAX` offset.
fn ensure_len(data: &[u8], offset: usize, needed: usize) -> Result<(), FormatError> {
if offset
.checked_add(needed)
.is_none_or(|end| end > data.len())
{
return Err(FormatError::UnexpectedEof {
expected: offset.saturating_add(needed),
available: data.len(),
});
}
Ok(())
}
fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
let s = size as usize;
if pos.checked_add(s).is_none_or(|end| end > data.len()) {
return Err(FormatError::UnexpectedEof {
expected: pos.saturating_add(s),
available: data.len(),
});
}
let slice = &data[pos..pos + s];
Ok(match size {
2 => u16::from_le_bytes([slice[0], slice[1]]) as u64,
4 => u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]) as u64,
8 => u64::from_le_bytes([
slice[0], slice[1], slice[2], slice[3], slice[4], slice[5], slice[6], slice[7],
]),
_ => return Err(FormatError::InvalidOffsetSize(size)),
})
}
impl LocalHeap {
/// Parse a local heap header at the given offset in the file data.
pub fn parse(
file_data: &[u8],
offset: usize,
offset_size: u8,
length_size: u8,
) -> Result<LocalHeap, FormatError> {
// signature(4) + version(1) + reserved(3) = 8, then length_size*2 + offset_size
let ls = length_size as usize;
let os = offset_size as usize;
let total = 8 + ls * 2 + os;
ensure_len(file_data, offset, total)?;
if &file_data[offset..offset + 4] != b"HEAP" {
return Err(FormatError::InvalidLocalHeapSignature);
}
let version = file_data[offset + 4];
if version != 0 {
return Err(FormatError::InvalidLocalHeapVersion(version));
}
let mut pos = offset + 8;
let data_segment_size = read_offset(file_data, pos, length_size)?;
pos += ls;
let free_list_head_offset = read_offset(file_data, pos, length_size)?;
pos += ls;
let data_segment_address = read_offset(file_data, pos, offset_size)?;
Ok(LocalHeap {
data_segment_size,
free_list_head_offset,
data_segment_address,
})
}
/// Walk the free list the way libhdf5 does when it loads a heap's data
/// (`H5HL__fl_deserialize`), rejecting a heap whose free list points
/// outside the data segment. libhdf5 refuses such a heap ("bad heap free
/// list"), and names read from it would be garbage.
///
/// libhdf5 only loads a heap when it needs a name from it (an empty
/// group's broken heap goes unnoticed), so call this before the first
/// [`Self::read_string`], not on parse.
///
/// The end of the list is `H5HL_FREE_NULL` (1); an all-ones value (the
/// undefined address) is accepted as "no free list" too.
pub fn validate_free_list(&self, file_data: &[u8], length_size: u8) -> Result<(), FormatError> {
const FREE_NULL: u64 = 1;
let ls = length_size as usize;
let undefined = if ls >= 8 {
u64::MAX
} else {
(1u64 << (8 * ls)) - 1
};
let size = self.data_segment_size;
let seg = self.data_segment_address;
let mut next = self.free_list_head_offset;
// Each free block holds two lengths, so a list longer than this
// revisits a block: a cycle.
let max_blocks = size / (2 * ls as u64) + 1;
let mut walked = 0u64;
while next != FREE_NULL && next != undefined {
if next >= size || walked >= max_blocks {
return Err(FormatError::InvalidLocalHeapFreeList);
}
walked += 1;
let at = seg
.checked_add(next)
.and_then(|a| usize::try_from(a).ok())
.ok_or(FormatError::InvalidLocalHeapFreeList)?;
let block_offset = next;
next = read_offset(file_data, at, length_size)?;
if next == 0 {
return Err(FormatError::InvalidLocalHeapFreeList);
}
let block_size = read_offset(file_data, at + ls, length_size)?;
if block_offset
.checked_add(block_size)
.is_none_or(|end| end > size)
{
return Err(FormatError::InvalidLocalHeapFreeList);
}
}
Ok(())
}
/// Read a null-terminated string from the heap's data segment at the given byte offset.
pub fn read_string(&self, file_data: &[u8], string_offset: u64) -> Result<String, FormatError> {
let seg_addr = self.data_segment_address as usize;
let str_start =
seg_addr
.checked_add(string_offset as usize)
.ok_or(FormatError::Overflow(
"local heap seg_addr + string_offset overflow".into(),
))?;
let seg_end = seg_addr
.checked_add(self.data_segment_size as usize)
.ok_or(FormatError::Overflow(
"local heap seg_addr + data_segment_size overflow".into(),
))?;
if str_start >= file_data.len() || str_start >= seg_end {
return Err(FormatError::UnexpectedEof {
expected: str_start + 1,
available: file_data.len(),
});
}
// Find null terminator
let search_end = seg_end.min(file_data.len());
let mut end = str_start;
while end < search_end && file_data[end] != 0 {
end += 1;
}
if end >= search_end {
return Err(FormatError::UnexpectedEof {
expected: end + 1,
available: search_end,
});
}
let s = core::str::from_utf8(&file_data[str_start..end])
.map_err(|_| FormatError::InvalidLocalHeapSignature)?;
Ok(String::from(s))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_heap_file(
heap_offset: usize,
data_seg_offset: usize,
strings: &[&str],
offset_size: u8,
length_size: u8,
) -> Vec<u8> {
// Build data segment
let mut data_seg = Vec::new();
for s in strings {
data_seg.extend_from_slice(s.as_bytes());
data_seg.push(0); // null terminator
}
let data_seg_size = data_seg.len();
let total_size = data_seg_offset + data_seg_size + 64;
let mut file = vec![0u8; total_size];
// Write heap header at heap_offset
let mut pos = heap_offset;
file[pos..pos + 4].copy_from_slice(b"HEAP");
pos += 4;
file[pos] = 0; // version
pos += 1;
// reserved 3
pos += 3;
// data_segment_size
write_val(&mut file, pos, data_seg_size as u64, length_size);
pos += length_size as usize;
// free_list_head_offset: H5HL_FREE_NULL (no free space)
write_val(&mut file, pos, 1, length_size);
pos += length_size as usize;
// data_segment_address
write_val(&mut file, pos, data_seg_offset as u64, offset_size);
// Write data segment
file[data_seg_offset..data_seg_offset + data_seg_size].copy_from_slice(&data_seg);
file
}
fn write_val(buf: &mut [u8], pos: usize, val: u64, size: u8) {
match size {
4 => buf[pos..pos + 4].copy_from_slice(&(val as u32).to_le_bytes()),
8 => buf[pos..pos + 8].copy_from_slice(&val.to_le_bytes()),
_ => panic!("test"),
}
}
#[test]
fn parse_rejects_near_usize_max_offset_without_panicking() {
// Found by fuzzing: `offset + total` overflowed for a crafted
// near-usize::MAX offset.
let file = build_heap_file(0, 100, &["hello"], 8, 8);
let result = LocalHeap::parse(&file, usize::MAX - 4, 8, 8);
assert!(
matches!(result, Err(FormatError::UnexpectedEof { .. })),
"expected a clean UnexpectedEof, got {result:?}"
);
}
#[test]
fn parse_heap_header() {
let file = build_heap_file(0, 100, &["hello", "world"], 8, 8);
let heap = LocalHeap::parse(&file, 0, 8, 8).unwrap();
assert_eq!(heap.data_segment_address, 100);
assert_eq!(heap.data_segment_size, 12); // "hello\0world\0"
}
#[test]
fn read_string_at_offset_0() {
let file = build_heap_file(0, 100, &["hello", "world"], 8, 8);
let heap = LocalHeap::parse(&file, 0, 8, 8).unwrap();
let s = heap.read_string(&file, 0).unwrap();
assert_eq!(s, "hello");
}
#[test]
fn read_string_at_offset_6() {
let file = build_heap_file(0, 100, &["hello", "world"], 8, 8);
let heap = LocalHeap::parse(&file, 0, 8, 8).unwrap();
let s = heap.read_string(&file, 6).unwrap();
assert_eq!(s, "world");
}
#[test]
fn invalid_signature() {
let mut file = build_heap_file(0, 100, &["x"], 8, 8);
file[0] = b'X';
let err = LocalHeap::parse(&file, 0, 8, 8).unwrap_err();
assert_eq!(err, FormatError::InvalidLocalHeapSignature);
}
#[test]
fn read_string_past_segment() {
let file = build_heap_file(0, 100, &["hi"], 8, 8);
let heap = LocalHeap::parse(&file, 0, 8, 8).unwrap();
let err = heap.read_string(&file, 100).unwrap_err();
assert!(matches!(err, FormatError::UnexpectedEof { .. }));
}
#[test]
fn parse_heap_4byte_offsets() {
let file = build_heap_file(0, 80, &["test"], 4, 4);
let heap = LocalHeap::parse(&file, 0, 4, 4).unwrap();
assert_eq!(heap.data_segment_address, 80);
let s = heap.read_string(&file, 0).unwrap();
assert_eq!(s, "test");
}
/// Heap with data segment `[a, b, c, 0-padding]` whose free list starts
/// at `head` and has one block `(next, size)` at offset 8.
fn heap_with_free_block(head: u64, next: u64, size: u64) -> Vec<u8> {
let mut file = build_heap_file(0, 100, &["abcdefg"], 8, 8);
file.resize(200, 0);
write_val(&mut file, 8, 32, 8); // data segment size
write_val(&mut file, 16, head, 8);
write_val(&mut file, 108, next, 8);
write_val(&mut file, 116, size, 8);
file
}
#[test]
fn free_list_inside_the_segment_is_accepted() {
let file = heap_with_free_block(8, 1, 24);
let heap = LocalHeap::parse(&file, 0, 8, 8).unwrap();
heap.validate_free_list(&file, 8).unwrap();
assert_eq!(heap.read_string(&file, 0).unwrap(), "abcdefg");
// An all-ones head is "no free list" too.
let file = heap_with_free_block(u64::MAX, 0, 0);
let heap = LocalHeap::parse(&file, 0, 8, 8).unwrap();
assert!(heap.validate_free_list(&file, 8).is_ok());
}
#[test]
fn bad_free_list_is_rejected_like_libhdf5() {
for (head, next, size, why) in [
(40, 1, 8, "head past the segment"),
(8, 1, 25, "block runs past the segment"),
(8, 0, 8, "next offset of zero"),
(8, 8, 8, "cycle"),
(8, 999, 8, "next past the segment"),
] {
let file = heap_with_free_block(head, next, size);
// The header itself parses; the free list is checked on use.
let heap = LocalHeap::parse(&file, 0, 8, 8).unwrap();
assert_eq!(
heap.validate_free_list(&file, 8).unwrap_err(),
FormatError::InvalidLocalHeapFreeList,
"{why}"
);
}
}
#[test]
fn invalid_version() {
let mut file = build_heap_file(0, 100, &["x"], 8, 8);
file[4] = 1; // bad version
let err = LocalHeap::parse(&file, 0, 8, 8).unwrap_err();
assert_eq!(err, FormatError::InvalidLocalHeapVersion(1));
}
}