[PATCH v1] win32: stop cmd.exe from expanding variables in quoted arguments

Gert Doering <[email protected]>
Newsgroups gmane.network.openvpn.devel
Message-ID <[email protected]>
From: Lev Stipakov <[email protected]>

Commit cf08504 quotes arguments that contain cmd.exe metacharacters, so a
certificate subject passed to --tls-verify cannot start a second command
when CreateProcess() runs a .bat or .cmd hook.

Quoting is not enough for %VAR% and !VAR!: cmd.exe expands those even
inside double quotes, and OpenVPN exports peer-controlled certificate
fields into the child environment. A subject that puts a quote and an
operator in one field (O=BREAK"&whoami&") and references it from another
(CN=%X509_0_O%) expands back into a quote and command operator after
wide_cmd_line() has already replaced the direct quotes, running a command
before the hook can reject the peer.

Replace % and ! in wide_cmd_line() along with the double quotes and CRLF,
so no argument can expand. This reuses two character classes that had no
users, CC_AT and CC_EQUAL, renamed to CC_PERCENT and CC_EXCLAMATION. Add
regression tests for a bare percent or bang, a closed expansion token, and
the reported subject.

This completes the CVE-2026-84256 fix for Windows batch hooks.

GitHub: OpenVPN/openvpn-private-issues#176
Reported-By: Darren Carreras
CVE: 2026-84256
Change-Id: I70dfdc70778458b78f80b8d0b0b786f18fe4fc76
Signed-off-by: Lev Stipakov <[email protected]>
Acked-by: Razvan Cojocaru <[email protected]>
Gerrit URL: https://gerrit.openvpn.net/c/openvpn/+/1895
---

This change was reviewed on Gerrit and approved by at least one
developer. I request to merge it to master.

Gerrit URL: https://gerrit.openvpn.net/c/openvpn/+/1895
This mail reflects revision 1 of this Change.

Acked-by according to Gerrit (reflected above):
Razvan Cojocaru <[email protected]>

        
diff --git a/src/openvpn/buffer.c b/src/openvpn/buffer.c
index 5ce2e39..5d00415 100644
--- a/src/openvpn/buffer.c
+++ b/src/openvpn/buffer.c
@@ -943,11 +943,11 @@
     {
         return true;
     }
-    if ((flags & CC_AT) && c == '@')
+    if ((flags & CC_PERCENT) && c == '%')
     {
         return true;
     }
-    if ((flags & CC_EQUAL) && c == '=')
+    if ((flags & CC_EXCLAMATION) && c == '!')
     {
         return true;
     }
diff --git a/src/openvpn/buffer.h b/src/openvpn/buffer.h
index 4471697..0a02e94 100644
--- a/src/openvpn/buffer.h
+++ b/src/openvpn/buffer.h
@@ -1660,8 +1660,8 @@
 #define CC_SINGLE_QUOTE  (1 << 21) /**< single quote */
 #define CC_DOUBLE_QUOTE  (1 << 22) /**< double quote */
 #define CC_REVERSE_QUOTE (1 << 23) /**< reverse quote */
-#define CC_AT            (1 << 24) /**< at sign */
-#define CC_EQUAL         (1 << 25) /**< equal sign */
+#define CC_PERCENT       (1 << 24) /**< percent sign */
+#define CC_EXCLAMATION   (1 << 25) /**< exclamation mark */
 #define CC_LESS_THAN     (1 << 26) /**< less than sign */
 #define CC_GREATER_THAN  (1 << 27) /**< greater than sign */
 #define CC_PIPE          (1 << 28) /**< pipe */
