Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in WebView
DescriptionUse after free in WebView
ComponentWebView
Bug ClassUAF
Tracker497634837
Fix commit3a65466607d9 (chromium/src) +14/-136
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Files Changed

  • android_webview/js_sandbox/service/js_sandbox_isolate.cc
  • android_webview/js_sandbox/service/js_sandbox_isolate.h
From 3a65466607d984f8232c31068616e09fe949fb5c Mon Sep 17 00:00:00 2001
From: Ashley Newson <[email protected]>
Date: Wed, 20 May 2026 12:52:41 -0700
Subject: [PATCH] [js_sandbox] Streamline consumeNamedDataAsArrayBuffer threading

consumeNamedDataAsArrayBuffer previously made use of a background
thread to read in named data. In practice, this is not helpful in the
common case. This change moves the data reading directly onto the
isolate thread, removing the need for multiple thread hops.

Bug: 497634837
Change-Id: I6639bf2cf2db3aae420fcdb1a6aef9aa1deec960
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7864364
Commit-Queue: Ashley Newson <[email protected]>
Reviewed-by: Abhijith Nair <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1633774}
---

diff --git a/android_webview/js_sandbox/service/js_sandbox_isolate.cc b/android_webview/js_sandbox/service/js_sandbox_isolate.cc
index bf1fd17..152b02f 100644
--- a/android_webview/js_sandbox/service/js_sandbox_isolate.cc
+++ b/android_webview/js_sandbox/service/js_sandbox_isolate.cc
@@ -484,66 +484,6 @@
   isolate_init_complete = true;
 }
 
-// Called from control sequence.
-void JsSandboxIsolate::ConvertPromiseToArrayBufferInControlSequence(
-    std::string name,
-    std::unique_ptr<v8::Global<v8::ArrayBuffer>> array_buffer,
-    std::unique_ptr<v8::Global<v8::Promise::Resolver>> resolver) {
-  cancelable_task_tracker_->PostTask(
-      isolate_task_runner_.get(), FROM_HERE,
-      base::BindOnce(
-          &JsSandboxIsolate::ConvertPromiseToArrayBufferInIsolateSequence,
-          base::Unretained(this), std::move(name), std::move(array_buffer),
-          std::move(resolver)));
-}
-
-// Called from control sequence.
-//
-// The array_buffer's API must only be used from the isolate thread.
-void JsSandboxIsolate::ConvertPromiseToFailureInControlSequence(
-    std::string name,
-    std::unique_ptr<v8::Global<v8::ArrayBuffer>> array_buffer,
-    std::unique_ptr<v8::Global<v8::Promise::Resolver>> resolver,
-    std::string reason) {
-  cancelable_task_tracker_->PostTask(
-      isolate_task_runner_.get(), FROM_HERE,
-      base::BindOnce(
-          &JsSandboxIsolate::ConvertPromiseToFailureInIsolateSequence,
-          base::Unretained(this), std::move(name), std::move(array_buffer),
-          std::move(resolver), std::move(reason)));
-}
-
-// Called from Thread pool.
-//
-// The array_buffer's API must only be used from the isolate thread, but the
-// internal data (inner_buffer) may be accessed in whatever thread is currently
-// processing the task, so long as array_buffer remains alive.
-void JsSandboxIsolate::ConvertPromiseToArrayBufferInThreadPool(
-    base::ScopedFD fd,
-    ssize_t length,
-    std::string name,
-    std::unique_ptr<v8::Global<v8::ArrayBuffer>> array_buffer,
-    std::unique_ptr<v8::Global<v8::Promise::Resolver>> resolver,
-    void* inner_buffer) {
-  if (base::ReadFromFD(fd.get(), UNSAFE_TODO(base::span(
-                                     static_cast<char*>(inner_buffer),
-                                     base::checked_cast<size_t>(length))))) {
-    control_task_runner_->PostTask(
-        FROM_HERE,
-        base::BindOnce(
-            &JsSandboxIsolate::ConvertPromiseToArrayBufferInControlSequence,
-            base::Unretained(this), std::move(name), std::move(array_buffer),
-            std::move(resolver)));
-  } else {
-    std::string failure_reason = "Reading data failed.";
-    control_task_runner_->PostTask(
-        FROM_HERE,
-        base::BindOnce(
-            &JsSandboxIsolate::ConvertPromiseToFailureInControlSequence,
-            base::Unretained(this), std::move(name), std::move(array_buffer),
-            std::move(resolver), std::move(failure_reason)));
-  }
-}
 
 // Called from isolate thread.
 v8::Local<v8::ObjectTemplate> JsSandboxIsolate::CreateAndroidNamespaceTemplate(
@@ -801,41 +741,6 @@
       JsSandboxIsolateCallback::ErrorType::kJsEvaluationError, error_message);
 }
 
