Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect reference resolution in Extensions
DescriptionIncorrect reference resolution in Extensions
ComponentExtensions
Sub-componentPlatform>Extensions
Bug ClassLogic Error
Tracker561892119
Fix commit2889bd9685a2 (chromium/src) +199/-50
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-13

Changed Functions

FunctionChangeNotes
TEST_F
extensions/common/file_util_unittest.cc
modified

Files Changed

  • chrome/browser/extensions/updater/extension_updater_unittest.cc
  • extensions/common/file_util.h
  • extensions/common/file_util.cc
  • extensions/common/extension_resource.cc
  • extensions/common/file_util_unittest.cc
From 2889bd9685a228611ae6f466cff63aaa8805eff5 Mon Sep 17 00:00:00 2001
From: Bryan Oltman <bryanoltman@google.com>
Date: Mon, 05 Oct 2026 17:25:28 -0700
Subject: [PATCH] Ensure extensions only install into their own directory

Updates file_util::InstallExtension and file_util::GetInstallTempDir to
verify that both the extension install destination (extensions_dir/id)
and install temp directory (extensions_dir/Temp) resolve strictly to
their expected child directories under the extensions root, rejecting
symbolic links and NTFS junction reparse points. Path normalization and
Windows fallback logic (e.g. for ramdisks) are shared with
ExtensionResource via a new file_util::NormalizeFilePath helper.

Because ChromeOS unpacks extensions inside the profile rather than
%TEMP%, this stricter validation also requires ExtensionUpdaterTest to
properly initialize TestExtensionSystem via Init() instead of passing an
empty install directory to CreateExtensionService().

Fixed: 561892119
Change-Id: I6a026d72d287b447f6d66f81abcee81e5b88bb0c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8469727
Reviewed-by: Andrea Orru <andreaorru@chromium.org>
Commit-Queue: Bryan Oltman <bryanoltman@google.com>
Cr-Commit-Position: refs/heads/main@{#1711841}
---
diff --git a/extensions/common/extension_resource.cc b/extensions/common/extension_resource.cc
index a694c7af..336480c 100644
--- a/extensions/common/extension_resource.cc
+++ b/extensions/common/extension_resource.cc
@@ -6,6 +6,8 @@
 
 #include "base/check.h"
 #include "base/files/file_util.h"
+#include "build/build_config.h"
+#include "extensions/common/file_util.h"
 
 namespace extensions {
 
@@ -56,34 +58,10 @@
   // normalize file paths. Without normalization parent references, Windows
   // short paths, or different path capitalization will cause `IsParent` to
   // return false.
-  bool extension_root_normalization_skipped = false;
   base::FilePath normalized_extension_root;
-  if (!base::NormalizeFilePath(extension_root, &normalized_extension_root)) {
-#if BUILDFLAG(IS_WIN)
-    // On Windows, `NormalizeFilePath` is implemented via
-    // `GetFinalPathNameByHandle`, which can fail in some cases. One such case
-    // which was reported by users is with paths on a ramdisk. For example, from
-    // the author of ImDisk:
-    //
-    // > ImDisk is a rather old product. It is designed to be as small and
-    // > simple as possible and compatible with Windows versions as old as NT
-    // > 3.51. By design it lacks support for certain "modern" OS features like
-    // > plug-and-play and Volume Mount Manager. The mentioned API function,
-    // > GetFinalPathNameByHandle, uses Volume Mount Manager and is therefore
-    // > not supported for ImDisk virtual disks.
-    //
-    // Since we can't normalize the path, fall back to `MakeAbsoluteFilePath`
-    // and proceed if the file exists.
-    normalized_extension_root = base::MakeAbsoluteFilePath(extension_root);
-    if (normalized_extension_root.empty() ||
-        !base::PathExists(normalized_extension_root)) {
-      return base::FilePath();
-    }
-
-    extension_root_normalization_skipped = true;
-#else
+  if (!file_util::NormalizeFilePath(extension_root,
+                                    &normalized_extension_root)) {
     return base::FilePath();
-#endif
   }
 
   base::FilePath full_path = normalized_extension_root.Append(relative_path);
@@ -107,22 +85,8 @@
   // We must resolve the absolute path of the combined path when
   // the relative path contains references to a parent folder (i.e., '..').
   // NormalizeFilePath will fail if the path doesn't exist.
-  if (base::FilePath full_path_normalized;
-      !extension_root_normalization_skipped &&
-      base::NormalizeFilePath(full_path, &full_path_normalized)) {
-    full_path = std::move(full_path_normalized);
-  } else {
-#if BUILDFLAG(IS_WIN)
-    // On Windows, if `NormalizeFilePath` fails, fall back to
-    // `MakeAbsoluteFilePath` and proceed if the file exists. This can happen
-    // if, for example, the file isn't accessible due to permissions.
-    full_path = base::MakeAbsoluteFilePath(full_path);
-    if (full_path.empty() || !base::PathExists(full_path)) {
-      return base::FilePath();
-    }
-#else
+  if (!file_util::NormalizeFilePath(full_path, &full_path)) {
     return base::FilePath();
-#endif
   }
 
   if (symlink_policy != FOLLOW_SYMLINKS_ANYWHERE &&
diff --git a/extensions/common/file_util.cc b/extensions/common/file_util.cc
index 43a86d25..504d3839 100644
--- a/extensions/common/file_util.cc
+++ b/extensions/common/file_util.cc
@@ -28,6 +28,7 @@
 #include "base/strings/stringprintf.h"
 #include "base/strings/utf_string_conversions.h"
 #include "base/values.h"
+#include "build/build_config.h"
 #include "extensions/common/constants.h"
 #include "extensions/common/extension.h"
 #include "extensions/common/extension_id.h"
@@ -105,6 +106,23 @@
   }
 }
 
+// Returns true if `dir` is not a symlink and resolves strictly to
+// `extensions_dir/dir.BaseName()`. This prevents pre-planted symlinks by
+// another profile writer from redirecting install steps to sibling or external
+// directories.
+bool ResolvesToDirectChildDir(const base::FilePath& dir,
+                              const base::FilePath& extensions_dir) {
+  if (base::IsLink(dir)) {
+    return false;
+  }
+  base::FilePath normalized_extensions_dir;
+  base::FilePath normalized_dir;
+  return file_util::NormalizeFilePath(extensions_dir,
+                                      &normalized_extensions_dir) &&
+         file_util::NormalizeFilePath(dir, &normalized_dir) &&
+         normalized_dir == normalized_extensions_dir.Append(dir.BaseName());
+}
+
 }  // namespace
 
 const base::FilePath::CharType kTempDirectoryName[] = FILE_PATH_LITERAL("Temp");
@@ -129,6 +147,14 @@
     }
   }
 
