fix(format): refuse chunk index entries libhdf5 mis-reads

- A dataset without filters stores every chunk at the chunk's full size.
  A chunk its index records at another size was read at that size, with
  the rest of the chunk left as zeros (cve-2025-44904's
  Scale_offset_float_data_le: 38- and 37-byte chunks for 48-byte chunks,
  where HDF5 2.0 fills the rest with whatever its buffer held). It is now
  refused, as later libhdf5 releases refuse it ("incorrect chunk size
  returned from index for unfiltered chunk"):
  chunked_read::list_chunks_for_read, used by every read path.
- A v1 B-tree chunk key carries 0 in the element-size dimension. libhdf5
  compares that coordinate when it looks a chunk up, so whether it finds a
  chunk keyed otherwise depends on where the key falls (in cve-2025-44905
  /Shuffle_float_data_le, offset 4096, it does not, and h5py reads fill
  values); we read the chunk. Such a key is now refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 10:36:01 -05:00
co-authored by Claude Opus 5.5
parent d110b1d945
commit 6b3d003950
3 changed files with 107 additions and 6 deletions
@@ -628,3 +628,47 @@ save("mdc_past_eof", bad)
"DSET",
);
}
/// Chunk index entries HDF5 2.0 mis-reads, refused here. An unfiltered
/// chunk the index records at less than the chunk's size (`cve-2025-44904`):
/// HDF5 2.0 fills the rest of the chunk with whatever its buffer held, and
/// later libhdf5 releases refuse it ("incorrect chunk size returned from
/// index for unfiltered chunk"); we read the rest as zeros. A chunk keyed
/// with a non-zero element offset (`cve-2025-44905`): libhdf5's lookup
/// compares that coordinate too, so whether it finds the chunk depends on
/// where the key falls (in `cve-2025-44905` it does not, and h5py reads
/// fill values; in this file it does); we read the chunk.
#[test]
fn chunk_index_entries_libhdf5_misreads_are_refused() {
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
run_python(
dir.path(),
r#"
good = os.path.join(d, "good.h5")
with h5py.File(good, "w", libver="earliest") as f:
f.create_dataset("d", data=np.arange(100, dtype="<i4"), chunks=(37,), fillvalue=-1)
data = bytearray(open(good, "rb").read())
tree = data.find(b"TREE")
while data[tree + 4] != 1: # the chunk index, not the root group's B-tree
tree = data.find(b"TREE", tree + 1)
assert tree > 0 and data[tree + 5] == 0
key = tree + 8 + 16 # the first chunk's key: size, filter mask, offsets
second = key + 24 + 8 # a key (4 + 4 + 2 x 8 bytes), then a child address
assert struct.unpack_from("<IIQQ", data, second) == (148, 0, 37, 0)
bad = bytearray(data); struct.pack_into("<I", bad, second, 100)
open(os.path.join(d, "short_chunk.h5"), "wb").write(bad)
bad = bytearray(data); struct.pack_into("<Q", bad, second + 16, 4096)
open(os.path.join(d, "element_offset.h5"), "wb").write(bad)
"#,
);
assert_eq!(
clawhdf5_reads(&dir.path().join("good.h5"), "d"),
Ok(()),
"good"
);
for name in ["short_chunk", "element_offset"] {
let err = clawhdf5_reads(&dir.path().join(format!("{name}.h5")), "d").unwrap_err();
assert!(err.starts_with("read:"), "{name}: {err}");
}
}