-// Called from isolate thread.
-void JsSandboxIsolate::ConvertPromiseToArrayBufferInIsolateSequence(
-    std::string name,
-    std::unique_ptr<v8::Global<v8::ArrayBuffer>> array_buffer,
-    std::unique_ptr<v8::Global<v8::Promise::Resolver>> resolver) {
-  v8::HandleScope handle_scope(isolate_holder_->isolate());
-  v8::Context::Scope scope(context_holder_->context());
-
-  resolver->Get(isolate_holder_->isolate())
-      ->Resolve(context_holder_->context(),
-                array_buffer->Get(isolate_holder_->isolate()))
-      .ToChecked();
-}
-
-// Called from isolate thread.
-//
-// We pass the array_buffer to the isolate thread so that it (or the handle)
-// only gets destructed from the isolate thread.
-void JsSandboxIsolate::ConvertPromiseToFailureInIsolateSequence(
-    std::string name,
-    std::unique_ptr<v8::Global<v8::ArrayBuffer>> array_buffer,
-    std::unique_ptr<v8::Global<v8::Promise::Resolver>> resolver,
-    std::string reason) {
-  v8::HandleScope handle_scope(isolate_holder_->isolate());
-  v8::Context::Scope scope(context_holder_->context());
-
-  // Allow array buffer to be garbage collectable before further V8 calls.
-  array_buffer = nullptr;
-
-  resolver->Get(isolate_holder_->isolate())
-      ->Reject(context_holder_->context(),
-               v8::Exception::Error(
-                   gin::StringToV8(isolate_holder_->isolate(), reason)))
-      .ToChecked();
-}
 
 // Called from isolate thread.
 void JsSandboxIsolate::ConsumeNamedDataAsArrayBuffer(gin::Arguments* args) {
@@ -900,22 +805,20 @@
 
   v8::Local<v8::ArrayBuffer> local_array_buffer =
       maybe_array_buffer.ToLocalChecked();
-  void* const inner_buffer = local_array_buffer->Data();
-  // V8 documentation provides no guarantees about the thread-safety of Globals
-  // - even move construction/destruction. Wrap it in a unique_ptr so that it
-  // can be treated as an opaque pointer until it's handed back to the isolate
-  // thread.
-  std::unique_ptr<v8::Global<v8::ArrayBuffer>> global_array_buffer(
-      std::make_unique<v8::Global<v8::ArrayBuffer>>(
-          isolate, std::move(local_array_buffer)));
-  base::ThreadPool::PostTask(
-      FROM_HERE, {base::MayBlock()},
-      base::BindOnce(&JsSandboxIsolate::ConvertPromiseToArrayBufferInThreadPool,
-                     base::Unretained(this), std::move(fd), length,
-                     std::move(name), std::move(global_array_buffer),
-                     std::make_unique<v8::Global<v8::Promise::Resolver>>(
-                         std::move(global_resolver)),
-                     inner_buffer));
+  gin::ArrayBuffer gin_array_buffer(local_array_buffer);
+  if (base::ReadFromFD(fd.get(),
+                       base::as_writable_chars(gin_array_buffer.span()))) {
+    global_resolver.Get(isolate_holder_->isolate())
+        ->Resolve(context_holder_->context(), local_array_buffer)
+        .ToChecked();
+  } else {
+    std::string reason = "Reading data failed.";
+    global_resolver.Get(isolate_holder_->isolate())
+        ->Reject(context_holder_->context(),
+                 v8::Exception::Error(
+                     gin::StringToV8(isolate_holder_->isolate(), reason)))
+        .ToChecked();
+  }
   args->Return(promise);
 }
 
diff --git a/android_webview/js_sandbox/service/js_sandbox_isolate.h b/android_webview/js_sandbox/service/js_sandbox_isolate.h
index 7158748..943f4ab 100644
--- a/android_webview/js_sandbox/service/js_sandbox_isolate.h
+++ b/android_webview/js_sandbox/service/js_sandbox_isolate.h
@@ -126,31 +126,6 @@
       base::android::ScopedJavaGlobalRef<jobject> pfd,
       scoped_refptr<JsSandboxIsolateCallback> callback,
       std::string errorMessage);