+  // Refuse to install if the destination does not resolve strictly to this
+  // extension's own directory.
+  if (!ResolvesToDirectChildDir(extension_dir, extensions_dir)) {
+    LOG(ERROR) << "Extension install directory " << extension_dir.value()
+               << " does not resolve to its expected extension directory.";
+    return base::FilePath();
+  }
+
   // Get a temp directory on the same file system as the profile.
   base::FilePath install_temp_dir = GetInstallTempDir(extensions_dir);
   base::ScopedTempDir extension_temp_dir;
@@ -486,18 +512,53 @@
       DLOG(WARNING) << "Can't write to path: " << temp_path.value();
       return base::FilePath();
     }
-    // This is a directory we can write to.
-    return temp_path;
-  }
-
-  // Directory doesn't exist, so create it.
-  if (!base::CreateDirectory(temp_path)) {
+  } else if (!base::CreateDirectory(temp_path)) {
     DLOG(WARNING) << "Couldn't create directory: " << temp_path.value();
     return base::FilePath();
   }
+
+  // Refuse to use the temp directory if it does not resolve strictly to
+  // `extensions_dir/Temp`.
+  if (!ResolvesToDirectChildDir(temp_path, extensions_dir)) {
+    LOG(ERROR) << "Extension install temp directory " << temp_path.value()
+               << " does not resolve to its expected directory.";
+    return base::FilePath();
+  }
+
   return temp_path;
 }
 
