Compare commits

...

1 Commits

Author SHA1 Message Date
Shelley Vohr
0c575026e4 fix: prevent use-after-free when destroying guest WebContents during event emission
Multiple event emission sites in WebContents destroy the underlying C++
object via a JavaScript event handler calling webContents.destroy(), then
continue to dereference the freed `this` pointer. This is exploitable
through <webview> guest WebContents because Destroy() calls `delete this`
synchronously for guests, unlike non-guests which safely defer deletion.

The fix has two layers:

1. A new `is_emitting_event_` flag is checked in Destroy() — when true,
   guest deletion is deferred to a posted task instead of executing
   synchronously. This is separate from `is_safe_to_delete_` (which
   gates LoadURL re-entrancy) to avoid rejecting legitimate loadURL
   calls from event handlers.

2. AutoReset<bool> guards on `is_emitting_event_` are added to
   CloseContents, RenderViewDeleted, DidFinishNavigation, and
   SetContentsBounds, preventing synchronous destruction while their
   Emit() calls are on the stack.

Destroy() now requires both `is_safe_to_delete_` (navigation re-entrancy)
and `!is_emitting_event_` (event emission) to allow synchronous guest
deletion. The existing AutoReset guards on `is_safe_to_delete_` in
DidStartNavigation, DidRedirectNavigation, and ReadyToCommitNavigation
are also now effective for guests.
2026-04-09 10:41:49 +02:00
3 changed files with 64 additions and 10 deletions

View File

@@ -1179,7 +1179,9 @@ void WebContents::DeleteThisIfAlive() {
void WebContents::Destroy() {
// The content::WebContents should be destroyed asynchronously when possible
// as user may choose to destroy WebContents during an event of it.
if (Browser::Get()->is_shutting_down() || is_guest()) {
if (Browser::Get()->is_shutting_down()) {
DeleteThisIfAlive();
} else if (is_guest() && is_safe_to_delete_ && !is_emitting_event_) {
DeleteThisIfAlive();
} else {
content::GetUIThreadTaskRunner({})->PostTask(
@@ -1439,17 +1441,21 @@ void WebContents::BeforeUnloadFired(content::WebContents* tab,
void WebContents::SetContentsBounds(content::WebContents* source,
const gfx::Rect& rect) {
base::AutoReset<bool> resetter(&is_emitting_event_, true);
if (!Emit("content-bounds-updated", rect))
observers_.Notify(&ExtendedWebContentsObserver::OnSetContentBounds, rect);
}
void WebContents::CloseContents(content::WebContents* source) {
Emit("close");
{
base::AutoReset<bool> resetter(&is_emitting_event_, true);
Emit("close");
auto* autofill_driver_factory =
AutofillDriverFactory::FromWebContents(web_contents());
if (autofill_driver_factory) {
autofill_driver_factory->CloseAllPopups();
auto* autofill_driver_factory =
AutofillDriverFactory::FromWebContents(web_contents());
if (autofill_driver_factory) {
autofill_driver_factory->CloseAllPopups();
}
}
Destroy();
@@ -1962,6 +1968,7 @@ void WebContents::FrameDeleted(content::FrameTreeNodeId frame_tree_node_id) {
}
void WebContents::RenderViewDeleted(content::RenderViewHost* render_view_host) {
base::AutoReset<bool> resetter(&is_emitting_event_, true);
const auto id = render_view_host->GetProcess()->GetID().GetUnsafeValue();
// This event is necessary for tracking any states with respect to
// intermediate render view hosts aka speculative render view hosts. Currently
@@ -2201,6 +2208,9 @@ void WebContents::DidFinishNavigation(
if (!navigation_handle->HasCommitted())
return;
base::AutoReset<bool> resetter(&is_emitting_event_, true);
bool is_main_frame = navigation_handle->IsInMainFrame();
content::RenderFrameHost* frame_host =
navigation_handle->GetRenderFrameHost();
@@ -2210,9 +2220,6 @@ void WebContents::DidFinishNavigation(
frame_routing_id = frame_host->GetRoutingID();
}
if (!navigation_handle->IsErrorPage()) {
// FIXME: All the Emit() calls below could potentially result in |this|
// being destroyed (by JS listening for the event and calling
// webContents.destroy()).
auto url = navigation_handle->GetURL();
bool is_same_document = navigation_handle->IsSameDocument();
if (is_same_document) {

View File

@@ -882,9 +882,15 @@ class WebContents final : public ExclusiveAccessContext,
const scoped_refptr<base::TaskRunner> print_task_runner_;
#endif
// Track navigation state in order to avoid potential re-entrancy crashes.
// Track navigation state in order to avoid potential re-entrancy crashes
// in LoadURL. Checked by LoadURL to reject re-entrant navigation attempts.
bool is_safe_to_delete_ = true;
// Set to true while dispatching JS events via Emit(). When true, Destroy()
// defers guest WebContents deletion to prevent use-after-free when a JS
// handler calls webContents.destroy() mid-emission.
bool is_emitting_event_ = false;
// Stores the frame that's currently in fullscreen, nullptr if there is none.
raw_ptr<content::RenderFrameHost> fullscreen_frame_ = nullptr;

View File

@@ -0,0 +1,41 @@
const { app, BrowserWindow, webContents } = require('electron');
app.whenReady().then(() => {
const w = new BrowserWindow({
show: false,
webPreferences: {
webviewTag: true,
nodeIntegration: true,
contextIsolation: false
}
});
w.loadURL('data:text/html,' + encodeURIComponent(
'<webview id="wv" src="about:blank" style="width:100px;height:100px"></webview>' +
'<script>document.getElementById("wv").addEventListener("dom-ready", function() {' +
' document.title = "READY:" + this.getWebContentsId();' +
'});</script>'
));
const check = setInterval(() => {
if (!w.getTitle().startsWith('READY:')) return;
clearInterval(check);
const guestId = parseInt(w.getTitle().split(':')[1]);
const guest = webContents.fromId(guestId);
if (!guest) {
app.quit();
return;
}
// Destroying a guest webContents inside an event handler previously caused
// a use-after-free because Destroy() deleted the C++ object synchronously
// while the event emission was still on the call stack.
guest.on('did-navigate-in-page', () => {
guest.destroy();
});
guest.executeJavaScript('history.pushState({}, "", "#trigger")');
setTimeout(() => app.quit(), 2000);
}, 200);
});