-  void ConvertPromiseToArrayBufferInThreadPool(
-      base::ScopedFD fd,
-      ssize_t length,
-      std::string name,
-      std::unique_ptr<v8::Global<v8::ArrayBuffer>> array_buffer,
-      std::unique_ptr<v8::Global<v8::Promise::Resolver>> resolver,
-      void* inner_buffer);
-  void ConvertPromiseToArrayBufferInControlSequence(
-      std::string name,
-      std::unique_ptr<v8::Global<v8::ArrayBuffer>> array_buffer,
-      std::unique_ptr<v8::Global<v8::Promise::Resolver>> resolver);
-  void ConvertPromiseToFailureInControlSequence(
-      std::string name,
-      std::unique_ptr<v8::Global<v8::ArrayBuffer>> array_buffer,
-      std::unique_ptr<v8::Global<v8::Promise::Resolver>> resolver,
-      std::string reason);
-  void ConvertPromiseToFailureInIsolateSequence(
-      std::string name,
-      std::unique_ptr<v8::Global<v8::ArrayBuffer>> array_buffer,
-      std::unique_ptr<v8::Global<v8::Promise::Resolver>> resolver,
-      std::string reason);
-  void ConvertPromiseToArrayBufferInIsolateSequence(
-      std::string name,
-      std::unique_ptr<v8::Global<v8::ArrayBuffer>> array_buffer,
Loading diff…

Original Bug Report

reported by [email protected]

Potential Use-After-Free in JsSandboxIsolate via untracked ThreadPool task

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A potential Use-After-Free (UAF) exists in JsSandboxIsolate when ConsumeNamedDataAsArrayBuffer posts a task directly to the base::ThreadPool. If the isolate is destroyed while this task is blocked reading a file descriptor, the task outlives the isolate, leading to a UAF write into a freed V8 ArrayBuffer and a UAF read/virtual call on the freed JsSandboxIsolate object.

Affected files:

  • android_webview/js_sandbox/service/js_sandbox_isolate.cc
  • android_webview/js_sandbox/service/js_sandbox_isolate.h

Estimated timestamp from git blame: 2025-11-07

Description

A potential Use-After-Free (UAF) vulnerability exists in JsSandboxIsolate within the Android WebView JS Sandbox service. The issue originates from an asynchronous task posted to the global base::ThreadPool that outlives the JsSandboxIsolate object and its associated V8 Isolate.

In android_webview/js_sandbox/service/js_sandbox_isolate.cc, the ConsumeNamedDataAsArrayBuffer method allows sandboxed JavaScript to read data from a pipe into an ArrayBuffer. To perform the read without blocking the main isolate thread, it posts a task (ConvertPromiseToArrayBufferInThreadPool) to the base::ThreadPool using base::Unretained(this). This task is passed a raw void* pointer (inner_buffer) pointing to the V8 ArrayBuffer’s backing store.

The worker task executes a blocking base::ReadFromFD call. If the isolate is destroyed (e.g., via the host app calling close(), or an error causing the isolate to terminate) while this task is blocked waiting for data on the file descriptor, the following sequence occurs:

  1. TerminateAndDestroy() is executed, which resets the cancelable_task_tracker_. However, this does not cancel the ThreadPool task because it was posted directly to base::ThreadPool, bypassing the tracker.
  2. DeleteSelf() is called on the isolate thread, deleting the JsSandboxIsolate C++ object.
  3. The destruction of the isolate triggers the disposal of the V8 isolate, which immediately frees all associated ArrayBuffer backing stores.

Once the attacker provides data to the pipe (or the writer is closed), the worker task resumes. This leads to two critical impacts:

  • Use-After-Free Write: base::ReadFromFD blindly writes data into the now-freed inner_buffer memory span. Since the inner_buffer is a raw pointer passed into an UNSAFE_TODO span, this write is not protected by MiraclePtr and provides a controlled memory corruption primitive.
  • Use-After-Free Read and Virtual Call: After reading, the task attempts to access this->control_task_runner_ (a scoped_refptr member of the deleted JsSandboxIsolate object) to post a completion task. This results in a UAF read and a virtual call on the resulting pointer, providing a path for vtable hijacking and potentially Remote Code Execution (RCE).

This vulnerability is significant because the JsSandbox service hosts untrusted JavaScript and can run multiple isolates within the same utility process. An exploit can be used to break cross-isolate boundaries and compromise the renderer-tier privileged utility process.

Potential Steps to Trigger (Theoretical)

Please note our tooling agent does not have the ability to run code, so these are suggested steps based on static analysis:

  1. An attacker gains the ability to execute arbitrary JavaScript within a sandboxed JsSandboxIsolate instance.
  2. The attacker ensures a named data pipe (file descriptor) is passed to the isolate, where they control the write-end of the pipe and can deliberately delay writing data.
  3. The attacker’s JavaScript code calls android.consumeNamedDataAsArrayBuffer(name).
  4. The underlying C++ code posts the ConvertPromiseToArrayBufferInThreadPool task, which blocks in base::ReadFromFD waiting for data.
  5. The attacker triggers the destruction of the isolate (e.g., by coordinating with a malicious host app to call close(), or by triggering an OOM condition within the isolate).
  6. The JsSandboxIsolate and the ArrayBuffer backing store are destroyed and freed.
  7. The attacker writes payload data to the pipe. The ThreadPool task unblocks, performs the UAF write into the freed ArrayBuffer backing store, and then accesses the freed this pointer to post the reply, triggering the vulnerability.

Suggested Fix

Do not use base::Unretained(this) and raw pointers to V8 memory for tasks posted to the ThreadPool.

Instead of allocating the ArrayBuffer upfront and passing its backing store to the thread pool, allocate an independent buffer (e.g., a std::vector<uint8_t> or std::string) in the thread pool, read the data into it, and then pass ownership of that buffer back to the control sequence. Once safely back on the isolate thread, allocate the ArrayBuffer and copy or transfer the data into it.

To manage the callback safely across threads, the state required to post back to the control sequence (like the task runner and the file descriptor) should be encapsulated in a reference-counted state object (base::RefCountedThreadSafe) or bound using base::BindPostTask combined with a base::WeakPtr (if appropriate for the threading model). This ensures the background task operates on valid memory and cannot interact with a destroyed JsSandboxIsolate.

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker