CVE-2026-6303
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/platform/graphics/image_frame_generator_test.cc |
modified | |
TESTthird_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc |
modified | |
ifthird_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.cc |
modified |
Files Changed
third_party/blink/renderer/platform/graphics/image_frame_generator_test.ccthird_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.ccthird_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.ccthird_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.h
Patch
From a6357144e7bfc5c8ecc7220de4e95b7aa799b221 Mon Sep 17 00:00:00 2001 From: Lukasz Anforowicz <[email protected]> Date: Wed, 01 Apr 2026 15:44:32 -0700 Subject: [PATCH] [rust png] Invalidate `already_started_frame_` after clearing frames. This CL ensures that `already_started_frame_` doesn't become stale after: * Re-allocating the memory buffer backing a frame. * Failing a call to `startIncrementalDecode` The regression tests in this CL have been mostly created by Gemini CLI and then reviewed and cleaned up by the author. Fixed: 496282147 Change-Id: I92349cc2a0d7b9d1d401ab7256cb941a4d8383d1 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7703000 Reviewed-by: Daniel Cheng <[email protected]> Commit-Queue: Łukasz Anforowicz <[email protected]> Cr-Commit-Position: refs/heads/main@{#1608827} --- diff --git a/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc b/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc index 8a853696..0bda0dd 100644 --- a/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc +++ b/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc @@ -26,6 +26,7 @@ #include "third_party/blink/renderer/platform/graphics/image_frame_generator.h" #include <memory> + #include "base/features.h" #include "base/location.h" #include "base/test/metrics/histogram_tester.h" @@ -39,8 +40,10 @@ #include "third_party/blink/renderer/platform/scheduler/public/post_cross_thread_task.h" #include "third_party/blink/renderer/platform/testing/task_environment.h" #include "third_party/blink/renderer/platform/testing/testing_platform_support.h" +#include "third_party/blink/renderer/platform/testing/unit_test_helpers.h" #include "third_party/blink/renderer/platform/wtf/cross_thread_functional.h" #include "third_party/blink/renderer/platform/wtf/shared_buffer.h" +#include "third_party/blink/renderer/platform/wtf/text/string_builder.h" #include "third_party/blink/renderer/platform/wtf/vector.h" namespace blink { @@ -413,4 +416,65 @@ EXPECT_EQ(kNotFound, requested_clear_except_frame_); } +// This is a regression test for https://crbug.com/496282147. +// +// This is a more realistic, product-like, almost-end-to-end version of the +// `AnimatedPNGTests.ClearingPartiallyDecodedFrame` unit test. +TEST_F(ImageFrameGeneratorTest, ClearingPartiallyDecodedFrame) { + StringBuilder file_path; + file_path.Append(test::BlinkWebTestsDir()); + file_path.Append( + "/images/resources/png-animated-three-independent-frames.png"); + std::optional<Vector<char>> full_data_vec = + test::ReadFromFile(file_path.ToString()); + ASSERT_TRUE(full_data_vec); + base::span<const uint8_t> full_data = base::as_byte_span(*full_data_vec); + SkISize size(50, 50); + + // Can't reuse `generator_` from `SetUp`, because it sets `is_multi_frame` to + // `false`. Can't use `SetFrameCount`, because this test needs to use a real + // `SkiaImageDecoderBase` decoder, rather than `UseMockImageDecoderFactory`. + constexpr bool kIsMultiframe = true; + const Vector<SkISize> kSupportedSizes = {}; + generator_ = + ImageFrameGenerator::Create(size, kIsMultiframe, ColorBehavior::kTag, + cc::AuxImage::kDefault, kSupportedSizes); + + // Partially decode frame 1. + // + // `fcTL` chunk starts at offset 180. `fdAT` at 218. + // Let's provide 240 bytes - in the middle of `fdAT` chunk. + // + // After this step `SkiaImageDecoderBase::already_started_frame_` is `1`. + SkBitmap bitmap; + bitmap.allocN32Pixels(size.width(), size.height()); + cc::PaintImage::GeneratorClientId client_id = + cc::PaintImage::GetNextGeneratorClientId(); + auto partial_data = SharedBuffer::Create(full_data.first(240u)); + auto segment_reader = SegmentReader::CreateFromSharedBuffer(partial_data); + bool success = generator_->DecodeAndScale(segment_reader.get(), + /*all_data_received=*/false, 1, + bitmap.pixmap(), client_id); + EXPECT_TRUE(success); + + // Decode an out-of-bounds frame to clear the cache and transitively call + // `ImageFrame::ClearPixelData`. + success = generator_->DecodeAndScale(segment_reader.get(), + /*all_data_received=*/false, 1000, + bitmap.pixmap(), client_id); + EXPECT_FALSE(success); + + // Resume decoding of frame 1. Despite starting with + // `SkiaImageDecoderBase::already_started_frame_` set to `1` this operation + // needs to call `SkCodec::startIncrementalDecode` because the old buffer has + // been freed in the previous step. + auto full_shared_buffer = SharedBuffer::Create(full_data); + auto full_segment_reader = + SegmentReader::CreateFromSharedBuffer(full_shared_buffer); + success = generator_->DecodeAndScale(full_segment_reader.get(), + /*all_data_received=*/true, 1, + bitmap.pixmap(), client_id); + EXPECT_TRUE(success); +} + } // namespace blink diff --git a/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc b/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc index 5f647c0..5fbd931 100644 --- a/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc +++ b/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc @@ -871,6 +871,53 @@ EXPECT_EQ(frame1->GetStatus(), ImageFrame::kFrameComplete); } +// This is a regression test for https://crbug.com/496282147. +// +// This test uses `blink::ImageDecoder` and `blink::ImageFrame` APIs in a way +// that doesn't necessarily reflect how they would actually be used in the +// product (e.g. calling `ClearPixelData` and/or calling `Append` instead of +// `SetData`). This nevertheless seems like a valid test, because: +// +// * Supporting all usage patterns allowed by the public APIs (and the type +// system) seems more robust then 1) adding extra requirements on the caller +// of these APIs (such as never clearing a partially decoded frame), and/or 2) +// discovering the callers that may violate such requirements. +// * A separate `ImageFrameGeneratorTest.ClearingPartiallyDecodedFrame` test +// shows how a similr usage pattern is indeed reachable via web-exposed APIs. +TEST(AnimatedPNGTests, ClearingPartiallyDecodedFrame) { + Vector<char> full_data = ReadFile( + "/images/resources/" + "png-animated-idat-part-of-animation.png"); + ASSERT_FALSE(full_data.empty()); + auto decoder = CreatePNGDecoder(); + + // Provide only enough data for the first frame to be partial. + const size_t kPartialDataSize = 160; + scoped_refptr<SharedBuffer> data = + SharedBuffer::Create(base::span(full_data).first(kPartialDataSize)); + decoder->SetData(data.get(), false); + + // Partially decode frame 0. + ImageFrame* frame0 = decoder->DecodeFrameBufferAtIndex(0); + ASSERT_TRUE(frame0); + EXPECT_EQ(frame0->GetStatus(), ImageFrame::kFramePartial); + + // Manually clear frame 0 pixel data. + frame0->ClearPixelData(); + + // Provide more data by appending to the same `SharedBuffer`. + // This avoids clobbering the decoder state with a new `SetData` call. + data->Append(base::span(full_data).subspan(kPartialDataSize)); + + // Try to decode frame 0 again. This verifies that + // `SkCodec::startIncrementalDecode` has been called to reinitialize decoding + // state - avoiding writing to the memory buffer that has been freed by + // `ClearPixelData` above. + frame0 = decoder->DecodeFrameBufferAtIndex(0); + ASSERT_TRUE(frame0); + EXPECT_EQ(frame0->GetStatus(), ImageFrame::kFrameComplete); +} + // Verify that a malformatted PNG, where the IEND appears before any frame data // (IDAT), invalidates the decoder. TEST(AnimatedPNGTests, VerifyIENDBeforeIDATInvalidatesDecoder) { diff --git a/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.cc b/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.cc index a6766b5d..f4a8c84 100644 --- a/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.cc +++ b/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.cc @@ -301,6 +301,11 @@ UpdateAggressivePurging(current_frame_index); if (frame.GetStatus() == ImageFrame::kFrameEmpty) { + // `AllocatePixelData` (or `TakeBitmapDataIfWritable` / `CopyBitmapData`) + // calls mean that we can't reuse old buffer pointers that may have been + // stashed in `SkCodec` by previous `startIncrementalDecode` calls. + already_started_frame_.reset(); + wtf_size_t required_previous_frame_index = frame.RequiredPreviousFrameIndex(); if (required_previous_frame_index == kNotFound) { @@ -404,6 +409,7 @@ options.fPriorFrame = prior_frame_; options.fZeroInitialized = SkCodec::kNo_ZeroInitialized; + already_started_frame_.reset(); SkCodec::Result start_incremental_decode_result = codec_->startIncrementalDecode(image_info, frame.Bitmap().getPixels(), frame.Bitmap().rowBytes(), &options); diff --git a/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.h b/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.h index ab2c197..86726262 100644 --- a/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.h +++ b/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.h @@ -98,8 +98,9 @@ const wtf_size_t reading_offset_ = 0;
Regression Test / PoC
diff --git a/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc b/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc
index 8a853696..0bda0dd 100644
--- a/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc
+++ b/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc
@@ -26,6 +26,7 @@
#include "third_party/blink/renderer/platform/graphics/image_frame_generator.h"
#include <memory>
+
#include "base/features.h"
#include "base/location.h"
#include "base/test/metrics/histogram_tester.h"
@@ -39,8 +40,10 @@
#include "third_party/blink/renderer/platform/scheduler/public/post_cross_thread_task.h"
#include "third_party/blink/renderer/platform/testing/task_environment.h"
#include "third_party/blink/renderer/platform/testing/testing_platform_support.h"
+#include "third_party/blink/renderer/platform/testing/unit_test_helpers.h"
#include "third_party/blink/renderer/platform/wtf/cross_thread_functional.h"
#include "third_party/blink/renderer/platform/wtf/shared_buffer.h"
+#include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
#include "third_party/blink/renderer/platform/wtf/vector.h"
namespace blink {
@@ -413,4 +416,65 @@
EXPECT_EQ(kNotFound, requested_clear_except_frame_);
}
+// This is a regression test for https://crbug.com/496282147.
+//
+// This is a more realistic, product-like, almost-end-to-end version of the
+// `AnimatedPNGTests.ClearingPartiallyDecodedFrame` unit test.
+TEST_F(ImageFrameGeneratorTest, ClearingPartiallyDecodedFrame) {
+ StringBuilder file_path;
+ file_path.Append(test::BlinkWebTestsDir());
+ file_path.Append(
+ "/images/resources/png-animated-three-independent-frames.png");
+ std::optional<Vector<char>> full_data_vec =
+ test::ReadFromFile(file_path.ToString());
+ ASSERT_TRUE(full_data_vec);
+ base::span<const uint8_t> full_data = base::as_byte_span(*full_data_vec);
+ SkISize size(50, 50);
+
+ // Can't reuse `generator_` from `SetUp`, because it sets `is_multi_frame` to
+ // `false`. Can't use `SetFrameCount`, because this test needs to use a real
+ // `SkiaImageDecoderBase` decoder, rather than `UseMockImageDecoderFactory`.
+ constexpr bool kIsMultiframe = true;
+ const Vector<SkISize> kSupportedSizes = {};
+ generator_ =
+ ImageFrameGenerator::Create(size, kIsMultiframe, ColorBehavior::kTag,
+ cc::AuxImage::kDefault, kSupportedSizes);
+
+ // Partially decode frame 1.
+ //
+ // `fcTL` chunk starts at offset 180. `fdAT` at 218.
+ // Let's provide 240 bytes - in the middle of `fdAT` chunk.
+ //
+ // After this step `SkiaImageDecoderBase::already_started_frame_` is `1`.
+ SkBitmap bitmap;
+ bitmap.allocN32Pixels(size.width(), size.height());
+ cc::PaintImage::GeneratorClientId client_id =
+ cc::PaintImage::GetNextGeneratorClientId();
+ auto partial_data = SharedBuffer::Create(full_data.first(240u));
+ auto segment_reader = SegmentReader::CreateFromSharedBuffer(partial_data);
+ bool success = generator_->DecodeAndScale(segment_reader.get(),
+ /*all_data_received=*/false, 1,
+ bitmap.pixmap(), client_id);
+ EXPECT_TRUE(success);
+
+ // Decode an out-of-bounds frame to clear the cache and transitively call
+ // `ImageFrame::ClearPixelData`.
+ success = generator_->DecodeAndScale(segment_reader.get(),
+ /*all_data_received=*/false, 1000,
+ bitmap.pixmap(), client_id);
+ EXPECT_FALSE(success);
+
+ // Resume decoding of frame 1. Despite starting with
+ // `SkiaImageDecoderBase::already_started_frame_` set to `1` this operation
+ // needs to call `SkCodec::startIncrementalDecode` because the old buffer has
+ // been freed in the previous step.
+ auto full_shared_buffer = SharedBuffer::Create(full_data);
+ auto full_segment_reader =
+ SegmentReader::CreateFromSharedBuffer(full_shared_buffer);
+ success = generator_->DecodeAndScale(full_segment_reader.get(),
+ /*all_data_received=*/true, 1,
+ bitmap.pixmap(), client_id);
+ EXPECT_TRUE(success);
+}
+
} // namespace blink
diff --git a/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc b/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc
index 5f647c0..5fbd931 100644
--- a/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc
+++ b/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc
@@ -871,6 +871,53 @@
EXPECT_EQ(frame1->GetStatus(), ImageFrame::kFrameComplete);
}
+// This is a regression test for https://crbug.com/496282147.
+//
+// This test uses `blink::ImageDecoder` and `blink::ImageFrame` APIs in a way
+// that doesn't necessarily reflect how they would actually be used in the
+// product (e.g. calling `ClearPixelData` and/or calling `Append` instead of
+// `SetData`). This nevertheless seems like a valid test, because:
+//
+// * Supporting all usage patterns allowed by the public APIs (and the type
+// system) seems more robust then 1) adding extra requirements on the caller
+// of these APIs (such as never clearing a partially decoded frame), and/or 2)
+// discovering the callers that may violate such requirements.
+// * A separate `ImageFrameGeneratorTest.ClearingPartiallyDecodedFrame` test
+// shows how a similr usage pattern is indeed reachable via web-exposed APIs.
+TEST(AnimatedPNGTests, ClearingPartiallyDecodedFrame) {
+ Vector<char> full_data = ReadFile(
+ "/images/resources/"
+ "png-animated-idat-part-of-animation.png");
+ ASSERT_FALSE(full_data.empty());
+ auto decoder = CreatePNGDecoder();
+
+ // Provide only enough data for the first frame to be partial.
+ const size_t kPartialDataSize = 160;
+ scoped_refptr<SharedBuffer> data =
+ SharedBuffer::Create(base::span(full_data).first(kPartialDataSize));
+ decoder->SetData(data.get(), false);
+
+ // Partially decode frame 0.
+ ImageFrame* frame0 = decoder->DecodeFrameBufferAtIndex(0);
+ ASSERT_TRUE(frame0);
+ EXPECT_EQ(frame0->GetStatus(), ImageFrame::kFramePartial);
+
+ // Manually clear frame 0 pixel data.
+ frame0->ClearPixelData();
+
+ // Provide more data by appending to the same `SharedBuffer`.
+ // This avoids clobbering the decoder state with a new `SetData` call.
+ data->Append(base::span(full_data).subspan(kPartialDataSize));
+
+ // Try to decode frame 0 again. This verifies that
+ // `SkCodec::startIncrementalDecode` has been called to reinitialize decoding
+ // state - avoiding writing to the memory buffer that has been freed by
+ // `ClearPixelData` above.
+ frame0 = decoder->DecodeFrameBufferAtIndex(0);
+ ASSERT_TRUE(frame0);
+ EXPECT_EQ(frame0->GetStatus(), ImageFrame::kFrameComplete);
+}
+
// Verify that a malformatted PNG, where the IEND appears before any frame data
// (IDAT), invalidates the decoder.
TEST(AnimatedPNGTests, VerifyIENDBeforeIDATInvalidatesDecoder) {
Original Bug Report
Potential Heap UAF Write in SkiaImageDecoderBase via Stale already_started_frame_
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A potential heap use-after-free (UAF) vulnerability exists in SkiaImageDecoderBase when decoding images via the WebCodecs API. A cached frame index is not reset during aggressive caching purges, allowing an attacker to reuse a freed pixel buffer pointer. This leads to an attacker-controlled out-of-bounds/UAF write in the renderer process.
Affected files:
third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.ccthird_party/skia/src/codec/SkWuffsCodec.cppthird_party/blink/renderer/modules/webcodecs/image_decoder_core.cc
Estimated timestamp from git blame: 2024-09-04
Verdict: A highly exploitable Heap Use-After-Free (UAF) write exists in SkiaImageDecoderBase.
Context Summary: Initial logic and frame decoding parameters are validated. When an ImageDecoder handles incremental GIF decoding, SkWuffsCodec caches a raw pointer (fIncrDecDst) to the active pixel buffer.
Vulnerability: Standard processing of ClearCacheExceptFrame frees cached pixel buffers to reclaim memory. However, the system fails to reset the already_started_frame_ tracker. We jump directly to the exploit primitive: when decoding resumes, the stale tracker bypasses the startIncrementalDecode() initialization block. SkWuffsCodec::incrementalDecode() executes immediately, writing attacker-controlled pixel data directly into the freed heap allocation via the dangling fIncrDecDst pointer. MiraclePtr provides no protection as this is a raw uint8_t* within third-party code.
Potential Steps to Trigger:
- Feed
ImageDecodera multi-frame GIF with partial data for Frame 1. - Decode Frame 1 to initialize the Skia codec pointer and set
already_started_frame_ = 1. - Abort the decode (
decoder.reset()) and decode Frame 0. This triggers cache clearing, freeing Frame 1’s buffer. - Perform heap grooming to place a target object in the freed slot.
- Provide the remaining GIF data and decode Frame 1. The decoder bypasses pointer re-initialization and writes GIF payload bytes into the target object.
Note: These are suggested potential steps, as Fortify LLM agent doesn’t yet have the ability to run code.
Suggested Fix:
Invalidate already_started_frame_ and notify the underlying codec whenever a partially decoded frame’s pixel data is cleared during cache eviction (e.g., inside ClearCacheExceptFrame or ClearPixelData).
Evaluated with Chrome root at commit: a3f5fcb392f2902650ca2b71820e7e418787e18b
Results 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. Please feel free to reach out to me if you have concerns or feedback.