CVE-2026-7974
Overview
Background
- `ImageDownloaderImpl`
- the Blink-renderer implementation of the image-downloader Mojo interface that owns in-flight fetchers in the
image_fetchers_container. - `MultiResolutionImageResourceFetcher::Dispose`
- tears down a fetcher and, per the commit message, synchronously invokes its bound completion callback.
- `ContextDestroyed`
- a lifecycle hook run when the execution context goes away, which must tear down all outstanding fetchers.
- `WTF::Vector`
- Blink’s vector type; erasing an element shifts/clears trailing slots, and a cached end iterator over it is invalidated by mutation during iteration.
Root Cause Analysis
ImageDownloaderImpl::ContextDestroyed iterated over the member image_fetchers_ with a range-based for loop and called fetcher->Dispose() on each entry. Dispose() synchronously runs the fetcher’s callback, which is bound to ImageDownloaderImpl::DidFetchImage, and that callback erases the just-disposed fetcher from image_fetchers_. Mutating the container during range iteration invalidated the loop’s cached end iterator and left stale pointers in the uncleared trailing slots of the WTF::Vector, so the loop dereferenced freed/dangling fetcher pointers — a use-after-free.
The fix moves the container into a local fetchers via std::move(image_fetchers_) and immediately clear()s the member, so the loop iterates a stable copy while image_fetchers_ is already empty when the synchronous DidFetchImage callbacks run. The erase inside the callback now operates on the emptied member and cannot perturb the iteration.
Dispose → DidFetchImage) mutated that same container; moving the contents to a local copy and clearing the member first makes the iteration stable and the re-entrant erase harmless.Attack Path
- Start image downloads
A page triggers image downloads so that
ImageDownloaderImplholds one or more live entries inimage_fetchers_. - Tear down the context
The execution context is destroyed, invoking
ContextDestroyed, which begins the range-based loop overimage_fetchers_. - Synchronous re-entrant erase
fetcher->Dispose()synchronously runsDidFetchImage, which erases the fetcher fromimage_fetchers_, invalidating the loop’s end iterator and leaving stale pointers in trailing slots. - Dangling dereference The loop continues over the mutated vector and dereferences a freed fetcher pointer, producing the use-after-free (inferred: further exploitation primitives are not shown by the patch).
Impact Assessment
ImageDownloaderImpl’s fetcher pointers within the renderer process. The advisory rates this medium severity (use after free in Blink) and does not report in-the-wild exploitation; any escalation to a controlled read/write or code execution is (inferred) and not demonstrated by the diff. The precondition is outstanding image fetchers at the moment the execution context is destroyed.Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/modules/image_downloader/image_downloader_impl.cc |
modified |
Files Changed
third_party/blink/renderer/modules/image_downloader/image_downloader_impl.cc
Audit Directions
- Synchronous callback re-entrancyAudit loops that call a method which may synchronously invoke a bound callback; verify the callback cannot mutate the container being iterated.
- Dispose/teardown orderingReview lifecycle hooks (
ContextDestroyed, destructors) that walk owned-object containers for the pattern of mutate-during-iterate, preferring move-then-clear-then-iterate. - WTF::Vector iteration with eraseSearch for range-based iteration over
WTF::Vector/sets where an in-loop call path can erase elements, invalidating cached iterators.
Patch
From e11da14a35755a60e35c2669ae046da1818c3edf Mon Sep 17 00:00:00 2001 From: Colin Blundell <[email protected]> Date: Mon, 30 Mar 2026 08:19:37 -0700 Subject: [PATCH] [Blink] Fix UaF in ImageDownloaderImpl::ContextDestroyed() ImageDownloaderImpl::ContextDestroyed() iterates over image_fetchers_ and calls Dispose() on each fetcher. MultiResolutionImageResourceFetcher::Dispose() synchronously runs its callback [1], which is bound to ImageDownloaderImpl::DidFetchImage() [2]. DidFetchImage() erases the fetcher from image_fetchers_ [3]. This synchronous mutation of the vector during range-based for loop iteration leads to Use-After-Free because the loop caches the end iterator and encounters stale pointers in the uncleared trailing slots of the WTF::Vector after erasure. This CL fixes the issue by moving image_fetchers_ to a local variable before iterating, ensuring the loop operates on a stable copy and image_fetchers_ is empty during the synchronous callbacks. [1] https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/image_downloader/multi_resolution_image_resource_fetcher.cc;l=168;drc=4376ecb9520cb832e52d06b36db3806440ab19b2;bpv=1;bpt=1 [2] https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/image_downloader/image_downloader_impl.cc;l=289;bpv=1;bpt=1 [3] https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/image_downloader/image_downloader_impl.cc;l=311-314;bpv=1;bpt=1 Bug: 497649372 Change-Id: Id7c951c17e54b47b3386425b42b1706c03a95db9 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7705801 Reviewed-by: Vasiliy Telezhnikov <[email protected]> Commit-Queue: Andrew Paseltiner <[email protected]> Cr-Commit-Position: refs/heads/main@{#1607114} --- diff --git a/third_party/blink/renderer/modules/image_downloader/image_downloader_impl.cc b/third_party/blink/renderer/modules/image_downloader/image_downloader_impl.cc index 70fd7123..de2aa57 100644 --- a/third_party/blink/renderer/modules/image_downloader/image_downloader_impl.cc +++ b/third_party/blink/renderer/modules/image_downloader/image_downloader_impl.cc @@ -325,11 +325,17 @@ } void ImageDownloaderImpl::ContextDestroyed() { - for (const auto& fetcher : image_fetchers_) { + // Calling `Dispose()` will end up calling back synchronously into + // DidFetchImage(). To avoid `image_fetchers_` being mutated while it's being + // iterated over, move its contents to a temporary var before doing the + // iteration. + auto fetchers = std::move(image_fetchers_); + image_fetchers_.clear(); + + for (const auto& fetcher : fetchers) { // Will run callbacks with an empty image vector. fetcher->Dispose(); } - image_fetchers_.clear(); } } // namespace blink
Original Bug Report
Potential Use-After-Free in ImageDownloaderImpl::ContextDestroyed
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A range-based for loop in ImageDownloaderImpl::ContextDestroyed iterates over a vector of unique_ptrs. Calling Dispose() on the elements synchronously removes them from the vector, leaving stale pointers in the vector’s trailing slots that the loop subsequently dereferences, leading to a Use-After-Free.
Affected files:
third_party/blink/renderer/modules/image_downloader/image_downloader_impl.ccthird_party/blink/renderer/modules/image_downloader/multi_resolution_image_resource_fetcher.cc
Estimated timestamp from git blame: 2020-02-16
Overview
There is a potential Use-After-Free (UAF) vulnerability in ImageDownloaderImpl::ContextDestroyed() in the renderer process. The issue stems from a range-based for loop iterating over WTF::Vector<std::unique_ptr<MultiResolutionImageResourceFetcher>> image_fetchers_. The loop body calls a method on the unique_ptr that synchronously mutates the vector by erasing the element. Because WTF::Vector::erase() uses memmove to shift elements but does not clear the trailing slots, the loop continues to iterate (using its cached end iterator) and eventually dereferences a stale unique_ptr in the uncleared memory, causing a UAF when it calls Dispose() on an already-deleted object.
Technical Details
- The Loop:
ImageDownloaderImpl::ContextDestroyed()iterates over theimage_fetchers_vector using a range-based for loop:void ImageDownloaderImpl::ContextDestroyed() { for (const auto& fetcher : image_fetchers_) { fetcher->Dispose(); } image_fetchers_.clear(); } - Synchronous Mutation:
MultiResolutionImageResourceFetcher::Dispose()synchronously runs its callback:std::move(callback_).Run(...). - Vector Erasure: This callback is bound to
ImageDownloaderImpl::DidFetchImage(), which finds the corresponding fetcher inimage_fetchers_and erases it:it = image_fetchers_.erase(it);. - Stale Pointers:
WTF::Vector::erase()callsTypeOperations::MoveOverlapping()(usingmemmove) to shift the remaining elements left. Crucially, because the default allocator isPartitionAlloc(wherekIsGarbageCollectedis false),ClearUnusedSlots()is a no-op. The trailing slots of the vector’s backing buffer retain the raw bit patterns of the movedunique_ptrs. - The UAF: The range-based for loop caches the
enditerator initially. Even though the vector shrinks with each erasure, the loop continues iterating until it reaches the originalend. It eventually encounters the uncleared, staleunique_ptrs in the trailing slots. These point to memory that was freed in previous iterations. The loop dereferences the stale pointer and callsDispose()on the attacker-controlled, freed memory chunk.
Potential Exploitability
This vulnerability could potentially lead to Remote Code Execution (RCE) in the renderer process.
Inside Dispose(), the code accesses the callback_ member, which is a base::OnceCallback. Executing this callback requires reading the bind_state_ pointer (a scoped_refptr<BindStateBase>) to find the polymorphic_invoke_ function pointer.
An attacker could attempt to groom the heap to reclaim the memory chunk of the freed MultiResolutionImageResourceFetcher during the synchronous window provided immediately after the erasure (e.g., during the Mojo IPC allocations in DidDownloadImage, which is called right after DidFetchImage). By controlling the contents of the reclaimed memory, the attacker could forge the BindStateHolder and hijack control flow.
This issue is not protected by MiraclePtr (BackupRefPtr) because the stale pointer is a raw T* stored inside a standard std::unique_ptr within the WTF::Vector’s raw backing store, completely bypassing BRP checks.
Suggested Fix
To fix this, the image_fetchers_ vector should be cleared safely before iterating, for example by swapping it with a local vector or taking ownership using std::exchange:
void ImageDownloaderImpl::ContextDestroyed() {
auto fetchers = std::move(image_fetchers_);
for (const auto& fetcher : fetchers) {
fetcher->Dispose();
}
}
(Note: These are suggested steps based on code analysis; a working proof of concept has not yet been executed to confirm exact exploitation parameters.)
Evaluated with Chrome root at commit: 876d480da1f794d87813cfa2e6ff4fcf9771e939
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.