CVE-2026-87504
Overview
Background
- `ProcessesTerminateFunction::Run()`
- the
chrome.processes.terminate()extension API handler that resolves achild_process_host_id_to a live process handle and terminates it if allowed. - `BrowserChildProcessHost::FromID()`
- a lookup that iterates the global
g_child_process_listto find a non-renderer child process host (e.g. utility, GPU, plugin) by id. - `BrowserThread::UI` / `BrowserThread::IO`
- Chromium’s two long-lived browser threads;
g_child_process_listis owned and mutated exclusively onBrowserThread::UI, so touching it fromBrowserThread::IOis a data race. - `PostTaskAndReplyWithResult`
- a cross-thread dispatch primitive that runs work on a target thread (here
BrowserThread::IO) and posts the result back, which is how the vulnerable code reachedFromID()off the UI thread.
Root Cause Analysis
When RenderProcessHost::FromID(child_process_host_id_) returned null, the pre-patch ProcessesTerminateFunction::Run() treated the target as a possible non-renderer child process and posted ProcessesTerminateFunction::GetProcessHandleOnIO to BrowserThread::IO via content::GetIOThreadTaskRunner({})->PostTaskAndReplyWithResult(...), then returned RespondLater(). That IO-thread callback called content::BrowserChildProcessHost::FromID(child_process_host_id), which walks g_child_process_list — a structure the commit message states is “strictly owned and mutated on the UI thread.” Because the guarding DCHECK_CURRENTLY_ON(content::BrowserThread::IO) is a debug-only assertion (compiled out of release builds), this traversal ran concurrently with UI-thread deletion of list nodes in BrowserChildProcessHostImpl::ForceShutdown() (inferred enclosing deleter, per the commit message), producing a heap use-after-free on the IO thread.
The fix removes the entire IO-thread round trip and instead calls content::BrowserChildProcessHost::FromID(child_process_host_id_) directly inside Run() on the UI thread, where g_child_process_list is safe to read, then either terminates the resolved handle or returns Error(errors::kProcessNotFound, ...).
The fix works because the lookup now executes on the same thread that owns and mutates the list, so the lookup can no longer race a concurrent node deletion.
g_child_process_list, via BrowserChildProcessHost::FromID()) from BrowserThread::IO, a stale legacy dispatch that survived the migration of child-process-host management to the UI thread; the fix eliminates the thread hop and performs the lookup synchronously on BrowserThread::UI.Attack Path
- Trigger the API on a non-renderer target
An extension holding the
processespermission callschrome.processes.terminate()with the id of a non-renderer child process (e.g. utility or GPU), soRenderProcessHost::FromID(child_process_host_id_)returns null and the pre-patch code takes the IO-thread branch. - Force the cross-thread lookup
Run()postsGetProcessHandleOnIOtoBrowserThread::IO, whereBrowserChildProcessHost::FromID()begins iteratingg_child_process_listoff its owning thread. - Race a concurrent shutdown
Concurrently on
BrowserThread::UI, that child process host is torn down (the commit attributes node deletion toBrowserChildProcessHostImpl::ForceShutdown()(inferred)), freeing a list node while the IO-thread iteration still dereferences it, yielding a heap use-after-free. - Escalation
Turning the freed-object read into a controlled read/write, information leak, or code execution is not demonstrated by the diff and depends on heap grooming of the freed
g_child_process_listnode(inferred).
Impact Assessment
processes API reaches a heap use-after-free on the IO thread of the browser process, driven by a UI/IO thread race over g_child_process_list; the diff only proves the memory-safety error itself. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 9.6. Any escalation to an info leak, read/write primitive, or code execution beyond the free is (inferred). The precondition is a build where the DCHECK_CURRENTLY_ON(content::BrowserThread::IO) guard is compiled out (release) plus the ability to invoke chrome.processes.terminate() on a non-renderer child process concurrently with that process shutting down.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/api/processes/processes_api.cc |
modified |
Files Changed
chrome/browser/extensions/api/processes/processes_api.ccchrome/browser/extensions/api/processes/processes_api.h
Audit Directions
- Cross-thread access to UI-owned globalsAudit other callers that reach
BrowserChildProcessHost::FromID()or otherwise touchg_child_process_list(and similar UI-thread-owned registries) fromBrowserThread::IOor worker threads, since the same pattern races node deletion. - DCHECK-only thread guardsFlag functions whose sole thread-affinity protection is a
DCHECK_CURRENTLY_ON(...), which is compiled out of release builds and therefore does not prevent the misuse it documents; prefer sequence checkers or moving the work to the correct thread. - Stale legacy IO-thread dispatchLook for other
PostTaskAndReplyWithResult/GetIOThreadTaskRunnerposts introduced before the child-process-host migration to the UI thread that were never updated, matching the “sole legacy caller” pattern called out in the commit message.
Patch
From 936b3c6dc1f1575f981c3c8f85bd18539bde4595 Mon Sep 17 00:00:00 2001
From: Gabriel Charette <gab@chromium.org>
Date: Fri, 07 Aug 2026 13:56:26 -0700
Subject: [PATCH] [gemini] Fix UAF in chrome.processes.terminate() by querying BrowserChildProcessHost on UI thread
`ProcessesTerminateFunction::Run()` previously posted a task to the IO thread to resolve non-renderer process handles via `BrowserChildProcessHost::FromID()`.
However, `BrowserChildProcessHost::FromID()` iterates `g_child_process_list`, which is strictly owned and mutated on the UI thread. In release builds (where `DCHECK_CURRENTLY_ON(BrowserThread::UI)` is compiled out), this cross-thread traversal raced with UI-thread node deletion in `BrowserChildProcessHostImpl::ForceShutdown()`, causing a heap-use-after-free on the IO thread.
Historical Note:
This asynchronous IO-thread lookup was introduced ~10 years ago (commit 98241839b528) when `BrowserChildProcessHost` and IPC channel handling lived on the IO thread. When child process host management was subsequently migrated to the UI thread, `ProcessesTerminateFunction` was overlooked and remained the sole legacy caller dispatching `FromID()` to the IO thread.
Fix this by invoking `BrowserChildProcessHost::FromID()` directly on the UI thread in `ProcessesTerminateFunction::Run()`, eliminating the IO thread task post.
Verified with the ASAN reproduction extension from crbug.com/499206649.
TAG=agy
CONV=33405ca9-4da2-4d0e-94d6-1a4e6e620582
Fixed: 499206649
Change-Id: Ic5dd79487e10d1e596a42d7b6e87a681870bc42b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8214341
Auto-Submit: Gabriel Charette <gab@chromium.org>
Reviewed-by: Reilly Grant <reillyg@chromium.org>
Commit-Queue: Reilly Grant <reillyg@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1675956}
---
diff --git a/chrome/browser/extensions/api/processes/processes_api.cc b/chrome/browser/extensions/api/processes/processes_api.cc
index 785b611..c18f5b7f 100644
--- a/chrome/browser/extensions/api/processes/processes_api.cc
+++ b/chrome/browser/extensions/api/processes/processes_api.cc
@@ -500,39 +500,21 @@
// Check if it's a renderer.
auto* render_process_host =
content::RenderProcessHost::FromID(child_process_host_id_);
- if (render_process_host)
+ if (render_process_host) {
return RespondNow(
TerminateIfAllowed(render_process_host->GetProcess().Handle()));
-
- // This could be a non-renderer child process like a plugin.
- // Try to get its handle from the BrowserChildProcessHost on the IO thread.
- content::GetIOThreadTaskRunner({})->PostTaskAndReplyWithResult(
- FROM_HERE,
- base::BindOnce(&ProcessesTerminateFunction::GetProcessHandleOnIO, this,
- child_process_host_id_),
- base::BindOnce(&ProcessesTerminateFunction::OnProcessHandleOnUI, this));
-
- // Promise to respond later.
- return RespondLater();
-}
-
-base::ProcessHandle ProcessesTerminateFunction::GetProcessHandleOnIO(
- int child_process_host_id) const {
- DCHECK_CURRENTLY_ON(content::BrowserThread::IO);
-
- auto* host = content::BrowserChildProcessHost::FromID(child_process_host_id);
- if (host) {
- return host->GetData().GetProcess().Handle();
}
- return base::kNullProcessHandle;
-}
+ // Check if it's a non-renderer child process (e.g. utility, GPU).
+ auto* browser_child_process_host =
+ content::BrowserChildProcessHost::FromID(child_process_host_id_);
+ if (browser_child_process_host) {
+ return RespondNow(TerminateIfAllowed(
+ browser_child_process_host->GetData().GetProcess().Handle()));
+ }
-void ProcessesTerminateFunction::OnProcessHandleOnUI(
- base::ProcessHandle handle) {
- DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
-
- Respond(TerminateIfAllowed(handle));
+ return RespondNow(Error(errors::kProcessNotFound,
+ base::NumberToString(child_process_host_id_)));
}
ExtensionFunction::ResponseValue
diff --git a/chrome/browser/extensions/api/processes/processes_api.h b/chrome/browser/extensions/api/processes/processes_api.h
index 0939467..8d535b1 100644
--- a/chrome/browser/extensions/api/processes/processes_api.h
+++ b/chrome/browser/extensions/api/processes/processes_api.h
@@ -146,10 +146,6 @@
private:
~ProcessesTerminateFunction() override = default;
- // Functions to get the process handle on the IO thread and post it back to
- // the UI thread from processing.
- base::ProcessHandle GetProcessHandleOnIO(int child_process_host_id) const;
- void OnProcessHandleOnUI(base::ProcessHandle handle);
// Terminates the process with `handle` if it's valid and is allowed to be
// terminated. Returns the response value of this extension function to be
Original Bug Report
Cross-thread use-after-free in browser process via chrome.processes.terminate()
Cross-thread use-after-free in browser process via chrome.processes.terminate()
Summary
The chrome.processes.terminate() extension API dispatches BrowserChildProcessHost::FromID() to the IO thread, but this function and the global g_child_process_list it iterates are exclusively owned by the UI thread with no synchronization. The only guards are DCHECK_CURRENTLY_ON(BrowserThread::UI) assertions, which are compiled out in release builds. When an extension rapidly calls terminate() on non-renderer child process IDs, the IO thread iterates the list concurrently with the UI thread destroying hosts and removing list nodes, producing a heap-use-after-free in the browser process. ASAN confirms the crash is not protected by MiraclePtr. Affects all desktop platforms (Linux, macOS, Windows). Requires the processes extension permission, currently gated to dev channel.
Bisect
Introducing Commit: 98241839b528f945fabb03f38fb1ea6a3e837f09
- Date: 2016-03-11
- Author: afakhry
- Review: https://codereview.chromium.org/1584473004
Root Cause
ProcessesTerminateFunction::Run() handles two categories of process IDs. For renderer processes it resolves the handle synchronously on the UI thread via RenderProcessHost::FromID(). For non-renderer child processes (utility, GPU, etc.) it posts GetProcessHandleOnIO to the IO thread:
// chrome/browser/extensions/api/processes/processes_api.cc
content::GetIOThreadTaskRunner({})->PostTaskAndReplyWithResult(
FROM_HERE,
base::BindOnce(&ProcessesTerminateFunction::GetProcessHandleOnIO, this,
child_process_host_id_),
base::BindOnce(&ProcessesTerminateFunction::OnProcessHandleOnUI, this));
GetProcessHandleOnIO then calls BrowserChildProcessHost::FromID(), which iterates g_child_process_list:
// content/browser/browser_child_process_host_impl.cc
BrowserChildProcessHost* BrowserChildProcessHost::FromID(int child_process_id) {
DCHECK_CURRENTLY_ON(BrowserThread::UI);
BrowserChildProcessHostImpl::BrowserChildProcessList* process_list =
g_child_process_list.Pointer();
for (BrowserChildProcessHostImpl* host : *process_list) {
if (host->GetData().id == child_process_id)
return host;
}
return nullptr;
}
The DCHECK_CURRENTLY_ON(BrowserThread::UI) assertion is the only thing preventing cross-thread access here, and it vanishes in release builds. The list type is std::list<raw_ptr<BrowserChildProcessHostImpl, CtnExperimental>>, a plain doubly-linked list with no mutex, no lock, and no sequence checker protecting it.
The race unfolds as follows. When an extension fires many terminate() calls targeting non-renderer child processes, each call queues a GetProcessHandleOnIO task on the IO thread. The first successful termination kills a child process (e.g. GPU). The process exit triggers an IPC disconnect notification that propagates through the UI thread: OnChildDisconnected invokes the host delegate’s destructor, which calls ForceShutdown():
// content/browser/browser_child_process_host_impl.cc
void BrowserChildProcessHostImpl::ForceShutdown() {
DCHECK_CURRENTLY_ON(BrowserThread::UI);
g_child_process_list.Get().remove(this);
child_process_host_->ForceShutdown();
}
This std::list::remove unlinks and frees the list node on the UI thread. Meanwhile, the IO thread is still processing queued FromID() calls, iterating the same list by following next pointers through heap-allocated std::list nodes. When it dereferences a node that the UI thread has already freed, ASAN detects a heap-use-after-free.
The freed 24-byte region is a std::list node containing the raw_ptr<BrowserChildProcessHostImpl> element plus the prev/next pointers. ASAN’s additional information confirms “MiraclePtr Status: NOT PROTECTED” because the use-after-free is on the list node itself, not on a raw_ptr-managed object.
A contrasting example exists within the same class. BrowserChildProcessHostIterator (used by code that legitimately needs to enumerate child processes) enforces DCHECK_CURRENTLY_ON(BrowserThread::UI) and is only called from UI-thread code paths. The processes.terminate() handler is the sole caller that incorrectly dispatches FromID() to the IO thread.
Reproduce
Tested at commit f7d73bbd27f24 on Linux x86_64 with the out/asan-release build configuration.
The PoC is a Manifest V3 extension that enumerates non-renderer child processes via chrome.processes.getProcessInfo() and fires concurrent terminate() calls to race the IO-thread lookup against UI-thread host destruction. The directory doubles as a loadable unpacked extension:
issue_processes_terminate_race_uaf/
├── manifest.json # MV3 extension manifest
├── background.js # PoC service worker
A 5ms sleep is inserted in BrowserChildProcessHost::FromID() to widen the race window for reliable reproduction. Apply patch.diff and rebuild:
cd ~/chromium/src
git apply issue_processes_terminate_race_uaf/patch.diff
autoninja -C out/asan-release chrome
Load the extension directory and launch:
ASAN_OPTIONS=detect_odr_violation=0 \
xvfb-run -a out/asan-release/chrome \
--disable-gpu \
--enable-experimental-extension-apis \
--load-extension=issue_processes_terminate_race_uaf \
--user-data-dir=/tmp/poc-$(date +%s) \
--no-first-run \
about:blank
The browser process crashes within seconds with a heap-use-after-free in BrowserChildProcessHost::FromID() on the IO thread, while the UI thread frees a list node via ForceShutdown().
==1272169==ERROR: AddressSanitizer: heap-use-after-free on address 0x7b73c90c1d48 at pc 0x7f443311242c bp 0x7b43b7044f70 sp 0x7b43b7044f68
READ of size 8 at 0x7b73c90c1d48 thread T10 (Chrome_IOThread)
#0 content::BrowserChildProcessHost::FromID(int) content/browser/browser_child_process_host_impl.cc:142
#1 extensions::ProcessesTerminateFunction::GetProcessHandleOnIO(int) chrome/browser/extensions/api/processes/processes_api.cc:524
freed by thread T0 (chrome) here:
#0 operator delete(void*, unsigned long)
#1 std::__Cr::list<...>::remove(...) gen/third_party/libc++/src/include/__new/allocate.h:63
#2 content::BrowserChildProcessHostImpl::ForceShutdown() content/browser/browser_child_process_host_impl.cc:268
#3 content::GpuProcessHost::ValidateHost(content::GpuProcessHost*) content/browser/gpu/gpu_process_host.cc:1249
previously allocated by thread T0 (chrome) here:
#0 operator new(unsigned long)
#1 content::BrowserChildProcessHostImpl::BrowserChildProcessHostImpl(...)
SUMMARY: AddressSanitizer: heap-use-after-free content/browser/browser_child_process_host_impl.cc:142 in content::BrowserChildProcessHost::FromID(int)
MiraclePtr Status: NOT PROTECTED
Full ASAN log is in asan.log.
Credit
Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.