CVE-2026-13794
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifbase/command_line.cc |
modified | |
TESTbase/command_line_unittest.cc |
modified |
Files Changed
base/command_line.ccbase/command_line_unittest.ccchrome/browser/web_applications/os_integration/web_app_shortcut_win.cc
Patch
From b9f096090bebe17e5088e11a5daca42e988865e9 Mon Sep 17 00:00:00 2001
From: Dan Murphy <dmurph@chromium.org>
Date: Fri, 29 May 2026 18:02:26 -0700
Subject: [PATCH] [base] Fix Windows CommandLine argument smuggling
A manifest-controlled PWA name on Windows can be crafted to inject
command-line arguments. ParseAsSingleArgument could match the "--single-
argument" switch inside the program path if it was quoted and contained
that string.
Fix this by estimating the end of the program path and searching for "--
single-argument" only after that path. Also add CHECKs to ensure a valid
program path end is found.
Additionally, sanitize "%" characters in web app shortcut filenames on
Windows to avoid ShellExecute expansion issues.
TAG=agy
CONV=823a5c45-9cb2-45bc-a45b-4c02939fca02
Fixed: 513893425
Fixed: b:514442159
Test: base_unittests --gtest_filter=CommandLineTest.*
Test: unit_tests --gtest_filter=WebAppShortcutWinTest.*
Change-Id: Id209dc44104f919fb1a37118bc82d82d1f1b10c3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7858094
Reviewed-by: Daniel Cheng <dcheng@chromium.org>
Commit-Queue: Daniel Cheng <dcheng@chromium.org>
Reviewed-by: Jesse McKenna <jessemckenna@google.com>
Commit-Queue: Daniel Murphy <dmurph@chromium.org>
Auto-Submit: Daniel Murphy <dmurph@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1638873}
---
diff --git a/base/command_line.cc b/base/command_line.cc
index 02796f0..8325518 100644
--- a/base/command_line.cc
+++ b/base/command_line.cc
@@ -15,6 +15,7 @@
#include "base/debug/debugging_buildflags.h"
#include "base/files/file_path.h"
#include "base/logging.h"
+#include "base/not_fatal_until.h"
#include "base/notreached.h"
#include "base/numerics/checked_math.h"
#include "base/strings/strcat.h"
@@ -173,6 +174,7 @@
return out;
}
+
#endif // BUILDFLAG(IS_WIN)
} // namespace
@@ -780,12 +782,53 @@
// Remove any previously parsed arguments.
argv_.resize(static_cast<size_t>(begin_args_));
- // Locate "--single-argument" in the process's raw command line. Results are
- // unpredictable if "--single-argument" appears as part of a previous
+ // Find the end of the program path in the raw command line to avoid
+ // matching switches inside the program path itself (which can happen
+ // with PWA launchers on Windows where the PWA name contains switches).
+ //
+ // We can estimate this by finding the first non-space character (start of
+ // program) and adding the length of the program name parsed by
+ // CommandLineToArgvW (stored in argv_[0]).
+ //
+ // Invariants we expect:
+ // 1. `CommandLineToArgvW` treats backslashes as literal characters (not
+ // escapes) when parsing the application name (first argument).
+ // 2. Windows filenames cannot contain double quotes (").
+ // 3. Thus, the program path in the raw command line can never contain
+ // escaped quotes. It is either unquoted (ends at first space) or quoted
+ // (starts and ends with double quotes, with no quotes in between).
+ //
+ // This means the length of `GetProgram()` is exactly the length of the
+ // program path in the raw command line (minus the outer quotes if it
+ // was quoted).
+ size_t program_start = raw_command_line_string_.find_first_not_of(L' ');
+ if (program_start == StringType::npos) {
+ return;
+ }
+
+ size_t switches_and_args_start = program_start + argv_[0].length();
+ const bool is_quoted = raw_command_line_string_[program_start] == L'"';
+ if (is_quoted) {
+ switches_and_args_start += 2; // Account for the start and end quotes.
+ }
+
+ if (switches_and_args_start >= raw_command_line_string_.length()) {
+ return;
+ }
+
+ if (is_quoted) {
+ CHECK_EQ(raw_command_line_string_[switches_and_args_start - 1], L'"',
+ base::NotFatalUntil::M154);
+ }
+
+ // Locate "--single-argument" in the process's switches and arguments. Results
+ // are unpredictable if "--single-argument" appears as part of a previous
// argument or switch.
const size_t single_arg_switch_position =
- raw_command_line_string_.find(single_arg_switch);
- DCHECK_NE(single_arg_switch_position, StringType::npos);
+ raw_command_line_string_.find(single_arg_switch, switches_and_args_start);
+ if (single_arg_switch_position == StringType::npos) {
+ return;
+ }
// Append the portion of the raw command line that starts one character past
// "--single-argument" as the one and only argument, or return if no
diff --git a/base/command_line_unittest.cc b/base/command_line_unittest.cc
index 23180c4..66233c9d 100644
--- a/base/command_line_unittest.cc
+++ b/base/command_line_unittest.cc
@@ -932,6 +932,80 @@
cl_without_arg.GetProgram());
EXPECT_TRUE(cl_without_arg.GetArgs().empty());
}
+
+TEST(CommandLineTest, ParseAsSingleArgumentWithSameSwitchInProgramPath) {
+ // This test verifies that:
+ // 1. `--single-argument` embedded within the quoted program path is correctly
+ // ignored and not treated as the trigger switch.
+ // 2. Legitimate switches placed before the actual `--single-argument` switch
+ // (like `--switch_before`) are still correctly parsed and preserved.
+ // 3. The actual `--single-argument` switch is correctly identified, and
+ // everything after it is treated as the single argument.
+ CommandLine cl = CommandLine::FromString(FILE_PATH_LITERAL(
+ "\"program --single-argument\" --switch_before=arg_before "
+ "--single-argument actual_arg"));
+ EXPECT_EQ(cl.GetProgram().value(),
+ FILE_PATH_LITERAL("program --single-argument"));
+ EXPECT_EQ(cl.GetArgs(),
+ CommandLine::StringVector({FILE_PATH_LITERAL("actual_arg")}));
+ EXPECT_TRUE(cl.HasSwitch("switch_before"));
+ EXPECT_EQ(cl.GetSwitchValueASCII("switch_before"), "arg_before");
+}
+
+TEST(CommandLineTest, ParseAsSingleArgumentWithSwitchAfter) {
+ // This test verifies that once `--single-argument` is encountered, any
+ // subsequent switch-like strings (e.g., `--switch_after`) are treated as part
+ // of the single argument payload and are not parsed as separate switches.
+ CommandLine cl = CommandLine::FromString(FILE_PATH_LITERAL(
+ "program --single-argument actual_arg --switch_after=arg_after"));
+ EXPECT_EQ(cl.GetProgram(), FilePath(FILE_PATH_LITERAL("program")));
+ EXPECT_EQ(cl.GetArgs(), CommandLine::StringVector({FILE_PATH_LITERAL(
+ "actual_arg --switch_after=arg_after")}));
+ EXPECT_FALSE(cl.HasSwitch("switch_after"));
+}
+
+TEST(CommandLineTest, ParseAsSingleArgumentWithLeadingSpaces) {
+ CommandLine cl = CommandLine::FromString(
+ FILE_PATH_LITERAL(" \"program\" --single-argument actual_arg"));
+ EXPECT_EQ(FilePath(FILE_PATH_LITERAL("program")), cl.GetProgram());
+ EXPECT_EQ(cl.GetArgs(),
+ CommandLine::StringVector({FILE_PATH_LITERAL("actual_arg")}));
+}
+
+TEST(CommandLineTest, ParseAsSingleArgumentWithLeadingSpacesNoQuotes) {
+ CommandLine cl = CommandLine::FromString(
+ FILE_PATH_LITERAL(" program --single-argument actual_arg"));
+ EXPECT_EQ(FilePath(FILE_PATH_LITERAL("program")), cl.GetProgram());
+ EXPECT_EQ(cl.GetArgs(),
+ CommandLine::StringVector({FILE_PATH_LITERAL("actual_arg")}));
+}
+
+TEST(CommandLineTest,
+ ParseAsSingleArgumentWithLeadingSpacesAndSameSwitchInProgramPath) {
+ CommandLine cl = CommandLine::FromString(FILE_PATH_LITERAL(
+ " \"program --single-argument\" --single-argument actual_arg"));
+ EXPECT_EQ(FilePath(FILE_PATH_LITERAL("program --single-argument")),
+ cl.GetProgram());
+ EXPECT_EQ(cl.GetArgs(),
+ CommandLine::StringVector({FILE_PATH_LITERAL("actual_arg")}));
+}
+
+TEST(CommandLineTest, ParseAsSingleArgumentWithUnmatchedQuote) {
+ CommandLine cl = CommandLine::FromString(
+ FILE_PATH_LITERAL("\"program --single-argument actual_arg"));
+ EXPECT_EQ(FilePath(FILE_PATH_LITERAL("program --single-argument actual_arg")),
+ cl.GetProgram());
+ EXPECT_TRUE(cl.GetArgs().empty());
+}
+
+TEST(CommandLineTest, ParseAsSingleArgumentWithUnquotedSpaces) {
+ CommandLine cl = CommandLine::FromString(
+ FILE_PATH_LITERAL("program path --single-argument actual_arg"));
+ EXPECT_EQ(FilePath(FILE_PATH_LITERAL("program")), cl.GetProgram());
+ EXPECT_EQ(cl.GetArgs(),
+ CommandLine::StringVector({FILE_PATH_LITERAL("actual_arg")}));
+}
+
#endif // BUILDFLAG(IS_WIN)
#if BUILDFLAG(ENABLE_COMMANDLINE_SEQUENCE_CHECKS)
diff --git a/chrome/browser/web_applications/os_integration/web_app_shortcut_win.cc b/chrome/browser/web_applications/os_integration/web_app_shortcut_win.cc
index 4e36c68..5f20a20 100644
--- a/chrome/browser/web_applications/os_integration/web_app_shortcut_win.cc
Regression Test / PoC
diff --git a/base/command_line_unittest.cc b/base/command_line_unittest.cc
index 23180c4..66233c9d 100644
--- a/base/command_line_unittest.cc
+++ b/base/command_line_unittest.cc
@@ -932,6 +932,80 @@
cl_without_arg.GetProgram());
EXPECT_TRUE(cl_without_arg.GetArgs().empty());
}
+
+TEST(CommandLineTest, ParseAsSingleArgumentWithSameSwitchInProgramPath) {
+ // This test verifies that:
+ // 1. `--single-argument` embedded within the quoted program path is correctly
+ // ignored and not treated as the trigger switch.
+ // 2. Legitimate switches placed before the actual `--single-argument` switch
+ // (like `--switch_before`) are still correctly parsed and preserved.
+ // 3. The actual `--single-argument` switch is correctly identified, and
+ // everything after it is treated as the single argument.
+ CommandLine cl = CommandLine::FromString(FILE_PATH_LITERAL(
+ "\"program --single-argument\" --switch_before=arg_before "
+ "--single-argument actual_arg"));
+ EXPECT_EQ(cl.GetProgram().value(),
+ FILE_PATH_LITERAL("program --single-argument"));
+ EXPECT_EQ(cl.GetArgs(),
+ CommandLine::StringVector({FILE_PATH_LITERAL("actual_arg")}));
+ EXPECT_TRUE(cl.HasSwitch("switch_before"));
+ EXPECT_EQ(cl.GetSwitchValueASCII("switch_before"), "arg_before");
+}
+
+TEST(CommandLineTest, ParseAsSingleArgumentWithSwitchAfter) {
+ // This test verifies that once `--single-argument` is encountered, any
+ // subsequent switch-like strings (e.g., `--switch_after`) are treated as part
+ // of the single argument payload and are not parsed as separate switches.
+ CommandLine cl = CommandLine::FromString(FILE_PATH_LITERAL(
+ "program --single-argument actual_arg --switch_after=arg_after"));
+ EXPECT_EQ(cl.GetProgram(), FilePath(FILE_PATH_LITERAL("program")));
+ EXPECT_EQ(cl.GetArgs(), CommandLine::StringVector({FILE_PATH_LITERAL(
+ "actual_arg --switch_after=arg_after")}));
+ EXPECT_FALSE(cl.HasSwitch("switch_after"));
+}
+
+TEST(CommandLineTest, ParseAsSingleArgumentWithLeadingSpaces) {
+ CommandLine cl = CommandLine::FromString(
+ FILE_PATH_LITERAL(" \"program\" --single-argument actual_arg"));
+ EXPECT_EQ(FilePath(FILE_PATH_LITERAL("program")), cl.GetProgram());
+ EXPECT_EQ(cl.GetArgs(),
+ CommandLine::StringVector({FILE_PATH_LITERAL("actual_arg")}));
+}
+
+TEST(CommandLineTest, ParseAsSingleArgumentWithLeadingSpacesNoQuotes) {
+ CommandLine cl = CommandLine::FromString(
+ FILE_PATH_LITERAL(" program --single-argument actual_arg"));
+ EXPECT_EQ(FilePath(FILE_PATH_LITERAL("program")), cl.GetProgram());
+ EXPECT_EQ(cl.GetArgs(),
+ CommandLine::StringVector({FILE_PATH_LITERAL("actual_arg")}));
+}
+
+TEST(CommandLineTest,
+ ParseAsSingleArgumentWithLeadingSpacesAndSameSwitchInProgramPath) {
+ CommandLine cl = CommandLine::FromString(FILE_PATH_LITERAL(
+ " \"program --single-argument\" --single-argument actual_arg"));
+ EXPECT_EQ(FilePath(FILE_PATH_LITERAL("program --single-argument")),
+ cl.GetProgram());
+ EXPECT_EQ(cl.GetArgs(),
+ CommandLine::StringVector({FILE_PATH_LITERAL("actual_arg")}));
+}
+
+TEST(CommandLineTest, ParseAsSingleArgumentWithUnmatchedQuote) {
+ CommandLine cl = CommandLine::FromString(
+ FILE_PATH_LITERAL("\"program --single-argument actual_arg"));
+ EXPECT_EQ(FilePath(FILE_PATH_LITERAL("program --single-argument actual_arg")),
+ cl.GetProgram());
+ EXPECT_TRUE(cl.GetArgs().empty());
+}
+
+TEST(CommandLineTest, ParseAsSingleArgumentWithUnquotedSpaces) {
+ CommandLine cl = CommandLine::FromString(
+ FILE_PATH_LITERAL("program path --single-argument actual_arg"));
+ EXPECT_EQ(FilePath(FILE_PATH_LITERAL("program")), cl.GetProgram());
+ EXPECT_EQ(cl.GetArgs(),
+ CommandLine::StringVector({FILE_PATH_LITERAL("actual_arg")}));
+}
+
#endif // BUILDFLAG(IS_WIN)
#if BUILDFLAG(ENABLE_COMMANDLINE_SEQUENCE_CHECKS)
diff --git a/chrome/browser/web_applications/os_integration/web_app_shortcut_win_unittest.cc b/chrome/browser/web_applications/os_integration/web_app_shortcut_win_unittest.cc
index b2fac5f1..918fd1a 100644
--- a/chrome/browser/web_applications/os_integration/web_app_shortcut_win_unittest.cc
+++ b/chrome/browser/web_applications/os_integration/web_app_shortcut_win_unittest.cc
@@ -115,6 +115,27 @@
// Test reserved names with extension (only base filename is checked).
EXPECT_EQ(base::FilePath(FILE_PATH_LITERAL("_COM1.lnk")),
GetSanitizedFileName(u"COM1.lnk"));
+ // Test sanitization of '%' character.
+ EXPECT_EQ(base::FilePath(FILE_PATH_LITERAL("percent 1")),
+ GetSanitizedFileName(u"percent%1"));
+
+ // Test sanitization of exploit strings from b/513893425.
+ // Case 1: %1 and NUL byte.
+ std::u16string input1 = u"Updated PoC %1";
+ input1.push_back(u'\0');
+ input1.append(
+ u" --single-argument --headless --utility-cmd-prefix=calc.exe inert");
+ EXPECT_EQ(base::FilePath(FILE_PATH_LITERAL(
+ "Updated PoC 1 --single-argument --headless "
+ "--utility-cmd-prefix=calc.exe inert")),
+ GetSanitizedFileName(input1));
+
+ // Case 2: %1 and no NUL byte, but multiple %1.
+ EXPECT_EQ(
+ base::FilePath(FILE_PATH_LITERAL(
+ "Victim 1 --headless --utility-cmd-prefix=calc.exe 1 inert")),
+ GetSanitizedFileName(
+ u"Victim %1 --headless --utility-cmd-prefix=calc.exe %1 inert"));
}
TEST_F(WebAppShortcutWinTest, GetShortcutPaths) {
Original Bug Report
Windows PWA handler launcher filename can smuggle --utility-cmd-prefix from manifest name, causing Chrome to execute attacker-chosen wrapper on utility process launch.
Report description
Windows PWA handler launcher filename can smuggle –utility-cmd-prefix from manifest name, causing Chrome to execute attacker-chosen wrapper on utility process launch.
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
https://chromium.googlesource.com/chromium/src
The problem
Please describe the technical details of the vulnerability
Bug location: Chromium src:
- base/command_line.cc
- chrome/browser/web_applications/os_integration/web_app_handler_registration_utils_win.cc
- chrome/browser/web_applications/chrome_pwa_launcher/chrome_pwa_launcher_main.cc
- content/browser/service_host/utility_process_host.cc
The problem: On Windows, a manifest-controlled PWA name is used to build the app-specific launcher .exe filename. If the name contains –single-argument, Chromium’s Windows parser matches that token inside the quoted executable path instead of the real shell-handler switch. The PWA launcher then passes the resulting tail to chrome.exe, where it is parsed as real switches. With –utility-cmd-prefix, Chrome launches an attacker-chosen wrapper when starting a utility process.
The cause: base::CommandLine::ParseAsSingleArgument() searches the raw command line with raw_command_line_string_.find(single_arg_switch). This is unsafe when the quoted program path itself can contain –single-argument. PWA Windows file/protocol handler registration derives that program path from the web app’s manifest name, which is attacker-controlled.
Impact analysis
An attacker-controlled website can offer a PWA for installation. After the user installs it and later invokes its registered protocol/file handler, Chrome can execute an attacker-chosen command as the current Windows user. No admin privileges are required. The confirmed PoC uses calc.exe as a benign wrapper.
The cause
What version of Chrome have you found the security issue in?
148.0.7778.168 stable
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Remote Code Execution (RCE)
How would you like to be publicly acknowledged for your report?
Daniel Rodríguez