CVE-2026-13870
Overview
Files Changed
android_webview/js_sandbox/service/js_sandbox_isolate.ccandroid_webview/js_sandbox/service/js_sandbox_isolate.h
Patch
From 3a65466607d984f8232c31068616e09fe949fb5c Mon Sep 17 00:00:00 2001
From: Ashley Newson <ashleynewson@chromium.org>
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 <ashleynewson@chromium.org>
Reviewed-by: Abhijith Nair <abhijithnair@chromium.org>
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,
Original Bug Report
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.ccandroid_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:
TerminateAndDestroy()is executed, which resets thecancelable_task_tracker_. However, this does not cancel the ThreadPool task because it was posted directly tobase::ThreadPool, bypassing the tracker.DeleteSelf()is called on the isolate thread, deleting theJsSandboxIsolateC++ object.- The destruction of the isolate triggers the disposal of the V8 isolate, which immediately frees all associated
ArrayBufferbacking 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::ReadFromFDblindly writes data into the now-freedinner_buffermemory span. Since theinner_bufferis a raw pointer passed into anUNSAFE_TODOspan, 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_(ascoped_refptrmember of the deletedJsSandboxIsolateobject) 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:
- An attacker gains the ability to execute arbitrary JavaScript within a sandboxed
JsSandboxIsolateinstance. - 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.
- The attacker’s JavaScript code calls
android.consumeNamedDataAsArrayBuffer(name). - The underlying C++ code posts the
ConvertPromiseToArrayBufferInThreadPooltask, which blocks inbase::ReadFromFDwaiting for data. - 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). - The
JsSandboxIsolateand theArrayBufferbacking store are destroyed and freed. - The attacker writes payload data to the pipe. The ThreadPool task unblocks, performs the UAF write into the freed
ArrayBufferbacking store, and then accesses the freedthispointer 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.