Repository navigation
[Shell] Azure CLI receives corrupted arguments in PowerShell #15529
Description
Activity
- ghost addedneeds-triageThis is a new issue that needs to be triaged to the appropriate team.This is a new issue that needs to be triaged to the appropriate team.
on Oct 15, 2020 - ghost removedneeds-triageThis is a new issue that needs to be triaged to the appropriate team.This is a new issue that needs to be triaged to the appropriate team.
on Oct 15, 2020 - addedService AttentionThis issue is responsible by Azure service team.This issue is responsible by Azure service team.
on Oct 15, 2020 The PowerShell issue seems to be fixed in PowerShell 7.3 : PowerShell/PowerShell#1995 (comment)
In Azure's realm, a bug did creep,
A silent shadow where functions leap,
With CLI's whispers, developers sigh,
Yet hope ignites as they reach for the sky.Reacted by Jiashuo LiReacted by Paul Michael AbramsTo dive deeper, the root cause is that CMD executes the value of
%*.Say
test.cmdhasecho %*
And we have
test.py:import subprocess subprocess.run(['test.cmd', 'a&b'])
Running
python test.pycan lead to code execution/injection:> python test.py D:\temp>echo a a 'b' is not recognized as an internal or external command, operable program or batch file.This is unimaginable on Linux or with any other programming language where you have to run
evalor similar functions to execute an argument's value.Azure CLI's entry scripts on Linux use
"$@"for argument pass-through:AZ_INSTALLER=RPM PYTHONPATH=\"\$bin_dir/../lib64/az/lib/${python_version}/site-packages\" \$python_cmd -sm azure.cli \"\$@\"
azure-cli/scripts/release/debian/prepare.sh
Line 112 in 833da42
${TAB}echo "\043!/usr/bin/env bash\nbin_dir=\140cd \"\044(dirname \"\044BASH_SOURCE[0]\")\"; pwd\140\nAZ_INSTALLER=DEB \"\044bin_dir\"/../../opt/az/bin/python3 -Im azure.cli \"\044\100\"" > debian/azure-cli/usr/bin/az Docs on
"$@":- https://www.gnu.org/software/bash/manual/bash.html#Special-Parameters
- https://www.gnu.org/software/bash/manual/bash.html#Arrays
When running
test.cmdfrom CMD, we can see the double quotes (") are actually included in%*, instead of being stripped:d:\temp>test.cmd "a b" d:\temp>echo "a b" "a b"Besides,
echo "a b"doesn't strip double quotes. This is another difference from Linux Bash:$ echo "a b" a bIn the 2.36-2.40 version timeframe
azpsgot introduced. Since then I am usingazpsto properly marshal PowerShell objects to az cli. But I can't seem to find the official docs on this topic anymore?
Surprisingly this issue only occurs on Windows machines. When I am running Linux,azpsis not available, and just runningazruns fine.Docs checked:
https://learn.microsoft.com/en-us/cli/azure/use-azure-cli-successfully-powershell?view=azure-cli-latest&tabs=read%2CBash1%2CBash2
https://github.com/Azure/azure-cli/blob/dev/doc/quoting-issues-with-powershell.mdExample:
$now = Get-Date $startTime = (Get-Date -Year $now.Year -Month $now.Month -Day 1 -Hour 0 -Minute 0 -Second 0 -Millisecond 0).AddMonths(-1).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") $endTime = ([datetime]$startTime).AddMonths(1).ToString("yyyy-MM-ddTHH:mm:ssZ") $query = "AuditLogs | where LoggedByService == `"PIM`"" az monitor log-analytics query --analytics-query $query --timespan "$startTime/$endTime" --workspace your-guid
Will fail on Windows with the error message:
ERROR: the following arguments are required: --workspace/-wIf you run the following, it just works:
azps monitor log-analytics query --analytics-query $query --timespan "$startTime/$endTime" --workspace your-guid
Unfortunately
azpsis not available on Linux, so this introduces a branch in your script if you want to run it on both platforms.Adding some analysis provided by Clause Opus 4.7. Prompt:
According to #15529 (comment), is this CMD behavior a CVE? Has it affected other programming languages or tools?
Short answer
The behavior described in @jiasli's comment — that CMD effectively executes the contents of
%*when a.cmd/.batfile is invoked — is exactly the root cause of the "BatBadBut" vulnerability disclosed in April 2024 by RyotaK. So yes, this CMD quirk has been the basis of multiple CVEs across many ecosystems.Is it a CVE?
It's not a single CVE against Windows/CMD itself — Microsoft considers the CMD argument-parsing behavior to be by design. Instead, it produced a family of CVEs against every runtime/library that spawns child processes on Windows without escaping for
cmd.exe. The umbrella name is BatBadBut (https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/).Languages/runtimes affected
The same underlying CMD behavior produced CVEs in (at least) the following — this is the list RyotaK published:
Language / Runtime CVE Rust ( std::process::Command)CVE-2024-24576 Node.js ( child_processwithoutshell)CVE-2024-27980 Go ( os/exec, prior fix CVE-2024-24576-class)Fixed in Go 1.22.x; tracked as a Go security advisory Haskell ( processpackage)CVE-2024-3566 Erlang/OTP CVE-2024-3566 (shared advisory) PHP ( proc_open)CVE-2024-1874 Python ( subprocess)Documentation-only fix — Python's stance is that callers must opt into shell=True-style handling; no CVE assigned, butsubprocessdocs were updated to warn about.bat/.cmdRuby, Julia, D, and others were also discussed in the original write-up.
Why it happens (matches the comment's example)
When a parent process calls
CreateProcesstargetingfoo.cmd, Windows silently routes execution throughcmd.exe /c. CMD then re-parses the command line using its own metacharacter rules (&,|,^,>,", etc.) — rules that are different from the C-runtime argv rules every other language escapes for. So an argument likea&bthat was perfectly safe to pass to a normal.exebecomes a command separator when the target happens to be a.bat/.cmd, exactly as shown in the comment:>python test.py D:\temp>echo a a 'b' is not recognized as an internal or external commandThat is the canonical BatBadBut proof-of-concept.
Relation to the Azure CLI issue
azon Windows is shipped asaz.cmd, so every invocation from PowerShell/Python/Node/etc. goes through CMD's re-parsing. The lost-quotes problem in #15529 is the benign face of the same mechanism — the same parsing layer that drops"around your JSON is the one that, with attacker-controlled input, would let&whoamiexecute. That's why the fixes in Rust/Node/Go/etc. all amount to "apply CMD-specific escaping (or refuse to run) when the target is*.bat/*.cmd."References
- BatBadBut write-up: https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/
- Rust advisory (CVE-2024-24576): https://blog.rust-lang.org/2024/04/09/cve-2024-24576.html
- Node.js April 2024 security release (CVE-2024-27980): https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2
- PHP CVE-2024-1874: https://nvd.nist.gov/vuln/detail/CVE-2024-1874
- Haskell/Erlang CVE-2024-3566: https://kb.cert.org/vuls/id/123335
Here is a more confusing symptom. In Command Prompt, either
&or\"can be passed correctly:>python -c "import sys; print(sys.argv)" "&calc.exe" ['-c', '&calc.exe'] >python -c "import sys; print(sys.argv)" "\"calc.exe" ['-c', '"calc.exe']But not when combined -
calc.exeis launched:>python -c "import sys; print(sys.argv)" "\"&calc.exe" ['-c', '"']As explained by copilot:
This is the clean BatBadBut demo, and it has a precise explanation. The key is to track CMD's quote-state for splitting purposes only, remembering that CMD treats every
"as a toggle and has no concept of\"as an escape.Walk the three command lines through CMD's splitter
CMD has one state bit:
inQuotes, startsfalse. Every"flips it.&outside quotes splits commands;&inside quotes is literal data.Case 1:
"&calc.exe"— workspython -c "import sys; print(sys.argv)" "&calc.exe" ^ ^ flip→in flip→outState at the
&: inside quotes →&is literal data, no split.CMD forwards the whole tail to Python. C runtime parses
"&calc.exe"→argvelement&calc.exe. ✅Case 2:
"\"calc.exe"— workspython -c "import sys; print(sys.argv)" "\"calc.exe" ^ ^ ^ in out inCMD sees three quotes in this token. The
\is literal (CMD has no opinion on it). State transitions: in → out → in. No&anywhere, so no split.CMD forwards the whole tail. Python's C runtime parses
"\"calc.exe": open-quote,\"decoded as literal",calc.exe, close-quote →argvelement"calc.exe. ✅Case 3:
"\"&calc.exe"— RCEpython -c "import sys; print(sys.argv)" "\"&calc.exe" ^ ^ ^ in out in ↑ & here is OUTSIDE quotesWalk it:
"→ in.\→ literal, still in."→ out. Now we hit&while outside quotes → command separator. CMD splits the line into two commands:python -c "import sys; print(sys.argv)" "\"calc.exe"
CMD runs command 1, then command 2.
Command 1 is handed to Python as
lpCommandLine=-c "import sys; print(sys.argv)" "\". Python's C runtime parses the last token"\": open-quote,\"= literal", end of string →argv[-1]=". Hence the output you saw:['-c', '"']Command 2 is
calc.exe"— CMD launchescalc.exe(the trailing"is a stray, ignored). 💥The one-sentence reason
Cases 1 and 2 each contain an even number of quotes before the danger character, so CMD's splitter is "inside quotes" (or back to start state) when it matters. Case 3 contains an odd number of quotes before
&— because CMD counts\"as two quote-toggles where the C runtime counts it as zero — so the splitter is "outside quotes" exactly when&shows up, and the command splits.That single off-by-one in how many quote toggles
\"represents is the entire BatBadBut vulnerability:Sequence C runtime sees CMD splitter sees \"0 quote toggles, 1 literal "1 quote toggle, 1 literal \Every other character agrees. Just that one disagreement, and it's enough to desynchronize the two state machines so an attacker can place
&on the "outside quotes" side of CMD's view while the caller (who escaped using C-runtime rules) was certain it was on the "inside quotes" side.Why your three experiments are the perfect minimal demo
You've isolated each variable:
- Case 1 proves
&alone is harmless if the attacker can't escape the quoted region. - Case 2 proves
\"alone is harmless if there's no metacharacter to weaponize. - Case 3 proves the combination is fatal, because
\"is the tool that escapes the quoted region (in CMD's eyes), and&is the payload that does damage once outside.
That's BatBadBut in three lines of CMD. The Rust/Node/Go fixes all amount to: when the target is
.bat/.cmd, refuse or sanitize any argument containing"so the attacker can never construct case 3. They cannot fix it by "escaping better" because there is no escaping that satisfies both parsers — the disagreement on\"is irreducible.The rule, precisely
When CMD processes a command line to invoke an external program, it does two things to each character:
- Inspect it — to update the
inQuotesstate bit and to decide whether metacharacters (&,|,>,<, whitespace) are syntax or data. - Forward it — copy the byte into the
lpCommandLinestring that's handed toCreateProcess.
For
", only step 1 happens with any effect. Step 2 forwards it verbatim. CMD reads the quote, but it does not eat it.Compare to the few characters CMD actually does consume:
Character Inspected? Forwarded to child? "Yes (toggles inQuotes)Yes — passed through \No (not special to CMD at all) Yes ^(outside quotes)Yes (escape next char) No — consumed ^(inside quotes)No (literal) Yes &, `` (outside quotes) Yes (split command) &, `` (inside quotes) No (literal) >,<(outside quotes)Yes (redirect) No — consumed, applied to handles %VAR%Yes (expand) Replaced with variable's value So
"is in the unusual position of being semantically meaningful but textually transparent. CMD uses it to make decisions, then passes it on so the next parser can use it to make its own decisions.Why this is the root of everything
This dual nature is the entire reason BatBadBut exists:
- Because CMD inspects
", the quote affects whether&is a splitter. - Because CMD forwards
", the quote also affects how the C runtime tokenizesargv. - The two parsers' rules for what
"(and especially\") mean are different. - Result: one byte sequence, two interpretations, one attack surface.
If CMD consumed quotes (like bash does — quotes are gone from
argvafter the shell is done), there'd be nothing for the C runtime to re-parse. If CMD ignored quotes (didn't toggle on them), there'd be no splitter-state desynchronization. It's the combination — inspect + forward — that creates the bug.The corollary that surprises people
A useful sanity check: in CMD, you can never actually strip a quote from an argument before it reaches the child. The only way to get a
"-free argv element is to not type the quote in the first place. There's no\escape, no doubling rule, no "consume this quote" syntax. Quotes always pass through.That's why batch files use
%~1to strip surrounding quotes — it's a feature of the%1substitution operator, not of CMD's command-line parser. The parser itself never removes a quote. Substitution operators are the only place in the entire CMD language where quote-removal happens.So your formulation is exactly right, and it's actually a better one-line summary than anything in the official docs: CMD parses quotes but doesn't consume them; the child gets to parse them again. Once you internalize that, every weird Windows shell behavior — including BatBadBut, including your azure-cli quoting bug, including the
%~1operator existing in the first place — falls out as a consequence.- added a commit that references this issue
on May 5, 2026
Describe the bug
When invoking
azin PowerShell, due to the known issue of PowerShell PowerShell/PowerShell#1995, arguments passed to Azure CLI may get corrupted. For example, literal double quotes (") are lost:This contradicts the behavior of Bash:
Impact
This is mainly affecting
az rest --body {JSON}(Cannot change the signInAudience using the Microsoft Graph API #15512)--query(az version --query "azure-cli" should return version number and not throw error #13152)Workaround
See https://github.com/Azure/azure-cli/blob/dev/doc/quoting-issues-with-powershell.md