CVE-2026-113034
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
IN_PROC_BROWSER_TEST_Fcontent/browser/web_contents/file_chooser_impl_browsertest.cc |
modified |
Files Changed
content/browser/web_contents/file_chooser_impl.cccontent/browser/web_contents/file_chooser_impl_browsertest.cc
Patch
From 28aaa7307ef8e24f44225a317ac78c5c72ef2b2c Mon Sep 17 00:00:00 2001 From: Nathan Parker <[email protected]> Date: Fri, 18 Sep 2026 10:00:37 -0700 Subject: [PATCH] [FileChooser] Check base_dir for symlinks in folder upload RemoveSymlinks() walks the ancestors of each enumerated file looking for symlinks, stopping at base_dir. The loop condition was base_dir.IsParent(path), but FilePath::IsParent() returns false for equal paths, so base_dir itself was never passed to IsLink(). base_dir is not always a directory the user picked: a compromised renderer can pass any path it has lexical read access to to EnumerateChosenDirectory(). If that path is a directory symlink, the enumeration follows it and the results bypass the filter, granting the renderer read access outside of the directory the user shared. Include base_dir in the walk, and add a regression test that uploads a directory symlink as the base folder. TAG=agy CONV=8a17a0ba-3c25-4374-8baf-2339b34a9927 Bug: 497482008 Change-Id: Ic1fac93bfbfb7755bdb056d4ef4319ae8fb390a6 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8423160 Reviewed-by: Alex Moshchuk <[email protected]> Commit-Queue: N Parker <[email protected]> Cr-Commit-Position: refs/heads/main@{#1700885} --- diff --git a/content/browser/web_contents/file_chooser_impl.cc b/content/browser/web_contents/file_chooser_impl.cc index cb7af71f..fe2c280 100644 --- a/content/browser/web_contents/file_chooser_impl.cc +++ b/content/browser/web_contents/file_chooser_impl.cc @@ -38,10 +38,20 @@ [&base_dir](const base::FilePath& file_path) { if (base::IsLink(file_path)) return true; - for (base::FilePath path = file_path.DirName(); base_dir.IsParent(path); + // Walk up the ancestors of `file_path`, up to and including + // `base_dir`. `base_dir` itself must be checked as well, because it is + // not necessarily a directory the user picked: a compromised renderer + // can pass any path it already has (lexical) read access to, including + // a symlink, to FileChooserImpl::EnumerateChosenDirectory(). + for (base::FilePath path = file_path.DirName();; path = path.DirName()) { if (base::IsLink(path)) return true; + // Stop at `base_dir`. Also stop if `path` is not under `base_dir`, + // which shouldn't happen, but guarantees termination at the root. + if (path == base_dir || !base_dir.IsParent(path)) { + break; + } } return false; }, diff --git a/content/browser/web_contents/file_chooser_impl_browsertest.cc b/content/browser/web_contents/file_chooser_impl_browsertest.cc index d99683526..7854d993 100644 --- a/content/browser/web_contents/file_chooser_impl_browsertest.cc +++ b/content/browser/web_contents/file_chooser_impl_browsertest.cc @@ -275,6 +275,52 @@ EvalJs(shell(), "document.getElementById('fileinput').files[0].name;")); } +// A compromised renderer can ask to enumerate any directory it has read access +// to, including a directory symlink pointing outside of the folder the user +// granted access to. Such a folder must not be uploaded. +// https://crbug.com/497482008 +IN_PROC_BROWSER_TEST_F(FileChooserImplBrowserTest, UploadFolderIsDirSymlink) { + EXPECT_TRUE(NavigateToURL( + shell(), GetTestUrl(".", "file_input_webkitdirectory.html"))); + + // The uploaded folder itself is a directory symbolic link. The symbolic link + // should not be followed, so no file should be uploaded. + base::FilePath dir_test_data; + ASSERT_TRUE(base::PathService::Get(DIR_TEST_DATA, &dir_test_data)); + base::FilePath folder_to_upload = dir_test_data.AppendASCII("file_chooser") + .AppendASCII("dir_with_dir_symlink") + .AppendASCII("symlink"); + + base::FilePath bar_file = folder_to_upload.AppendASCII("bar.txt"); + + // Skip the test if symbolic links are not supported. + { + base::ScopedAllowBlockingForTesting allow_blocking; + if (!base::IsLink(folder_to_upload)) { + GTEST_SKIP() << "Symbolic links are not supported on this platform."; + } + } + + std::unique_ptr<FileChooserDelegate> delegate(new FileChooserDelegate( + {bar_file}, folder_to_upload, base::OnceClosure())); + shell()->web_contents()->SetDelegate(delegate.get()); + // The resulting file list is empty, so the file input may fire either + // `change` or `cancel`, depending on whether it considers the selection to + // have changed. + EXPECT_TRUE(ExecJs(shell(), + "(async () => {" + " let listener = new Promise(resolve => {" + " fileinput.onchange = resolve;" + " fileinput.oncancel = resolve;" + " });" + " fileinput.click();" + " await listener;" + "})()")); + + EXPECT_EQ( + 0, EvalJs(shell(), "document.getElementById('fileinput').files.length;")); +} + // Ensure that FileChooserImpl::FileSelected does not grant permissions if // invoked with Mode::kSave, as a defense against compromised renderers. // See https://crbug.com/435684924.
Regression Test / PoC
diff --git a/content/browser/web_contents/file_chooser_impl_browsertest.cc b/content/browser/web_contents/file_chooser_impl_browsertest.cc
index d99683526..7854d993 100644
--- a/content/browser/web_contents/file_chooser_impl_browsertest.cc
+++ b/content/browser/web_contents/file_chooser_impl_browsertest.cc
@@ -275,6 +275,52 @@
EvalJs(shell(), "document.getElementById('fileinput').files[0].name;"));
}
+// A compromised renderer can ask to enumerate any directory it has read access
+// to, including a directory symlink pointing outside of the folder the user
+// granted access to. Such a folder must not be uploaded.
+// https://crbug.com/497482008
+IN_PROC_BROWSER_TEST_F(FileChooserImplBrowserTest, UploadFolderIsDirSymlink) {
+ EXPECT_TRUE(NavigateToURL(
+ shell(), GetTestUrl(".", "file_input_webkitdirectory.html")));
+
+ // The uploaded folder itself is a directory symbolic link. The symbolic link
+ // should not be followed, so no file should be uploaded.
+ base::FilePath dir_test_data;
+ ASSERT_TRUE(base::PathService::Get(DIR_TEST_DATA, &dir_test_data));
+ base::FilePath folder_to_upload = dir_test_data.AppendASCII("file_chooser")
+ .AppendASCII("dir_with_dir_symlink")
+ .AppendASCII("symlink");
+
+ base::FilePath bar_file = folder_to_upload.AppendASCII("bar.txt");
+
+ // Skip the test if symbolic links are not supported.
+ {
+ base::ScopedAllowBlockingForTesting allow_blocking;
+ if (!base::IsLink(folder_to_upload)) {
+ GTEST_SKIP() << "Symbolic links are not supported on this platform.";
+ }
+ }
+
+ std::unique_ptr<FileChooserDelegate> delegate(new FileChooserDelegate(
+ {bar_file}, folder_to_upload, base::OnceClosure()));
+ shell()->web_contents()->SetDelegate(delegate.get());
+ // The resulting file list is empty, so the file input may fire either
+ // `change` or `cancel`, depending on whether it considers the selection to
+ // have changed.
+ EXPECT_TRUE(ExecJs(shell(),
+ "(async () => {"
+ " let listener = new Promise(resolve => {"
+ " fileinput.onchange = resolve;"
+ " fileinput.oncancel = resolve;"
+ " });"
+ " fileinput.click();"
+ " await listener;"
+ "})()"));
+
+ EXPECT_EQ(
+ 0, EvalJs(shell(), "document.getElementById('fileinput').files.length;"));
+}
+
// Ensure that FileChooserImpl::FileSelected does not grant permissions if
// invoked with Mode::kSave, as a defense against compromised renderers.
// See https://crbug.com/435684924.
Original Bug Report
Renderer Sandbox Escape via Symlink Check Bypass in FileChooserImpl
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A logic error in FileChooserImpl::RemoveSymlinks fails to check if the base directory of a file enumeration is itself a symbolic link. A compromised renderer can exploit this by requesting enumeration of a symlink within a directory it already has permission to read, bypassing security checks and gaining unauthorized read access to arbitrary local files.
Affected files:
content/browser/web_contents/file_chooser_impl.ccchrome/browser/file_select_helper.cccontent/browser/child_process_security_policy_impl.cc
Estimated timestamp from git blame: 2022-11-03
Summary
A potential vulnerability exists in content/browser/web_contents/file_chooser_impl.cc where the RemoveSymlinks function incorrectly assumes the base directory of an enumeration is not a symlink. This allows a compromised renderer to gain read access to arbitrary files on the local filesystem by exploiting a directory symlink within a path it already has permissions for.
Vulnerability Details
The FileChooserImpl::EnumerateChosenDirectory method allows a renderer to request an enumeration of a directory. Before proceeding, it checks if the renderer has permission to read the directory using ChildProcessSecurityPolicyImpl::CanReadFile. If the user has previously granted access to a parent directory (e.g., via a drag-and-drop of a folder), this check will pass even if the requested subdirectory is a symbolic link. This is because CanReadFile performs a purely lexical path-based prefix check without resolving symlinks on disk.
Once the enumeration completes (which successfully follows the symlink), the results are processed by RemoveSymlinks to filter out any files that are symlinks or are located within a directory symlink. The implementation is as follows:
// Removes any file that is a symlink or is inside a directory symlink.
// For |kUploadFolder| mode only. |base_dir| is the folder being uploaded.
std::vector<blink::mojom::FileChooserFileInfoPtr> RemoveSymlinks(
std::vector<blink::mojom::FileChooserFileInfoPtr> files,
base::FilePath base_dir) {
DCHECK(!base_dir.empty());
auto to_remove = std::ranges::remove_if(
files,
[&base_dir](const base::FilePath& file_path) {
if (base::IsLink(file_path))
return true;
for (base::FilePath path = file_path.DirName(); base_dir.IsParent(path);
path = path.DirName()) {
if (base::IsLink(path))
return true;
}
return false;
},
[](const auto& file) { return file->get_native_file()->file_path; });
files.erase(to_remove.begin(), to_remove.end());
return files;
}
The issue lies in the loop condition base_dir.IsParent(path). According to the implementation of base::FilePath::IsParent, a path is not its own parent; it returns false if the two paths are equal.
Consequently, when RemoveSymlinks processes a file directly inside the base_dir (e.g., base_dir/file.txt), path is initialized to base_dir. The loop condition base_dir.IsParent(base_dir) immediately evaluates to false, and the loop terminates without ever checking if base_dir itself is a symbolic link via base::IsLink(path).
If the base_dir is a symlink, the files within it are not filtered out, and the browser proceeds to call GrantReadFile on the renderer process for the resulting paths, effectively granting access to files outside the intended scope.
Potential Attack Scenario
Note: These are potential steps, as we do not yet have the ability to run code to confirm the exploit.
- Victim Interaction: The user is tricked into dropping a directory (let’s call it
D) onto an attacker-controlled webpage (e.g., onto an<input type="file" webkitdirectory>element). - Attacker Preparation: Unknown to the user, the directory
Dcontains a symbolic link namedlinkthat points to a sensitive local directory outside ofD(e.g.,D/linkpoints to/etc/or a user’s home directory). - Initial Permission Grant: When the user drops the directory, the browser process grants the renderer process read access to the entire directory
D. - Renderer Compromise: The attacker compromises the renderer process, gaining the ability to execute arbitrary code within the sandbox.
- Malicious Invocation: The compromised renderer binds the
blink.mojom.FileChooserMojo interface and callsEnumerateChosenDirectory("D/link"). - Permission Check: The browser verifies permission for
D/link. This passes because it lexically falls under the existing grant forD. - Enumeration: The browser recursively enumerates the contents of
D/link, following the symlink to the target directory (e.g.,/etc/). - Filter Bypass:
RemoveSymlinksprocesses the enumerated files (e.g.,D/link/passwd). Due to theIsParentbug, it fails to detect thatD/linkis a symlink. - Privilege Escalation: The browser explicitly grants the renderer permission to read the enumerated files (e.g.,
D/link/passwd). - Arbitrary File Read: The renderer can now read the contents of sensitive files via standard file APIs or Mojo interfaces, achieving a sandbox escape.
Suggested Fix
The loop in RemoveSymlinks should be modified to also check base_dir for being a link, or the iteration logic should be adjusted. For example:
for (base::FilePath path = file_path.DirName(); ;
path = path.DirName()) {
if (base::IsLink(path))
return true;
if (path == base_dir) // Reached the top, stop here after checking base_dir
break;
}
Alternatively, IsParent could be replaced with base_dir == path || base_dir.IsParent(path) in the loop condition.
Evaluated with Chrome root at commit: 876d480da1f794d87813cfa2e6ff4fcf9771e939
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.