+bool NormalizeFilePath(const base::FilePath& path,
+                       base::FilePath* normalized_path) {
+  if (base::FilePath result; base::NormalizeFilePath(path, &result)) {
+    *normalized_path = std::move(result);
+    return true;
+  }
+#if BUILDFLAG(IS_WIN)
+  // On Windows, `base::NormalizeFilePath` is implemented via
+  // `GetFinalPathNameByHandle`, which can fail in some cases. One such case
+  // which was reported by users is with paths on a ramdisk. For example, from
+  // the author of ImDisk (see crbug.com/442094023):
+  //
+  // > ImDisk is a rather old product. It is designed to be as small and
+  // > simple as possible and compatible with Windows versions as old as NT
+  // > 3.51. By design it lacks support for certain "modern" OS features like
+  // > plug-and-play and Volume Mount Manager. The mentioned API function,
+  // > GetFinalPathNameByHandle, uses Volume Mount Manager and is therefore
+  // > not supported for ImDisk virtual disks.
+  //
+  // It can also fail if a file isn't accessible due to permissions. Since we
+  // can't normalize the path, fall back to `base::MakeAbsoluteFilePath` and
+  // proceed if the path exists.
+  base::FilePath result = base::MakeAbsoluteFilePath(path);
+  if (!result.empty() && base::PathExists(result)) {
+    *normalized_path = std::move(result);
+    return true;
+  }
+#endif
+  return false;
+}
+
 base::FilePath ExtensionURLToRelativeFilePath(const GURL& url) {
   std::string_view url_path = url.path();
   if (url_path.empty() || url_path[0] != '/') {
diff --git a/extensions/common/file_util.h b/extensions/common/file_util.h
index c3ecbd2..97c3396 100644
--- a/extensions/common/file_util.h
+++ b/extensions/common/file_util.h
@@ -136,6 +136,14 @@
 // an empty file path on failure.
 base::FilePath GetInstallTempDir(const base::FilePath& extensions_dir);
 
+// Normalizes `path` into `normalized_path` via `base::NormalizeFilePath`. On
+// Windows, falls back to `base::MakeAbsoluteFilePath` (requiring `path` to
+// exist) when `base::NormalizeFilePath` fails (e.g. on ramdisks that do not
+// support `GetFinalPathNameByHandle`, see crbug.com/442094023, or when a file
+// cannot be opened due to permissions).
+bool NormalizeFilePath(const base::FilePath& path,
+                       base::FilePath* normalized_path);
+
 // Get a relative file path from a chrome-extension:// URL.
 base::FilePath ExtensionURLToRelativeFilePath(const GURL& url);
 
diff --git a/chrome/browser/extensions/updater/extension_updater_unittest.cc b/chrome/browser/extensions/updater/extension_updater_unittest.cc
index 921b8fda..1c044adf 100644
--- a/chrome/browser/extensions/updater/extension_updater_unittest.cc
+++ b/chrome/browser/extensions/updater/extension_updater_unittest.cc
@@ -1617,9 +1617,7 @@
     TestingProfile profile;
     // TODO(crbug.com/396722906): Delete this when CrxInstaller no longer has
     // dependencies on ExtensionService.
-    static_cast<TestExtensionSystem*>(ExtensionSystem::Get(&profile))
-        ->CreateExtensionService(base::CommandLine::ForCurrentProcess(),
-                                 base::FilePath(), false);
+    static_cast<TestExtensionSystem*>(ExtensionSystem::Get(&profile))->Init();
     scoped_refptr<MockCrxInstaller> mock_installer =
         base::MakeRefCounted<MockCrxInstaller>(&profile);
 
diff --git a/extensions/common/file_util_unittest.cc b/extensions/common/file_util_unittest.cc
index ce5882b..5d08dac23 100644
--- a/extensions/common/file_util_unittest.cc
+++ b/extensions/common/file_util_unittest.cc
@@ -38,6 +38,10 @@
 #include "ui/base/l10n/l10n_util.h"
 #include "url/gurl.h"
 
+#if BUILDFLAG(IS_WIN)
+#include "base/test/file_path_reparse_point_win.h"
+#endif
+
 using extensions::mojom::ManifestLocation;
 
 namespace extensions {
@@ -284,6 +288,120 @@
   EXPECT_TRUE(base::DirectoryExists(extensions_dir));
 }
 
+// Verifies that InstallExtension refuses to install an extension whose
+// destination directory is a symlink pointing to a sibling extension
+// directory (https://crbug.com/561892119).
+TEST_F(FileUtilTest, InstallExtensionRejectsSymlinkToSiblingExtension) {
+  base::ScopedTempDir temp;
+  ASSERT_TRUE(temp.CreateUniqueTempDir());
+
+  base::FilePath profile_dir = temp.GetPath().AppendASCII("Default");
+  base::FilePath extensions_dir = profile_dir.AppendASCII("TestExtensions");
+  ASSERT_TRUE(base::CreateDirectory(extensions_dir));
+
+  base::FilePath sibling_dir = extensions_dir.AppendASCII("sibling_dir");
+  ASSERT_TRUE(base::CreateDirectory(sibling_dir));
+
+  base::FilePath target_dir = extensions_dir.AppendASCII(kExtensionId);
+#if BUILDFLAG(IS_POSIX)
+  ASSERT_TRUE(base::CreateSymbolicLink(sibling_dir, target_dir));
+#elif BUILDFLAG(IS_WIN)
+  ASSERT_TRUE(base::CreateDirectory(target_dir));
+  auto reparse_point =
+      base::test::FilePathReparsePoint::Create(target_dir, sibling_dir);
+  ASSERT_TRUE(reparse_point.has_value());
+#endif
+
+  base::FilePath src = temp.GetPath().AppendASCII("src");
+  ASSERT_TRUE(base::CreateDirectory(src));
+  base::FilePath extension_content;
+  ASSERT_TRUE(base::CreateTemporaryFileInDir(src, &extension_content));
+
+  base::FilePath installed_path =
+      file_util::InstallExtension(src, kExtensionId, "1.0", extensions_dir);
+  EXPECT_TRUE(installed_path.empty());
+
+  EXPECT_TRUE(base::IsDirectoryEmpty(sibling_dir));
+  EXPECT_TRUE(base::DirectoryExists(src));
+}
+
+// Verifies that InstallExtension refuses to install an extension whose
+// destination directory is a symlink pointing outside the extensions
+// directory (https://crbug.com/561892119).
+TEST_F(FileUtilTest, InstallExtensionRejectsSymlinkOutsideExtensionsDir) {
+  base::ScopedTempDir temp;
+  ASSERT_TRUE(temp.CreateUniqueTempDir());
+
+  base::FilePath profile_dir = temp.GetPath().AppendASCII("Default");
+  base::FilePath extensions_dir = profile_dir.AppendASCII("TestExtensions");
+  ASSERT_TRUE(base::CreateDirectory(extensions_dir));
+
+  base::FilePath outside_dir = temp.GetPath().AppendASCII("outside");
+  ASSERT_TRUE(base::CreateDirectory(outside_dir));
+
+  base::FilePath target_dir = extensions_dir.AppendASCII(kExtensionId);
+#if BUILDFLAG(IS_POSIX)
+  ASSERT_TRUE(base::CreateSymbolicLink(outside_dir, target_dir));
+#elif BUILDFLAG(IS_WIN)
+  ASSERT_TRUE(base::CreateDirectory(target_dir));
+  auto reparse_point =
+      base::test::FilePathReparsePoint::Create(target_dir, outside_dir);
+  ASSERT_TRUE(reparse_point.has_value());
+#endif
+
+  base::FilePath src = temp.GetPath().AppendASCII("src");
+  ASSERT_TRUE(base::CreateDirectory(src));
+  base::FilePath extension_content;
+  ASSERT_TRUE(base::CreateTemporaryFileInDir(src, &extension_content));
+
+  base::FilePath installed_path =
+      file_util::InstallExtension(src, kExtensionId, "1.0", extensions_dir);
+  EXPECT_TRUE(installed_path.empty());
+
+  EXPECT_TRUE(base::IsDirectoryEmpty(outside_dir));
+  EXPECT_TRUE(base::DirectoryExists(src));
+}
+
+// Verifies that InstallExtension refuses to install an extension when
+// `extensions_dir/Temp` is a symlink pointing outside the expected temp
+// directory (https://crbug.com/561892119).
+TEST_F(FileUtilTest, InstallExtensionRejectsSymlinkTempDir) {
+  base::ScopedTempDir temp;
+  ASSERT_TRUE(temp.CreateUniqueTempDir());
+
+  base::FilePath profile_dir = temp.GetPath().AppendASCII("Default");
+  base::FilePath extensions_dir = profile_dir.AppendASCII("TestExtensions");
+  ASSERT_TRUE(base::CreateDirectory(extensions_dir));
+
+  base::FilePath outside_dir = temp.GetPath().AppendASCII("outside");
+  ASSERT_TRUE(base::CreateDirectory(outside_dir));
+
+  base::FilePath temp_dir =
+      extensions_dir.Append(file_util::kTempDirectoryName);
+#if BUILDFLAG(IS_POSIX)
+  ASSERT_TRUE(base::CreateSymbolicLink(outside_dir, temp_dir));
+#elif BUILDFLAG(IS_WIN)
+  ASSERT_TRUE(base::CreateDirectory(temp_dir));
+  auto reparse_point =
+      base::test::FilePathReparsePoint::Create(temp_dir, outside_dir);
+  ASSERT_TRUE(reparse_point.has_value());
+#endif
+
+  base::FilePath src = temp.GetPath().AppendASCII("src");
+  ASSERT_TRUE(base::CreateDirectory(src));
+  base::FilePath extension_content;
+  ASSERT_TRUE(base::CreateTemporaryFileInDir(src, &extension_content));
+
+  EXPECT_TRUE(file_util::GetInstallTempDir(extensions_dir).empty());
+
+  base::FilePath installed_path =
+      file_util::InstallExtension(src, kExtensionId, "1.0", extensions_dir);
+  EXPECT_TRUE(installed_path.empty());
+
+  EXPECT_TRUE(base::IsDirectoryEmpty(outside_dir));
+  EXPECT_TRUE(base::DirectoryExists(src));
+}
+
 TEST_F(FileUtilTest, LoadExtensionWithMetadataFolder) {
   RunUnderscoreDirectoriesTest({"_metadata"});
 }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/extensions/updater/extension_updater_unittest.cc b/chrome/browser/extensions/updater/extension_updater_unittest.cc
index 921b8fda..1c044adf 100644
--- a/chrome/browser/extensions/updater/extension_updater_unittest.cc
+++ b/chrome/browser/extensions/updater/extension_updater_unittest.cc
@@ -1617,9 +1617,7 @@
     TestingProfile profile;
     // TODO(crbug.com/396722906): Delete this when CrxInstaller no longer has
     // dependencies on ExtensionService.
-    static_cast<TestExtensionSystem*>(ExtensionSystem::Get(&profile))
-        ->CreateExtensionService(base::CommandLine::ForCurrentProcess(),
-                                 base::FilePath(), false);
+    static_cast<TestExtensionSystem*>(ExtensionSystem::Get(&profile))->Init();
     scoped_refptr<MockCrxInstaller> mock_installer =
         base::MakeRefCounted<MockCrxInstaller>(&profile);
diff --git a/extensions/common/file_util_unittest.cc b/extensions/common/file_util_unittest.cc
index ce5882b..5d08dac23 100644
--- a/extensions/common/file_util_unittest.cc
+++ b/extensions/common/file_util_unittest.cc
@@ -38,6 +38,10 @@
 #include "ui/base/l10n/l10n_util.h"
 #include "url/gurl.h"
 
+#if BUILDFLAG(IS_WIN)
+#include "base/test/file_path_reparse_point_win.h"
+#endif
+
 using extensions::mojom::ManifestLocation;
 
 namespace extensions {
@@ -284,6 +288,120 @@
   EXPECT_TRUE(base::DirectoryExists(extensions_dir));
 }
 
+// Verifies that InstallExtension refuses to install an extension whose
+// destination directory is a symlink pointing to a sibling extension
+// directory (https://crbug.com/561892119).
+TEST_F(FileUtilTest, InstallExtensionRejectsSymlinkToSiblingExtension) {
+  base::ScopedTempDir temp;
+  ASSERT_TRUE(temp.CreateUniqueTempDir());
+
+  base::FilePath profile_dir = temp.GetPath().AppendASCII("Default");
+  base::FilePath extensions_dir = profile_dir.AppendASCII("TestExtensions");
+  ASSERT_TRUE(base::CreateDirectory(extensions_dir));
+
+  base::FilePath sibling_dir = extensions_dir.AppendASCII("sibling_dir");
+  ASSERT_TRUE(base::CreateDirectory(sibling_dir));
+
+  base::FilePath target_dir = extensions_dir.AppendASCII(kExtensionId);
+#if BUILDFLAG(IS_POSIX)
+  ASSERT_TRUE(base::CreateSymbolicLink(sibling_dir, target_dir));
+#elif BUILDFLAG(IS_WIN)
+  ASSERT_TRUE(base::CreateDirectory(target_dir));
+  auto reparse_point =
+      base::test::FilePathReparsePoint::Create(target_dir, sibling_dir);
+  ASSERT_TRUE(reparse_point.has_value());
+#endif
+
+  base::FilePath src = temp.GetPath().AppendASCII("src");
+  ASSERT_TRUE(base::CreateDirectory(src));
+  base::FilePath extension_content;
+  ASSERT_TRUE(base::CreateTemporaryFileInDir(src, &extension_content));
+
+  base::FilePath installed_path =
+      file_util::InstallExtension(src, kExtensionId, "1.0", extensions_dir);
+  EXPECT_TRUE(installed_path.empty());
+
+  EXPECT_TRUE(base::IsDirectoryEmpty(sibling_dir));
+  EXPECT_TRUE(base::DirectoryExists(src));
+}
+
+// Verifies that InstallExtension refuses to install an extension whose
+// destination directory is a symlink pointing outside the extensions
+// directory (https://crbug.com/561892119).
+TEST_F(FileUtilTest, InstallExtensionRejectsSymlinkOutsideExtensionsDir) {
+  base::ScopedTempDir temp;
+  ASSERT_TRUE(temp.CreateUniqueTempDir());
+
+  base::FilePath profile_dir = temp.GetPath().AppendASCII("Default");
+  base::FilePath extensions_dir = profile_dir.AppendASCII("TestExtensions");
+  ASSERT_TRUE(base::CreateDirectory(extensions_dir));
+
+  base::FilePath outside_dir = temp.GetPath().AppendASCII("outside");
+  ASSERT_TRUE(base::CreateDirectory(outside_dir));
+
+  base::FilePath target_dir = extensions_dir.AppendASCII(kExtensionId);
+#if BUILDFLAG(IS_POSIX)
+  ASSERT_TRUE(base::CreateSymbolicLink(outside_dir, target_dir));
+#elif BUILDFLAG(IS_WIN)
+  ASSERT_TRUE(base::CreateDirectory(target_dir));
+  auto reparse_point =
+      base::test::FilePathReparsePoint::Create(target_dir, outside_dir);
+  ASSERT_TRUE(reparse_point.has_value());
+#endif
+
+  base::FilePath src = temp.GetPath().AppendASCII("src");
+  ASSERT_TRUE(base::CreateDirectory(src));
+  base::FilePath extension_content;
+  ASSERT_TRUE(base::CreateTemporaryFileInDir(src, &extension_content));
+
+  base::FilePath installed_path =
+      file_util::InstallExtension(src, kExtensionId, "1.0", extensions_dir);
+  EXPECT_TRUE(installed_path.empty());
+
+  EXPECT_TRUE(base::IsDirectoryEmpty(outside_dir));
+  EXPECT_TRUE(base::DirectoryExists(src));
+}
+
+// Verifies that InstallExtension refuses to install an extension when
+// `extensions_dir/Temp` is a symlink pointing outside the expected temp
+// directory (https://crbug.com/561892119).
+TEST_F(FileUtilTest, InstallExtensionRejectsSymlinkTempDir) {
+  base::ScopedTempDir temp;
+  ASSERT_TRUE(temp.CreateUniqueTempDir());
+
+  base::FilePath profile_dir = temp.GetPath().AppendASCII("Default");
+  base::FilePath extensions_dir = profile_dir.AppendASCII("TestExtensions");
+  ASSERT_TRUE(base::CreateDirectory(extensions_dir));
+
+  base::FilePath outside_dir = temp.GetPath().AppendASCII("outside");
+  ASSERT_TRUE(base::CreateDirectory(outside_dir));
+
+  base::FilePath temp_dir =
+      extensions_dir.Append(file_util::kTempDirectoryName);
+#if BUILDFLAG(IS_POSIX)
+  ASSERT_TRUE(base::CreateSymbolicLink(outside_dir, temp_dir));
+#elif BUILDFLAG(IS_WIN)
+  ASSERT_TRUE(base::CreateDirectory(temp_dir));
+  auto reparse_point =
+      base::test::FilePathReparsePoint::Create(temp_dir, outside_dir);
+  ASSERT_TRUE(reparse_point.has_value());
+#endif
+
+  base::FilePath src = temp.GetPath().AppendASCII("src");
+  ASSERT_TRUE(base::CreateDirectory(src));
+  base::FilePath extension_content;
+  ASSERT_TRUE(base::CreateTemporaryFileInDir(src, &extension_content));
+
+  EXPECT_TRUE(file_util::GetInstallTempDir(extensions_dir).empty());
+
+  base::FilePath installed_path =
+      file_util::InstallExtension(src, kExtensionId, "1.0", extensions_dir);
+  EXPECT_TRUE(installed_path.empty());
+
+  EXPECT_TRUE(base::IsDirectoryEmpty(outside_dir));
+  EXPECT_TRUE(base::DirectoryExists(src));
+}
+
 TEST_F(FileUtilTest, LoadExtensionWithMetadataFolder) {
   RunUnderscoreDirectoriesTest({"_metadata"});
 }
Loading diff…

Original Bug Report

reported by vm...@google.com

Sandbox escape via symlink following in file_util::InstallExtension on macOS

Project Fortify, 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: extensions::file_util::InstallExtension runs in the unsandboxed browser process and performs the final step of every CRX install/update using only symlink-following filesystem primitives, with no containment or link check. On macOS the network-service seatbelt grants file* over the entire user-data directory, so a compromised (always-sandboxed) network service can replace <profile>/Extensions/<id> with a symlink to any existing user-writable directory. On the next silent extension auto-update, the browser resolves the link and plants the fully attacker-controlled unpacked CRX tree outside the sandbox grant — demonstrated end-to-end on real macOS through persistent user-level code execution (vim package autoload). This is a deterministic, race-free network-service→user sandbox-escape primitive.

Affected files:

Estimated timestamp from git blame: 2019-01-14

Summary

The macOS network service is unconditionally sandboxed (sandbox/policy/features.cc:166-168), but its seatbelt profile allows file* over each network-context parent directory — and Chrome passes the user-data directory itself as that parameter (chrome/browser/chrome_content_browser_client.cc:5746-5750, content/browser/sandbox_parameters_mac.mm:146-159). <profile>/Extensions is therefore fully renameable/symlinkable by a compromised network service, while direct writes outside the grant stay denied. That over-broad grant is reported separately; this report covers the browser-side defect that converts it into an escape.

The browser-side defect: InstallExtension (extensions/common/file_util.cc:118-189) trusts the Extensions/<id> and Extensions/Temp paths lexically. Every primitive it uses resolves symlinks — base::PathExists is access(2), base::Move is rename(2) after only a lexical .. check — so a pre-planted symlink at Extensions/<id> redirects the final install rename() to an arbitrary existing user-writable directory, executed at browser (unsandboxed) privilege. The install completes silently: prefs record the lexical path, and the extension loads through the link afterward.

The attack was reproduced on real macOS (Apple Silicon) with unmodified Chromium and the real composed network-service seatbelt profile:

  • Inside the seatbelt: writes/mkdir at the victim directory and $HOME → DENIED; rename(Extensions/<id> → .bak) and symlink(victim dir AT Extensions/<id>) → ALLOWED.
  • On the next auto-update the browser moved the verified, unpacked CRX tree through the link into <victim>/.vim/pack/2.0_0/.
  • Running vim as the victim executed the planted plugin — persistent user-level code execution outside all Chrome sandboxes.

Root Cause Analysis

  1. Planting capability (precondition, filed separately): sandbox/policy/mac/network.sb:12-19 — (allow file* (subpath (param NETWORK_SERVICE_STORAGE_PATH_<i>))), with the user-data dir as a parameter (content/browser/sandbox_parameters_mac.mm:146-159; chrome/browser/chrome_content_browser_client.cc:5746-5750,6992-6996). A compromised network service may rename Extensions/<id> aside and create a symlink node there whose target is anywhere (link node inside the grant, target outside) — POC-verified under the real profile.
  2. Existence check follows the link: extensions/common/file_util.cc:126 — base::PathExists(extension_dir) is access(2) (base/files/file_util_posix.cc:636-645), resolves the planted symlink to the (existing) target, returns true, and CreateDirectory is skipped. No lstat/IsLink/NormalizeFilePath check exists anywhere in the function.
  3. Version slot chosen through the link: extensions/common/file_util.cc:151-158 —
    // [`extensions/common/file_util.cc:152-154`](https://source.chromium.org/chromium/chromium/src/+/main:extensions/common/file_util.cc;l=152)
    base::FilePath candidate = extension_dir.AppendASCII(
        base::StringPrintf("%s_%u", version.c_str(), i));   // lexical Extensions/<id>/<ver>_<i>
    if (!base::PathExists(candidate)) {                     // access(2) stats <target>/<ver>_<i>
    
  4. Privileged move resolves the symlinked parent: extensions/common/file_util.cc:175 — base::Move rejects only lexical .. (base/files/file_util.cc:187-191) then calls rename(2) (base/files/file_util_posix.cc:1529-1554, with a CopyDirectory fallback that also traverses the link). The kernel resolves the symlinked parent component and lands the attacker-controlled tree at <target>/<version>_0/ with browser privilege.
  5. Staging variant: extensions/common/file_util.cc:133-142 with GetInstallTempDir (:471-499) — a symlink at Extensions/Temp equivalently redirects the mkdtemp + staging move.
  6. Silent trigger, no user interaction: chrome/browser/extensions/updater/extension_updater.cc:818-844 → CrxInstaller::CreateSilent → CompleteInstall (extensions/browser/crx_installer.cc:917-939) → default EmptyExtensionAssetsManager (extensions/browser/extension_assets_manager.cc:25-35) → InstallExtension. Contrast: the front half (SandboxedUnpacker, extensions/browser/sandboxed_unpacker.cc:76-105,263-350) already defends against a filesystem attacker by working in a NormalizeFilePath-normalized Darwin temp dir; the back half has no equivalent.
  7. Cleanup shares the defect: extensions/common/file_util.cc:200-219 — UninstallExtension’s containment checks compare lexical parents only, so later version garbage collection recursively deletes through the same planted link (observed in the reproduction).

Reproduction

  1. From code running under the network-service seatbelt: rename("<profile>/Extensions/<id>", ".../.bak"), then symlink("<victim>/.vim/pack", "<profile>/Extensions/<id>") — both allowed by the profile; direct writes to the victim path are denied.
  2. Have extension <id> update (periodic check, startup check, or chrome.runtime.requestUpdateCheck); the compromised network service serves the update response and CRX bytes for the attacker’s own genuinely signed extension. Signature validation is honored, not bypassed — it constrains content authorship, not install destination.
  3. Expected: the browser refuses to install because Extensions/<id> does not resolve inside the extensions root.

Observed (executed on real macOS 26.6.2, unmodified Chromium, real seatbelt on the plant step): the browser’s InstallExtension renamed the verified unpacked tree through the link into <victim>/.vim/pack/2.0_0/; the install completed silently (prefs record the new version/path, extension alive and loaded through the link after restart); launching vim as the victim executed the planted pack/2.0_0/start/*/plugin/*.vim payload. The same redirect reproduced on Linux against the identical sink code. Note: the reproduction delivered the benign v1 extension via the per-user external-extensions mechanism as a stand-in for a CWS-installed extension; the sink and caller chain are identical for both delivery paths, and the redirected write occurred even while the extension was still in DISABLE_EXTERNAL_EXTENSION state (never user-approved).

Severity

high — This is a sandbox escape from the always-sandboxed macOS network service to unsandboxed-browser-privilege writes at arbitrary existing user-writable paths, with demonstrated persistent user-level code execution. It matches the rubric’s high tier (compromised sandboxed process → unsandboxed-process escalation) and the direct precedent of CVE-2026-11677 (compromised macOS network process → sandbox escape, Chromium severity High). No demotion applies: the exploit is deterministic, race-free (pre-planted link), silent, and needs no user interaction at exploit time on a default configuration. Not critical because it is not directly reachable from web content — network-service compromise is required first, plus a benign attacker-authored signed extension as the content vehicle (without one, the redirect still misplaces any updating extension, but content control would require racing the staged tree).

Suggested Fix Direction

In InstallExtension (and GetInstallTempDir), verify that Extensions/<id> and Extensions/Temp resolve — after base::NormalizeFilePath — inside the extensions root before the moves, refusing the install otherwise; this check was prototyped and blocks the attack while legitimate install/update flows pass. A complete fix should use no-follow traversal (openat + O_NOFOLLOW) or post-move re-verification to close the residual check-then-use race, and the same containment belongs in the uninstall/garbage-collection path, whose current checks are lexical only.


POC: file_util::InstallExtension follows pre-planted symlinks in the network-service-writable <profile>/Extensions tree — compromised macOS network service redirects browser-privilege extension install to arbitrary user-writable path

Strategy: standalone (attacker binary running under Chromium’s real macOS network-service seatbelt plants the symlink; stock Chromium triggers the sink via its own extension auto-update pipeline) Bug Type: CWE-59 link following (logic bug — no memory corruption / ASan report; the oracle is browser-privilege file placement outside the sandbox grant)

What is demonstrated

extensions::file_util::InstallExtension (extensions/common/file_util.cc:118-189) runs in the unsandboxed browser process and uses only symlink-following primitives (PathExists/CreateDirectory/mkdtemp/rename). The macOS network-service seatbelt (sandbox/policy/mac/network.sb) grants (allow file* (subpath <user-data-dir>)), so a compromised network service can rename <profile>/Default/Extensions/<id> aside and replace it with a symlink to any existing user-writable directory. On the next extension update, the browser resolves the symlink and plants the fully attacker-controlled unpacked CRX tree at <target>/<version>_0/ — outside everything the network sandbox confines the attacker to.

Demonstrated end-to-end on real macOS (Apple Silicon, macOS 26.6.2):

  1. Seatbelt half — netsvc_sim applies Chromium’s actual composed network-service profile (common.sb + network.sb, parameters computed exactly as content/browser/sandbox_parameters_mac.mm does, applied via the same sandbox_init_with_parameters call as sandbox/mac/seatbelt.cc), then proves inside it:
    • write/mkdir at the victim dir (~victim/.vim/pack) → DENIED
    • write in $HOME → DENIED
    • rename(Extensions/<id> → .bak) → ALLOWED (inside grant)
    • symlink(victim-dir, Extensions/<id>) → ALLOWED (link node inside grant; target outside)
  2. Browser half — stock Chromium (out/mac-arm64, unmodified) with the benign v1.0 extension installed auto-updates to v2.0: the browser’s own updater downloads the CRX, verifies it, and InstallExtension renames the unpacked tree through the planted symlink into the victim directory. The install completes silently; prefs record path = &lt;id&gt;/2.0_1, version = 2.0; after a browser restart the extension is alive at 2.0, loaded through the symlink.
  3. Impact oracle — the planted dir name <version>_N matches vim’s package-loader pattern pack/<any>/start/<any>/plugin/*.vim; running /usr/bin/vim as the victim executes the planted x.vim (POC_VIM_PAYLOAD_EXECUTED.txt is created) → persistent user-level code execution outside all Chrome sandboxes.

The identical sink was also reproduced on Linux (out/asan, same InstallExtension code) — see linux_run_output.log.

Files

file role
proof-extension/ v1 (benign) and v2 (payload) extension sources
make_crx.py CRX3 packer (RSA-PKCS1-SHA256 proof, id from key)
poc_key.pem test signing key (fixes extension id phflbhkimpcdjkaigiidafdonkjkkaoi)
server.py update server (Omaha-style updates.xml + CRX; stands in for the update endpoint whose bytes a compromised network service controls anyway)
plant_symlink.sh the planting step, plain-shell form (Linux run)
netsvc_sim.c network-service stand-in: applies the real seatbelt profile, probes denies, performs the plant (macOS run)
netsvc_profile.sb cat common.sb network.sb from the source tree (what Chrome passes for the network service)
run_netsvc_sim.sh computes the seatbelt parameters like sandbox_parameters_mac.mm and runs netsvc_sim
run_poc_macos.sh full macOS driver (phases 1-4)
run_poc_linux.sh full Linux driver (phases 1-4)
macos_run_output.log, logs-macos/ macOS evidence (see esp. netsvc_sim.log, final_state_evidence.txt, network_service_ps.txt)
linux_run_output.log, logs-linux/ Linux evidence
bandaid-fix.patch minimal fix + verification evidence in logs-linux/bandaid_*

Prerequisites

  • Chromium build: out/mac-arm64 (component) for macOS, or any Linux build.
  • Extension id/CRXs: python3 make_crx.py proof-extension/v1 poc_key.pem ext_v1.crx (prints the id), same for v2.

Reproduction (macOS, all user-level, no root)

# scratch dir S contains: Chromium.app + out-dir *.dylib + icudtl.dat,
# netsvc_sim (clang -target arm64-apple-macos12 netsvc_sim.c), netsvc_profile.sb,
# run_netsvc_sim.sh, run_poc_macos.sh, server.py, ext_v1.crx, ext_v2.crx,
# extension_id.txt
bash $S/run_poc_macos.sh $S

The driver: installs benign v1 via the per-user external-extensions mechanism (<user-data-dir>/External Extensions/<id>.json with external_update_url → the standard ExtensionUpdater → CrxInstaller::CreateSilent pipeline; stand-in for “victim has the attacker’s store extension installed”); stops the browser; runs the seatbelt-confined plant; bumps the served version to 2.0; restarts the browser and waits for the browser’s own update check (the extension can also force one via chrome.runtime.requestUpdateCheck, see proof-extension/*/sw.js).

Reproduction (Linux): ./run_poc_linux.sh (same flow; plant runs as a plain shell step since the Linux network service is not sandboxed anyway).

Expected Result

  • netsvc_sim: victim-dir writes DENIED, plant ALLOWED (under the real profile).
  • After the v2 update: <victim>/.vim/pack/2.0_0/ contains the attacker CRX tree (PAYLOAD.txt, start/p/plugin/x.vim), written by the unsandboxed browser. Prefs record the extension at 2.0 with no error; extension loads through the symlink after restart.
  • vim (victim $HOME) creates POC_VIM_PAYLOAD_EXECUTED.txt.

Output (macOS run, abridged; full logs in logs-macos/)

--- Chrome's real network service runs under the seatbelt:
seatbelt-client=27
[netsvc-sim] network-service seatbelt profile applied
[netsvc-sim] sandbox_check(getpid()) = 1 (1 => sandboxed)
[netsvc-sim] write file .../victimhome/.vim/pack/netsvc_direct_write.txt   => DENIED (Operation not permitted)
[netsvc-sim] mkdir .../victimhome/.vim/pack/netsvc_direct_mkdir            => DENIED (Operation not permitted)
[netsvc-sim] write file /Users/f-visor-bot-1/netsvc_direct_write_home.txt  => DENIED (Operation not permitted)
[netsvc-sim] readdir .../chrome-profile/Default/Extensions                 => ALLOWED
[netsvc-sim] rename .../Extensions/phflbhki... -> .../Extensions/.phflbhki....bak => ALLOWED
[netsvc-sim] symlink .../victimhome/.vim/pack AT .../Extensions/phflbhki...       => ALLOWED

--- content written by the unsandboxed browser OUTSIDE the profile:
.../victimhome/.vim/pack/2.0_0/start/p/plugin/x.vim
.../victimhome/.vim/pack/2.0_0/manifest.json
.../victimhome/.vim/pack/2.0_0/PAYLOAD.txt

--- extension state recorded in prefs (Secure Preferences, after clean shutdown):
 "path": "phflbhkimpcdjkaigiidafdonkjkkaoi/2.0_1"   manifest.version = 2.0

--- after restart, updater sees the extension alive at 2.0 (loaded through the symlink):
[...] extension_downloader.cc:867] phflbhkimpcdjkaigiidafdonkjkkaoi is at '2.0'

--- vim executed the planted payload:
code execution via redirected Chrome extension install: Sun Sep 13 17:35:52 2026

Bandaid Fix

bandaid-fix.patch adds a base::NormalizeFilePath containment check in InstallExtension: the (existing) Extensions/<id> dir and the Extensions/Temp dir must resolve, after following symlinks, to locations inside the extensions root, else the install is refused.

Verified on Linux: with the fix, the attacked update is refused —

ERROR:extensions/common/file_util.cc:152] Extension install directory
/tmp/chrome-poc-profile/Default/Extensions/phflbhkimpcdjkaigiidafdonkjkkaoi
does not resolve inside the extensions root (symlink?); refusing to install.

