Medium watch ✔︎ verified CVSS 8.1 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in Codecs
DescriptionOut of bounds read in Codecs
ComponentCodecs
Bug ClassOOB
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar — factors set by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: Mechanism is supported: tile.rs:397-399 takes the copy height from the destination, there was no yuv_format check before the fix, and the luma height is used for chroma slices. Only issue: the dav1d.rs:255 citation is off; image.height is at pre-fix :257.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker497926602
Fix commit7466a44ac808 (CrabbyAvif (upstream, via Chromium mirror)) +44/-0
Related commits4f3bb6135b3e companion · CrabbyAvif (upstream, via Chromium mirror)
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-30 (per vendor advisory)
Fix landed2026-03-31 (public git)
Disclosed2026-05-05
Patch gap35 days — fix public in source before the advisory

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:372 routine that copies one grid tile’s planes into the destination grid image.
`copy_and_overlay_from_tile`
src/decoder/tile.rs:428 routine that overlays a pointer-backed tile onto the destination buffer.
`Pixels::from_raw_pointer`
wrapper (called at src/codecs/dav1d.rs:254 and the analogous src/codecs/avm.rs site) that builds a bounds-checked slice over a decoder plane from a pointer plus a height and row stride.

Root Cause Analysis

Re-analyzed on 2026-10-03 against the actual fix commit 7466a44ac808. An earlier analysis was withdrawn because it had been written against a dependency roll.
When merging grid tiles, 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.
Key insight
A grid tile with a different YUV subsampling than the destination was copied using the destination’s (larger) plane height, and an oversized plane slice built from the luma height defeated the bounds check; the fix rejects format-mismatched tiles and sizes each plane slice to its own height.

Attack Path

  1. 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)
  2. Bypass format validation Deliver the cells so the mismatched tile reaches copy_from_tile/copy_and_overlay_from_tile without a format-compatibility check (pre-fix src/decoder/tile.rs:372/:428 had none). (inferred — the state-loss mechanism is not in the diff)
  3. 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.
  4. Defeat the bounds check Because the tile’s chroma Pixels slice was sized with the full luma image.height argument (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.
  5. 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

An attacker who gets the renderer to decode a crafted multi-format AVIF grid can leak adjacent renderer-process heap memory into the decoded pixels, which may then be read back through canvas APIs. The advisory rates this medium severity with no in-the-wild exploitation; it is an information-disclosure/OOB-read rather than a write.

Files Changed

  • src/decoder/tile.rs
  • tests/data/grid_cell0_444_cell1_420.avif
  • tests/decoder.rs

Audit Directions

  • Per-plane dimensions
    Flag any code that uses a single (luma) height/width for all planes when sizing or bounds-checking subsampled chroma buffers.
  • Cross-source copy invariants
    Where 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 length
    Treat a PointerSlice/Pixels slice 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.
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(())
+}
Loading diff…

Original Bug Report

reported by vm...@google.com

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.rs
  • third_party/crabbyavif/src/src/codecs/dav1d.rs
  • third_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.

  1. Craft an AVIF image with a 1-row, multi-column grid.
  2. Encode Tile 0 as a YUV 4:4:4 keyframe.
  3. Encode Tile 1 as a YUV 4:2:0 keyframe.
  4. Serve the file over HTTP with chunked encoding. Send Tile 0 data, then stall the connection to trigger an incremental decode pause in Chrome.
  5. Send Tile 1 data. The decoder resumes, loses first_cell_image state, bypasses validation, and OOB-reads the Tile 1 chroma buffer.
  6. Render the resulting image to an offscreen <canvas> and use getImageData() to extract the leaked heap memory.

Suggested Fix

  1. Persist Grid State: Move first_cell_image out of the temporary GridImageHelper and store it in a persistent structure (e.g., Decoder or TileInfo) so format validation works across incremental decode boundaries.
  2. Correct Slice Bounds: In dav1d.rs, use the appropriate plane height when creating the slice: image.height(plane.into()) instead of image.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.

View on issue tracker