CVE-2026-106409
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/app_shim/app_mode_loader_mac.mm |
modified | |
ifchrome/common/mac/app_mode_common.mm |
modified | |
TESTchrome/common/mac/app_mode_chrome_locator_browsertest.mm |
modified | |
TESTchrome/common/mac/app_mode_common_unittest.mm |
modified |
Files Changed
chrome/common/mac/app_mode_chrome_locator_browsertest.mmchrome/common/mac/app_mode_chrome_locator.mmchrome/test/BUILD.gnchrome/app_shim/app_mode_loader_mac.mmchrome/common/mac/app_mode_common.hchrome/common/mac/app_mode_common.mmchrome/browser/apps/app_shim/app_shim_listener_browsertest.mmchrome/common/mac/app_mode_common_unittest.mm
Patch
From 947ac61f7d01baa259fc79129835ffc04524ca41 Mon Sep 17 00:00:00 2001
From: Marijn Kruisselbrink <mek@chromium.org>
Date: Wed, 09 Sep 2026 14:38:43 -0700
Subject: [PATCH] Validate framework_version in app shim loader on macOS
When an app shim is launched directly by the user on macOS, it reads
<user_data_dir>/RunningChromeVersion to determine the framework version
to dlopen.
Previously, ChromeConnectionConfig::DecodeFromPath did not validate
the framework_version string. An attacker in a sandboxed process with
write access to user_data_dir could set the symlink target to a
relative path traversal string (e.g., ../../../../evil:1). When spliced
into the framework path by GetFrameworkDylibPathAndStructure, this could
escape the Chrome bundle and load an arbitrary dylib into the
unsandboxed app shim process.
This change:
- Validates that framework_version is a valid base::Version in
ChromeConnectionConfig::DecodeFromPath, returning std::nullopt on
failure.
- Updates app_mode_loader to ignore malformed symlinks and fall back
safely to the bundled "Current" framework.
- Adds defense-in-depth validation in GetFrameworkDylibPathAndStructure
to reject any version string that contains path separators or parent
references.
- Adds unit tests for ChromeConnectionConfig and a browser test for
path traversal handling in GetChromeBundleInfo.
Bug: 559097675
Change-Id: I6faacbf4d01a0ea07c58dd6e165be1690c5bbdf2
Include-Ci-Only-Tests: chromium.mac:mac26-arm64-rel-tests|browser_tests
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8376892
Commit-Queue: Marijn Kruisselbrink <mek@chromium.org>
Reviewed-by: Avi Drissman <avi@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1694887}
---
diff --git a/chrome/app_shim/app_mode_loader_mac.mm b/chrome/app_shim/app_mode_loader_mac.mm
index 60ddd071..903da22 100644
--- a/chrome/app_shim/app_mode_loader_mac.mm
+++ b/chrome/app_shim/app_mode_loader_mac.mm
@@ -8,9 +8,10 @@
// minimal and do as little work as possible (with as much work done on
// framework side as possible).
+#import <Cocoa/Cocoa.h>
#include <dlfcn.h>
-#import <Cocoa/Cocoa.h>
+#include <optional>
#include "base/allocator/early_zone_registration_apple.h"
#include "base/apple/foundation_util.h"
@@ -116,11 +117,16 @@
user_data_dir.Append(app_mode::kRunningChromeVersionSymlinkName),
&encoded_config);
if (!encoded_config.empty()) {
- config =
+ std::optional<app_mode::ChromeConnectionConfig> parsed_config =
app_mode::ChromeConnectionConfig::DecodeFromPath(encoded_config);
- mojo_ipcz_config = config.is_mojo_ipcz_enabled
- ? app_mode::MojoIpczConfig::kEnabled
- : app_mode::MojoIpczConfig::kDisabled;
+ if (parsed_config) {
+ config = *parsed_config;
+ mojo_ipcz_config = config.is_mojo_ipcz_enabled
+ ? app_mode::MojoIpczConfig::kEnabled
+ : app_mode::MojoIpczConfig::kDisabled;
+ } else {
+ NSLog(@"Failed to decode framework version from symlink");
+ }
}
// If the version file does exist, it may have been left by a crashed
// Chrome process. Ensure the process is still running.
diff --git a/chrome/common/mac/app_mode_chrome_locator.mm b/chrome/common/mac/app_mode_chrome_locator.mm
index 5b4a6b3..926085a 100644
--- a/chrome/common/mac/app_mode_chrome_locator.mm
+++ b/chrome/common/mac/app_mode_chrome_locator.mm
@@ -30,6 +30,12 @@
std::optional<PathAndStructure> GetFrameworkDylibPathAndStructure(
NSString* bundle_path,
NSString* version) {
+ base::FilePath version_path = base::apple::NSStringToFilePath(version);
+ if (version_path.empty() || version_path.BaseName() != version_path ||
+ version_path.ReferencesParent() || version_path.value() == ".") {
+ return std::nullopt;
+ }
+
// NEW STYLE:
// Chromium.app/Contents/Frameworks/Chromium Framework.framework/
// Versions/<version>/Chromium Framework
diff --git a/chrome/common/mac/app_mode_common.h b/chrome/common/mac/app_mode_common.h
index 37849384..2db4a93 100644
--- a/chrome/common/mac/app_mode_common.h
+++ b/chrome/common/mac/app_mode_common.h
@@ -6,6 +6,8 @@
#define CHROME_COMMON_MAC_APP_MODE_COMMON_H_
#include <CoreServices/CoreServices.h>
+
+#include <optional>
#include <string>
#include "base/files/file_path.h"
@@ -257,8 +259,10 @@
// Generates a path value which encodes the contents of this structure.
base::FilePath EncodeAsPath() const;
- // Parses a path value into a configuration.
- static ChromeConnectionConfig DecodeFromPath(const base::FilePath& path);
+ // Parses a path value into a configuration, returning std::nullopt if the
+ // path is invalid or the framework version fails validation.
+ static std::optional<ChromeConnectionConfig> DecodeFromPath(
+ const base::FilePath& path);
};
} // namespace app_mode
diff --git a/chrome/common/mac/app_mode_common.mm b/chrome/common/mac/app_mode_common.mm
index d27f812a..da4811c 100644
--- a/chrome/common/mac/app_mode_common.mm
+++ b/chrome/common/mac/app_mode_common.mm
@@ -6,11 +6,13 @@
#import <Foundation/Foundation.h>
+#include <optional>
#include <type_traits>
#include "base/check.h"
#include "base/strings/strcat.h"
#include "base/strings/string_split.h"
+#include "base/version.h"
#include "components/version_info/version_info.h"
namespace app_mode {
@@ -86,20 +88,32 @@
}
// static
-ChromeConnectionConfig ChromeConnectionConfig::DecodeFromPath(
+std::optional<ChromeConnectionConfig> ChromeConnectionConfig::DecodeFromPath(
const base::FilePath& path) {
- DCHECK(!path.empty());
+ if (path.empty()) {
+ return std::nullopt;
+ }
const std::vector<std::string> parts = base::SplitString(
path.value(), ":", base::KEEP_WHITESPACE, base::SPLIT_WANT_ALL);
- DCHECK(!parts.empty());
+ if (parts.size() != 1 && parts.size() != 2) {
+ return std::nullopt;
+ }
+ if (!base::Version(parts[0]).IsValid()) {
+ return std::nullopt;
+ }
if (parts.size() == 1) {
// Assume MojoIpcz is disabled for path values which predate the
// introduction of the MojoIpcz bit.
- return {.framework_version = parts[0], .is_mojo_ipcz_enabled = false};
+ return ChromeConnectionConfig{.framework_version = parts[0],
+ .is_mojo_ipcz_enabled = false};
}
- return {.framework_version = parts[0],
- .is_mojo_ipcz_enabled = parts[1] == "1"};
+ if (parts[1] != "0" && parts[1] != "1") {
+ return std::nullopt;
+ }
+
+ return ChromeConnectionConfig{.framework_version = parts[0],
+ .is_mojo_ipcz_enabled = parts[1] == "1"};
}
} // namespace app_mode
diff --git a/chrome/browser/apps/app_shim/app_shim_listener_browsertest.mm b/chrome/browser/apps/app_shim/app_shim_listener_browsertest.mm
index 0619276..bc4891c 100644
--- a/chrome/browser/apps/app_shim/app_shim_listener_browsertest.mm
+++ b/chrome/browser/apps/app_shim/app_shim_listener_browsertest.mm
@@ -351,6 +351,7 @@
EXPECT_TRUE(base::ReadSymbolicLink(version_path_, &encoded_config));
auto config =
app_mode::ChromeConnectionConfig::DecodeFromPath(encoded_config);
- EXPECT_EQ(version_info::GetVersionNumber(), config.framework_version);
- EXPECT_TRUE(config.is_mojo_ipcz_enabled);
+ ASSERT_TRUE(config.has_value());
+ EXPECT_EQ(version_info::GetVersionNumber(), config->framework_version);
+ EXPECT_TRUE(config->is_mojo_ipcz_enabled);
}
diff --git a/chrome/common/mac/app_mode_chrome_locator_browsertest.mm b/chrome/common/mac/app_mode_chrome_locator_browsertest.mm
index ddc843b..6d3b129 100644
--- a/chrome/common/mac/app_mode_chrome_locator_browsertest.mm
+++ b/chrome/common/mac/app_mode_chrome_locator_browsertest.mm
@@ -119,3 +119,27 @@
base::DeleteFile(fake_version_directory);
}
+
+TEST(ChromeLocatorTest, GetChromeBundleInfoWithPathTraversal) {
+ base::FilePath chrome_bundle_path;
+ GetChromeBundlePath(&chrome_bundle_path);
+ ASSERT_TRUE(base::DirectoryExists(chrome_bundle_path));
+
+ base::FilePath default_executable_path;
+ base::FilePath default_framework_path;
+ base::FilePath default_framework_dylib_path;
+ ASSERT_TRUE(app_mode::GetChromeBundleInfo(
+ chrome_bundle_path, std::string(), &default_executable_path,
+ &default_framework_path, &default_framework_dylib_path));
+
+ base::FilePath executable_path;
+ base::FilePath framework_path;
+ base::FilePath framework_dylib_path;
+ // Path traversal should be rejected and default to the latest version.
+ EXPECT_TRUE(app_mode::GetChromeBundleInfo(
+ chrome_bundle_path, std::string("../../evil"), &executable_path,
+ &framework_path, &framework_dylib_path));
+ EXPECT_EQ(default_framework_dylib_path, framework_dylib_path);
+ EXPECT_EQ(default_framework_path, framework_path);
+ EXPECT_EQ(default_executable_path, executable_path);
+}
diff --git a/chrome/common/mac/app_mode_common_unittest.mm b/chrome/common/mac/app_mode_common_unittest.mm
new file mode 100644
index 0000000..ac838e6
--- /dev/null
+++ b/chrome/common/mac/app_mode_common_unittest.mm
@@ -0,0 +1,94 @@
+// 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/common/mac/app_mode_common.h"
+
+#include <optional>
+
+#include "base/files/file_path.h"
+#include "components/version_info/version_info.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace app_mode {
+
+TEST(AppModeCommonTest, EncodeAndDecodeValid) {
+ ChromeConnectionConfig original =
+ ChromeConnectionConfig::GenerateForCurrentProcess();
+ base::FilePath encoded = original.EncodeAsPath();
+
+ std::optional<ChromeConnectionConfig> decoded =
+ ChromeConnectionConfig::DecodeFromPath(encoded);
+ ASSERT_TRUE(decoded.has_value());
+ EXPECT_EQ(original.framework_version, decoded->framework_version);
+ EXPECT_EQ(version_info::GetVersionNumber(), decoded->framework_version);
+ EXPECT_EQ(original.is_mojo_ipcz_enabled, decoded->is_mojo_ipcz_enabled);
+ EXPECT_TRUE(decoded->is_mojo_ipcz_enabled);
+
+ // Valid version with MojoIpcz enabled.
+ decoded = ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4:1")));
+ ASSERT_TRUE(decoded.has_value());
+ EXPECT_EQ("1.2.3.4", decoded->framework_version);
+ EXPECT_TRUE(decoded->is_mojo_ipcz_enabled);
+
+ // Valid version with MojoIpcz disabled.
+ decoded = ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4:0")));
+ ASSERT_TRUE(decoded.has_value());
+ EXPECT_EQ("1.2.3.4", decoded->framework_version);
+ EXPECT_FALSE(decoded->is_mojo_ipcz_enabled);
+
+ // Legacy single part (no MojoIpcz bit).
+ decoded = ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4")));
+ ASSERT_TRUE(decoded.has_value());
+ EXPECT_EQ("1.2.3.4", decoded->framework_version);
+ EXPECT_FALSE(decoded->is_mojo_ipcz_enabled);
+}
+
+TEST(AppModeCommonTest, DecodeInvalid) {
+ // Empty path.
+ EXPECT_FALSE(
+ ChromeConnectionConfig::DecodeFromPath(base::FilePath()).has_value());
+
+ // Path traversal attacks.
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("../../evil:1")))
+ .has_value());
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("../evil")))
+ .has_value());
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("/tmp/evil:1")))
+ .has_value());
+
+ // Non-numeric or invalid versions.
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("invalid_version:1")))
+ .has_value());
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.a:1")))
+ .has_value());
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL(":1")))
+ .has_value());
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4/foo:1")))
+ .has_value());
+
+ // Invalid MojoIpcz flags.
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4:2")))
+ .has_value());
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4:true")))
+ .has_value());
+
+ // Extra colon-delimited components.
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4:1:extra")))
+ .has_value());
+}
+
+} // namespace app_mode
diff --git a/chrome/test/BUILD.gn b/chrome/test/BUILD.gn
index 9311e5f..c8e3dfbf 100644
--- a/chrome/test/BUILD.gn
+++ b/chrome/test/BUILD.gn
@@ -5747,6 +5747,7 @@
sources += [
"../../testing/gtest_mac_unittest.mm",
"../app_shim/app_shim_controller_unittest.mm",
+ "../common/mac/app_mode_common_unittest.mm",
]
data_deps += [ "//chrome:chrome_framework" ]
Regression Test / PoC
diff --git a/chrome/browser/apps/app_shim/app_shim_listener_browsertest.mm b/chrome/browser/apps/app_shim/app_shim_listener_browsertest.mm
index 0619276..bc4891c 100644
--- a/chrome/browser/apps/app_shim/app_shim_listener_browsertest.mm
+++ b/chrome/browser/apps/app_shim/app_shim_listener_browsertest.mm
@@ -351,6 +351,7 @@
EXPECT_TRUE(base::ReadSymbolicLink(version_path_, &encoded_config));
auto config =
app_mode::ChromeConnectionConfig::DecodeFromPath(encoded_config);
- EXPECT_EQ(version_info::GetVersionNumber(), config.framework_version);
- EXPECT_TRUE(config.is_mojo_ipcz_enabled);
+ ASSERT_TRUE(config.has_value());
+ EXPECT_EQ(version_info::GetVersionNumber(), config->framework_version);
+ EXPECT_TRUE(config->is_mojo_ipcz_enabled);
}
diff --git a/chrome/common/mac/app_mode_chrome_locator_browsertest.mm b/chrome/common/mac/app_mode_chrome_locator_browsertest.mm
index ddc843b..6d3b129 100644
--- a/chrome/common/mac/app_mode_chrome_locator_browsertest.mm
+++ b/chrome/common/mac/app_mode_chrome_locator_browsertest.mm
@@ -119,3 +119,27 @@
base::DeleteFile(fake_version_directory);
}
+
+TEST(ChromeLocatorTest, GetChromeBundleInfoWithPathTraversal) {
+ base::FilePath chrome_bundle_path;
+ GetChromeBundlePath(&chrome_bundle_path);
+ ASSERT_TRUE(base::DirectoryExists(chrome_bundle_path));
+
+ base::FilePath default_executable_path;
+ base::FilePath default_framework_path;
+ base::FilePath default_framework_dylib_path;
+ ASSERT_TRUE(app_mode::GetChromeBundleInfo(
+ chrome_bundle_path, std::string(), &default_executable_path,
+ &default_framework_path, &default_framework_dylib_path));
+
+ base::FilePath executable_path;
+ base::FilePath framework_path;
+ base::FilePath framework_dylib_path;
+ // Path traversal should be rejected and default to the latest version.
+ EXPECT_TRUE(app_mode::GetChromeBundleInfo(
+ chrome_bundle_path, std::string("../../evil"), &executable_path,
+ &framework_path, &framework_dylib_path));
+ EXPECT_EQ(default_framework_dylib_path, framework_dylib_path);
+ EXPECT_EQ(default_framework_path, framework_path);
+ EXPECT_EQ(default_executable_path, executable_path);
+}
diff --git a/chrome/common/mac/app_mode_common_unittest.mm b/chrome/common/mac/app_mode_common_unittest.mm
new file mode 100644
index 0000000..ac838e6
--- /dev/null
+++ b/chrome/common/mac/app_mode_common_unittest.mm
@@ -0,0 +1,94 @@
+// 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/common/mac/app_mode_common.h"
+
+#include <optional>
+
+#include "base/files/file_path.h"
+#include "components/version_info/version_info.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace app_mode {
+
+TEST(AppModeCommonTest, EncodeAndDecodeValid) {
+ ChromeConnectionConfig original =
+ ChromeConnectionConfig::GenerateForCurrentProcess();
+ base::FilePath encoded = original.EncodeAsPath();
+
+ std::optional<ChromeConnectionConfig> decoded =
+ ChromeConnectionConfig::DecodeFromPath(encoded);
+ ASSERT_TRUE(decoded.has_value());
+ EXPECT_EQ(original.framework_version, decoded->framework_version);
+ EXPECT_EQ(version_info::GetVersionNumber(), decoded->framework_version);
+ EXPECT_EQ(original.is_mojo_ipcz_enabled, decoded->is_mojo_ipcz_enabled);
+ EXPECT_TRUE(decoded->is_mojo_ipcz_enabled);
+
+ // Valid version with MojoIpcz enabled.
+ decoded = ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4:1")));
+ ASSERT_TRUE(decoded.has_value());
+ EXPECT_EQ("1.2.3.4", decoded->framework_version);
+ EXPECT_TRUE(decoded->is_mojo_ipcz_enabled);
+
+ // Valid version with MojoIpcz disabled.
+ decoded = ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4:0")));
+ ASSERT_TRUE(decoded.has_value());
+ EXPECT_EQ("1.2.3.4", decoded->framework_version);
+ EXPECT_FALSE(decoded->is_mojo_ipcz_enabled);
+
+ // Legacy single part (no MojoIpcz bit).
+ decoded = ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4")));
+ ASSERT_TRUE(decoded.has_value());
+ EXPECT_EQ("1.2.3.4", decoded->framework_version);
+ EXPECT_FALSE(decoded->is_mojo_ipcz_enabled);
+}
+
+TEST(AppModeCommonTest, DecodeInvalid) {
+ // Empty path.
+ EXPECT_FALSE(
+ ChromeConnectionConfig::DecodeFromPath(base::FilePath()).has_value());
+
+ // Path traversal attacks.
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("../../evil:1")))
+ .has_value());
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("../evil")))
+ .has_value());
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("/tmp/evil:1")))
+ .has_value());
+
+ // Non-numeric or invalid versions.
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("invalid_version:1")))
+ .has_value());
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.a:1")))
+ .has_value());
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL(":1")))
+ .has_value());
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4/foo:1")))
+ .has_value());
+
+ // Invalid MojoIpcz flags.
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4:2")))
+ .has_value());
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4:true")))
+ .has_value());
+
+ // Extra colon-delimited components.
+ EXPECT_FALSE(ChromeConnectionConfig::DecodeFromPath(
+ base::FilePath(FILE_PATH_LITERAL("1.2.3.4:1:extra")))
+ .has_value());
+}
+
+} // namespace app_mode
diff --git a/chrome/test/BUILD.gn b/chrome/test/BUILD.gn
index 9311e5f..c8e3dfbf 100644
--- a/chrome/test/BUILD.gn
+++ b/chrome/test/BUILD.gn
@@ -5747,6 +5747,7 @@
sources += [
"../../testing/gtest_mac_unittest.mm",
"../app_shim/app_shim_controller_unittest.mm",
+ "../common/mac/app_mode_common_unittest.mm",
]
data_deps += [ "//chrome:chrome_framework" ]
Original Bug Report
macOS: unvalidated framework_version in the app shim turns a sandboxed process into code execution as the user
VULNERABILITY DETAILS
app_mode_loader splices an unvalidated string into a path and dlopen()s the result. The
string comes from a symlink in user_data_dir, and the shim is ad-hoc signed with library
validation disabled, so the loaded code needs no signature.
When the user launches an installed shim directly, it has no
--launched-by-chrome-framework-dylib-path, so it reads
<user_data_dir>/RunningChromeVersion (app_mode_loader_mac.mm:114-117).
ChromeConnectionConfig::DecodeFromPath (app_mode_common.mm:89-103) takes everything before
the first : as framework_version. That string is never validated — I grepped every use
of framework_version along the path and found no check.
app_mode_chrome_locator.mm:36-43 then splices it into the framework path:
[NSString pathWithComponents:@[bundle, @"Contents", @"Frameworks", kFrameworkName,
@"Versions", version, kFrameworkExecutableName]]
pathWithComponents: joins with / and does not reject .., so the result can leave the
bundle. fileExistsAtPath: then succeeds and the path is passed to dlopen()
(app_mode_loader_mac.mm:183). A __attribute__((constructor)) runs during dlopen(), so the
payload does not need to export ChromeAppModeStart.
The one guard on this path helps the attacker rather than hindering: :127-135 discards the
version only when Chrome is not running, and in this scenario Chrome is running, because the
attacker is inside it.
The payload needs no valid signature. web_app_shortcut_creator.mm:418-441 ad-hoc signs every
shim with com.apple.security.cs.disable-library-validation, applied whenever
kUseAdHocSigningForWebAppShims is enabled — FEATURE_ENABLED_BY_DEFAULT
(chrome_features.cc:67).
That entitlement is what makes this link bear load, and it is unique. Measured with codesign
on the shipped 152.0.7977.83 bundle, every binary Chrome ships enforces library validation and
none carries the entitlement:
Google Chrome flags=0x12a00(kill,restrict,library-validation,runtime)
Google Chrome Helper flags=0x12a00(kill,restrict,library-validation,runtime)
Google Chrome Helper (Alerts) flags=0x12a00(kill,restrict,library-validation,runtime)
Google Chrome Helper (GPU) flags=0x10a00(kill,restrict,runtime)
Google Chrome Helper (Renderer) flags=0x10a00(kill,restrict,runtime)
Helpers/app_mode_loader flags=0x12a00(kill,restrict,library-validation,runtime), no entitlements
The (GPU) and (Renderer) helpers lack the explicit flag but the hardened runtime enforces
library validation on its own: I signed two binaries with exactly those flag combinations and
dlopened a foreign dylib into each — both refused, “mapping process and mapped file
(non-platform) have different Team IDs”. The generated shim is therefore the only Chrome process
that will load an unsigned dylib, and the entitlement comes from Chrome’s own re-signing at
install time, not from the shipped template.
The write itself is reachable because the network service is granted file* over user_data_dir
(chrome_content_browser_client.cc:5725 → sandbox_parameters_mac.mm:161-174 →
network.sb:17), and RunningChromeVersion lives at the root of that directory
(app_shim_listener.mm:91-95). That grant is intended — the network service stores its own data
there — so this report does not treat it as the defect. The defect is that a version string
taken from that directory becomes a path component without validation.
IMPACT
A compromised network service — a sandboxed process whose job is parsing hostile network data —
obtains arbitrary code execution with the user’s full privileges, in a process that runs
with no sandbox at all. The payload runs as euid=501 in app_mode_loader, outside every
confinement the network service is subject to. Measured below as cell R2.
Factors that legitimately reduce this, stated up front:
- It is one link in a chain — reaching the network service needs a separate bug, so
severity-guidelines.md:57-58applies. - It requires at least one installed web app shim.
- It requires the user to launch that shim after the payload is planted. That is ordinary use, not a constructed scenario, but it is a genuine interaction requirement.
No command-line flags and no non-default features are needed.
WHY MOJOJS IS NOT SUFFICIENT
The attacker position is the network service, not a renderer, so MojoJS cannot reach it. A
renderer cannot perform this attack at all: measured in cell A below, a renderer may write
nothing but /dev/null, and is denied on user_data_dir, while the network service is allowed
there. Per docs/security/vrp-faq.md:74-80, the compromise is therefore simulated with a
patch — attached, process-guarded, deriving its target only from a path the network service was
already given.
MEASURED VERSUS INFERRED
Measured: the sandbox verdicts in cell A (sandbox_check() against live pids, both polarities);
the shim outcomes R1/R2/R3; the signing flags and entitlements of every shipped binary; the
two-binary dlopen experiment. Not claimed: that any particular web app is installed on a given
victim’s machine.
VERSION
Chrome Version: 152.0.7977.83 stable Operating System: macOS 26.6.2 (Darwin 25.6.0), arm64
REPRODUCTION CASE
- Install Google Chrome 152.0.7977.83 stable on macOS arm64 and start it normally. No flags.
- python3 poc.py
Part A asks the kernel what the live processes are permitted to do. Controls of both polarities
are included, because sandbox_check() also returns non-zero when it cannot canonicalise a path,
so an all-negative table would be indistinguishable from a broken probe:
network(22457) user_data_dir/<entry> file-write-unlink ALLOWED
network(22457) user_data_dir/<entry> file-write-data ALLOWED
network(22457) [control] /etc/hosts file-write-data denied
renderer(25427) user_data_dir/<entry> file-write-unlink denied
renderer(25427) [control] /dev/null file-write-data ALLOWED
Part B builds an unsigned dylib named Google Chrome Framework under user_data_dir, then two
shims from Chrome’s shipped Helpers/app_mode_loader, signed the way UpdateSignature does and
differing only in AppShimEntitlements():
R1 original symlink, shim signed as Chrome signs it -> payload not loaded
R2 traversal symlink, shim signed as Chrome signs it -> payload EXECUTED
host=.../Vuln.app/Contents/MacOS/app_mode_loader pid=95625 euid=501
R3 traversal symlink, same shim WITHOUT the entitlement -> payload not loaded
R1 shows the payload is not picked up incidentally. R3 shows the entitlement is load-bearing.
poc.py restores RunningChromeVersion and deletes the payload directory in a finally block. It
takes no arguments and needs no root. The two shims are signed by the PoC only because the test
machine had no web app installed; on a real machine Chrome already did that signing at install
time.
Step 2 demonstrates the chain on a released build. To see the write performed by the attacker rather than by a script, apply the attached patch:
- git apply network_service_plants_symlink.patch
(base commit
1dfbd5b0b1a1374759b99ba1dc25afff78180b4f) - Build, run Chrome normally, then launch an installed web app shim.
The patch performs only the one write this report is about, from inside the network service. It
is guarded on --utility-sub-type=network.mojom.NetworkService so it cannot run in the browser,
a renderer, or an in-process NetworkService; it derives user_data_dir from
params_->file_paths->data_directory, a path the browser already hands the service; and it adds
no logging.
SUGGESTED FIX
Validate framework_version before it becomes a path component. It is a version string, so
base::SafeBaseName or a [0-9.]+ match is enough, applied in
ChromeConnectionConfig::DecodeFromPath (app_mode_common.mm:89-103) so every consumer of the
decoded config is covered rather than only the one call site.
The in-tree contrast is ExtensionResource::GetFilePath (extension_resource.cc:51), which
normalises its root separately and documents that IsParent does not normalise; and
web_app_shortcut_creator.mm’s own GetShortcutBasename, which returns
std::optional<base::SafeBaseName> so traversal is rejected at the type level rather than by a
string check.
A second, independent hardening would be to stop introducing
com.apple.security.cs.disable-library-validation into shims, or to scope it, since the
measurements above show it is the only place in the product where that protection is off.
CREDIT INFORMATION
Reporter credit: Quyền Sơn (@zer0qs)