CVE-2026-17654
Overview
Background
- OOP network fetcher
- On macOS the updater runs downloads in a separate
AppNetWorkerprocess so it can authenticate with proxies while the parent runs as root; per the commit message this worker was moved to run as the low-privilege"nobody"user. - `DownloadToFile` / `DownloadToStream`
- The Mojo
mojom::FetchServiceRPC the parent invokes on the worker to fetch a URL; pre-patch it handed the worker a writable::base::File output_file, post-patch it hands amojo::ScopedDataPipeProducerHandle response_stream. - CRX validation
- The updater verifies a downloaded CRX package before use; a TOCTOU (time-of-check-to-time-of-use) gap exists if the file can change between validation and use.
- `NetworkFileFetcher` / `NetworkStreamFetcher`
- The worker-side fetcher object; the former wrote HTTP bytes directly into the supplied file, the latter streams them back over the data pipe (with
net/chunk_queuebuffering when the pipe is full).
Root Cause Analysis
Per the commit message, the parent (root) updater previously passed a writable ::base::File output_file handle into the "nobody"-privileged OOP fetcher via FetchServiceImpl::DownloadToFile, and the fetcher (NetworkFileFetcher) wrote the download directly to that file on disk. Because the low-privilege worker retained the writable handle, it could modify the file’s contents after the root process had performed CRX validation, violating the invariant that a validated file is immutable through use — a classic TOCTOU condition. The commit states the TOCTOU itself is fixed separately by having the root process clone the file provided by the less-privileged process; this patch is a defense-in-depth hardening that removes the write capability entirely.
The fix refactors the RPC to FetchServiceImpl::DownloadToStream, which accepts a mojo::ScopedDataPipeProducerHandle instead of a file: the worker now streams response bytes back to the parent, and the parent — not the untrusted "nobody" process — writes the file to disk. This works because the fetcher never obtains a handle to any sensitive filesystem path, so it can no longer race the root process’s validation.
Attack Path
- Compromise or coerce the worker
An attacker who controls the
"nobody"-privilegedAppNetWorkerprocess (or its network responses) participates in a download initiated via the pre-patchDownloadToFileRPC, which handed the worker a writable::base::File output_file. - Retain the file handle Rather than releasing the handle after writing, the worker keeps the writable descriptor to the destination path open, per the commit message (“could retain a file handle and modify content after CRX validation”).
- Race the validation After the root updater performs CRX validation on the on-disk file, the worker uses its retained handle to overwrite the file contents, so the root process subsequently uses attacker-controlled bytes that were never validated (TOCTOU).
- Privilege escalation
The commit message states this “could lead to LPE”; the concrete mechanism by which swapped post-validation content is executed or otherwise leveraged by the root process is not shown in this diff
(inferred).
Impact Assessment
"nobody" fetcher process could modify a downloaded CRX after the root updater validated it, and the advisory/commit describes this as a TOCTOU that “could lead to LPE” (local privilege escalation) on macOS. The precondition is control of the OOP fetcher process (which the commit notes is the “insecure” component) during an updater download, running as root. This patch does not itself demonstrate code execution; the escalation from a post-validation content swap to root-level compromise is stated by the commit but not proven by the diff (inferred), and the commit notes the TOCTOU is actually remediated by a separate file-cloning change.Changed Functions
| Function | Change | Notes |
|---|---|---|
TestDrainerchrome/updater/app/app_net_worker_unittest.cc |
modified | |
AppNetWorkerTestchrome/updater/app/app_net_worker_unittest.cc |
modified |
Files Changed
chrome/updater/BUILD.gnchrome/updater/app/app_net_worker.ccchrome/updater/app/app_net_worker_unittest.cc
Audit Directions
- Handles crossing a privilege boundaryAudit every IPC/Mojo interface where a higher-privilege process passes a writable
base::File, fd, or shared-memory region to a lower-privilege process; prefer streaming data back up to the trusted side over granting the untrusted side a persistent handle. - Validate-then-use on mutable storageLook for CRX/package/signature validation performed on an on-disk file that another process can still write; ensure the validated bytes are pinned (cloned, sealed, or read from an immutable copy) before use.
- Privilege-reduction regressionsWhen a component is moved to a lower-privilege user (as this fetcher was moved to
"nobody"), re-review what filesystem and handle capabilities it retains, since demoting the process can open new races that the previous privilege level masked.
Patch
From 3fe58100b42fb607e3c62a5cd010ce44d9fa22c1 Mon Sep 17 00:00:00 2001 From: Noah Rose Ledesma <[email protected]> Date: Tue, 23 Jun 2026 14:26:37 -0700 Subject: [PATCH] Stream downloads from OOP fetcher The updater relies on an out of process network fetcher on macOS to authenticate with some proxies when running as root. Previously, this worker operated in the bootstrap namespace of a logged-in user but with root's uid. A recent change to mitigate a vulnerability made this fetcher run as the "nobody" user instead. This created a separate gap by which the insecure "nobody" process could retain a file handle and modify content after CRX validation, resulting in a TOCTOU error in the root process that could lead to LPE. The TOCTOU error is resolved separately by the root process by cloning the file provided by the less-priviledged process. However, it is still prudent to limit the capabilities of the fetcher by not allowing it to write directly to sensitive file system paths. Refactor the out of process network fetcher's API to stream bytes to the parent process which writes the file. Bug: 522314940 Change-Id: I8e26d4f3ea921bb6a1de2aba0316cf326a6a6964 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7946001 Commit-Queue: Noah Rose Ledesma <[email protected]> Reviewed-by: Tom Sepez <[email protected]> Reviewed-by: Adam Norberg <[email protected]> Cr-Commit-Position: refs/heads/main@{#1651283} --- diff --git a/chrome/updater/BUILD.gn b/chrome/updater/BUILD.gn index 6b48ea28..fb1d21ec 100644 --- a/chrome/updater/BUILD.gn +++ b/chrome/updater/BUILD.gn @@ -275,6 +275,8 @@ "mac/setup/mac_setup.mm", "mac/setup/wake_task.h", "mac/setup/wake_task.mm", + "net/chunk_queue.h", + "net/chunk_queue.mm", "net/fetcher_callback_adapter.cc", "net/fetcher_callback_adapter.h", "net/network_fetcher_mac.mm", @@ -1146,6 +1148,7 @@ "mac/setup/ks_tickets_unittest.mm", "mac/setup/setup_unittest.mm", "mac/setup/wake_task_unittest.mm", + "net/chunk_queue_unittest.mm", "policy/mac/managed_preference_policy_manager_impl_unittest.mm", "policy/mac/managed_preference_policy_manager_unittest.cc", "test/integration_tests_mac.h", diff --git a/chrome/updater/app/app_net_worker.cc b/chrome/updater/app/app_net_worker.cc index 1786606c..a4e2e38 100644 --- a/chrome/updater/app/app_net_worker.cc +++ b/chrome/updater/app/app_net_worker.cc @@ -20,6 +20,7 @@ #include "base/functional/bind.h" #include "base/logging.h" #include "base/memory/ref_counted.h" +#include "base/memory/scoped_refptr.h" #include "base/memory/weak_ptr.h" #include "base/sequence_checker.h" #include "base/task/bind_post_task.h" @@ -37,6 +38,7 @@ #include "mojo/public/cpp/bindings/remote.h" #include "mojo/public/cpp/bindings/self_owned_receiver.h" #include "mojo/public/cpp/platform/platform_channel.h" +#include "mojo/public/cpp/system/data_pipe.h" #include "mojo/public/cpp/system/invitation.h" #include "mojo/public/cpp/system/message_pipe.h" #include "url/gurl.h" @@ -92,7 +94,7 @@ : public base::RefCountedThreadSafe<FileDownloadObserverWrapper> { public: explicit FileDownloadObserverWrapper( - mojom::FetchService::DownloadToFileCallback callback) { + mojom::FetchService::DownloadToStreamCallback callback) { std::move(callback).Run(observer_.BindNewPipeAndPassReceiver()); } @@ -101,11 +103,6 @@ observer_->OnResponseStarted(http_status_code, content_length); } - void OnProgress(int64_t current) { - DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); - observer_->OnProgress(current); - } - void OnDownloadComplete(int32_t net_error, int64_t content_length) { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); observer_->OnDownloadComplete(net_error, content_length); @@ -138,10 +135,10 @@ std::vector<mojom::HttpHeaderPtr> additional_headers, mojom::FetchService::PostRequestCallback callback) override; - void DownloadToFile( + void DownloadToStream( const ::GURL& url, - ::base::File output_file, - mojom::FetchService::DownloadToFileCallback callback) override; + mojo::ScopedDataPipeProducerHandle response_stream, + mojom::FetchService::DownloadToStreamCallback callback) override; private: SEQUENCE_CHECKER(sequence_checker_); @@ -202,28 +199,25 @@ std::move(fetcher), wrapper)); } -void FetchServiceImpl::DownloadToFile( - const ::GURL& url, - ::base::File output_file, - mojom::FetchService::DownloadToFileCallback callback) { +void FetchServiceImpl::DownloadToStream( + const GURL& url, + mojo::ScopedDataPipeProducerHandle response_stream, + mojom::FetchService::DownloadToStreamCallback callback) { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); auto wrapper = base::MakeRefCounted<FileDownloadObserverWrapper>(std::move(callback)); - std::unique_ptr<NetworkFileFetcher> file_fetcher = - std::make_unique<NetworkFileFetcher>(); - NetworkFileFetcher* file_fetcher_ptr = file_fetcher.get(); - file_fetcher_ptr->Download( - url, std::move(output_file), + auto stream_fetcher = base::MakeRefCounted<NetworkStreamFetcher>(); + stream_fetcher->Download( + url, std::move(response_stream), base::BindRepeating(&FileDownloadObserverWrapper::OnResponseStarted, wrapper), - base::BindRepeating(&FileDownloadObserverWrapper::OnProgress, wrapper), base::BindOnce( - [](std::unique_ptr<NetworkFileFetcher> /*file_fetcher*/, + [](scoped_refptr<NetworkStreamFetcher> /*stream_fetcher*/, scoped_refptr<FileDownloadObserverWrapper> wrapper, int32_t net_error, int64_t content_length) { wrapper->OnDownloadComplete(net_error, content_length); }, - std::move(file_fetcher), wrapper)); + stream_fetcher, wrapper)); } // AppNetWorker runs networking tasks in a dedicated process. diff --git a/chrome/updater/app/app_net_worker_unittest.cc b/chrome/updater/app/app_net_worker_unittest.cc index a25b498a..e9f3541 100644 --- a/chrome/updater/app/app_net_worker_unittest.cc +++ b/chrome/updater/app/app_net_worker_unittest.cc @@ -11,6 +11,7 @@ #include <utility> #include <vector> +#include "base/barrier_closure.h" #include "base/files/file_util.h" #include "base/files/scoped_temp_file.h" #include "base/functional/bind.h" @@ -38,8 +39,11 @@ #include "mojo/public/cpp/bindings/remote.h" #include "mojo/public/cpp/bindings/self_owned_receiver.h" #include "mojo/public/cpp/platform/platform_channel.h" +#include "mojo/public/cpp/system/data_pipe.h" +#include "mojo/public/cpp/system/data_pipe_drainer.h" #include "mojo/public/cpp/system/invitation.h" #include "mojo/public/cpp/system/message_pipe.h" +#include "mojo/public/cpp/system/wait.h" #include "net/base/net_errors.h" #include "net/http/http_request_headers.h" #include "net/http/http_status_code.h" @@ -51,6 +55,31 @@ namespace updater { +namespace { + +class TestDrainer : public mojo::DataPipeDrainer::Client { + public: + TestDrainer(mojo::ScopedDataPipeConsumerHandle consumer, + base::OnceClosure on_complete) + : drainer_(this, std::move(consumer)), + on_complete_(std::move(on_complete)) {} + + // mojo::DataPipeDrainer::Client: + void OnDataAvailable(base::span<const uint8_t> data) override { + data_.append(reinterpret_cast<const char*>(data.data()), data.size()); + } + void OnDataComplete() override { std::move(on_complete_).Run(); } + + const std::string& data() const { return data_; } + + private: + mojo::DataPipeDrainer drainer_; + base::OnceClosure on_complete_; + std::string data_; +}; + +} // namespace + class AppNetWorkerTest : public ::testing::Test {
Regression Test / PoC
diff --git a/chrome/updater/app/app_net_worker_unittest.cc b/chrome/updater/app/app_net_worker_unittest.cc
index a25b498a..e9f3541 100644
--- a/chrome/updater/app/app_net_worker_unittest.cc
+++ b/chrome/updater/app/app_net_worker_unittest.cc
@@ -11,6 +11,7 @@
#include <utility>
#include <vector>
+#include "base/barrier_closure.h"
#include "base/files/file_util.h"
#include "base/files/scoped_temp_file.h"
#include "base/functional/bind.h"
@@ -38,8 +39,11 @@
#include "mojo/public/cpp/bindings/remote.h"
#include "mojo/public/cpp/bindings/self_owned_receiver.h"
#include "mojo/public/cpp/platform/platform_channel.h"
+#include "mojo/public/cpp/system/data_pipe.h"
+#include "mojo/public/cpp/system/data_pipe_drainer.h"
#include "mojo/public/cpp/system/invitation.h"
#include "mojo/public/cpp/system/message_pipe.h"
+#include "mojo/public/cpp/system/wait.h"
#include "net/base/net_errors.h"
#include "net/http/http_request_headers.h"
#include "net/http/http_status_code.h"
@@ -51,6 +55,31 @@
namespace updater {
+namespace {
+
+class TestDrainer : public mojo::DataPipeDrainer::Client {
+ public:
+ TestDrainer(mojo::ScopedDataPipeConsumerHandle consumer,
+ base::OnceClosure on_complete)
+ : drainer_(this, std::move(consumer)),
+ on_complete_(std::move(on_complete)) {}
+
+ // mojo::DataPipeDrainer::Client:
+ void OnDataAvailable(base::span<const uint8_t> data) override {
+ data_.append(reinterpret_cast<const char*>(data.data()), data.size());
+ }
+ void OnDataComplete() override { std::move(on_complete_).Run(); }
+
+ const std::string& data() const { return data_; }
+
+ private:
+ mojo::DataPipeDrainer drainer_;
+ base::OnceClosure on_complete_;
+ std::string data_;
+};
+
+} // namespace
+
class AppNetWorkerTest : public ::testing::Test {
protected:
void SetUp() override {
@@ -155,14 +184,17 @@
}));
ASSERT_TRUE(test_server.Start());
- base::ScopedTempFile output;
- ASSERT_TRUE(output.Create());
- base::File output_file(output.path(),
- base::File::FLAG_OPEN_ALWAYS | base::File::FLAG_WRITE);
- ASSERT_TRUE(output_file.IsValid());
+ mojo::ScopedDataPipeProducerHandle producer;
+ mojo::ScopedDataPipeConsumerHandle consumer;
+ ASSERT_EQ(mojo::CreateDataPipe(nullptr, producer, consumer), MOJO_RESULT_OK);
+
base::RunLoop run_loop;
- remote_->DownloadToFile(
- test_server.GetURL("/"), std::move(output_file),
+
+ base::RepeatingClosure barrier =
+ base::BarrierClosure(2, run_loop.QuitClosure());
+
+ remote_->DownloadToStream(
+ test_server.GetURL("/"), std::move(producer),
MakeFileDownloadObserver(
base::BindLambdaForTesting(
[&](int32_t http_status_code, int64_t content_length) {
@@ -171,20 +203,89 @@
EXPECT_EQ(content_length, payload_size.value());
}
}),
- base::BindLambdaForTesting([&](int64_t current) {
- EXPECT_LE(current, payload_size.value());
- }),
base::BindLambdaForTesting(
[&](int32_t net_error, int64_t content_length) {
EXPECT_EQ(net_error, 0);
EXPECT_EQ(content_length, payload_size.value());
- run_loop.Quit();
+ barrier.Run();
})));
+
+ TestDrainer drainer(std::move(consumer), barrier);
run_loop.Run();
- EXPECT_TRUE(base::ContentsEqual(output.path(), payload_path));
+
+ EXPECT_EQ(drainer.data(), payload);
}
-TEST_F(AppNetWorkerTest, DownloadMultipleFiles) {
+TEST_F(AppNetWorkerTest, DownloadFileRecoversFromFullPipe) {
+ std::string payload(5000, 'A');
+ net::EmbeddedTestServer test_server;
+ test_server.RegisterRequestHandler(base::BindLambdaForTesting(
+ [&](const net::test_server::HttpRequest& request)
+ -> std::unique_ptr<net::test_server::HttpResponse> {
+ auto http_response =
+ std::make_unique<net::test_server::BasicHttpResponse>();
+ http_response->set_code(net::HTTP_OK);
+ http_response->set_content(payload);
+ http_response->set_content_type("application/octet-stream");
+ return http_response;
+ }));
+ ASSERT_TRUE(test_server.Start());
+
+ mojo::ScopedDataPipeProducerHandle producer;
+ mojo::ScopedDataPipeConsumerHandle consumer;
+ constexpr static size_t kPipeCapacity = 1024;
+ ASSERT_EQ(mojo::CreateDataPipe(kPipeCapacity, producer, consumer),
+ MOJO_RESULT_OK);
+
+ base::RunLoop run_loop;
+
+ base::RepeatingClosure barrier =
+ base::BarrierClosure(2, run_loop.QuitClosure());
+
+ base::RunLoop response_wait_loop;
+ bool response_started = false;
+
+ remote_->DownloadToStream(
+ test_server.GetURL("/"), std::move(producer),
+ MakeFileDownloadObserver(
+ base::BindLambdaForTesting(
+ [&](int32_t http_status_code, int64_t content_length) {
+ EXPECT_EQ(http_status_code, net::HTTP_OK);
+ EXPECT_EQ(content_length, 5000);
+ response_started = true;
+ response_wait_loop.Quit();
+ }),
+ base::BindLambdaForTesting(
+ [&](int32_t net_error, int64_t content_length) {
+ EXPECT_EQ(net_error, 0);
+ EXPECT_EQ(content_length, 5000);
+ barrier.Run();
+ })));
+
+ response_wait_loop.Run();
+ EXPECT_TRUE(response_started);
+
+ // Wait until the writer in the child process has filled the pipe.
+ while (true) {
+ size_t bytes_available = 0;
+ MojoResult result = consumer->ReadData(
+ MOJO_READ_DATA_FLAG_QUERY, base::span<uint8_t>(), bytes_available);
+ if (result == MOJO_RESULT_OK && bytes_available >= kPipeCapacity) {
+ break;
+ }
+ base::RunLoop loop;
+ base::ThreadPool::PostDelayedTask(FROM_HERE, loop.QuitClosure(),
+ base::Milliseconds(10));
+ loop.Run();
+ }
+
+ TestDrainer drainer(std::move(consumer), barrier);
+ run_loop.Run();
+
+ EXPECT_EQ(drainer.data(), payload);
+}
+
+TEST_F(AppNetWorkerTest, DownloadMultiple) {
base::FilePath payload_path = updater::test::GetTestFilePath("signed.exe.gz");
std::optional<int64_t> payload_size = base::GetFileSize(payload_path);
ASSERT_TRUE(payload_size.has_value());
@@ -207,14 +308,17 @@
ASSERT_TRUE(test_server.Start());
for (int i = 0; i < 2; ++i) {
- base::ScopedTempFile output;
- ASSERT_TRUE(output.Create());
- base::File output_file(
- output.path(), base::File::FLAG_OPEN_ALWAYS | base::File::FLAG_WRITE);
- ASSERT_TRUE(output_file.IsValid());
+ mojo::ScopedDataPipeProducerHandle producer;
+ mojo::ScopedDataPipeConsumerHandle consumer;
+ ASSERT_EQ(mojo::CreateDataPipe(nullptr, producer, consumer),
+ MOJO_RESULT_OK);
+
base::RunLoop loop;
- remote_->DownloadToFile(
- test_server.GetURL("/payload"), std::move(output_file),
+ base::RepeatingClosure barrier =
+ base::BarrierClosure(2, loop.QuitClosure());
+
+ remote_->DownloadToStream(
+ test_server.GetURL("/payload"), std::move(producer),
MakeFileDownloadObserver(
base::BindLambdaForTesting(
[&](int32_t http_status_code, int64_t content_length) {
@@ -223,17 +327,16 @@
EXPECT_EQ(content_length, payload_size.value());
}
}),
- base::BindLambdaForTesting([&](int64_t current) {
- EXPECT_LE(current, payload_size.value());
- }),
base::BindLambdaForTesting(
[&](int32_t net_error, int64_t content_length) {
EXPECT_EQ(net_error, 0);
EXPECT_EQ(content_length, payload_size.value());
- loop.Quit();
+ barrier.Run();
})));
+
+ TestDrainer drainer(std::move(consumer), barrier);
loop.Run();
- EXPECT_TRUE(base::ContentsEqual(output.path(), payload_path));
+ EXPECT_EQ(drainer.data(), payload);
}
}
diff --git a/chrome/updater/net/chunk_queue_unittest.mm b/chrome/updater/net/chunk_queue_unittest.mm
new file mode 100644
index 0000000..f6df393
--- /dev/null
+++ b/chrome/updater/net/chunk_queue_unittest.mm
@@ -0,0 +1,188 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "chrome/updater/net/chunk_queue.h"
+
+#import <Foundation/Foundation.h>
+
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <string>
+
+#include "base/functional/bind.h"
+#include "base/notreached.h"
+#include "base/test/bind.h"
+#include "mojo/public/c/system/types.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+@interface MultiRangeData : NSData {
+ @private
+ std::vector<std::string> _ranges;
+}
+
+- (instancetype)initWithRanges:(std::vector<std::string>)ranges;
+@end
+
+@implementation MultiRangeData
+
+- (instancetype)initWithRanges:(std::vector<std::string>)ranges {
+ if ((self = [super init])) {
+ _ranges = std::move(ranges);
+ }
+ return self;
+}
+
+- (id)copyWithZone:(NSZone*)zone {
+ return self;
+}
+
+- (NSUInteger)length {
+ NSUInteger total = 0;
+ for (const auto& r : _ranges) {
+ total += r.length();
+ }
+ return total;
+}
+
+- (const void*)bytes {
+ NOTREACHED();
+}
+
+- (void)enumerateByteRangesUsingBlock:
+ (void(NS_NOESCAPE ^)(const void* bytes, NSRange byteRange, BOOL* stop))
+ block {
+ NSUInteger current_offset = 0;
+ for (const auto& r : _ranges) {
+ BOOL stop = NO;
+ block(r.data(), NSMakeRange(current_offset, r.length()), &stop);
+ if (stop) {
+ break;
+ }
+ current_offset += r.length();
+ }
+}
+@end
+
+namespace updater {
+
+TEST(ChunkQueueTest, PushAndEmpty) {
+ ChunkQueue queue;
... (truncated)
Original Bug Report
Potential TOCTOU Privilege Escalation in macOS Updater via Writable net-worker File Descriptor
Flapjack, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in the macOS Chromium updater. The root-privileged updater passes a writable file descriptor to a lower-privileged net-worker process, which can retain the descriptor even after the file is moved to the update cache. A compromised worker could overwrite the update payload after signature verification but before extraction, potentially leading to arbitrary code execution as root.
Affected files:
chrome/updater/net/network_fetcher_mac.mmchrome/updater/net/mac/mojom/updater_fetcher.mojom
Estimated timestamp from git blame: 2024-08-05
Description
A Time-of-Check to Time-of-Use (TOCTOU) vulnerability in the macOS updater design could allow a local attacker to escalate privileges to root.
When downloading an update, the root-privileged updater spawns an unprivileged net-worker process to handle the network request. To receive the downloaded data, the updater creates a temporary file with write permissions (base::File::FLAG_OPEN_ALWAYS | base::File::FLAG_WRITE) and passes the file descriptor to the net-worker via Mojo.
Once the download completes, the root updater moves the file into the CrxCache using base::Move. On POSIX systems, if the temporary directory and the cache directory are on the same filesystem (which is standard on macOS APFS), base::Move uses the rename() system call. rename() changes the file path but preserves the underlying inode.
Because the inode remains the same, an attacker who compromises the net-worker can duplicate (dup()) the writable file descriptor before the download completes. After the root updater verifies the file’s cryptographic signature (Time-of-Check), the attacker can use the retained file descriptor to overwrite the file with a malicious payload before the updater extracts and installs it (Time-of-Use).
Suggested Attacker Steps
Note: These are potential steps based on static code analysis. Our tooling agent does not currently have the ability to run code or provide a working Proof of Concept.
- Compromise the
net-worker: The attacker obtains code execution within the unprivilegednet-workerprocess (e.g., via a sandbox escape from a renderer process to the local console user). - Intercept the File Descriptor: When the root updater calls
FetchService::DownloadToFile, thenet-workerreceives a writablebase::File. The attacker executes adup()system call to retain a persistent, writable handle to this file descriptor. - Provide a Valid Payload: The attacker writes a legitimately signed Google update CRX into the file so that it passes initial security checks, and signals download completion via Mojo.
- Wait for Move and Verify: The root updater calls
CrxCache::Put(which callsrename()), and thenUnpacker::Verifysuccessfully validates the signed payload. - Trigger the Race Condition (TOCTOU): Immediately after verification succeeds and before
Unpacker::BeginUnzippingbegins extraction, the attacker uses the duplicated file descriptor to overwrite the file contents with a malicious ZIP archive. - Achieve Privilege Escalation: The root updater currently performs ZIP extraction in-process (
OutOfProcessUnzipper::Unzip). It extracts the maliciously modified installer and executes it, resulting in arbitrary code execution as root.
Code References
OutOfProcessNetworkFetcher::DownloadToFileexplicitly creates the file withbase::File::FLAG_WRITEand passes it over Mojo (chrome/updater/net/network_fetcher_mac.mm).CrxCacheImpl::Putusesbase::Moveto relocate the file, which preserves the inode (components/update_client/crx_cache.cc).Unpacker::Verifyvalidates the signature before unzipping (components/update_client/unpacker.cc), creating the TOCTOU window.
Suggested Fix
There are two primary ways to resolve this issue:
- Avoid shared writable file descriptors: Modify the architecture so the
net-workerdoes not write directly to a file. Instead, thenet-workershould stream the downloaded bytes back to the parent process via a Mojo data pipe, and the root process should write the bytes to disk itself. - Break the inode link: In
CrxCacheImpl::Put, do not usebase::Movefor files coming from untrusted sources. Instead, usebase::CopyFilefollowed by deleting the original. This ensures the cached file has a brand-new inode that no unprivileged process holds an open handle to.
Evaluated with Chrome root at commit: 2155cb00003ec35716a76ed3246eae995f87b7ff
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.