Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Codecs
DescriptionUse after free in Codecs
ComponentCodecs
Bug ClassUAF
Tracker500104917
Fix commitb3cd8e4aa3f6 (chromium/src) +85/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-04-28

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/platform/graphics/image_decoding_store.cc
modified
TEST_F
third_party/blink/renderer/platform/graphics/image_decoding_store_test.cc
modified

Files Changed

  • third_party/blink/renderer/platform/graphics/image_decoding_store.cc
  • third_party/blink/renderer/platform/graphics/image_decoding_store_test.cc
From b3cd8e4aa3f6c3f0fe05c7bca29d4215cddcd825 Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <apaseltiner@chromium.org>
Date: Fri, 10 Apr 2026 07:33:16 -0700
Subject: [PATCH] Fix Use-After-Free in ImageDecodingStore

This CL fixes a UAF vulnerability in ImageDecodingStore where a raw
pointer was added to an intrusive LRU list before map insertion. If
insertion failed (e.g. due to duplicate keys), the object was freed
while the dangling pointer remained in the list.

The logic is now inverted to attempt map insertion first. This ensures
that the LRU list and memory usage accounting are only updated if the
insertion actually succeeds. If a duplicate key is encountered, the
redundant object is safely destroyed without ever being added to the
LRU list. MemoryUsageInBytes() and Generator() are only called on
successful insertions to avoid unnecessary work for duplicate entries.

This approach was chosen for its simplicity and because it guarantees
internal consistency regardless of how a duplicate is triggered.
Duplicate keys remain possible even with perfect decoder math due to
potential race conditions. For example, multiple threads can
simultaneously experience a cache miss for the same key in LockDecoder
and independently proceed to create and insert their own decoders via
InsertDecoder. While LockDecoder ensures that a cached decoder has only
one user at a time, it does not prevent these redundant insertion
attempts.

Regression tests added in ImageDecodingStoreTest.

Bug: 500104917
Change-Id: I5205cc5168ff2dc58be94bfe9be6b06e2d17fce5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7736761
Reviewed-by: Colin Blundell <blundell@chromium.org>
Commit-Queue: Andrew Paseltiner <apaseltiner@chromium.org>
Reviewed-by: Wan-Teh Chang <wtc@google.com>
Cr-Commit-Position: refs/heads/main@{#1612855}
---

diff --git a/third_party/blink/renderer/platform/graphics/image_decoding_store.cc b/third_party/blink/renderer/platform/graphics/image_decoding_store.cc
index be865654..eb6074e8 100644
--- a/third_party/blink/renderer/platform/graphics/image_decoding_store.cc
+++ b/third_party/blink/renderer/platform/graphics/image_decoding_store.cc
@@ -111,7 +111,9 @@
       generator, 0, std::move(decoder), client_id);
 
   base::AutoLock lock(lock_);
-  DCHECK(!decoder_cache_map_.Contains(new_cache_entry->CacheKey()));
+  // Note: duplicate insertions can happen if multiple threads experience a
+  // cache miss for the same key and both attempt to insert a decoder.
+  // InsertCacheInternal handles this safely.
   InsertCacheInternal(std::move(new_cache_entry), &decoder_cache_map_,
                       &decoder_cache_key_map_);
 }
