CVE-2026-3936
Overview
Background
- `JsCommunication`
- A renderer-side, per-
RenderFrameobject incomponents/js_injectionthat holds the document-start/end scripts an embedder (e.g. Android WebView’saddDocumentStartJavaScript) registered and injects them into matching frames. - `RunScripts`
- The
JsCommunicationmethod that, for a givenmojom::DocumentInjectionTime, iterates overscripts_, checks each script’sorigin_matcheragainst the frame’s security origin, and executes matching scripts in the main or an isolated world. - `base::WeakPtr`
- A non-owning pointer that becomes null once the
base::WeakPtrFactorythat issued it is destroyed with its owner, so code can detect that an object is gone. - Re-entrancy via script execution
WebLocalFrame::ExecuteScript*runs JavaScript synchronously. That script can trigger frame detach or navigation, which can destroy theRenderFrameand its observers while the C++ caller is still on the stack.
Root Cause Analysis
Before the patch, JsCommunication::RunScripts looped over scripts_ and, for each matching entry, called render_frame()->GetWebFrame()->ExecuteScript(...) or ExecuteScriptInIsolatedWorld(...), then continued the loop using implicit this (both scripts_ and render_frame()). Running the injected script synchronously can destroy the JsCommunication object, as the patch’s added comment says (“executing a script may cause JsCommunication object to be destroyed”). The next loop iteration, or the range-for iterator advance over the now-destroyed scripts_, then used freed memory. The violated invariant is that this stays alive across a call into JavaScript.
The fix moves the loop into a static RunScriptsInternal that receives a base::WeakPtr<JsCommunication> from a new weak_ptr_factory_, reaches all members through that weak pointer, and checks if (!js_communication) return; right after each script execution, before the iterator advances or any member is touched again. RunScripts itself does nothing after the call, as noted by “Careful this may be destroyed.”
JsCommunication survives synchronous execution of the scripts it injects, but that JavaScript can tear down the frame and its observer. The fix holds the object only through a WeakPtr and bails out as soon as it is invalidated after each execution.Attack Path
- Embedder registers injected scripts
A WebView-based app registers one or more document-start scripts (via
JsCommunication) that match the attacker’s origin, or several scripts so the loop runs more than once. - Victim loads attacker content
The WebView navigates to attacker-controlled web content, and
RunScriptsfires at the document injection time. - Script execution tears down the frame
During
ExecuteScript/ExecuteScriptInIsolatedWorld, JavaScript running in that frame (injected or reached via the page’s own handlers) causes the frame and itsJsCommunicationto be destroyed (inferred: for example by detaching the iframe synchronously). - Loop resumes on freed object
Control returns to the unpatched loop, which advances the iterator over
scripts_and callsrender_frame()on the freedJsCommunication, causing a use-after-free in the renderer.
Impact Assessment
JsCommunication object in the renderer process. If exploited, an attacker could corrupt renderer memory and possibly run code inside the sandboxed renderer (inferred). Reachability depends on the embedder using the js_injection script-injection feature (Android WebView apps using document-start JavaScript, or other embedders of the component), on at least one registered script matching the frame’s origin, and on web content able to destroy the frame during injection. A stock Chrome browser tab that does not use this component is not shown to be affected. The vendor advisory rates it medium severity (“Use after free in WebView”); the advisory data provided does not report in-the-wild exploitation.Changed Functions
| Function | Change | Notes |
|---|---|---|
forcomponents/js_injection/renderer/js_communication.cc |
modified | |
ifcomponents/js_injection/renderer/js_communication.cc |
modified | |
JsObjectInfocomponents/js_injection/renderer/js_communication.h |
modified |
Files Changed
components/js_injection/renderer/js_communication.cccomponents/js_injection/renderer/js_communication.h
Audit Directions
- Loops that call into scriptLook for loops over member containers that call
ExecuteScript,ExecuteScriptInIsolatedWorld,CallFunction, event dispatch or other synchronous JS entry points without re-validatingthis(viaWeakPtrorRenderFrameObserverlifetime) afterwards, especially in otherRenderFrameObserversubclasses (extensions content scripts, autofill, translate). - Range-for over members during re-entrant callsEven with a liveness check, iterating a member
std::vectorwhile JavaScript can mutate it (e.g. via Mojo messages that add or remove scripts) can invalidate iterators. Auditscripts_-style containers for copy-before-iterate. - Code after re-entrant calls in observer callbacksIn
DidCreateDocumentElement,DidFinishDocumentLoad,RunScriptsAtDocumentStart-style hooks, check for any member access following a call that can run JavaScript or detach the frame.
Patch
From 34de88b274c41f245c28b123e03de9bc03e99163 Mon Sep 17 00:00:00 2001
From: Dave Tapuska <dtapuska@chromium.org>
Date: Thu, 12 Feb 2026 09:47:21 -0800
Subject: [PATCH] [webview] Fix an issue around lifecycle of WebLocalFrame
Ensure that we monitor destruction of the JsCommunication object.
Bug: 481920229
Change-Id: Ibbc6eacf0cb44fc737b0228c5d231d6fddf0caea
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7567944
Commit-Queue: Dave Tapuska <dtapuska@chromium.org>
Reviewed-by: Bo Liu <boliu@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1584064}
---
diff --git a/components/js_injection/renderer/js_communication.cc b/components/js_injection/renderer/js_communication.cc
index 4bfd8976..984f9f1 100644
--- a/components/js_injection/renderer/js_communication.cc
+++ b/components/js_injection/renderer/js_communication.cc
@@ -210,22 +210,38 @@
}
void JsCommunication::RunScripts(mojom::DocumentInjectionTime injection_time) {
- url::Origin frame_origin =
- url::Origin(render_frame()->GetWebFrame()->GetSecurityOrigin());
- for (const auto& script : scripts_) {
+ RunScriptsInternal(weak_ptr_factory_.GetWeakPtr(), injection_time);
+ // Careful `this` may be destroyed.
+}
+
+// static
+void JsCommunication::RunScriptsInternal(
+ base::WeakPtr<JsCommunication> js_communication,
+ mojom::DocumentInjectionTime injection_time) {
+ CHECK(js_communication);
+ url::Origin frame_origin = url::Origin(
+ js_communication->render_frame()->GetWebFrame()->GetSecurityOrigin());
+ for (const auto& script : js_communication->scripts_) {
if (!script->origin_matcher.Matches(frame_origin)) {
continue;
}
if (script->injection_time == injection_time) {
if (script->js_world == content::ISOLATED_WORLD_ID_GLOBAL) {
- render_frame()->GetWebFrame()->ExecuteScript(
+ js_communication->render_frame()->GetWebFrame()->ExecuteScript(
blink::WebScriptSource(script->script));
} else {
- render_frame()->GetWebFrame()->ExecuteScriptInIsolatedWorld(
- script->js_world, blink::WebScriptSource(script->script),
- blink::BackForwardCacheAware::kAllow);
+ js_communication->render_frame()
+ ->GetWebFrame()
+ ->ExecuteScriptInIsolatedWorld(
+ script->js_world, blink::WebScriptSource(script->script),
+ blink::BackForwardCacheAware::kAllow);
}
}
+ // Careful, executing a script may cause JsCommunication object to be
+ // destroyed.
+ if (!js_communication) {
+ return;
+ }
}
}
diff --git a/components/js_injection/renderer/js_communication.h b/components/js_injection/renderer/js_communication.h
index deb050f2..797305e 100644
--- a/components/js_injection/renderer/js_communication.h
+++ b/components/js_injection/renderer/js_communication.h
@@ -61,6 +61,10 @@
class JsObjectInfo;
struct JavaScriptExecutable;
+ static void RunScriptsInternal(
+ base::WeakPtr<JsCommunication> js_communication,
+ mojom::DocumentInjectionTime injection_time);
+
void BindPendingReceiver(
mojo::PendingAssociatedReceiver<mojom::JsCommunication> pending_receiver);
@@ -80,6 +84,7 @@
mojo::AssociatedRemote<mojom::JsObjectsClient> client_remote_;
base::WeakPtrFactory<JsCommunication> weak_ptr_factory_for_bindings_{this};
+ base::WeakPtrFactory<JsCommunication> weak_ptr_factory_{this};
};
} // namespace js_injection
Original Bug Report
UAF in JsCommunication, leading to RCE
Steps to reproduce the problem
Tested on Pixel 9 Pro, Chromium version 144.0.7559.109.
- Build
app.zipand Install python -m http.server- Visit
index.html - UAF and Crash
Problem Description
JsCommunication is a content::RenderFrameObserver, which is destructed along with the destruction of the RenderFrameImpl [1].
On iOS and Android platforms, application developers can use interfaces (such as addDocumentStartJavaScript) to inject JavaScript code that runs at DocumentEnd or DocumentStart. JavaScript injected through this interface also runs in iframes. When the JavaScript executes, the scripts in the vector are processed one by one in a for-loop[2],[3].
void JsCommunication::OnDestruct() {
delete this; // [1]
}
void JsCommunication::RunScripts(mojom::DocumentInjectionTime injection_time) {
url::Origin frame_origin =
url::Origin(render_frame()->GetWebFrame()->GetSecurityOrigin());
for (const auto& script : scripts_) { // [2]
if (!script->origin_matcher.Matches(frame_origin)) {
continue;
}
if (script->injection_time == injection_time) {
if (script->js_world == content::ISOLATED_WORLD_ID_GLOBAL) {
render_frame()->GetWebFrame()->ExecuteScript(
blink::WebScriptSource(script->script));
} else {
render_frame()->GetWebFrame()->ExecuteScriptInIsolatedWorld( // [3]
script->js_world, blink::WebScriptSource(script->script),
blink::BackForwardCacheAware::kAllow);
}
}
}
}
The injected JavaScript can easily be redirected to code controlled by an attacker, for example, through getter, setter, or redefining functions in the prototype. An attacker can then remove the iframe, causing the RenderFrameImpl to be destroyed, which in turn destroys the JsCommunication object. When the next loop attempts to execute JavaScript, a UAF occurs, which is not protected by miraclePtr
Although triggering this UAF requires certain preconditions, in practice, many browsers (such as the Pawxy browser) utilize this interface, and it is also widely used by third-party libraries (e.g., react-native-webview).
app.zipis a simple WebView-based browser that injects two JavaScript codes via theaddDocumentStartJavaScriptinterface.
private void injectStartUpScripts() {
if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
Set<String> allowedOriginRules = Collections.singleton("*");
try {
WebViewCompat.addDocumentStartJavaScript(
webView,
"console.log(`${new Number(1.1)}`);",
allowedOriginRules
);
// WebViewCompat.addDocumentStartJavaScript(
// webView,
// "console.log(global_value)",
// allowedOriginRules
// );
WebViewCompat.addDocumentStartJavaScript(
webView,
"console.log('Second Script Injected');",
allowedOriginRules
);
} catch (Exception e) {
Log.e(TAG, "addDocumentStartJavaScript failed");
}
} else {
Toast.makeText(this, "addDocumentStartJavaScript not supported", Toast.LENGTH_SHORT).show();
}
}
- Using this browser to visit the attacker’s webpage.
- The attacker’s webpage redefines
Number.prototype.toStringinside an iframe.
node.contentWindow.Number.prototype.toString = function () {
node.remove();
};
- The first injected JavaScript executes and triggers the attacker’s code, which removes the iframe, ultimately leading to the destruction of
JsCommunication. - In the next iteration of the loop, a UAF occurs.
Summary
UAF in JsCommunication, leading to RCE
Custom Questions
Reporter credit:
Am4deu$
Additional Data
Category: Security
Chrome Channel: Stable
Regression: N/A \
- https://developer.android.com/reference/androidx/webkit/WebViewCompat#addDocumentStartJavaScript(android.webkit.WebView,java.lang.String,java.util.Set%3Cjava.lang.String%3E
- https://developer.android.com/reference/androidx/webkit/WebViewCompat#addDocumentStartJavaScript(android.webkit.WebView,java.lang.String,java.util.Set<java.lang.String