e25025bb3236808c89ef3ebb1f039ccc5566e83c [mojo][fuchsia] Guard handle reset and reject writes on channel shutdown
Triage note: Fuchsia-only race hardening bringing parity with Windows/Linux; fails with a bad-handle error rather than a corruption primitive. Bug:547827650.
The bug at a glance
A real but low-impact data race, Fuchsia-only. ChannelFuchsia::ShutDownOnIOThread() reset/released the zx::channel handle without holding write_lock_ or setting reject_writes_, so a concurrent Write() on another thread could still enter zx_channel_write() and race the handle teardown. The documented worst case is a write against ZX_HANDLE_INVALID returning ZX_ERR_BAD_HANDLE - a benign, well-defined failure, not memory corruption. Zircon handle values are integers guarded by the kernel, so racing a write against a reset does not by itself produce a UAF in the mojo address space; the kernel rejects the stale handle. The residual concern is a torn-read/torn-write on the handle member and the small possibility of writing to a handle value the process later reuses for something else, which is why it is treated as security-relevant hardening rather than noise. Impact is bounded to Fuchsia builds, and the fix’s own framing is ‘bringing ChannelFuchsia to parity with the Windows and Linux implementations’ - i.e. closing a platform gap. Hence low / CVSS ~2.5.
Mojo IPC channel teardown on Fuchsia. The IO thread runs ShutDownOnIOThread() to close the underlying Zircon channel handle. On other platforms this path holds the write lock and flips reject_writes_ so that any in-flight or subsequent Write() bails out instead of touching a handle being destroyed. The Fuchsia implementation skipped both, leaving a window where Write() and handle reset run concurrently on the same handle_ member.
Root cause
Pre-fix ShutDownOnIOThread() did: read_watch_.reset(); then if (leak_handle_) std::ignore = handle_.release(); handle_.reset(); - all outside write_lock_ and without setting reject_writes_. ChannelFuchsia::Write() (running on a caller/IO thread) checks reject_writes_ under write_lock_ and, if false, proceeds to zx_channel_write() on handle_. Because shutdown neither took the lock nor set the flag, a Write() could observe reject_writes_==false and then call zx_channel_write() while ShutDownOnIOThread() is concurrently doing handle_.reset()/release(). This is a data race on the handle_ member (a non-atomic zx::channel wrapping an integer handle) and on the reject_writes_ decision. Observable effects: (a) the write races the reset and zx_channel_write() runs against ZX_HANDLE_INVALID -> ZX_ERR_BAD_HANDLE; (b) undefined-behavior torn access to handle_ under TSAN. The fix wraps the handle release/reset in { base::AutoLock lock(write_lock_); reject_writes_ = true; ... }, so shutdown now (1) serializes with Write() via write_lock_, and (2) sets reject_writes_ before touching the handle, guaranteeing any Write() that acquires the lock afterward sees reject_writes_==true and returns without calling zx_channel_write(). handle_.reset() (or release() in the leak_handle_ case) now happens strictly under the lock. This matches the Windows/POSIX channel shutdown discipline.
Key code
channel_fuchsia.cc: take write_lock_ and set reject_writes_ before releasing the handle
read_watch_.reset();
{
base::AutoLock lock(write_lock_);
reject_writes_ = true;
if (leak_handle_) {
std::ignore = handle_.release();
} else {
handle_.reset();
}
}
// May destroy |this| if it was the last reference.
self_ = nullptr;
Patch walkthrough
mojo/core/channel_fuchsia.cc— The only file touched. Previously ShutDownOnIOThread() released/reset handle_ with no locking and never set reject_writes_. The patch introduces a scoped block that takes base::AutoLock lock(write_lock_), sets reject_writes_ = true, and then performs the leak_handle_ ? release() : reset() on handle_ inside that critical section. The prior unconditional handle_.reset() that sat outside the lock is folded into the else-branch under the lock. read_watch_.reset() stays before the lock (it is IO-thread-only), and self_ = nullptr stays after. Result: writes are rejected and the handle teardown is serialized against Write() exactly as on Windows/Linux.
Background
reject_writes_ / write_lock_ — Cross-platform mojo Channel machinery. write_lock_ serializes Write() with teardown; reject_writes_ is a bool that, once set, makes Write() short-circuit instead of touching the transport. The Windows and POSIX channel backends already set reject_writes_ under write_lock_ during shutdown; Fuchsia had drifted from that contract.
Zircon channel handles (zx::channel) — A zx::channel wraps a zx_handle_t (a 32-bit kernel-managed integer). release() detaches ownership without closing; reset() closes and invalidates it to ZX_HANDLE_INVALID. Operations on an invalid handle return ZX_ERR_BAD_HANDLE - the kernel enforces validity, so a stale handle write is a clean error rather than an in-process memory access.
leak_handle_ — When set, shutdown intentionally leaks (releases without closing) the handle rather than closing it, e.g. to hand the peer endpoint off. The fix preserves this branch but performs it under the lock.
Vulnerability window
- Platform drift — ChannelFuchsia::ShutDownOnIOThread() implemented handle teardown without the reject_writes_/write_lock_ discipline used by the Windows and POSIX backends.
- Analysis (bug 547827650) — Identified that concurrent Write() could race the handle reset, potentially issuing zx_channel_write() against ZX_HANDLE_INVALID (ZX_ERR_BAD_HANDLE).
- Fix (2026-09-17) — Wez took write_lock_ and set reject_writes_ before releasing/resetting the handle, restoring parity across platforms. Landed to main@{#1700479}; no backport tag in the diff.
Proof of concept
No PoC and no test are present in the diff, and none is straightforward: the bug is a timing race between the IO-thread shutdown and a concurrent Write(), only on Fuchsia, whose worst documented outcome is a ZX_ERR_BAD_HANDLE write failure rather than an observable corruption. Demonstration would require a Fuchsia build under TSAN with a thread issuing Write() while the channel is being shut down; even then the signal is a data-race report, not an exploit primitive. Fabricating exploit code would be dishonest, so none is provided.
Exploitation
- Platform gate — Fuchsia-only. Chrome’s Fuchsia surface is narrow, so the affected population is small relative to Win/Mac/Linux/Android.
- Race window — Requires a Write() to run on one thread while ShutDownOnIOThread() resets the handle on the IO thread - an inherently narrow, non-attacker-timed window in normal IPC teardown.
- Impact ceiling — Kernel handle validation bounds the outcome to a failed/misdirected write (ZX_ERR_BAD_HANDLE). No in-process UAF arises from the handle race itself; the security value is eliminating UB torn access and any chance of writing to a since-reused handle value.
Detection & hunting
For defenders and detection engineers:
- TSAN data-race reports —
- ZX_ERR_BAD_HANDLE from zx_channel_write —
Audit directions
- Per-platform Channel shutdown parity —
- Other handle_ accesses —
- leak_handle_ paths —