d40f4efab0e0cee61e29c8540abff5285bf5275d [WebCore] Use-after-free in ImageLoader::dispatchPendingLoadEvent / dispatchPendingErrorEvent
Triage note: dispatchPendingLoadEvent() took `Ref document = element().document()` without protecting the element itself; a nested run loop (showModalDialog) fires m_derefElementTimer dropping m_protectedElement while the method is still on the stack. Fix adds `Ref protectedElement = element()` to keep the element alive across event dispatch.
Contents
The bug at a glance
Fully reachable from unprivileged web content: an <img> in a <picture>, a load/error handler, and showModalDialog are all ordinary DOM features, and internals is only needed for the test harness, not the bug. The defect is a use-after-free of the ImageLoader (owned by the HTMLImageElement) that runs on freed this when the trailing updatedHasPendingEvent() executes, a classic renderer-side UAF that is a strong basis for a controlled type-confusion / heap-reuse primitive and thus RCE. CVSS 8.8 reflects network attack vector, low complexity (single reload handler), no privileges, and high impact on all three of C/I/A within the content process.
This is a textbook “protect the wrong object” lifetime bug, and the giveaway is right there in the old line: dispatchPendingLoadEvent() took Ref document = element().document(), dutifully keeping the document alive while dispatching author script, but never pinned the element whose loader it was executing inside. The element’s life was left to a full-expression temporary and a member m_protectedElement that a 0-second m_derefElementTimer is allowed to clear. What makes it delicious is the reentrancy chain: the load handler removes the <img> from its <picture>, which re-arms that very timer, then opens a showModalDialog whose nested run loop fires the timer mid-dispatch, dropping the last real reference. The loader is a unique_ptr member of the element, so when the stack finally unwinds and the temporary Ref dies, ~HTMLImageElement deletes the loader out from under its own still-running method, and the innocuous trailing updatedHasPendingEvent() dereferences freed memory.
Root cause
ImageLoader is owned by its host element via a unique_ptr<HTMLImageLoader> member of HTMLImageElement, so the loader’s lifetime is strictly bounded by the element’s. dispatchPendingLoadEvent() and dispatchPendingErrorEvent() both dispatch author-observable events (dispatchLoadEvent() / an errorEvent) and then touch this again after the dispatch via updatedHasPendingEvent(). Across that dispatch, the only things keeping the element alive were the member m_protectedElement (managed by an m_derefElementTimer that can be scheduled with a 0s delay) and, in the error path, a full-expression-scoped protect(element()) temporary that dies the instant that statement completes.
The reaching path is a reentrancy loop driven from the load handler. When the load event is dispatched, author script running in the handler removes the <img> from its enclosing <picture> (the test does pic.textContent = ''). That mutation re-enters the image-source selection logic, selectImageSource(RelevantMutation::Yes), which calls updatedHasPendingEvent() and re-arms m_derefElementTimer for a 0s fire. The handler then calls showModalDialog(), which spins a nested run loop. Inside that nested loop the 0s m_derefElementTimer fires and clears m_protectedElement, releasing the element reference that was holding everything up.
Why it is unsafe: after showModalDialog returns and the load handler unwinds, control comes back to dispatchPendingLoadEvent() still on the stack. With m_protectedElement already cleared, the last remaining strong reference to the element is the transient one, and when it drops to zero ~HTMLImageElement runs, which destroys the unique_ptr<HTMLImageLoader> and therefore frees this. The method then executes its trailing updatedHasPendingEvent() on the freed ImageLoader, a use-after-free where the object’s own destructor was triggered synchronously inside one of its methods by attacker-controlled reentrancy. This is a lifetime/ownership bug: a member protection ref plus a full-expression temporary are insufficient to survive a nested run loop that can drop them.
The fix adds an explicit stack Ref to the element that outlives the entire method body in both functions: Ref protectedElement = element(); followed by Ref document = protectedElement->document();. Because the loader is owned by the element, holding the element alive keeps this ImageLoader alive across the dispatch and across the trailing updatedHasPendingEvent(), regardless of the m_derefElementTimer clearing m_protectedElement. In dispatchPendingErrorEvent() the transient protect(element())->dispatchEvent(...) is also replaced with protectedElement->dispatchEvent(...), reusing the durable stack reference instead of a full-expression temporary.
Key code
ImageLoader::dispatchPendingLoadEvent(): pin the element, not just the document, for the whole method.
void ImageLoader::dispatchPendingLoadEvent()
{
if (!m_image)
return;
m_hasPendingLoadEvent = false;
- Ref document = element().document();
+ Ref protectedElement = element();
+ Ref document = protectedElement->document();
if (document->canEverRender())
dispatchLoadEvent();
// ... author script above can drop m_protectedElement via a 0s m_derefElementTimer
// fired inside a nested showModalDialog run loop; protectedElement keeps
// the element (and thus this loader) alive for the trailing updatedHasPendingEvent().
updatedHasPendingEvent();
}
Patch walkthrough
Source/WebCore/loader/ImageLoader.cpp— In dispatchPendingLoadEvent(),Ref document = element().document();is replaced byRef protectedElement = element(); Ref document = protectedElement->document();, introducing a stack reference to the element that spans the load-event dispatch and the trailing updatedHasPendingEvent(). In dispatchPendingErrorEvent() the same protectedElement Ref is added, and the error dispatch is changed from the full-expression-scopedprotect(element())->dispatchEvent(...)toprotectedElement->dispatchEvent(...), so both the event dispatch and the post-dispatchthisaccess are covered by a reference that a 0s m_derefElementTimer cannot invalidate.LayoutTests/fast/images/image-load-event-in-modal-dialog-crash.html— Regression test that builds the exact reentrancy chain: a <picture> with a <source> and an <img> in a shadow root, a capturing load listener that removes the <img> (pic.textContent = ‘’) to re-arm m_derefElementTimer, then calls showModalDialog to spin a nested run loop that fires the timer mid-dispatch. It finishes on a subsequent task so the crash point (the trailing updatedHasPendingEvent()) is reached; PASS is simply not crashing.LayoutTests/fast/images/resources/self-closing-modal-dialog.html— The modal document loaded by showModalDialog; a 10ms timer calls testRunner.abortModal() and window.close() so the nested run loop unwinds on its own, letting the parent handler return and the vulnerable trailing code run.LayoutTests/fast/images/image-load-event-in-modal-dialog-crash-expected.txt— Expected output: two showModalDialog deprecation console messages and ‘PASS if no crash.’, encoding that the test is purely a crash-regression check.
Background
ImageLoader ownership — ImageLoader (and its subclass HTMLImageLoader) is owned by its host element through a unique_ptr member on the element. The loader has no independent refcount keeping it alive; when the element is destroyed, the loader is destroyed with it, so any method running on the loader is unsafe once the element dies.
m_protectedElement and m_derefElementTimer — ImageLoader keeps a self-managed protection reference to its element in m_protectedElement and uses a 0-second m_derefElementTimer to decide when to release it. updatedHasPendingEvent() adjusts this protection based on whether events are still pending, and can schedule the timer to drop the reference asynchronously.
selectImageSource(RelevantMutation::Yes) — Called when a <picture>/<img>’s effective source may have changed (e.g. removing the <img> from its <picture>). It invokes updatedHasPendingEvent(), which is what re-arms m_derefElementTimer during the load handler, setting up the drop of m_protectedElement.
showModalDialog and nested run loops — showModalDialog() blocks the caller and spins a nested event loop until the dialog closes. This lets timers (including the 0s m_derefElementTimer) fire while an outer C++ method is still on the stack, the reentrancy that turns a deferred deref into a use-after-free.
Vulnerability window
- Setup — A <picture> containing a <source> and an <img> loads its image; ImageLoader queues a pending load event and holds the element via m_protectedElement.
- Dispatch — dispatchPendingLoadEvent() runs, takes only
Ref document = element().document(), and calls dispatchLoadEvent(), invoking the author’s load handler. - Re-arm timer — The handler removes the <img> from its <picture> (pic.textContent = ‘’), re-entering selectImageSource(RelevantMutation::Yes) -> updatedHasPendingEvent(), which arms the 0s m_derefElementTimer.
- Nested loop drops the ref — The handler calls showModalDialog(); its nested run loop fires m_derefElementTimer, clearing m_protectedElement and removing the element’s protecting reference.
- Free — The dialog closes and the handler returns; as the stack unwinds the last strong reference to the element drops, ~HTMLImageElement runs and destroys the unique_ptr<HTMLImageLoader>, freeing
this. - Use-after-free — Back in dispatchPendingLoadEvent(), the trailing updatedHasPendingEvent() executes on the freed ImageLoader. Post-patch, protectedElement keeps the element (and loader) alive, so this access is safe.
Proof of concept
Reconstructed directly from the committed layout test. The capturing load listener removes the <img> from its <picture> to schedule the 0s m_derefElementTimer, then blocks in showModalDialog whose nested run loop fires that timer, dropping m_protectedElement while dispatchPendingLoadEvent() is still executing. On a vulnerable build the trailing updatedHasPendingEvent() runs on the freed loader. This is the crash trigger; a real exploit would additionally spray the freed slot (see exploitation).
<!-- Reconstructed from fast/images/image-load-event-in-modal-dialog-crash.html.
Needs ShowModalDialogEnabled; the load handler re-arms m_derefElementTimer
and a nested showModalDialog run loop fires it mid-dispatch. -->
<div id="host" style="display:none"></div>
<script>
var host = document.getElementById('host');
var sr = host.attachShadow({mode: 'open'});
sr.innerHTML = '<picture id="pic"><source srcset="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"><img></picture>';
var pic = sr.getElementById('pic');
pic.addEventListener('load', function listener() {
pic.removeEventListener('load', listener, true);
pic.textContent = ''; // re-arms m_derefElementTimer
showModalDialog('resources/self-closing-modal-dialog.html'); // nested loop fires it
}, /*capture*/ true);
</script>
Exploitation
- Deterministic free — The chain gives a reliable synchronous free of the ImageLoader at a known point (the timer fires inside the nested run loop and the free completes as the load handler unwinds), which is unusually controllable for a UAF because the attacker chooses exactly when showModalDialog returns.
- Heap grooming / reallocation — Between the free and the trailing updatedHasPendingEvent(), the attacker has JS execution (in the setTimeout continuation and, on some paths, within nested-loop callbacks) to allocate objects of the ImageLoader’s size class to reoccupy the freed slot, aiming for a controlled type confusion on the subsequent member accesses.
- Primitive from confused access — updatedHasPendingEvent() reads/writes ImageLoader members (event-pending flags, the element/timer state); a groomed replacement object can turn those into a controlled read or write. Difficulty is moderate: the reachable member operations are limited, so escalation likely chains this with other primitives, but the deterministic free makes it a credible RCE building block rather than a pure DoS.
Detection & hunting
For defenders and SOC / detection engineers:
- ASan use-after-free in ImageLoader — heap-use-after-free with the free stack in ~HTMLImageElement / the HTMLImageLoader unique_ptr destructor and the read/write stack in ImageLoader::updatedHasPendingEvent() called from dispatchPendingLoadEvent/dispatchPendingErrorEvent is the exact signature.
- showModalDialog during image event dispatch — SOC/telemetry can flag pages that call window.showModalDialog from inside an image load/error handler, especially combined with same-tick DOM mutation of a <picture>/<img>; showModalDialog is deprecated and rare in benign content.
- Fuzzer oracle — DOM fuzzers should combine nested run loops (showModalDialog/alert) with element removal inside media/image event handlers and run under ASan; reentrancy that destroys the element whose handler is executing is the class to target.
Audit directions
- Other ImageLoader event paths — Review every ImageLoader method that dispatches events and then touches
thisor the element afterward (beyond the two fixed here) to confirm each holds a durable stack Ref to the element rather than relying on m_protectedElement. - Loaders owned by elements via unique_ptr — Audit other element-owned helper objects (media loaders, form-associated helpers) whose methods dispatch script and then re-touch members; the ownership shape (helper is a unique_ptr member) means protecting the helper requires protecting the owner element.
- full-expression protect() patterns — Grep for
protect(element())->andRef x = element().something()where the protection lasts only a single statement but script can run and re-enter afterward; these are the same too-short-lifetime smell fixed here. - 0s deref timers and nested run loops — Look for other m_…Timer members with 0-delay that drop the sole protecting reference to an object, then find any code path (showModalDialog, synchronous XHR, print) that can spin a nested run loop while a method holding that object is on the stack.