Skip to content

[Shell] Azure CLI receives corrupted arguments in PowerShell #15529

Description

@jiasli

Describe the bug

When invoking az in 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:

# PowerShell
> az '{"signInAudience":"AzureADAndMicrosoftAccounts"}' --debug
Command arguments: ['{signInAudience:AzureADAndMicrosoftAccounts}', '--debug']

This contradicts the behavior of Bash:

# Bash
$ az '{"signInAudience":"AzureADAndMicrosoftAccounts"}' --debug
Command arguments: ['{"signInAudience":"AzureADAndMicrosoftAccounts"}', '--debug']

Impact

This is mainly affecting

Workaround

See https://github.com/Azure/azure-cli/blob/dev/doc/quoting-issues-with-powershell.md

Activity

  1. ghost added
    needs-triageThis is a new issue that needs to be triaged to the appropriate team.
    on Oct 15, 2020
  2. ghost removed
    needs-triageThis is a new issue that needs to be triaged to the appropriate team.
    on Oct 15, 2020
  3. added this to the Backlog milestone on Oct 15, 2020
  4. jiasli commented on Oct 15, 2020

    @jiasli
    ContributorAuthor

    All similar issues are categorized under the Shell - PowerShell label. By now, received #7054, #8070, #8630, #8827, #9047, #9228, #9742, #10370, #10997, #11003, #1100, #11641, #11668, #12970, #13152, #13208, #13340, #13882, #15214, #15512.

  5. bb-froggy commented on Aug 11, 2022

    @bb-froggy

    The PowerShell issue seems to be fixed in PowerShell 7.3 : PowerShell/PowerShell#1995 (comment)

  6. pauly31 commented on Jan 6, 2025

    @pauly31

    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.

  7. jiasli commented on Feb 26, 2025

    @jiasli
    ContributorAuthor

    To dive deeper, the root cause is that CMD executes the value of %*.

    Say test.cmd has

    echo %*

    And we have test.py:

    import subprocess
    subprocess.run(['test.cmd', 'a&b'])

    Running python test.py can 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 eval or similar functions to execute an argument's value.

    The design may even date back to !

  8. jiasli commented on Feb 26, 2025

    @jiasli
    ContributorAuthor

    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 \"\$@\"

    ${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 "$@":

    When running test.cmd from 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 b
    
  9. cveld commented on May 13, 2025

    @cveld

    In the 2.36-2.40 version timeframe azps got introduced. Since then I am using azps to 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, azps is not available, and just running az runs 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.md

    Example:

    $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/-w

    If you run the following, it just works:

    azps monitor log-analytics query --analytics-query $query --timespan "$startTime/$endTime" --workspace your-guid

    Unfortunately azps is not available on Linux, so this introduces a branch in your script if you want to run it on both platforms.

  10. jiasli commented on Apr 24, 2026

    @jiasli
    ContributorAuthor

    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/.bat file 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_process without shell) 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 (process package) 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, but subprocess docs were updated to warn about .bat/.cmd

    Ruby, 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 CreateProcess targeting foo.cmd, Windows silently routes execution through cmd.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 like a&b that was perfectly safe to pass to a normal .exe becomes 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 command
    

    That is the canonical BatBadBut proof-of-concept.

    Relation to the Azure CLI issue

    az on Windows is shipped as az.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 &whoami execute. 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

  11. jiasli commented on Apr 24, 2026

    @jiasli
    ContributorAuthor

    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.exe is 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, starts false. Every " flips it. & outside quotes splits commands; & inside quotes is literal data.

    Case 1: "&calc.exe" — works

    python -c "import sys; print(sys.argv)" "&calc.exe"
                                            ^         ^
                                          flip→in   flip→out
    

    State at the &: inside quotes → & is literal data, no split.

    CMD forwards the whole tail to Python. C runtime parses "&calc.exe" → argv element &calc.exe. ✅

    Case 2: "\"calc.exe" — works

    python -c "import sys; print(sys.argv)" "\"calc.exe"
                                            ^ ^         ^
                                          in  out      in
    

    CMD 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 → argv element "calc.exe. ✅

    Case 3: "\"&calc.exe" — RCE

    python -c "import sys; print(sys.argv)" "\"&calc.exe"
                                            ^ ^          ^
                                          in  out       in
                                                ↑
                                             &  here  is  OUTSIDE  quotes
    

    Walk it: " → in. \ → literal, still in. " → out. Now we hit & while outside quotes → command separator. CMD splits the line into two commands:

    1. python -c "import sys; print(sys.argv)" "\"
    2. 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 launches calc.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:

    1. Inspect it — to update the inQuotes state bit and to decide whether metacharacters (&, |, >, <, whitespace) are syntax or data.
    2. Forward it — copy the byte into the lpCommandLine string that's handed to CreateProcess.

    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 tokenizes argv.
    • 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 argv after 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 %~1 to strip surrounding quotes — it's a feature of the %1 substitution 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 %~1 operator existing in the first place — falls out as a consequence.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions