Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect reference resolution in WebAppInstalls
DescriptionIncorrect reference resolution in WebAppInstalls
ComponentWebAppInstalls
Sub-componentUI>Browser>WebAppInstalls
Bug ClassLogic Error
Tracker559097675
Fix commit947ac61f7d01 (chromium/src) +165/-15
CISA KEVNot listed
CreditedQuyen Son at VinFast (@zer0qs)
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
chrome/app_shim/app_mode_loader_mac.mm
modified
if
chrome/common/mac/app_mode_common.mm
modified
TEST
chrome/common/mac/app_mode_chrome_locator_browsertest.mm
modified
TEST
chrome/common/mac/app_mode_common_unittest.mm
modified

Files Changed

  • chrome/common/mac/app_mode_chrome_locator_browsertest.mm
  • chrome/common/mac/app_mode_chrome_locator.mm
  • chrome/test/BUILD.gn
  • chrome/app_shim/app_mode_loader_mac.mm
  • chrome/common/mac/app_mode_common.h
  • chrome/common/mac/app_mode_common.mm
  • chrome/browser/apps/app_shim/app_shim_listener_browsertest.mm
  • chrome/common/mac/app_mode_common_unittest.mm
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" ]
Loading diff…

Regression Test / PoC

shipped with the fix
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" ]
Loading diff…

Original Bug Report

reported by tr...@gmail.com

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-58 applies.
  • 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

  1. Install Google Chrome 152.0.7977.83 stable on macOS arm64 and start it normally. No flags.
  2. 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:

  1. git apply network_service_plants_symlink.patch (base commit 1dfbd5b0b1a1374759b99ba1dc25afff78180b4f)
  2. 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)

View on issue tracker