diff --git a/src/openvpn/win32-util.c b/src/openvpn/win32-util.c
index 4e3819c..e2c8810 100644
--- a/src/openvpn/win32-util.c
+++ b/src/openvpn/win32-util.c
@@ -94,7 +94,10 @@
     {
         const char *arg = a->argv[i];
         strcpy(work, arg);
-        string_mod(work, CC_PRINT, CC_DOUBLE_QUOTE | CC_CRLF, '_');
+        /* cmd.exe expands %VAR% and !VAR! even inside quotes, so a value like
+         * %X509_0_O% could turn back into a quote and start a new command.
+         * Replace those along with the double quotes and CRLF. */
+        string_mod(work, CC_PRINT, CC_DOUBLE_QUOTE | CC_CRLF | CC_PERCENT | CC_EXCLAMATION, '_');
         if (i)
         {
             buf_printf(&buf, " ");
diff --git a/tests/unit_tests/openvpn/test_argv.c b/tests/unit_tests/openvpn/test_argv.c
index 5b6e26e..1fc73d8 100644
--- a/tests/unit_tests/openvpn/test_argv.c
+++ b/tests/unit_tests/openvpn/test_argv.c
@@ -271,15 +271,13 @@
         { "CN=a,b;c=d", L"script.bat 0 CN=a,b;c=d" },
         /* a space has always forced quoting */
         { "O=Ctrl, CN=y", L"script.bat 0 \"O=Ctrl, CN=y\"" },
-        /* cmd.exe operators */
+        /* cmd.exe operators that quoting neutralizes */
         { "CN=x&ver", L"script.bat 0 \"CN=x&ver\"" },
         { "CN=x|ver", L"script.bat 0 \"CN=x|ver\"" },
         { "CN=x>f", L"script.bat 0 \"CN=x>f\"" },
         { "CN=x<f", L"script.bat 0 \"CN=x<f\"" },
         { "CN=x^f", L"script.bat 0 \"CN=x^f\"" },
-        { "CN=x%f", L"script.bat 0 \"CN=x%f\"" },
         { "CN=x(f)", L"script.bat 0 \"CN=x(f)\"" },
-        { "CN=x!f", L"script.bat 0 \"CN=x!f\"" },
         /* a double quote is replaced, so quoting cannot be broken out of */
         { "CN=a\"b", L"script.bat 0 CN=a_b" },
     };
@@ -300,6 +298,49 @@
         gc_free(&gc);
     }
 }
+
+/*
+ * cmd.exe expands %VAR% and !VAR! even inside quotes, and OpenVPN puts
+ * peer-controlled data (e.g. certificate subject fields) on the command line,
+ * so these characters are replaced to stop a value from expanding back into a
+ * quote and command operator.
+ */
+static void
+wide_cmd_line__replaces_cmd_expansion(void **state)
+{
+    static const struct
+    {
+        const char *arg;
+        const WCHAR *expected;
+    } cases[] = {
+        /* a bare percent or bang is replaced */
+        { "CN=x%f", L"script.bat 0 CN=x_f" },
+        { "CN=x!f", L"script.bat 0 CN=x_f" },
+        /* a closed expansion token is replaced, so nothing expands */
+        { "CN=%X509_0_O%", L"script.bat 0 CN=_X509_0_O_" },
+        { "CN=!X509_0_O!", L"script.bat 0 CN=_X509_0_O_" },
+        /* the reported bypass: quotes in one field are already replaced, and
+         * neutralizing % stops %X509_0_O% from re-injecting them */
+        { "O=BREAK\"&whoami&\", CN=%X509_0_O%",
+          L"script.bat 0 \"O=BREAK_&whoami&_, CN=_X509_0_O_\"" },
+    };
+
+    for (size_t i = 0; i < SIZE(cases); i++)
+    {
+        struct gc_arena gc = gc_new();
+        struct argv a = argv_new();
+
+        argv_printf(&a, "%s %d %s", "script.bat", 0, cases[i].arg);
+        assert_int_equal(a.argc, 3);
+
+        WCHAR *cmd_line = wide_cmd_line(&a, &gc);
+        assert_non_null(cmd_line);
+        assert_int_equal(wcscmp(cmd_line, cases[i].expected), 0);
+
+        argv_free(&a);
+        gc_free(&gc);
+    }
+}
 #endif /* _WIN32 */
 
 int
@@ -323,6 +364,7 @@
         cmocka_unit_test(argv_insert_head__empty_argv__head_only),
 #ifdef _WIN32
         cmocka_unit_test(wide_cmd_line__quotes_only_what_cmd_would_reinterpret),
+        cmocka_unit_test(wide_cmd_line__replaces_cmd_expansion),
 #endif
     };
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.