mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
codecs: check TIFF structural tags, predicted rows and JPEG chunk frames (#10983)
* codecs: check TIFF structural tags, predicted rows and JPEG chunk frames - Duplicate structural tags: the directory checks read a tag's first entry and the tiff crate its last, so a small SamplesPerPixel first and a large one last was admitted with one value and sized with the other. A repeated structural tag is now refused. - Floating-point predictor: the tiff crate reads each tile row at its full stored width into a buffer of its own, outside the scratch cap. That row (TileWidth x samples x sample size) must now fit the cap. - JPEG-compressed chunks: the tiff crate decodes each chunk into a whole JPEG image sized by the embedded frame header, with no limits. Each chunk's frame (read from the chunk or JPEGTables, at most 256 marker segments in) must now fit the nominal strip or tile, as libtiff requires, and its decoded size must fit the scratch cap. * address review: JPEG chunk dedup by count, offset-2 frame, shared fill budget * address review: bound JPEG chunk list by scratch cap * address review: reserve JPEG chunk list at the budgeted length
This commit is contained in:
1 parent
4b1055c732
commit
f45711c749
2 files changed
+161
No files matched your search
@@ -496,6 +496,104 @@ fn multiband_layout(photometric: u64, has_alpha: bool, gray_alpha: bool) -> Chan
|
||||
}
|
||||
}
|
||||
|
||||
const TAG_PREDICTOR: u16 = 317;
|
||||
const TAG_JPEG_TABLES: u16 = 347;
|
||||
/// Marker segments looked at before a JPEG chunk's frame header. Real chunks hold a handful
|
||||
/// (SOI, DRI, SOF, DHT, DQT, SOS); the cap keeps chunks that all point at one long run of
|
||||
/// segments from costing chunks x segments.
|
||||
const MAX_JPEG_CHUNK_MARKERS: usize = 256;
|
||||
|
||||
/// Fill bytes (`0xFF` runs before a marker) all JPEG chunks of a TIFF may scan together. Each
|
||||
/// distinct chunk offset rescans its own run, so this bounds the total work.
|
||||
const MAX_JPEG_FILL_BYTES: usize = 1 << 24;
|
||||
|
||||
/// The frame header (width, height, components) of a JPEG stream: `Ok(None)` when it has none
|
||||
/// before its first scan, `Err` when more than [`MAX_JPEG_CHUNK_MARKERS`] segments come first.
|
||||
fn jpeg_frame(b: &[u8], fill: &mut usize) -> Result<Option<(usize, usize, usize)>, CodecError> {
|
||||
if b.get(..2) != Some(&[0xFF, 0xD8][..]) {
|
||||
return Ok(None);
|
||||
}
|
||||
jpeg_frame_from(b, 2, fill)
|
||||
}
|
||||
|
||||
/// [`jpeg_frame`] from byte `start` on, with no SOI check. `fill` counts the fill bytes scanned.
|
||||
fn jpeg_frame_from(b: &[u8], start: usize, fill: &mut usize) -> Result<Option<(usize, usize, usize)>, CodecError> {
|
||||
let be16 = |at: usize| b.get(at..at + 2).and_then(|v| <[u8; 2]>::try_from(v).ok()).map(|v| usize::from(u16::from_be_bytes(v)));
|
||||
let mut i = start;
|
||||
for _ in 0..MAX_JPEG_CHUNK_MARKERS {
|
||||
while b.get(i) == Some(&0xFF) && b.get(i + 1) == Some(&0xFF) {
|
||||
i += 1;
|
||||
*fill += 1;
|
||||
if *fill > MAX_JPEG_FILL_BYTES {
|
||||
return Err(CodecError::LimitExceeded("JPEG strips or tiles scan too many fill bytes".into()));
|
||||
}
|
||||
}
|
||||
if b.get(i) != Some(&0xFF) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(&code) = b.get(i + 1) else { return Ok(None) };
|
||||
match code {
|
||||
0xC0..=0xCF if !matches!(code, 0xC4 | 0xC8 | 0xCC) => {
|
||||
let frame = be16(i + 5).zip(be16(i + 7)).zip(b.get(i + 9));
|
||||
return Ok(frame.map(|((h, w), &nc)| (w, h, usize::from(nc))));
|
||||
}
|
||||
0xD9 | 0xDA => return Ok(None),
|
||||
0x01 | 0xD0..=0xD8 => i += 2,
|
||||
_ => match be16(i + 2) {
|
||||
Some(len) => i += 2 + len,
|
||||
None => return Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(err("a JPEG strip or tile has too many marker segments before its frame header"))
|
||||
}
|
||||
|
||||
/// Refuses JPEG strips or tiles whose embedded frame is larger than the strip or tile
|
||||
/// (`nominal`, unclipped) or whose decoded frame exceeds `cap` bytes. Chunks sharing an offset
|
||||
/// are checked once.
|
||||
fn check_jpeg_chunks(
|
||||
f: &File<'_>,
|
||||
offsets: &ifd::Entry,
|
||||
sizes: &ifd::Entry,
|
||||
expected: u64,
|
||||
tables: Option<&[u8]>,
|
||||
nominal: (usize, usize),
|
||||
cap: usize,
|
||||
) -> Result<(), CodecError> {
|
||||
// The list below holds 16 bytes per chunk; bound it by the scratch cap, not only by the file size.
|
||||
if expected.saturating_mul(16) > cap as u64 {
|
||||
return Err(CodecError::LimitExceeded(format!("{expected} JPEG strips or tiles")));
|
||||
}
|
||||
// Reserve exactly the budgeted length: collecting from filter_map could over-allocate past `cap`.
|
||||
let mut chunks: Vec<(u64, u64)> = Vec::with_capacity(expected as usize);
|
||||
chunks.extend((0..expected).filter_map(|i| Some((f.uint(offsets, i)?, f.uint(sizes, i)?))));
|
||||
chunks.sort_unstable();
|
||||
// Entries sharing an offset but not a byte count are different chunks: check each one.
|
||||
chunks.dedup();
|
||||
let len = f.b.len() as u64;
|
||||
let mut fill = 0;
|
||||
for (at, n) in chunks {
|
||||
let end = at.saturating_add(n).min(len);
|
||||
let Some(data) = usize::try_from(at).ok().zip(usize::try_from(end).ok()).and_then(|(a, e)| f.b.get(a..e)) else { continue };
|
||||
let mut frame = match jpeg_frame(data, &mut fill)? {
|
||||
Some(frame) => Some(frame),
|
||||
None => tables.map(|t| jpeg_frame(t, &mut fill)).transpose()?.flatten(),
|
||||
};
|
||||
// Without SOI, the tiff crate drops the chunk's first two bytes, so a frame at offset 2 is decoded.
|
||||
if frame.is_none() && data.get(..2) != Some(&[0xFF, 0xD8][..]) {
|
||||
frame = jpeg_frame_from(data, 2, &mut fill)?;
|
||||
}
|
||||
let Some((w, h, nc)) = frame else { continue };
|
||||
if w > nominal.0 || h > nominal.1 {
|
||||
return Err(err(format!("a {w}x{h} JPEG frame in a {}x{} strip or tile", nominal.0, nominal.1)));
|
||||
}
|
||||
if w.checked_mul(h).and_then(|px| px.checked_mul(nc.max(1))).is_none_or(|bytes| bytes > cap) {
|
||||
return Err(CodecError::LimitExceeded(format!("a {w}x{h} JPEG strip or tile")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The chunk tables must match the geometry. Checked in 64 bits, before the `tiff` crate does
|
||||
/// the same arithmetic in narrower types.
|
||||
fn check_chunk_tables(f: &File<'_>, dir: &Ifd, (w, h): (u32, u32), spp: u64, planar: bool, tiled: bool) -> Result<(u64, u64, u64), CodecError> {
|
||||
@@ -673,6 +771,14 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
|
||||
if let Some(e) = dir.entries.iter().find(|e| ifd::STRUCTURAL_TAGS.contains(&e.tag) && f.data(e).is_none()) {
|
||||
return Err(err(format!("tag {} points past the end of the file", e.tag)));
|
||||
}
|
||||
// This parser reads the first entry of a tag and the `tiff` crate the last, so a repeated
|
||||
// structural tag (a small SamplesPerPixel first, a large one last) would be checked here with
|
||||
// one value and sized by the crate with another.
|
||||
let mut structural: Vec<u16> = dir.entries.iter().map(|e| e.tag).filter(|t| ifd::STRUCTURAL_TAGS.contains(t)).collect();
|
||||
structural.sort_unstable();
|
||||
if let Some(w) = structural.windows(2).find(|w| w[0] == w[1]) {
|
||||
return Err(err(format!("tag {} appears more than once", w[0])));
|
||||
}
|
||||
let need = |tag: u16, name: &str| f.tag_uint(dir, tag).ok_or_else(|| err(format!("missing or unreadable {name}")));
|
||||
let w = u32::try_from(need(ifd::TAG_WIDTH, "ImageWidth")?).map_err(|_| err("ImageWidth out of range"))?;
|
||||
let h = u32::try_from(need(ifd::TAG_HEIGHT, "ImageLength")?).map_err(|_| err("ImageLength out of range"))?;
|
||||
@@ -844,6 +950,25 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
|
||||
.and_then(|s| s.checked_mul(src.bytes()))
|
||||
.ok_or_else(|| CodecError::LimitExceeded("tile size".into()))?;
|
||||
let scratch_cap = chunk_bytes.saturating_mul(2).max(64 << 20).min(limits.alloc_usize());
|
||||
// The floating-point predictor makes the `tiff` crate read each row of a tile at its full
|
||||
// stored width (TileWidth, not clipped to the image) into a buffer of its own, outside the
|
||||
// scratch buffer: that row must fit the same cap.
|
||||
if f.tag_uint(dir, TAG_PREDICTOR) == Some(3) {
|
||||
let stored_row = cw.checked_mul(chunk_spp).and_then(|n| n.checked_mul(src.bytes()));
|
||||
if stored_row.is_none_or(|n| n > scratch_cap) {
|
||||
return Err(CodecError::LimitExceeded(format!("a {cw} sample wide predicted tile row")));
|
||||
}
|
||||
}
|
||||
// JPEG-compressed chunks (compression 7) are decoded by the `tiff` crate into a whole JPEG
|
||||
// image sized by the embedded frame header, with no limits, before the chunk's bytes are
|
||||
// taken. The frame must fit the strip or tile it stands for, as libtiff requires, and the
|
||||
// scratch cap.
|
||||
if compression == 7 {
|
||||
let tables = dir.get(TAG_JPEG_TABLES).and_then(|e| f.data(e));
|
||||
let (nominal_w, nominal_h) =
|
||||
(cw, if tiled { ch } else { f.tag_uint(dir, ifd::TAG_ROWS_PER_STRIP).and_then(|r| usize::try_from(r).ok()).unwrap_or(hu) });
|
||||
check_jpeg_chunks(f, offsets, sizes, expected, tables, (nominal_w, nominal_h), scratch_cap)?;
|
||||
}
|
||||
// One chunk as the decoder actually reads it: a tile keeps its declared size at an edge,
|
||||
// which can be more than the clipped image bytes above.
|
||||
let scratch_bytes = cw.saturating_mul(ch).saturating_mul(chunk_spp).saturating_mul(src.bytes());
|
||||
|
||||
@@ -174,3 +174,39 @@ fn default_tiff_is_baseline() {
|
||||
assert_eq!(decode(&bytes).expect("decode").data(), img.data(), "{layout:?}: round trip");
|
||||
}
|
||||
}
|
||||
|
||||
fn refusal(bytes: &[u8]) -> String {
|
||||
match decode(bytes) {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(i) => panic!("decoded a {}x{} image", i.width(), i.height()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_structural_tag_is_refused() {
|
||||
// SamplesPerPixel 1 first (what the directory checks read), 64 last (what the backend reads).
|
||||
let dir = strips(4, 1, 8, 1, 1, 1, &[1, 2, 3, 4]).tag(277, SHORT, &[64]);
|
||||
let bytes = build(true, false, &[dir], &[0]).bytes;
|
||||
let e = refusal(&bytes);
|
||||
assert!(e.contains("277 appears more than once"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_strip_larger_than_its_strip_is_refused() {
|
||||
let mut jpeg = Vec::new();
|
||||
jpeg_encoder::Encoder::new(&mut jpeg, 95).encode(&[100u8; 64 * 64], 64, 64, jpeg_encoder::ColorType::Luma).expect("encode the JPEG strip");
|
||||
let mut dir = with_compression(strips(16, 16, 8, 1, 1, 16, &[0; 256]), 7);
|
||||
dir.chunks = vec![jpeg];
|
||||
let e = refusal(&build(true, false, &[dir], &[0]).bytes);
|
||||
assert!(e.contains("64x64 JPEG frame in a 16x16 strip"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_float_predicted_tile_row_wider_than_the_scratch_cap_is_refused() {
|
||||
// A 16x16 32-bit float image in one tile 2^25 samples wide: the floating-point predictor
|
||||
// would make the backend allocate a 128 MiB row for it.
|
||||
let mut dir = Dir { entries: base(16, 16, 32, 1, 1), chunks: vec![vec![0; 64]], tiled: true };
|
||||
dir = with_compression(dir, 8).tag(317, SHORT, &[3]).tag(322, LONG, &[1 << 25]).tag(323, LONG, &[16]).tag(339, SHORT, &[3]);
|
||||
let e = refusal(&build(true, false, &[dir], &[0]).bytes);
|
||||
assert!(e.contains("predicted tile row"), "{e}");
|
||||
}
|
||||
Reference in new issue
Block a user