← All issues

[10] DocumentLoader ScriptDisallowedScope Bypass via PluginView Destruction

Severity: Medium | Component: WebCore document loader | 7fec184

Rated Medium because the observable effect is JavaScript execution during a ScriptDisallowedScope (assertion failure in debug builds, silent re-entrancy in release builds), and the projected escalation — memory corruption via DOM mutation during style recalculation — would require the attacker's JS callback to hit specific stale references on the recalculation call stack, which is plausible but unverified.

In DocumentLoader::removePlugInStreamLoader(), the synchronous call to checkLoadComplete() is replaced with an asynchronous task queued on the document's event loop. The new code guards on m_frame and m_frame->document() being non-null, and captures Ref { *this } to prevent the DocumentLoader from being destroyed before the task runs.

Source/WebCore/loader/DocumentLoader.cpp

void DocumentLoader::removePlugInStreamLoader(ResourceLoader& loader)
{
ASSERT(m_plugInStreamLoaders.contains(&loader));
-
m_plugInStreamLoaders.remove(&loader);
- checkLoadComplete();
+ if (m_frame && m_frame->document()) {
+ protect(m_frame->document())->eventLoop().queueTask(TaskSource::Networking, [protectedThis = Ref { *this }]() {
+ protectedThis->checkLoadComplete();
+ });
+ }
}

LayoutTests/fast/pdf-plugin-destruction-dispatches-print-event-crash.html

+<embed id="embed" style="top: 200vmin; visibility: hidden;" type="application/pdf" src="about:blank"></embed>
+<script>
+ window.testRunner?.dumpAsText();
+ window.testRunner?.waitUntilDone();
+ window.addEventListener("load", _ => {
+ embed.setAttribute("onsubmit", "");
+ window.print();
+ requestAnimationFrame(_ => requestAnimationFrame(_ => window.testRunner?.notifyDone()));
+ });
+</script>

Synchronous script-triggering callback from within a ScriptDisallowedScope during style recalculation.

ScriptDisallowedScope is a RAII guard used in WebKit that asserts no JavaScript execution occurs on the main thread for the scope's lifetime. It is enforced via assertions in debug builds; in release builds, the check may not prevent execution, making violations a real attack surface rather than just a debug crash.

Document::updateStyleIfNeeded() triggers style recalculation for the document. During this operation, a ScriptDisallowedScope is active because the style/layout tree may be in an intermediate state. PluginView represents an embedded plugin (e.g., PDF viewer). When a PluginView is destroyed — for instance, because a style update determines the plugin element is no longer rendered — its associated stream loaders are cleaned up via DocumentLoader::removePlugInStreamLoader(). checkLoadComplete() is a DocumentLoader method that checks whether all subresources have finished loading and may trigger completion callbacks, including event dispatch.

Before the fix, removePlugInStreamLoader() called checkLoadComplete() synchronously. This function could be reached during updateStyleIfNeeded(), which creates a ScriptDisallowedScope. When a style update triggered the destruction of a PluginView (the PDF embed in the test case), the chain updateStyleIfNeeded() → PluginView destruction → removePlugInStreamLoader()checkLoadComplete()dispatchPrintEvent fired JavaScript event listeners inside the ScriptDisallowedScope. In release builds, where ScriptDisallowedScope does not enforce the invariant, JavaScript executes while the DOM/style tree is in an intermediate, inconsistent state during style recalculation.

🔒

Explores the re-entrancy implications and what an attacker could achieve with script execution during style recalculation

Subscribe to read more

🔒

Multiple audit patterns identified for finding similar ScriptDisallowedScope violations across WebKit's object lifecycle and event dispatch paths

Subscribe to read more