CVE-2026-7992
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
switchchrome/browser/shell_integration_linux.cc |
modified | |
ifchrome/browser/shell_integration_linux.cc |
modified | |
TESTchrome/browser/shell_integration_linux_unittest.cc |
modified |
Files Changed
chrome/browser/shell_integration_linux.ccchrome/browser/shell_integration_linux_unittest.cc
Patch
From 01b5ff6e0e8cb3f8e574839f31b4ea8e88d9b975 Mon Sep 17 00:00:00 2001
From: Tom Anderson <thomasanderson@chromium.org>
Date: Thu, 02 Apr 2026 17:21:46 -0700
Subject: [PATCH] [Linux] Escape % characters in .desktop file Exec key
The XDG Desktop Entry Specification defines several field codes starting
with % (e.g., %u, %U, %c) that are expanded by the desktop environment
when launching an application from a .desktop file. If a URL or other
argument contains a literal %, it must be escaped as %% to prevent
unexpected expansion.
This CL modifies QuoteArgForDesktopFileExec to automatically escape %
as %%, ensuring that literal % characters in URLs (such as in search
queries or page titles) are handled correctly. It also ensures that
intended field codes like %U are preserved by handling them separately
from the quoted command line.
Fixed: 499067529
Change-Id: Ifb37797fe66fea97be9cc231b79ccab6714cbfe5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7727988
Reviewed-by: Erik Chen <erikchen@chromium.org>
Commit-Queue: Thomas Anderson <thomasanderson@chromium.org>
Commit-Queue: Erik Chen <erikchen@chromium.org>
Auto-Submit: Thomas Anderson <thomasanderson@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1609581}
---
diff --git a/chrome/browser/shell_integration_linux.cc b/chrome/browser/shell_integration_linux.cc
index 451839e..94c5e9b 100644
--- a/chrome/browser/shell_integration_linux.cc
+++ b/chrome/browser/shell_integration_linux.cc
@@ -180,15 +180,20 @@
std::string QuoteArgForDesktopFileExec(const std::string& arg) {
// http://standards.freedesktop.org/desktop-entry-spec/latest/ar01s06.html
+ // Literal % characters must be escaped as %%.
+ std::string arg_copy = arg;
+ RE2::GlobalReplace(&arg_copy, "%", "%%");
+
// Quoting is only necessary if the argument has a reserved character.
- if (arg.find_first_of(" \t\n\"'\\><~|&;$*?#()`") == std::string::npos)
- return arg; // No quoting necessary.
+ if (arg_copy.find_first_of(" \t\n\"'\\><~|&;$*?#()`") == std::string::npos) {
+ return arg_copy; // No quoting necessary.
+ }
std::string quoted = "\"";
- for (size_t i = 0; i < arg.size(); ++i) {
+ for (size_t i = 0; i < arg_copy.size(); ++i) {
// Note that the set of backslashed characters is smaller than the
// set of reserved characters.
- switch (arg[i]) {
+ switch (arg_copy[i]) {
case '"':
case '`':
case '$':
@@ -196,7 +201,7 @@
quoted += '\\';
break;
}
- quoted += arg[i];
+ quoted += arg_copy[i];
}
quoted += '"';
@@ -247,8 +252,6 @@
info.name.c_str());
std::string launch_url_str = info.exec_launch_url.spec();
- // Escape % as %%.
- RE2::GlobalReplace(&launch_url_str, "%", "%%");
base::CommandLine current_cmd(command_line);
current_cmd.AppendSwitchASCII(switches::kAppLaunchUrlForShortcutsMenuItem,
launch_url_str);
@@ -597,6 +600,7 @@
base::CommandLine modified_command_line(command_line);
// Set the "MimeType" key.
+ bool include_U = false;
if (!mime_type.empty() && mime_type.find("\n") == std::string::npos &&
mime_type.find("\r") == std::string::npos) {
std::string full_mime_type = mime_type;
@@ -614,12 +618,15 @@
// Note: We only include this parameter if the application is actually able
// to handle files, to prevent it showing up in the list of all applications
// which can handle files.
- modified_command_line.AppendArg("%U");
+ include_U = true;
}
// Set the "Exec" key.
std::string final_path =
QuoteCommandLineForDesktopFileExec(modified_command_line);
+ if (include_U) {
+ final_path += " %U";
+ }
g_key_file_set_string(key_file, kDesktopEntry, "Exec", final_path.c_str());
// Set the "Icon" key.
diff --git a/chrome/browser/shell_integration_linux_unittest.cc b/chrome/browser/shell_integration_linux_unittest.cc
index dab61a5..410d53d 100644
--- a/chrome/browser/shell_integration_linux_unittest.cc
+++ b/chrome/browser/shell_integration_linux_unittest.cc
@@ -335,7 +335,7 @@
"Type=Application\n"
"Name=http://evil.com/evil%20--join-the-b0tnet\n"
"Exec=/opt/google/chrome/google-chrome "
- "--app=http://evil.com/evil%20--join-the-b0tnet\n"
+ "--app=http://evil.com/evil%%20--join-the-b0tnet\n"
"Icon=chrome-http__evil.com_evil\n"
"StartupWMClass=evil.com__evil%20--join-the-b0tnet\n"},
{"http://evil.com/evil; rm -rf /; \"; rm -rf $HOME >ownz0red",
@@ -347,11 +347,11 @@
"Type=Application\n"
"Name=Innocent Title\n"
"Exec=/opt/google/chrome/google-chrome "
- "\"--app=http://evil.com/evil;%20rm%20-rf%20/;%20%22;%20rm%20"
+ "\"--app=http://evil.com/evil;%%20rm%%20-rf%%20/;%%20%%22;%%20rm%%20"
// Note: $ is escaped as \$ within an arg to Exec, and then
// the \ is escaped as \\ as all strings in a Desktop file should
// be; finally, \\ becomes \\\\ when represented in a C++ string!
- "-rf%20\\\\$HOME%20%3Eownz0red\"\n"
+ "-rf%%20\\\\$HOME%%20%%3Eownz0red\"\n"
"Icon=chrome-http__evil.com_evil\n"
"StartupWMClass=evil.com__evil;%20rm%20-rf%20_;%20%22;%20"
"rm%20-rf%20$HOME%20%3Eownz0red\n"},
@@ -364,8 +364,8 @@
"Type=Application\n"
"Name=Innocent Title\n"
"Exec=/opt/google/chrome/google-chrome "
- "--app=http://evil.com/evil%20%7C%20cat%20%60echo%20ownz0red"
- "%60%20%3E/dev/null\n"
+ "--app=http://evil.com/evil%%20%%7C%%20cat%%20%%60echo%%20ownz0red"
+ "%%60%%20%%3E/dev/null\n"
"Icon=chrome-http__evil.com_evil\n"
"StartupWMClass=evil.com__evil%20%7C%20cat%20%60echo%20ownz0red"
"%60%20%3E_dev_null\n"},
@@ -379,11 +379,12 @@
"Type=Application\n"
"Name=Paint\n"
"MimeType=image/png;image/jpg;" +
- shell_integration_linux::GetDirectLaunchMimeTypeHandler() + "\n"
- "Exec=/opt/google/chrome/google-chrome --app=https://paint.app/ %U\n"
- "Icon=chrome-https__paint.app\n"
- "Categories=Image\n"
- "StartupWMClass=paint.app\n"},
+ shell_integration_linux::GetDirectLaunchMimeTypeHandler() +
+ "\n"
+ "Exec=/opt/google/chrome/google-chrome --app=https://paint.app/ %U\n"
+ "Icon=chrome-https__paint.app\n"
+ "Categories=Image\n"
+ "StartupWMClass=paint.app\n"},
// Test evil mime type.
{"https://paint.app", "Evil Paint", "chrome-https__paint.app", "Image",
@@ -409,11 +410,12 @@
"Type=Application\n"
"Name=Test App\n"
"MimeType=image/png;image/jpeg;" +
- shell_integration_linux::GetDirectLaunchMimeTypeHandler() + "\n"
- "Exec=/opt/google/chrome/google-chrome --app=https://test.app/ %U\n"
- "Icon=chrome-https__test.app\n"
- "Categories=App\n"
- "StartupWMClass=test.app\n"},
+ shell_integration_linux::GetDirectLaunchMimeTypeHandler() +
+ "\n"
+ "Exec=/opt/google/chrome/google-chrome --app=https://test.app/ %U\n"
+ "Icon=chrome-https__test.app\n"
+ "Categories=App\n"
+ "StartupWMClass=test.app\n"},
});
for (size_t i = 0; i < std::size(test_cases); i++) {
@@ -700,6 +702,37 @@
"Action1", kDesktopFileContents));
}
+TEST(ShellIntegrationLinuxTest,
+ GetDesktopFileContentsForUrlShortcutEscapesPercent) {
+ std::string title = "A\" --gpu-launcher=\"xcalc\" \"B";
+ GURL url("https://evil.example/?q=%c");
+ base::FilePath icon_path("/tmp/icon.png");
+ base::FilePath profile_path("/tmp/profile");
+
+ std::string contents =
+ GetDesktopFileContentsForUrlShortcut(title, url, icon_path, profile_path);
+
+ // The URL in Exec should have % escaped as %%.
+ EXPECT_TRUE(contents.find("Exec=") != std::string::npos);
+ EXPECT_TRUE(contents.find("https://evil.example/?q=%%c") != std::string::npos)
+ << "Contents: " << contents;
+}
+
+TEST(ShellIntegrationLinuxTest, GetDesktopFileContentsEscapesPercent) {
+ const base::FilePath kChromeExePath("/opt/google/chrome/google-chrome");
+ GURL url("https://evil.example/?q=%c");
+ std::u16string title = u"Evil App";
+ std::string icon_name = "icon";
+
+ std::string contents = GetDesktopFileContents(
Regression Test / PoC
diff --git a/chrome/browser/shell_integration_linux_unittest.cc b/chrome/browser/shell_integration_linux_unittest.cc
index dab61a5..410d53d 100644
--- a/chrome/browser/shell_integration_linux_unittest.cc
+++ b/chrome/browser/shell_integration_linux_unittest.cc
@@ -335,7 +335,7 @@
"Type=Application\n"
"Name=http://evil.com/evil%20--join-the-b0tnet\n"
"Exec=/opt/google/chrome/google-chrome "
- "--app=http://evil.com/evil%20--join-the-b0tnet\n"
+ "--app=http://evil.com/evil%%20--join-the-b0tnet\n"
"Icon=chrome-http__evil.com_evil\n"
"StartupWMClass=evil.com__evil%20--join-the-b0tnet\n"},
{"http://evil.com/evil; rm -rf /; \"; rm -rf $HOME >ownz0red",
@@ -347,11 +347,11 @@
"Type=Application\n"
"Name=Innocent Title\n"
"Exec=/opt/google/chrome/google-chrome "
- "\"--app=http://evil.com/evil;%20rm%20-rf%20/;%20%22;%20rm%20"
+ "\"--app=http://evil.com/evil;%%20rm%%20-rf%%20/;%%20%%22;%%20rm%%20"
// Note: $ is escaped as \$ within an arg to Exec, and then
// the \ is escaped as \\ as all strings in a Desktop file should
// be; finally, \\ becomes \\\\ when represented in a C++ string!
- "-rf%20\\\\$HOME%20%3Eownz0red\"\n"
+ "-rf%%20\\\\$HOME%%20%%3Eownz0red\"\n"
"Icon=chrome-http__evil.com_evil\n"
"StartupWMClass=evil.com__evil;%20rm%20-rf%20_;%20%22;%20"
"rm%20-rf%20$HOME%20%3Eownz0red\n"},
@@ -364,8 +364,8 @@
"Type=Application\n"
"Name=Innocent Title\n"
"Exec=/opt/google/chrome/google-chrome "
- "--app=http://evil.com/evil%20%7C%20cat%20%60echo%20ownz0red"
- "%60%20%3E/dev/null\n"
+ "--app=http://evil.com/evil%%20%%7C%%20cat%%20%%60echo%%20ownz0red"
+ "%%60%%20%%3E/dev/null\n"
"Icon=chrome-http__evil.com_evil\n"
"StartupWMClass=evil.com__evil%20%7C%20cat%20%60echo%20ownz0red"
"%60%20%3E_dev_null\n"},
@@ -379,11 +379,12 @@
"Type=Application\n"
"Name=Paint\n"
"MimeType=image/png;image/jpg;" +
- shell_integration_linux::GetDirectLaunchMimeTypeHandler() + "\n"
- "Exec=/opt/google/chrome/google-chrome --app=https://paint.app/ %U\n"
- "Icon=chrome-https__paint.app\n"
- "Categories=Image\n"
- "StartupWMClass=paint.app\n"},
+ shell_integration_linux::GetDirectLaunchMimeTypeHandler() +
+ "\n"
+ "Exec=/opt/google/chrome/google-chrome --app=https://paint.app/ %U\n"
+ "Icon=chrome-https__paint.app\n"
+ "Categories=Image\n"
+ "StartupWMClass=paint.app\n"},
// Test evil mime type.
{"https://paint.app", "Evil Paint", "chrome-https__paint.app", "Image",
@@ -409,11 +410,12 @@
"Type=Application\n"
"Name=Test App\n"
"MimeType=image/png;image/jpeg;" +
- shell_integration_linux::GetDirectLaunchMimeTypeHandler() + "\n"
- "Exec=/opt/google/chrome/google-chrome --app=https://test.app/ %U\n"
- "Icon=chrome-https__test.app\n"
- "Categories=App\n"
- "StartupWMClass=test.app\n"},
+ shell_integration_linux::GetDirectLaunchMimeTypeHandler() +
+ "\n"
+ "Exec=/opt/google/chrome/google-chrome --app=https://test.app/ %U\n"
+ "Icon=chrome-https__test.app\n"
+ "Categories=App\n"
+ "StartupWMClass=test.app\n"},
});
for (size_t i = 0; i < std::size(test_cases); i++) {
@@ -700,6 +702,37 @@
"Action1", kDesktopFileContents));
}
+TEST(ShellIntegrationLinuxTest,
+ GetDesktopFileContentsForUrlShortcutEscapesPercent) {
+ std::string title = "A\" --gpu-launcher=\"xcalc\" \"B";
+ GURL url("https://evil.example/?q=%c");
+ base::FilePath icon_path("/tmp/icon.png");
+ base::FilePath profile_path("/tmp/profile");
+
+ std::string contents =
+ GetDesktopFileContentsForUrlShortcut(title, url, icon_path, profile_path);
+
+ // The URL in Exec should have % escaped as %%.
+ EXPECT_TRUE(contents.find("Exec=") != std::string::npos);
+ EXPECT_TRUE(contents.find("https://evil.example/?q=%%c") != std::string::npos)
+ << "Contents: " << contents;
+}
+
+TEST(ShellIntegrationLinuxTest, GetDesktopFileContentsEscapesPercent) {
+ const base::FilePath kChromeExePath("/opt/google/chrome/google-chrome");
+ GURL url("https://evil.example/?q=%c");
+ std::u16string title = u"Evil App";
+ std::string icon_name = "icon";
+
+ std::string contents = GetDesktopFileContents(
+ kChromeExePath, "evil-app", url, std::string(), title, icon_name,
+ base::FilePath(), "", "", false, "", {});
+
+ EXPECT_TRUE(contents.find("Exec=") != std::string::npos);
+ EXPECT_TRUE(contents.find("https://evil.example/?q=%%c") != std::string::npos)
+ << "Contents: " << contents;
+}
+
} // namespace shell_integration_linux
namespace shell_integration {
Original Bug Report
Argument Injection/RCE in Linux Desktop Shortcuts via Unescaped %c
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 without the security team.
Overview: When creating a desktop shortcut on Linux, Chromium fails to escape the % character in the URL used for the .desktop file’s Exec key. An attacker can craft a URL containing the %c XDG field code and a specially crafted page title with double quotes to break out of shell quoting. When the user launches the shortcut, the Linux desktop environment expands %c to the malicious title, allowing the injection of arbitrary command-line flags (e.g., --gpu-launcher) and leading to a potential sandbox escape and RCE.
Affected files:
chrome/browser/shell_integration_linux.cc
Estimated timestamp from git blame: 2024-05-01
Description
In chrome/browser/shell_integration_linux.cc, the function GetDesktopFileContentsForUrlShortcut constructs the contents of a .desktop file for a web page. The page’s URL is included in the Exec key, and the page’s title is used for the Name key.
While the URL is quoted using QuoteCommandLineForDesktopFileExec, the % character is not escaped. According to the XDG Desktop Entry Specification, %c is a field code that expands to the Name of the application (the page title). Notably, another function in the exact same file, SetActionsForDesktopApplication, correctly anticipates this by explicitly escaping % as %% (see chrome/browser/shell_integration_linux.cc:251), but this protection is missing in the URL-shortcut path.
Impact
Because field code expansion happens before shell argument parsing in standard Linux launchers (like those using GLib’s g_shell_parse_argv), an attacker can supply a title containing double quotes to break out of the URL’s quoting context. This allows the injection of arbitrary Chromium command-line flags. By injecting flags such as --gpu-launcher, an attacker can achieve a potential sandbox escape and Remote Code Execution (RCE) with the user’s privileges.
Potential Reproduction Steps
(Note: These are suggested steps to trigger the vulnerability, as our automated tooling cannot run local exploit code to provide a verified working proof of concept.)
- Host a malicious page at
https://evil.example/?q=%c. The inclusion of the?character is required because it forces Chromium’sQuoteArgForDesktopFileExecto wrap the URL in double quotes. - Set the document title to
A" --gpu-launcher="xcalc" "B. - Convince a Linux user to visit the page and select Save and Share -> Create Shortcut… from the Chrome menu.
- When the user accepts the prompt, Chromium writes the
.desktopfile to disk. Because%is unescaped, theExecline will look similar to:Exec=/opt/google/chrome/chrome --profile-directory=Default "https://evil.example/?q=%c" - When the victim launches the shortcut from their application menu, the Linux launcher expands the
%cmacro using theNamekey (the attacker’s title). The expanded execution string becomes:... "https://evil.example/?q=A" --gpu-launcher="xcalc" "B" - The launcher parses the string into an argument array. The injected double quotes perfectly close the URL’s quotes, causing
--gpu-launcher=xcalcto be parsed as a separate, distinct command-line flag. - Chromium launches, and the unsandboxed browser process applies the
--gpu-launcherflag, executingxcalc(or any other attacker-specified binary/script) outside the sandbox.
Suggested Fix
Apply the same % escaping logic used in SetActionsForDesktopApplication to GetDesktopFileContentsForUrlShortcut. Specifically, before the URL is appended to the base::CommandLine and passed to QuoteCommandLineForDesktopFileExec, you should escape % characters:
std::string launch_url_str = url.spec();
// Escape % as %% to prevent XDG field code expansion.
RE2::GlobalReplace(&launch_url_str, "%", "%%");
This ensures that literal % characters in the URL are parsed correctly by the Linux desktop environment and not interpreted as field codes.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
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.