@@ -228,18 +230,27 @@
                                              U* cache_map,
                                              V* identifier_map) {
   lock_.AssertAcquired();
-  const size_t cache_entry_bytes = cache_entry->MemoryUsageInBytes();
-  heap_memory_usage_in_bytes_ += cache_entry_bytes;
+  const typename U::KeyType key = cache_entry->CacheKey();
 
-  // m_orderedCacheList is used to support LRU operations to reorder cache
+  // Attempt to insert into the cache map first. If the key already exists,
+  // the unique_ptr is not consumed and will be destroyed, which is correct
+  // for a duplicate entry.
+  auto result = cache_map->insert(key, std::move(cache_entry));
+  if (!result.is_new_entry) {
+    return;
+  }
+
+  // Only add to the LRU list and update memory usage if this is a new entry.
+  T* entry_ptr = result.stored_value->value.get();
+
+  // ordered_cache_list_ is used to support LRU operations to reorder cache
   // entries quickly.
-  ordered_cache_list_.Append(cache_entry.get());
+  ordered_cache_list_.Append(entry_ptr);
+  heap_memory_usage_in_bytes_ += entry_ptr->MemoryUsageInBytes();
 
-  typename U::KeyType key = cache_entry->CacheKey();
-  typename V::AddResult result = identifier_map->insert(
-      cache_entry->Generator(), typename V::MappedType());
-  result.stored_value->value.insert(key);
-  cache_map->insert(key, std::move(cache_entry));
+  typename V::AddResult id_result =
+      identifier_map->insert(entry_ptr->Generator(), typename V::MappedType());
+  id_result.stored_value->value.insert(key);
 
   TRACE_COUNTER1(TRACE_DISABLED_BY_DEFAULT("blink.image_decoding"),
                  "ImageDecodingStoreHeapMemoryUsageBytes",
diff --git a/third_party/blink/renderer/platform/graphics/image_decoding_store_test.cc b/third_party/blink/renderer/platform/graphics/image_decoding_store_test.cc
index 9781cb9a..1881c7a 100644
--- a/third_party/blink/renderer/platform/graphics/image_decoding_store_test.cc
+++ b/third_party/blink/renderer/platform/graphics/image_decoding_store_test.cc
@@ -235,4 +235,68 @@
   EXPECT_EQ(image_decoding_store_.CacheEntries(), 0);
 }
 
+// Regression test for crbug.com/500104917.
+TEST_F(ImageDecodingStoreTest, DuplicateInsert) {
+  auto decoder1 = std::make_unique<MockImageDecoder>(this);
+  decoder1->SetSize(1, 1);
+  image_decoding_store_.InsertDecoder(generator_.get(),
+                                      cc::PaintImage::kDefaultGeneratorClientId,
+                                      std::move(decoder1));
+  EXPECT_EQ(1, image_decoding_store_.CacheEntries());
+
+  auto decoder2 = std::make_unique<MockImageDecoder>(this);
+  decoder2->SetSize(1, 1);
+
+  // Duplicate insertion should be handled safely.
+  image_decoding_store_.InsertDecoder(generator_.get(),
+                                      cc::PaintImage::kDefaultGeneratorClientId,
+                                      std::move(decoder2));
+
+  // Should still have only 1 entry.
+  EXPECT_EQ(1, image_decoding_store_.CacheEntries());
+
+  // Pruning should work correctly and not crash.
+  image_decoding_store_.SetCacheLimitInBytes(0);
+  EXPECT_EQ(0, image_decoding_store_.CacheEntries());
+}
+
+// Regression test for crbug.com/500104917.
+// Simulates a race condition where multiple threads experience a cache miss
+// for the same key and both attempt to insert a decoder.
+TEST_F(ImageDecodingStoreTest, LockDecoderMissRace) {
+  const SkISize size = SkISize::Make(1, 1);
+  const ImageDecoder::AlphaOption alpha = ImageDecoder::kAlphaPremultiplied;
+  const cc::PaintImage::GeneratorClientId client_id =
+      cc::PaintImage::kDefaultGeneratorClientId;
+
+  ImageDecoder* decoder;
+  // Thread 1: experiences a cache miss.
+  EXPECT_FALSE(image_decoding_store_.LockDecoder(generator_.get(), size, alpha,
+                                                 client_id, &decoder));
+
+  // Thread 2: also experiences a cache miss for the same key because Thread 1
+  // hasn't called InsertDecoder() yet.
+  EXPECT_FALSE(image_decoding_store_.LockDecoder(generator_.get(), size, alpha,
+                                                 client_id, &decoder));
+
+  // Thread 1: completes decoding and inserts the decoder.
+  auto decoder1 = std::make_unique<MockImageDecoder>(this);
+  decoder1->SetSize(1, 1);
+  image_decoding_store_.InsertDecoder(generator_.get(), client_id,
+                                      std::move(decoder1));
+  EXPECT_EQ(1, image_decoding_store_.CacheEntries());
+
+  // Thread 2: also completes decoding and attempts to insert its own decoder
+  // for the same key. This is a duplicate insertion.
+  auto decoder2 = std::make_unique<MockImageDecoder>(this);
+  decoder2->SetSize(1, 1);
+  image_decoding_store_.InsertDecoder(generator_.get(), client_id,
+                                      std::move(decoder2));
+
+  // The store should handle the duplicate safely and maintain consistency.
+  EXPECT_EQ(1, image_decoding_store_.CacheEntries());
+  image_decoding_store_.SetCacheLimitInBytes(0);
+  EXPECT_EQ(0, image_decoding_store_.CacheEntries());
+}
+
 }  // namespace blink
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/platform/graphics/image_decoding_store_test.cc b/third_party/blink/renderer/platform/graphics/image_decoding_store_test.cc
index 9781cb9a..1881c7a 100644
--- a/third_party/blink/renderer/platform/graphics/image_decoding_store_test.cc
+++ b/third_party/blink/renderer/platform/graphics/image_decoding_store_test.cc
@@ -235,4 +235,68 @@
   EXPECT_EQ(image_decoding_store_.CacheEntries(), 0);
 }
 
