CVE-2026-118341
Overview
Background
- `OomInterventionImpl`
- a renderer-side controller (
third_party/blink/renderer/controller/oom_intervention_impl.cc) that reacts to near-OOM conditions by navigating away ad frames and/or purging V8 memory, driven over a Mojo receiver from the browser. - `MemoryUsageMonitor`
- a singleton observer registry;
OomInterventionImplregisters itself as an observer and is notified to runCheck(). - `Check()`
- the detection routine; on
oom_detectedit iterates the local frames of ordinary pages and, per frame, callsNavigateLocalAdsFrames()and/orLocalFrame::ForciblyPurgeV8Memory(). - Synchronous script execution
- both
NavigateLocalAdsFrames()andForciblyPurgeV8Memory()can run JavaScript synchronously (e.g., unload/detach handlers), which can disconnect theOomInterventionMojo receiver and thereby destroythisbefore the loop finishes. - `base::WeakPtrFactory`
- standard mechanism to detect that
thishas been destroyed mid-call.
Root Cause Analysis
Check() iterated over local frames calling NavigateLocalAdsFrames(local_frame) and local_frame->ForciblyPurgeV8Memory() without any guard that this survived each call. Both operations can synchronously execute page script (frame navigation/detach, GC-triggering unload handlers), and that script can disconnect the OomIntervention Mojo receiver, which destroys the OomInterventionImpl instance. After such a synchronous destruction, the loop continued to touch this (reading navigate_ads_enabled_/purge_v8_memory_enabled_ and, further down, host_->OnHighMemoryUsage() plus the old trailing MemoryUsageMonitorInstance().RemoveObserver(this)), i.e., a classic re-entrant use-after-free of the controller during its own iteration. The old code also removed the observer only after the frame loop, so the monitor could still hold this while script ran.
The patch takes auto weak_this = weak_factory_.GetWeakPtr(); before the loop and, immediately after each NavigateLocalAdsFrames() and each ForciblyPurgeV8Memory() call, checks if (!weak_this) return; to bail out the moment the instance has been destroyed by synchronous script. It also moves MemoryUsageMonitorInstance().RemoveObserver(this) to before the frame iteration begins (and removes the now-redundant later removal), so the monitor no longer references this across the script-executing calls. The header adds base::WeakPtrFactory<OomInterventionImpl> weak_factory_{this};. The advisory’s Use after free in Modularization matches the diff; the “Modularization” component label is just where this controller lives. The new test DestroyedDuringV8PurgeMemory deletes the intervention from a frame-detached callback during ForciblyPurgeV8Memory() and expects clean survival.
Check() kept using this across NavigateLocalAdsFrames()/ForciblyPurgeV8Memory() calls that can synchronously run script and disconnect the Mojo receiver, destroying the OomInterventionImpl mid-loop — a re-entrant UAF, now gated by a WeakPtr early-return.Attack Path
- Set up frames Attacker page creates a child frame (and a nested frame) whose handlers run on detach/readystatechange. (web-reachable)
- Arm self-destruction
Script arranges that during frame navigation/V8 purge, a handler detaches the frame driving the intervention and/or disconnects the
OomInterventionreceiver, destroyingOomInterventionImpl. - Trigger OOM path
A near-OOM condition causes the browser to invoke
Check()withnavigate_ads_enabled_/purge_v8_memory_enabled_. (near-OOM triggering is a precondition) - Re-entrant free
Inside the frame loop,
NavigateLocalAdsFrames()/ForciblyPurgeV8Memory()runs the attacker’s synchronous script, which freesthis. - Primitive
The loop (old code) continues to dereference the freed
OomInterventionImpl/host_, a renderer use-after-free. (escalation inferred)
Impact Assessment
OomInterventionImpl and its host_. Strength beyond the UAF and any escalation to RCE are (inferred); the diff proves only the re-entrant-destruction lifetime bug and its guard.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/controller/oom_intervention_impl.cc |
modified | |
MockOomInterventionImplthird_party/blink/renderer/controller/oom_intervention_impl_test.cc |
modified | |
mock_memory_usage_monitor_third_party/blink/renderer/controller/oom_intervention_impl_test.cc |
modified | |
OomInterventionImplTestthird_party/blink/renderer/controller/oom_intervention_impl_test.cc |
modified | |
TEST_Fthird_party/blink/renderer/controller/oom_intervention_impl_test.cc |
modified |
Files Changed
third_party/blink/renderer/controller/oom_intervention_impl.hthird_party/blink/renderer/controller/oom_intervention_impl.ccthird_party/blink/renderer/controller/oom_intervention_impl_test.cc
Audit Directions
- Iteration over frames/pages with synchronous scriptAudit other controller loops that call frame navigation or
ForciblyPurgeV8Memory()/GC without re-checkingthis; synchronous script can free the iterating object. - Observer lifetime vs. callback side effectsVerify observers that run script-executing work remove themselves from registries before, not after, the script-executing phase.
- Mojo-receiver-owned objectsReview renderer objects destroyed by receiver disconnection for any method that keeps using
this/member pointers after calling into page script.
Patch
From 42ba36caf7a24a82b692fe6ce833e8dadc2e40fc Mon Sep 17 00:00:00 2001
From: Kenichi Ishibashi <bashi@chromium.org>
Date: Tue, 20 Oct 2026 05:24:29 -0700
Subject: [PATCH] Ensure OomInterventionImpl is alive during Check()
OomInterventionImpl::Check() iterates over local frames and invokes
NavigateLocalAdsFrames() and LocalFrame::ForciblyPurgeV8Memory(), which
may synchronously run script and disconnect the OomIntervention receiver
before the loop finishes.
Guard `this` with a WeakPtr in Check() and return early if the instance
is destroyed during frame iteration. Also remove `this` from
MemoryUsageMonitor before iterating over frames.
Bug: 574911701
Change-Id: I5c5761e0b7b6bc3f06c69c376563e36295848455
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8619132
Commit-Queue: Kenichi Ishibashi <bashi@chromium.org>
Reviewed-by: Kentaro Hara <haraken@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1722812}
---
diff --git a/third_party/blink/renderer/controller/oom_intervention_impl.cc b/third_party/blink/renderer/controller/oom_intervention_impl.cc
index 10ab8c4c..83d9c279 100644
--- a/third_party/blink/renderer/controller/oom_intervention_impl.cc
+++ b/third_party/blink/renderer/controller/oom_intervention_impl.cc
@@ -121,7 +121,9 @@
if (oom_detected) {
base::debug::SetCrashKeyString(GetStateCrashKey(), "during");
+ MemoryUsageMonitorInstance().RemoveObserver(this);
+ auto weak_this = weak_factory_.GetWeakPtr();
if (navigate_ads_enabled_ || purge_v8_memory_enabled_) {
// Copy Page::OrdinaryPages() to avoid UAF. Synchronous JS
// execution during iteration can create new pages, which causes rehashing
@@ -134,10 +136,18 @@
auto* local_frame = DynamicTo<LocalFrame>(frame);
if (!local_frame)
continue;
- if (navigate_ads_enabled_)
+ if (navigate_ads_enabled_) {
NavigateLocalAdsFrames(local_frame);
- if (purge_v8_memory_enabled_)
+ if (!weak_this) {
+ return;
+ }
+ }
+ if (purge_v8_memory_enabled_) {
local_frame->ForciblyPurgeV8Memory();
+ if (!weak_this) {
+ return;
+ }
+ }
}
}
}
@@ -149,7 +159,6 @@
}
host_->OnHighMemoryUsage();
- MemoryUsageMonitorInstance().RemoveObserver(this);
// Send memory pressure notification to trigger GC.
task_runner_->PostTask(FROM_HERE, BindOnce(&TriggerGC));
// Notify V8GCForContextDispose that page navigation gc is needed when
diff --git a/third_party/blink/renderer/controller/oom_intervention_impl.h b/third_party/blink/renderer/controller/oom_intervention_impl.h
index b4f92b46..2e98c50 100644
--- a/third_party/blink/renderer/controller/oom_intervention_impl.h
+++ b/third_party/blink/renderer/controller/oom_intervention_impl.h
@@ -8,6 +8,7 @@
#include <memory>
#include "base/gtest_prod_util.h"
+#include "base/memory/weak_ptr.h"
#include "base/task/single_thread_task_runner.h"
#include "base/types/pass_key.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
@@ -84,6 +85,7 @@
bool navigate_ads_enabled_ = false;
bool purge_v8_memory_enabled_ = false;
std::unique_ptr<ScopedPagePauser> pauser_;
+ base::WeakPtrFactory<OomInterventionImpl> weak_factory_{this};
};
} // namespace blink
diff --git a/third_party/blink/renderer/controller/oom_intervention_impl_test.cc b/third_party/blink/renderer/controller/oom_intervention_impl_test.cc
index f6155da..16341f0 100644
--- a/third_party/blink/renderer/controller/oom_intervention_impl_test.cc
+++ b/third_party/blink/renderer/controller/oom_intervention_impl_test.cc
@@ -8,6 +8,7 @@
#include <utility>
+#include "base/memory/raw_ptr.h"
#include "base/run_loop.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "mojo/public/cpp/bindings/pending_remote.h"
@@ -15,6 +16,7 @@
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/oom_intervention/oom_intervention_types.h"
#include "third_party/blink/public/platform/scheduler/test/renderer_scheduler_test_support.h"
+#include "third_party/blink/public/web/web_script_source.h"
#include "third_party/blink/renderer/controller/crash_memory_metrics_reporter_impl.h"
#include "third_party/blink/renderer/core/exported/web_view_impl.h"
#include "third_party/blink/renderer/core/frame/frame_test_helpers.h"
@@ -65,23 +67,25 @@
// Mock intervention class that uses a mock MemoryUsageMonitor.
class MockOomInterventionImpl : public OomInterventionImpl {
public:
- MockOomInterventionImpl()
+ explicit MockOomInterventionImpl(
+ MockMemoryUsageMonitor* mock_memory_usage_monitor)
: OomInterventionImpl(scheduler::GetSingleThreadTaskRunnerForTesting()),
- mock_memory_usage_monitor_(std::make_unique<MockMemoryUsageMonitor>()) {
+ mock_memory_usage_monitor_(mock_memory_usage_monitor) {}
+ ~MockOomInterventionImpl() override {
+ mock_memory_usage_monitor_->RemoveObserver(this);
}
- ~MockOomInterventionImpl() override {}
MemoryUsageMonitor& MemoryUsageMonitorInstance() override {
return *mock_memory_usage_monitor_;
}
MockMemoryUsageMonitor* mock_memory_usage_monitor() {
- return mock_memory_usage_monitor_.get();
+ return mock_memory_usage_monitor_;
}
private:
std::unique_ptr<OomInterventionMetrics> metrics_;
- std::unique_ptr<MockMemoryUsageMonitor> mock_memory_usage_monitor_;
+ raw_ptr<MockMemoryUsageMonitor> mock_memory_usage_monitor_;
};
} // namespace
@@ -89,7 +93,9 @@
class OomInterventionImplTest : public testing::Test {
public:
void SetUp() override {
- intervention_ = std::make_unique<MockOomInterventionImpl>();
+ mock_memory_usage_monitor_ = std::make_unique<MockMemoryUsageMonitor>();
+ intervention_ = std::make_unique<MockOomInterventionImpl>(
+ mock_memory_usage_monitor_.get());
}
Page* DetectOnceOnBlankPage() {
@@ -118,6 +124,7 @@
protected:
test::TaskEnvironment task_environment_;
+ std::unique_ptr<MockMemoryUsageMonitor> mock_memory_usage_monitor_;
std::unique_ptr<MockOomInterventionImpl> intervention_;
frame_test_helpers::WebViewHelper web_view_helper_;
std::unique_ptr<SimRequest> main_resource_;
@@ -254,4 +261,53 @@
EXPECT_TRUE(frame->DomWindow()->IsContextDestroyed());
}
+TEST_F(OomInterventionImplTest, DestroyedDuringV8PurgeMemory) {
+ MemoryUsage usage;
+ usage.private_footprint_bytes = kTestPMFThreshold + 1024;
+ usage.swap_bytes = 0;
+ usage.vm_size_bytes = 0;
+ intervention_->mock_memory_usage_monitor()->SetMockMemoryUsage(usage);
+
+ WebViewImpl* web_view = web_view_helper_.InitializeAndLoad("about:blank");
+ web_view->MainFrameImpl()
+ ->GetFrame()
+ ->GetDocument()
+ ->body()
+ ->SetInnerHTMLWithoutTrustedTypes(
+ "<iframe name='child' src='about:blank'></iframe>");
+
+ WebLocalFrame* child_web_frame = web_view_helper_.LocalMainFrame()
+ ->FindFrameByName(WebString("child"))
+ ->ToWebLocalFrame();
+ frame_test_helpers::PumpPendingRequestsForFrameToLoad(child_web_frame);
+
+ child_web_frame->ExecuteScript(WebScriptSource(
+ WebString("document.open();"
+ "document.write(\"<iframe id='gc' name='gc'></iframe>\");"
+ "document.onreadystatechange = () => {"
+ " document.getElementById('gc').remove();"
+ "};")));
+
+ WebLocalFrame* gc_web_frame = web_view_helper_.LocalMainFrame()
+ ->FindFrameByName(WebString("gc"))
+ ->ToWebLocalFrame();
+
+ // Simulate OomInterventionImpl being deleted when synchronous JS runs during
+ // ForciblyPurgeV8Memory().
+ auto* gc_client = static_cast<frame_test_helpers::TestWebFrameClient*>(
+ To<WebLocalFrameImpl>(gc_web_frame)->Client());
+ bool destroyed_during_purge = false;
+ gc_client->SetFrameDetachedCallback(blink::BindOnce(
+ [](std::unique_ptr<MockOomInterventionImpl>* intervention,
+ bool* destroyed_flag) {
+ intervention->reset();
+ *destroyed_flag = true;
+ },
+ base::Unretained(&intervention_),
+ base::Unretained(&destroyed_during_purge)));
+
+ RunDetection(true, true, true);
+ EXPECT_TRUE(destroyed_during_purge);
+}
+
} // namespace blink
Regression Test / PoC
diff --git a/third_party/blink/renderer/controller/oom_intervention_impl_test.cc b/third_party/blink/renderer/controller/oom_intervention_impl_test.cc
index f6155da..16341f0 100644
--- a/third_party/blink/renderer/controller/oom_intervention_impl_test.cc
+++ b/third_party/blink/renderer/controller/oom_intervention_impl_test.cc
@@ -8,6 +8,7 @@
#include <utility>
+#include "base/memory/raw_ptr.h"
#include "base/run_loop.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "mojo/public/cpp/bindings/pending_remote.h"
@@ -15,6 +16,7 @@
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/oom_intervention/oom_intervention_types.h"
#include "third_party/blink/public/platform/scheduler/test/renderer_scheduler_test_support.h"
+#include "third_party/blink/public/web/web_script_source.h"
#include "third_party/blink/renderer/controller/crash_memory_metrics_reporter_impl.h"
#include "third_party/blink/renderer/core/exported/web_view_impl.h"
#include "third_party/blink/renderer/core/frame/frame_test_helpers.h"
@@ -65,23 +67,25 @@
// Mock intervention class that uses a mock MemoryUsageMonitor.
class MockOomInterventionImpl : public OomInterventionImpl {
public:
- MockOomInterventionImpl()
+ explicit MockOomInterventionImpl(
+ MockMemoryUsageMonitor* mock_memory_usage_monitor)
: OomInterventionImpl(scheduler::GetSingleThreadTaskRunnerForTesting()),
- mock_memory_usage_monitor_(std::make_unique<MockMemoryUsageMonitor>()) {
+ mock_memory_usage_monitor_(mock_memory_usage_monitor) {}
+ ~MockOomInterventionImpl() override {
+ mock_memory_usage_monitor_->RemoveObserver(this);
}
- ~MockOomInterventionImpl() override {}
MemoryUsageMonitor& MemoryUsageMonitorInstance() override {
return *mock_memory_usage_monitor_;
}
MockMemoryUsageMonitor* mock_memory_usage_monitor() {
- return mock_memory_usage_monitor_.get();
+ return mock_memory_usage_monitor_;
}
private:
std::unique_ptr<OomInterventionMetrics> metrics_;
- std::unique_ptr<MockMemoryUsageMonitor> mock_memory_usage_monitor_;
+ raw_ptr<MockMemoryUsageMonitor> mock_memory_usage_monitor_;
};
} // namespace
@@ -89,7 +93,9 @@
class OomInterventionImplTest : public testing::Test {
public:
void SetUp() override {
- intervention_ = std::make_unique<MockOomInterventionImpl>();
+ mock_memory_usage_monitor_ = std::make_unique<MockMemoryUsageMonitor>();
+ intervention_ = std::make_unique<MockOomInterventionImpl>(
+ mock_memory_usage_monitor_.get());
}
Page* DetectOnceOnBlankPage() {
@@ -118,6 +124,7 @@
protected:
test::TaskEnvironment task_environment_;
+ std::unique_ptr<MockMemoryUsageMonitor> mock_memory_usage_monitor_;
std::unique_ptr<MockOomInterventionImpl> intervention_;
frame_test_helpers::WebViewHelper web_view_helper_;
std::unique_ptr<SimRequest> main_resource_;
@@ -254,4 +261,53 @@
EXPECT_TRUE(frame->DomWindow()->IsContextDestroyed());
}
+TEST_F(OomInterventionImplTest, DestroyedDuringV8PurgeMemory) {
+ MemoryUsage usage;
+ usage.private_footprint_bytes = kTestPMFThreshold + 1024;
+ usage.swap_bytes = 0;
+ usage.vm_size_bytes = 0;
+ intervention_->mock_memory_usage_monitor()->SetMockMemoryUsage(usage);
+
+ WebViewImpl* web_view = web_view_helper_.InitializeAndLoad("about:blank");
+ web_view->MainFrameImpl()
+ ->GetFrame()
+ ->GetDocument()
+ ->body()
+ ->SetInnerHTMLWithoutTrustedTypes(
+ "<iframe name='child' src='about:blank'></iframe>");
+
+ WebLocalFrame* child_web_frame = web_view_helper_.LocalMainFrame()
+ ->FindFrameByName(WebString("child"))
+ ->ToWebLocalFrame();
+ frame_test_helpers::PumpPendingRequestsForFrameToLoad(child_web_frame);
+
+ child_web_frame->ExecuteScript(WebScriptSource(
+ WebString("document.open();"
+ "document.write(\"<iframe id='gc' name='gc'></iframe>\");"
+ "document.onreadystatechange = () => {"
+ " document.getElementById('gc').remove();"
+ "};")));
+
+ WebLocalFrame* gc_web_frame = web_view_helper_.LocalMainFrame()
+ ->FindFrameByName(WebString("gc"))
+ ->ToWebLocalFrame();
+
+ // Simulate OomInterventionImpl being deleted when synchronous JS runs during
+ // ForciblyPurgeV8Memory().
+ auto* gc_client = static_cast<frame_test_helpers::TestWebFrameClient*>(
+ To<WebLocalFrameImpl>(gc_web_frame)->Client());
+ bool destroyed_during_purge = false;
+ gc_client->SetFrameDetachedCallback(blink::BindOnce(
+ [](std::unique_ptr<MockOomInterventionImpl>* intervention,
+ bool* destroyed_flag) {
+ intervention->reset();
+ *destroyed_flag = true;
+ },
+ base::Unretained(&intervention_),
+ base::Unretained(&destroyed_during_purge)));
+
+ RunDetection(true, true, true);
+ EXPECT_TRUE(destroyed_during_purge);
+}
+
} // namespace blink