High CVSS 7.5 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in WebAppInstalls
DescriptionInsufficient validation of untrusted input in WebAppInstalls
ComponentWebAppInstalls
Sub-componentBase
Bug ClassInput Validation
Tracker513893425
Fix commitb9f096090beb (chromium/src) +145/-4
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedDaniel Rodríguez
Reported2026-05-16 · bounty $2500 (per vendor advisory)
Fix landed2026-05-30 (public git)
Disclosed2026-06-30
Patch gap31 days — fix public in source before the advisory

Changed Functions

FunctionChangeNotes
if
base/command_line.cc
modified
TEST
base/command_line_unittest.cc
modified

Files Changed

  • base/command_line.cc
  • base/command_line_unittest.cc
  • chrome/browser/web_applications/os_integration/web_app_shortcut_win.cc
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
Loading diff…

Regression Test / PoC

shipped with the fix
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) {
Loading diff…

Original Bug Report

reported by ro...@gmail.com

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

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

View on issue tracker