CVE-2026-7981
Overview
Background
- CrabbyAvif
- the Rust AVIF decoder used by Chrome; it assembles grid/overlay images by copying decoded tiles into a destination
Image. - `copy_from_tile`
src/decoder/tile.rs:372routine that copies one grid tile’s planes into the destination grid image.- `copy_and_overlay_from_tile`
src/decoder/tile.rs:428routine that overlays a pointer-backed tile onto the destination buffer.- `Pixels::from_raw_pointer`
- wrapper (called at
src/codecs/dav1d.rs:254and the analogoussrc/codecs/avm.rssite) that builds a bounds-checked slice over a decoder plane from a pointer plus a height and row stride.
Root Cause Analysis
7466a44ac808. An earlier analysis was withdrawn because it had been written against a dependency roll.copy_from_tile derives the number of rows to copy from the destination geometry: on the last grid row src_height_to_copy = checked_sub!(u32_from_usize(self.height(plane))?, height_so_far)? (src/decoder/tile.rs:397-399), then copies that many rows out of the source tile via tile.row(plane, y) / tile.row16(plane, y) in the loop at src/decoder/tile.rs:408-422. Pre-fix, neither copy_from_tile nor copy_and_overlay_from_tile checked that the source tile’s yuv_format matched the destination’s, so a tile with denser chroma subsampling than the grid (e.g. a 4:2:0 tile into a grid sized from a 4:4:4 first tile) makes src_height_to_copy exceed the tile’s actual chroma rows. That over-read would normally be caught by Rust’s slice bounds check, but the companion defect made the check ineffective: in the Pixels::from_raw_pointer call at src/codecs/dav1d.rs:254 the plane slice was built with the full luma image.height argument (src/codecs/dav1d.rs:257), and the analogous src/codecs/avm.rs site did the same, so a subsampled chroma plane’s slice was sized to twice its real allocation. The combination lets the copy read past the end of the dav1d/avm chroma allocation into adjacent renderer heap, matching the advisory’s out-of-bounds read in Codecs. The primary commit 7466a44a fixes this by rejecting the mismatch before any copy: both functions now begin with if category != Category::Alpha && self.yuv_format != tile.yuv_format { return AvifError::invalid_image_grid(...) } (added diff lines). The companion commit 4f3bb613 fixes the oversized slice by passing u32_from_usize(image.height(plane.into()))? instead of image.height to Pixels::from_raw_pointer, so each plane’s slice is sized to its own height. Note the reporter proposed persisting first_cell_image state in decoder/mod.rs; the shipped fix instead enforces format compatibility at copy time in tile.rs, so the exact state-loss path the reporter described is (inferred) and not what the diff changes.Attack Path
- Craft a mixed-format grid Build an AVIF grid whose first cell is 4:4:4 and a later cell is 4:2:0, so the grid geometry and a tile disagree on chroma height. (trigger per reporter)
- Bypass format validation
Deliver the cells so the mismatched tile reaches
copy_from_tile/copy_and_overlay_from_tilewithout a format-compatibility check (pre-fixsrc/decoder/tile.rs:372/:428had none). (inferred — the state-loss mechanism is not in the diff) - Over-read the chroma plane
The last-row height is taken from the destination (
src/decoder/tile.rs:397-399), so the copy loop (:408-422) reads more chroma rows than the tile holds. - Defeat the bounds check
Because the tile’s chroma
Pixelsslice was sized with the full lumaimage.heightargument (src/codecs/dav1d.rs:257), the oversized slice lets the over-read pass Rust’s bounds check and copy adjacent heap bytes into the output image. - Exfiltrate the leak
Render the decoded image to a
<canvas>and read it back to recover the leaked heap bytes. (inferred — canvas read-back not in shown material)
Impact Assessment
Files Changed
src/decoder/tile.rstests/data/grid_cell0_444_cell1_420.aviftests/decoder.rs
Audit Directions
- Per-plane dimensionsFlag any code that uses a single (luma) height/width for all planes when sizing or bounds-checking subsampled chroma buffers.
- Cross-source copy invariantsWhere tiles/regions are copied into a composite image, require that source and destination agree on depth and subsampling before the copy, not only on width/height.
- Trusting a slice lengthTreat a
PointerSlice/Pixelsslice length as only as trustworthy as the dimensions used to build it; an inflated length silently converts an OOB access into a passing bounds check.
Patch
diff --git a/src/decoder/tile.rs b/src/decoder/tile.rs
index ad2325c..4ba393e 100644
--- a/src/decoder/tile.rs
+++ b/src/decoder/tile.rs
@@ -376,6 +376,12 @@
tile_index: u32,
category: Category,
) -> AvifResult<()> {
+ // When copying non-alpha planes, the source and destination depth and yuv_format has
+ // to be the same. This restriction does not apply to alpha planes since only Y plane is
+ // copied.
+ if category != Category::Alpha && self.yuv_format != tile.yuv_format {
+ return AvifError::invalid_image_grid("mismatch in grid tile yuv formats.");
+ }
let row_index = tile_index / grid.columns;
let column_index = tile_index % grid.columns;
for plane in category.planes() {
@@ -432,6 +438,12 @@
tile_index: u32,
category: Category,
) -> AvifResult<()> {
+ // When copying non-alpha planes, the source and destination depth and yuv_format has
+ // to be the same. This restriction does not apply to alpha planes since only Y plane is
+ // copied.
+ if category != Category::Alpha && self.yuv_format != tile.yuv_format {
+ return AvifError::invalid_image_grid("mismatch in overlay tile yuv formats.");
+ }
// This function is used only when |tile| contains pointers and self contains buffers.
for plane in category.planes() {
let plane = *plane;
diff --git a/tests/data/grid_cell0_444_cell1_420.avif b/tests/data/grid_cell0_444_cell1_420.avif
new file mode 100644
index 0000000..5098ac0
--- /dev/null
+++ b/tests/data/grid_cell0_444_cell1_420.avif
Binary files differ
diff --git a/tests/decoder.rs b/tests/decoder.rs
index 7b8817d..93416ee 100644
--- a/tests/decoder.rs
+++ b/tests/decoder.rs
@@ -1619,3 +1619,35 @@
assert_eq!(res, Ok(()));
assert_eq!(decoder.image().unwrap().depth, 8);
}
+
+#[test]
+fn b_497926602() -> AvifResult<()> {
+ // The file has two cells with the mdat payloads (offset, size) at (424, 36) and (460, 35).
+ let data =
+ std::fs::read(get_test_file("grid_cell0_444_cell1_420.avif")).expect("Unable to read file");
+ let len = data.len();
+ // Make only the first cell available.
+ let available_size_rc = Rc::new(RefCell::new(460));
+ let mut decoder = decoder::Decoder::default();
+ decoder.settings.allow_incremental = true;
+ let io = Box::new(CustomIO {
+ available_size_rc: available_size_rc.clone(),
+ data,
+ });
+ decoder.set_io(io);
+ assert!(decoder.parse().is_ok());
+ // Incremental decoding is not supported on Android.
+ if !HAS_NON_ANDROID_DECODER {
+ return Ok(());
+ }
+ // Decode the first cell.
+ assert!(matches!(decoder.next_image(), Err(AvifError::WaitingOnIo)));
+ // Make the second cell available.
+ *available_size_rc.borrow_mut() = len;
+ // Now, trying to decode should fail with an error other than WaitingOnIo since the cells have
+ // different YUV formats.
+ let res = decoder.next_image();
+ assert!(res.is_err());
+ assert!(!matches!(res, Err(AvifError::WaitingOnIo)));
+ Ok(())
+}
Original Bug Report
Heap OOB Read in crabbyavif via incremental decode state loss and oversized slice
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A potential heap out-of-bounds read exists in the crabbyavif library during incremental grid image decoding. A state tracking flaw allows mixing incompatible YUV formats in a grid, which interacts with an oversized buffer slice in the dav1d wrapper to bypass Rust bounds checks. This can lead to renderer heap memory being leaked into the decoded image, which an attacker could read via a <canvas> element.
Affected files:
third_party/crabbyavif/src/src/decoder/mod.rsthird_party/crabbyavif/src/src/codecs/dav1d.rsthird_party/crabbyavif/src/src/image.rs
Estimated timestamp from git blame: 2025-04-11
Summary
A potential heap out-of-bounds (OOB) read vulnerability exists in the crabbyavif library when processing grid images during incremental decoding. Two separate logic flaws combine to create this issue: a validation bypass caused by state loss when resuming an incremental decode, and an oversized PointerSlice created in the dav1d codec wrapper. Together, these allow an attacker to leak adjacent heap memory from the renderer process into the decoded image pixels.
Technical Details
1. Validation Bypass in GridImageHelper
In third_party/crabbyavif/src/src/decoder/mod.rs, the decode_grid() function handles grid assembly. During incremental decoding (which is enabled for still images in Blink), if the network stalls, the function yields. When more data arrives, decode_grid() is called again.
On each call, it constructs a fresh GridImageHelper instance:
// decoder/mod.rs:1894
let mut grid_image_helper = GridImageHelper {
// ...
first_cell_image: None,
// ...
};
Because first_cell_image is initialized to None on every resume, copy_from_cell_image() fails to perform consistency checks on subsequent tiles if the decode was interrupted:
// decoder/mod.rs:425
} else if self.first_cell_image.is_some()
&& !cell_image.has_same_properties_and_cicp(self.first_cell_image.unwrap_ref())
{
return AvifError::invalid_image_grid("grid image contains mismatched tiles");
}
This bypass allows an attacker to mix tiles with different subsampling formats (e.g., a YUV 4:4:4 tile followed by a YUV 4:2:0 tile).
2. Oversized PointerSlice in dav1d Wrapper
In third_party/crabbyavif/src/src/codecs/dav1d.rs, the wrapper incorrectly uses the full image.height (the luma height) when creating PointerSlice wrappers for all planes:
// codecs/dav1d.rs:255
image.planes[plane] = Some(Pixels::from_raw_pointer(
dav1d_picture.data[plane] as *mut u8,
image.depth as u32,
image.height, // ERROR: Uses luma height for chroma planes
image.row_bytes[plane],
)?);
For YUV 4:2:0 formats, chroma buffers are half the luma height. Passing the full height creates a slice that overestimates the allocation size, defeating Rust’s runtime bounds checks.
3. Triggering the OOB Read
In third_party/crabbyavif/src/src/image.rs, copy_from_tile merges a tile into the grid. If a 1-row grid is initialized as YUV 4:4:4 (from the first tile), but a subsequent tile is YUV 4:2:0, the code calculates the copy height based on the destination grid’s height:
// image.rs:494
let src_height_to_copy = if row_index == grid.rows - 1 {
// ...
checked_sub!(u32_from_usize(self.height(plane))?, height_so_far)?
}
This evaluates to the full luma height. The decoder then copies luma_height rows from the source tile’s smaller chroma buffer. Because the PointerSlice from step 2 is oversized, the Rust bounds check succeeds, and crabbyavif silently reads past the end of the dav1d allocation, copying adjacent renderer heap memory into the image.
Suggested Attacker Steps (Theoretical)
Note: These are potential steps as our tooling does not currently run live code.
- Craft an AVIF image with a 1-row, multi-column grid.
- Encode Tile 0 as a YUV 4:4:4 keyframe.
- Encode Tile 1 as a YUV 4:2:0 keyframe.
- Serve the file over HTTP with chunked encoding. Send Tile 0 data, then stall the connection to trigger an incremental decode pause in Chrome.
- Send Tile 1 data. The decoder resumes, loses
first_cell_imagestate, bypasses validation, and OOB-reads the Tile 1 chroma buffer. - Render the resulting image to an offscreen
<canvas>and usegetImageData()to extract the leaked heap memory.
Suggested Fix
- Persist Grid State: Move
first_cell_imageout of the temporaryGridImageHelperand store it in a persistent structure (e.g.,DecoderorTileInfo) so format validation works across incremental decode boundaries. - Correct Slice Bounds: In
dav1d.rs, use the appropriate plane height when creating the slice:image.height(plane.into())instead ofimage.height.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.