— the victim dir stays empty and the extension remains at 1.0 (logs-linux/bandaid_blocked_run.log). Regression-checked: with no symlink planted, v1 install and v2 update complete normally (no refusals; prefs reach 2.0). Note: a check-then-use race remains in principle (the planting writer could swap the dir for a symlink between the check and the rename); a complete fix needs no-follow traversal (e.g. openat + O_NOFOLLOW) or an equivalent post-move verification.

Notes and caveats (honesty about stand-ins)

  • Unmodified Chromium performs the vulnerable install in both runs; no Chromium code or checks were altered or disabled.
  • Delivery stand-in: the real-world premise is a CWS-signed extension of the attacker updating over CWS infrastructure (whose responses the compromised network service serves). The POC substitutes the standard per-user external-extensions deployment (external_update_url + self-signed CRX3, plain-CRX3 verifier per extensions::GetExternalVerifierFormat) purely to get the benign v1 installed and updates served locally. The sink (CrxInstaller::CompleteInstall → InstallExtension) and its behavior are identical for both delivery paths. Notably, on macOS the redirected write happened even though the stand-in extension was still DISABLE_EXTERNAL_EXTENSION (never user-approved) — no user interaction of any kind was needed at exploit time.
  • Flags: --no-startup-window (launch without a browser window from an ssh session — not headless; full multi-process browser incl. the seatbelt-sandboxed network service, see logs-macos/network_service_ps.txt), --user-data-dir, --no-first-run, --no-default-browser-check, --enable-logging/--vmodule (evidence only), --hide-crash-restore-bubble. None affect installer or sandbox behavior. Seatbelt params ENABLE_LOGGING=TRUE / DISABLE_SANDBOX_DENIAL_LOGGING=FALSE mirror Chrome’s --enable-sandbox-logging and only toggle denial logging.
  • Bonus observation: Chromium’s extension garbage collector later deletes non-current version dirs through the same symlink (recursive delete outside the profile) — the whole back half of the install/uninstall path is link-unsafe, not just the install move.
  • The update check fires at browser startup for update-URL extensions and periodically (~5h); the attacker’s extension can force it via chrome.runtime.requestUpdateCheck (demonstrated in the Linux run: updatecheck: update_available {"version":"2.0"} from the service worker). No race is involved anywhere — the symlink sits in place indefinitely.

Attached file hierarchy:

The following files were flattened for attachment. Double-underscores (__) in filenames represent directory separators.

  • proof-extension__v1__manifest.json → proof-extension/v1/manifest.json
  • proof-extension__v1__sw.js → proof-extension/v1/sw.js
  • proof-extension__v2__PAYLOAD.txt → proof-extension/v2/PAYLOAD.txt
  • proof-extension__v2__manifest.json → proof-extension/v2/manifest.json
  • proof-extension__v2__start__p__plugin__x.vim → proof-extension/v2/start/p/plugin/x.vim
  • proof-extension__v2__sw.js → proof-extension/v2/sw.js

The following directories were attached as zip archives:

  • logs-linux.zip → logs-linux/
  • logs-macos.zip → logs-macos/

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.

View on issue tracker
Links in the report