+// Regression test for crbug.com/500104917.
+TEST_F(ImageDecodingStoreTest, DuplicateInsert) {
+  auto decoder1 = std::make_unique<MockImageDecoder>(this);
+  decoder1->SetSize(1, 1);
+  image_decoding_store_.InsertDecoder(generator_.get(),
+                                      cc::PaintImage::kDefaultGeneratorClientId,
+                                      std::move(decoder1));
+  EXPECT_EQ(1, image_decoding_store_.CacheEntries());
+
+  auto decoder2 = std::make_unique<MockImageDecoder>(this);
+  decoder2->SetSize(1, 1);
+
+  // Duplicate insertion should be handled safely.
+  image_decoding_store_.InsertDecoder(generator_.get(),
+                                      cc::PaintImage::kDefaultGeneratorClientId,
+                                      std::move(decoder2));
+
+  // Should still have only 1 entry.
+  EXPECT_EQ(1, image_decoding_store_.CacheEntries());
+
+  // Pruning should work correctly and not crash.
+  image_decoding_store_.SetCacheLimitInBytes(0);
+  EXPECT_EQ(0, image_decoding_store_.CacheEntries());
+}
+
+// Regression test for crbug.com/500104917.
+// Simulates a race condition where multiple threads experience a cache miss
+// for the same key and both attempt to insert a decoder.
+TEST_F(ImageDecodingStoreTest, LockDecoderMissRace) {
+  const SkISize size = SkISize::Make(1, 1);
+  const ImageDecoder::AlphaOption alpha = ImageDecoder::kAlphaPremultiplied;
+  const cc::PaintImage::GeneratorClientId client_id =
+      cc::PaintImage::kDefaultGeneratorClientId;
+
+  ImageDecoder* decoder;
+  // Thread 1: experiences a cache miss.
+  EXPECT_FALSE(image_decoding_store_.LockDecoder(generator_.get(), size, alpha,
+                                                 client_id, &decoder));
+
+  // Thread 2: also experiences a cache miss for the same key because Thread 1
+  // hasn't called InsertDecoder() yet.
+  EXPECT_FALSE(image_decoding_store_.LockDecoder(generator_.get(), size, alpha,
+                                                 client_id, &decoder));
+
+  // Thread 1: completes decoding and inserts the decoder.
+  auto decoder1 = std::make_unique<MockImageDecoder>(this);
+  decoder1->SetSize(1, 1);
+  image_decoding_store_.InsertDecoder(generator_.get(), client_id,
+                                      std::move(decoder1));
+  EXPECT_EQ(1, image_decoding_store_.CacheEntries());
+
+  // Thread 2: also completes decoding and attempts to insert its own decoder
+  // for the same key. This is a duplicate insertion.
+  auto decoder2 = std::make_unique<MockImageDecoder>(this);
+  decoder2->SetSize(1, 1);
+  image_decoding_store_.InsertDecoder(generator_.get(), client_id,
+                                      std::move(decoder2));
+
+  // The store should handle the duplicate safely and maintain consistency.
+  EXPECT_EQ(1, image_decoding_store_.CacheEntries());
+  image_decoding_store_.SetCacheLimitInBytes(0);
+  EXPECT_EQ(0, image_decoding_store_.CacheEntries());
+}
+
 }  // namespace blink
Loading diff…

Original Bug Report

reported by vm...@google.com

Use-After-Free in ImageDecodingStore via Duplicate Key Insertion

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.

Overview: A Use-After-Free (UAF) vulnerability exists in ImageDecodingStore due to a raw pointer being appended to an intrusive list before a hash map insertion. If the insertion fails due to a duplicate key, the object is destroyed but the dangling pointer remains in the list. This can be triggered by a float precision flaw during progressive JPEG decoding.

Affected files:

  • third_party/blink/renderer/platform/graphics/image_decoding_store.cc
  • third_party/blink/renderer/platform/graphics/image_decoding_store.h
  • third_party/blink/renderer/platform/image-decoders/jpeg/jpeg_image_decoder.cc

