d3c3b2604c6d8685baa506fd5b62b3305bb36f1f Forbid script execution during plugin destruction
Triage note: The commit notes the UAF 'doesn't happen' currently (crbug 553126730); proactive hardening that removes the need for post-op liveness checks, not a shipped live-bug fix.
The bug at a glance
This is a defense-in-depth CL, not the repair of a live, reachable UAF. The commit message and bug 553126730 explicitly state that no plugin is currently known to synchronously detach its frame from Destroy(), so the dangerous re-entrancy window is not exercised by shipping code today. What the change removes is a latent, hard-to-audit invariant: as long as AllowUserAgentScript wrapped web_plugin_->Destroy(), any plugin implementation (including future ones or embedder-supplied WebPlugin subclasses) could run arbitrary script mid-teardown and free the objects the lifecycle update walk still holds. Because the trigger requires a cooperating plugin that runs script inside its own Destroy(), the renderer-side attacker cannot reach it through ordinary web content in current builds; hence low, CVSS ~3.5. The severity would rise materially if any in-tree or embedder plugin ever did execute script from Destroy(), which is precisely the state the CL now makes impossible to regress into unnoticed.
The renderer runs a document lifecycle update (style/layout). During that update a plugin element is detached and WebPluginContainerImpl tears down its WebPlugin. The teardown path previously opened a ScriptForbiddenScope::AllowUserAgentScript window around Destroy(), i.e. it deliberately re-permitted synchronous JavaScript at the exact moment when the object graph is half-dismantled and the caller (the lifecycle walk) still holds raw references into the frame/element/container it is in the middle of updating. Script that ran there could synchronously detach the frame or mutate the tree, freeing objects the caller dereferences after Destroy() returns.
Root cause
WebPluginContainerImpl::Dispose() (around line 867) is invoked from the lifecycle update while the engine is walking layout/paint state. The old code reasoned ‘plugins may execute script on being detached during the lifecycle update’ and therefore installed AllowUserAgentScript to let that script run. But allowing script inside a lifecycle update violates Blink’s core re-entrancy rule: script can run event handlers, remove the plugin element, detach the frame, and destroy the very LocalFrame / Document / WebPluginContainerImpl whose stack frames are still live above Dispose(). When control returns from web_plugin_->Destroy(), the code touches web_plugin_ (sets it to nullptr) and the outer lifecycle walk resumes touching now-freed nodes -> use-after-free. The fix inverts the policy: instead of AllowUserAgentScript it installs a ScriptForbiddenScope forbid_script for the duration of Destroy(). Any attempt to enter script (ExecuteScript, microtasks, event dispatch) now hits ScriptForbiddenScope::IsScriptForbidden()==true and is dropped/CHECK-guarded, so no synchronous detach can occur, and the walk no longer needs to re-validate object liveness after every style/layout step (the stated maintenance benefit).
Key code
web_plugin_container_impl.cc: teardown now forbids script instead of re-allowing it
if (web_plugin_) {
// Plugins must not execute script while being detached.
ScriptForbiddenScope forbid_script;
CHECK(web_plugin_->Container() == this);
web_plugin_->Destroy();
web_plugin_ = nullptr;
}
Patch walkthrough
third_party/blink/renderer/core/exported/web_plugin_container_impl.cc— The load-bearing change. The ScriptForbiddenScope::AllowUserAgentScript object (which permitted UA script) is replaced by a plain ScriptForbiddenScope (which forbids all script) scoped over the web_plugin_->Destroy() call. The CHECK(web_plugin_->Container() == this) and web_plugin_ = nullptr are unchanged. Net effect: from the moment teardown begins until the WebPlugin is dropped, IsScriptForbidden() is true, so no plugin callback can synchronously run JS and reenter the lifecycle update.third_party/blink/renderer/core/dom/element_test.cc— The ScriptOnDestroyPlugin fake is updated to assert the new invariant: its Destroy() now does ASSERT_TRUE(ScriptForbiddenScope::IsScriptForbidden()) (was ASSERT_FALSE). The comment flips from ‘assert that script is allowed in Destroy’ to ‘forbidden’. This locks the policy at the unit level.third_party/blink/renderer/core/exported/web_view_test.cc— WebViewTest.DetachPluginInLayout is rewritten to be a genuine regression test. The in-test ScriptInDestroyPlugin still calls frame_->ExecuteScript(“console.log(‘done’)”) from Destroy() and records via a destroyed_ out-parameter that Destroy() actually ran. The assertions change from expecting the console message ‘done’ to expecting PluginDestroyed()==true AND Message().IsNull()==true, i.e. the plugin was destroyed but its attempt to execute script was suppressed (no console output). This proves the ScriptForbiddenScope now swallows the plugin’s script rather than letting it detach the frame.
Background
ScriptForbiddenScope / AllowUserAgentScript — A RAII guard in Blink. Constructing ScriptForbiddenScope sets a thread-global ‘script forbidden’ counter; while >0, ScriptForbiddenScope::IsScriptForbidden() returns true and paths that would run JS (event dispatch, microtask draining, ExecuteScript) are asserted-against or short-circuited. AllowUserAgentScript is the inverse escape hatch that temporarily permits UA-initiated script even inside a forbidden region. This CL removes an AllowUserAgentScript and adds a forbidding scope.
Document lifecycle update — The style -> layout -> compositing -> paint pipeline that Blink runs synchronously. It holds many raw pointers into the DOM/layout tree and assumes the tree does not mutate under it. Running script mid-update is a classic source of UAF; Blink normally forbids script during these phases.
WebPluginContainerImpl / WebPlugin::Destroy — WebPluginContainerImpl bridges a Blink plugin element to an embedder-provided WebPlugin. Dispose() runs during teardown and calls WebPlugin::Destroy(), which for some implementations historically ran script (e.g. to fire unload-like callbacks). That historical need is what the old AllowUserAgentScript catered to.
Vulnerability window
- Latent invariant — Teardown wrapped Destroy() in AllowUserAgentScript, permitting synchronous script during a lifecycle update - a UAF-prone window kept alive only by the fact that no plugin actually used it.
- Analysis (bug 553126730) — It was confirmed that no current plugin synchronously detaches the frame from Destroy(), so the window is unused in practice.
- Fix (2026-09-14) — Etienne Pierre-Doray replaced the allow-scope with a forbid-scope, removing the ability to reenter script during plugin disposal and eliminating the need for post-hoc liveness checks.
- Disposition — Landed to main@{#1697344}; low-severity hardening, no backport tag observed in the diff.
Proof of concept
No exploit exists and none is warranted: current builds have no plugin that runs script from Destroy(), so there is nothing to trigger. The diff does contain a regression/behavior test (WebViewTest.DetachPluginInLayout) that models the dangerous case - a plugin that calls ExecuteScript() from Destroy() - and asserts the new policy suppresses it. Reproduced below verbatim from the diff as evidence of behavior, not as an attack. It demonstrates that the plugin’s script is now dropped (Message().IsNull()) while Destroy() still completes.
class ScriptInDestroyPlugin : public FakeWebPlugin {
public:
ScriptInDestroyPlugin(WebLocalFrame* frame,
const WebPluginParams& params,
bool* destroyed)
: FakeWebPlugin(params), frame_(frame), destroyed_(destroyed) {}
void Destroy() override {
*destroyed_ = true;
frame_->ExecuteScript(WebScriptSource("console.log('done')"));
// Deletes this.
FakeWebPlugin::Destroy();
}
private:
raw_ptr<WebLocalFrame, UnprotectedInRelease | DanglingUntriaged> frame_;
raw_ptr<bool> destroyed_;
};
// ... UpdateAllLifecyclePhases() detaches the plugin; assertions:
EXPECT_TRUE(frame_client.PluginDestroyed());
EXPECT_TRUE(frame_client.Message().IsNull()); // script was suppressed
Exploitation
- Prerequisite — Requires a WebPlugin implementation whose Destroy() synchronously runs script that detaches the frame or removes the plugin element. No such in-tree/web-reachable plugin is known, so in current builds the primitive is not reachable from web content.
- Hypothetical trigger — Force a lifecycle update that detaches the plugin (e.g. remove/reflow the plugin element), have Destroy() run script that detaches the frame, then let the outer walk dereference the freed frame/container -> UAF.
- Post-fix — ScriptForbiddenScope makes any such synchronous script a no-op / CHECK, so the freeing step cannot occur; exploitation path is closed by construction rather than by liveness checks.
Detection & hunting
For defenders and detection engineers:
- ScriptForbiddenScope violations —
- Crash clustering —
Audit directions
- Other AllowUserAgentScript sites —
- WebPlugin::Destroy implementations —
- Lifecycle-update reentrancy —