CVE-2026-95347
Overview
Background
- `DataPipeDrainer`
- A helper that asynchronously reads bytes out of a Mojo data pipe and forwards them to a client via callbacks such as
OnDataAvailableandOnDataComplete. - `drainer_`
- The
std::unique_ptr<DataPipeDrainer>member owned by the fetcher’s callback object that keeps the drainer alive while data is being pulled from the pipe. - `base::SequencedTaskRunner`
- An interface for posting tasks that run one at a time on a specific sequence, used here to defer work until after the current callback has returned.
- `base::DoNothingWithBoundArgs`
- A helper that produces a no-op callback which merely owns the arguments bound into it, so those objects are destroyed only when the posted task runs.
Root Cause Analysis
The macOS network fetcher’s callback object receives completion and error notifications directly from its own DataPipeDrainer while that drainer is executing inside DataPipeDrainer::ReadData(). In both OnDataComplete() and ClosePipeAndSignalMojoError(), the original code called drainer_.reset(), which synchronously destroyed the DataPipeDrainer object whose stack frame in ReadData() was still active and about to touch its own members. This violated the invariant that an object must not be freed while one of its methods is still on the call stack, producing a use-after-free when control returned into DataPipeDrainer::ReadData().
The fix preserves the intent to release the drainer but defers the destruction by moving drainer_ into a base::DoNothingWithBoundArgs callback posted to base::SequencedTaskRunner::GetCurrentDefault(), so the object stays alive through the rest of the callback and ReadData() and is only destroyed later on the same sequence. Because the deferred task runs after the current call stack has fully unwound, no live DataPipeDrainer method frame can reference freed memory.
DataPipeDrainer synchronously via drainer_.reset() from inside a callback that the drainer itself invoked while still running in DataPipeDrainer::ReadData(); the fix defers the destruction to a posted sequenced task so the drainer outlives the callback that frees it.Attack Path
- Trigger a fetch
The updater performs a network fetch on macOS, whose
DataPipeDrainerbegins draining response bytes from the Mojo data pipe insideDataPipeDrainer::ReadData(). - Drive a completion or error callback
The drainer, still executing
ReadData(), invokes the client’sOnDataComplete()on end-of-data, or a pipe error routes intoClosePipeAndSignalMojoError(). - Synchronous self-destruction
The callback calls
drainer_.reset(), freeing the veryDataPipeDrainerinstance whoseReadData()frame is still on the stack. - Use-after-free on return
Control unwinds back into
DataPipeDrainer::ReadData(), which dereferences its now-freed members, causing the use-after-free. - Exploitation via crafted response An attacker able to influence the fetched response stream or pipe closure timing shapes the freed-memory access into a potential memory-corruption primitive in the updater.
Impact Assessment
DataPipeDrainer::ReadData() is still executing.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/updater/net/network_fetcher_mac.mm |
modified |
Files Changed
chrome/updater/net/network_fetcher_mac.mm
Audit Directions
- Synchronous self-owned deletion in callbacksSearch for
unique_ptr::reset()or delete of a member object performed inside a callback that the same object invoked, where the object’s method may still be on the stack. - Client callbacks from streaming/draining helpersReview
DataPipeDrainerand similar pipe/stream readers whoseOnData*completion or error callbacks destroy the reader that is mid-ReadData()or equivalent loop. - Deferred-destruction consistencyAudit other platform network fetchers and updater code for the same pattern to ensure destruction is deferred via
base::SequencedTaskRunnerposting rather than executed synchronously during a callback.
Patch
From 83b0a752f1e716896861d24dc0c9e76e8e1da2fb Mon Sep 17 00:00:00 2001 From: Noah Rose Ledesma <[email protected]> Date: Fri, 21 Aug 2026 09:45:10 -0700 Subject: [PATCH] Defer DataPipeDrainer destruction in fetcher Destroying the DataPipeDrainer during its client's callback can lead to a UAF. Defer its deletion by posting it to the sequence. Fixed: 550181232 Change-Id: If98ea8ee09fb2ca13468fc13f1e301d26a6a6964 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8276952 Reviewed-by: Sorin Jianu <[email protected]> Commit-Queue: Noah Rose Ledesma <[email protected]> Cr-Commit-Position: refs/heads/main@{#1684008} --- diff --git a/chrome/updater/net/network_fetcher_mac.mm b/chrome/updater/net/network_fetcher_mac.mm index 26473718..cd2993c 100644 --- a/chrome/updater/net/network_fetcher_mac.mm +++ b/chrome/updater/net/network_fetcher_mac.mm @@ -596,14 +596,18 @@ void OnDataComplete() override { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); - drainer_.reset(); + // Defer resetting the drainer to avoid UAF in DataPipeDrainer::ReadData(). + base::SequencedTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, base::DoNothingWithBoundArgs(std::move(drainer_))); MaybeSignalComplete(); } private: void ClosePipeAndSignalMojoError() { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); - drainer_.reset(); + // Defer resetting the drainer to avoid UAF in DataPipeDrainer::ReadData(). + base::SequencedTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, base::DoNothingWithBoundArgs(std::move(drainer_))); file_.Close(); if (completion_callback_) { base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
Original Bug Report
Heap-use-after-free in macOS Updater fallback stream reader on destination write failure
Report description
Heap-use-after-free in macOS Updater fallback stream reader on destination write failure
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
The problem
Please describe the technical details of the vulnerability
Summary
The macOS Chromium Updater synchronously destroys an active
mojo::DataPipeDrainer from inside its OnDataAvailable() callback when a
destination-file write fails. DataPipeDrainer::ReadData() then resumes after
the callback and dereferences the freed drainer’s source_ member.
I reproduced this through independent manual runs and a clean packaged replay
using the installed ASAN updater, its production
out-of-process fallback network worker, a valid CRX download, and a disposable
filesystem that reaches ENOSPC. The preserved run reports a heap-use-after-
free in the installed updater parent.
Tested revision: 34a482b3cd98d6d599f43091babae366fb9c3d8e
Tested updater: 154.0.8012.0, macOS arm64 ASAN
Also affected: Stable 152.0.7977.54
Reproduction
From the root of a Chromium checkout on macOS arm64:
gn gen out/cand048-asan --args='is_asan=true is_debug=false dcheck_always_on=true symbol_level=1 blink_symbol_level=1 v8_symbol_level=2 use_remoteexec=false use_siso=false angle_enable_metal=false'
autoninja -C out/cand048-asan ChromiumUpdater_test.app ChromiumUpdaterUtil
../security-research/chromium-vrp-nday-audit-2026-08-21/reports/updater-stream-reader-drainer-uaf-522314940/poc/run_product_asan_poc.sh
The script:
- creates and mounts a disposable 256 MiB HFS+ image;
- installs the ASAN Chromium updater at user scope;
- registers a benign test app through the installed
ksadminIPC path; - starts the installed update service with the image as its temp directory;
- forces the production primary network fetches to fail so the updater launches
its real
--net-workerfallback; - fills the volume only after the production download directory exists; and
- streams Chromium’s valid updater qualification CRX after the volume reaches zero free blocks.
Expected final output:
PASS: installed updater reproduced heap-use-after-free
client_exit=1 (IPC loss is expected after ASAN abort)
The cleanup trap restores any pre-existing override file, detaches and removes the disposable image, deletes the test registration, and uninstalls the test updater.
ASAN result
==47546==ERROR: AddressSanitizer: heap-use-after-free
READ of size 8 at 0x611000014688 thread T5
#0 mojo::DataPipeDrainer::ReadData()
freed by thread T5 here:
#1 DownloadStreamReader::ClosePipeAndSignalMojoError()
#2 DownloadStreamReader::OnDataAvailable(...)
#3 mojo::DataPipeDrainer::ReadData()
0x611000014688 is located 8 bytes inside of 248-byte region
The independent volume watcher records:
ENOSPC armed: ... remaining=0 write_errno=28
Offline symbolization of the binary offsets gives:
mojo::DataPipeDrainer::ReadData()
mojo/public/cpp/system/data_pipe_drainer.cc:36
DownloadStreamReader::ClosePipeAndSignalMojoError()
chrome/updater/net/network_fetcher_mac.mm:606
DownloadStreamReader::OnDataAvailable(...)
chrome/updater/net/network_fetcher_mac.mm:589
Root cause
On write failure, OnDataAvailable() calls:
void ClosePipeAndSignalMojoError() {
drainer_.reset();
file_.Close();
...
}
But the executing drainer still has code after the client callback:
client_->OnDataAvailable(buffer);
source_->EndReadData(buffer.size());
The reset frees the current DataPipeDrainer; the following source_ access
is the ASAN-reported read from freed memory.
Regression history
The bug was introduced by commit
3fe58100b42fb607e3c62a5cd010ce44d9fa22c1 (2026-06-23), which implemented
streamed downloads from the out-of-process fetcher as the fix for issue
522314940. The vulnerable code remains in Stable 152.0.7977.54 and current
main.
The original issue is CVE-2026-17654. This is a separate lifetime bug in the replacement design, although I expect high incomplete-fix/grouping risk.
Direct-upload attachments
I am uploading every attachment directly rather than as a ZIP. The “PoC” and “Logs” headings are categories only; none of the uploaded filenames has a folder prefix.
Direct-upload attachments
PoC
| Filename | SHA-256 |
|---|---|
run_product_asan_poc.sh |
29034c63a1237d3c9a4f9a64cd55e8dba162eeff603da5b19c619383f2925b53 |
local_omaha_enospc_server.py |
9fcee95feef8e8d4d0b0ed6f2b078c5c355afbaf55bdbef28ca51dbe3404d12d |
enospc_vnode_watcher.c |
7b1b00e169a77b0e7d596a8743119d4b58849b6a1f32a86bde21e483d011d517 |
Logs
| Filename | SHA-256 |
|---|---|
updater-product-asan-replay-20260821-132344-47365.47546 |
abfb686c4ff0c569aed5da580eed921f551626526c0f71c3c09f8b78f44ff917 |
product-symbolization.txt |
d9c813638b3a84da09f3cd8687ad5fc2fee1d8d42fb31d514fe114e7898cbde1 |
product-enospc-watcher-replay-20260821-132344-47365.log |
e2c9b8ae926a37ed47d2c10e155563b9f6c75522990a870cb31e4aa5a8459e81 |
product-server-replay-20260821-132344-47365.log |
2ee4d5462a3bd1f28d4e212ffd74f1a8b93597b196cee179364bf9628b9202c8 |
product-updater-client-replay-20260821-132344-47365.log |
f80e6cbeeefffdd4d6949820de9406bb2386dc3141523232a166abfad6494667 |
product-updater-service-replay-20260821-132344-47365.log |
078f2adac35ffef9a1f40ac1506e98feaf6c727db99079d79d13a55d457cbcf6 |
Impact analysis
Security impact and scope
This is demonstrated product memory corruption in a non-renderer process, not a unit-test-only lifetime pattern. The trigger requires a destination write failure; the PoC uses a bounded disposable volume and a valid update payload.
The preserved run is user scope. The same class is used by the system-scope updater parent, where the parent is privileged and the fallback network worker is unprivileged.
The cause
What version of Chrome have you found the security issue in?
152.0.7977.54
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Memory Corruption (in a non-sandboxed process)
How would you like to be publicly acknowledged for your report?
a45hif