Estimated timestamp from git blame: 2024-06-29

Summary

A highly exploitable Use-After-Free (UAF) vulnerability exists in the Blink rendering engine’s ImageDecodingStore. The issue occurs because a raw pointer to a newly created DecoderCacheEntry is appended to an intrusive LRU list (ordered_cache_list_) before the entry is inserted into a hash map (decoder_cache_map_). If the hash map insertion fails due to a duplicate key, the std::unique_ptr holding the entry is destroyed, but the raw pointer remains in the intrusive list.

Because the list pointers in CacheEntry are explicitly annotated with RAW_PTR_EXCLUSION, this memory is not protected by MiraclePtr (BackupRefPtr). Subsequent cache pruning operations will perform virtual method calls on the freed object, potentially leading to Remote Code Execution (RCE) in the sandboxed renderer process.

Vulnerability Details

There are two components to this vulnerability: the trigger and the UAF logic flaw.

1. The Trigger (Math Precision Loss): In JPEGImageDecoder::DesiredScaleNumerator, an image downscale factor is calculated using a single-precision float cast ((float)max_decoded_bytes). For very large image dimensions, this cast loses precision. Consequently, a request to scale an image to a specific size (e.g., 4101x4101) can result in an actual DecodedSize() that slightly differs (e.g., 2734x2734).

When ImageDecoderWrapper::Decode attempts to find a cached decoder, it searches using the requested size (LockDecoder(4101x4101)). When it fails to find one, it decodes the image and caches the new decoder using its actual size (InsertDecoder(2734x2734)). On subsequent decode passes of the same progressive JPEG, LockDecoder misses again, causing a second attempt to insert a decoder with the same 2734x2734 key.

2. The UAF Flaw (InsertCacheInternal): In third_party/blink/renderer/platform/graphics/image_decoding_store.cc, the InsertCacheInternal method performs the following sequence:

// 1. Appends raw pointer to the list.
ordered_cache_list_.Append(cache_entry.get());

// ... 
// 2. Attempts map insertion.
cache_map->insert(key, std::move(cache_entry));

If the key already exists (as in the duplicate trigger described above), WTF::HashMap::insert returns without consuming the unique_ptr. The function returns, the unique_ptr goes out of scope, and the memory is freed. However, the raw pointer remains in ordered_cache_list_. Furthermore, heap_memory_usage_in_bytes_ is incremented prior to the insertion but never decremented on failure, causing a memory leak that can artificially force the cache to permanently prune on every subsequent decode.

Potential Exploit Scenario

Note: These are potential steps based on code analysis; our tooling does not yet execute live exploit code.

  1. Trigger: An attacker hosts a malicious webpage serving a progressive JPEG with extremely large dimensions, styled with CSS to a specific scale that triggers the float precision loss.
  2. Duplicate Insertion: The browser performs an initial decode of the first JPEG scan and caches the decoder. As the next scan arrives, the math mismatch causes a cache miss, resulting in a second cache insertion attempt for the same key.
  3. UAF Creation: The duplicate insertion fails. The second DecoderCacheEntry is freed, leaving a dangling pointer in the ordered_cache_list_ intrusive list.
  4. Heap Grooming: The attacker uses JavaScript to allocate objects of similar size in the FastMalloc partition, reclaiming the freed DecoderCacheEntry memory block. The attacker populates this block with controlled data, setting the use_count_ (at offset 0x8) to 0 and pointing the vtable pointer (at offset 0x0) to a controlled ROP chain.
  5. Execution: The attacker triggers one final image decode, or the memory leak forces a Prune(). The Prune() method iterates over the list, encounters the dangling pointer, passes the !UseCount() check, and calls RemoveFromCacheInternal. This immediately invokes the virtual method cache_entry->GetType(), hijacking control flow to the attacker’s payload.

Suggested Fix

  1. Fix the UAF in InsertCacheInternal: The logic should be inverted. Attempt the cache_map->insert() operation first. If it succeeds (i.e., is_new_entry is true), then append the raw pointer to ordered_cache_list_ and increment heap_memory_usage_in_bytes_.
  2. Fix the Math Precision: In JPEGImageDecoder::DesiredScaleNumerator, use double instead of float for intermediate calculations involving wtf_size_t (which is a 32-bit integer) to prevent precision loss when calculating dimensions for large images.

Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad


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. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker