Skip to main content
All articles

Argument injection: how a soft hyphen became CVE-2024-4577 and put 9.8 on the board

You escaped the shell metacharacters and the command still ran. Argument injection is the bug that survives shell-safe APIs, here is the mechanism, the PHP-CGI case that got mass-exploited, and the one-character fix.

7 min read0 views

Everyone learns command injection: user input reaches a shell, an attacker adds ; id, game over. The fix is drilled in early, do not build shell strings, pass an argument array, use subprocess.run([...], shell=False).

So you do that. And you are still vulnerable, because there is a second bug hiding behind the first one, and the standard fix does nothing about it.

Argument injection is when the attacker cannot add a new command but can add new flags to the command you already intended to run. No semicolons, no backticks, no pipes, nothing your input filter is looking for. Just a leading dash.

The mechanism

Consider a perfectly sensible file-conversion endpoint:

subprocess.run(["convert", user_filename, "output.png"], shell=False)

There is no shell. Nothing is interpolated into a string. The classic advice has been followed exactly. And if user_filename is -write, or @/etc/passwd, or one of ImageMagick's several dozen option flags, the attacker has changed what the program does without changing which program runs.

The general shape is:

<trusted binary> <ATTACKER-CONTROLLED> <trusted args>
                        ↑
              if this can start with "-",
              it is not an argument, it is an option

The reason this class survives is that the security boundary everyone defends is the shell, and this bug is on the other side of it. shell=False guarantees the operating system will not reinterpret your string. It guarantees nothing about how the target program parses argv.

Programs that are especially rewarding here:

ProgramFlagEffect
curl-o, --output, -KWrite to an arbitrary path; read options from an arbitrary file
wget--output-document, --use-askpassArbitrary write; command execution
tar--to-command, --checkpoint-actionCommand execution
rsync-eCommand execution
zip--unzip-commandCommand execution
ssh-o ProxyCommand=Command execution
find-execCommand execution
git--upload-pack, -c core.pager=Command execution
php-cgi-d, -rSet arbitrary INI directives; run arbitrary code

That last row is where this stops being theoretical.

CVE-2024-4577: the soft hyphen

June 2024. CVSS 9.8. Unauthenticated remote code execution. Added to CISA's Known Exploited Vulnerabilities catalog and mass-scanned within 48 hours of disclosure, with ransomware operators among the earliest adopters. Found by DEVCORE.

The setup: PHP running in CGI mode on Windows. There is a well-known ancestor bug here, CVE-2012-1823, where you could pass command-line options to the PHP binary through the query string, ?-d+allow_url_include%3d1+-r+.... PHP fixed that in 2012 by rejecting query strings that look like option sequences.

CVE-2024-4577 is that fix being walked around by an operating system feature nobody in the security conversation had modelled.

Windows has a Best-Fit character mapping: when converting Unicode to a legacy code page, characters with no exact representation are mapped to a visually similar one. Under certain locales, notably Chinese (Simplified and Traditional) and Japanese. The soft hyphen, U+00AD (0xAD), best-fit maps to the ordinary hyphen-minus, 0x2D.

So the chain runs:

  1. Attacker sends a request whose query string contains 0xAD where a - would go.
  2. PHP's CVE-2012-1823 guard inspects the string, sees 0xAD, and does not consider it an option delimiter. The check passes.
  3. The string is handed to a Win32 API that performs the Best-Fit conversion.
  4. 0xAD becomes 0x2D.
  5. php-cgi now parses an argument list containing real hyphens, and -d lets you set arbitrary INI directives while -r runs arbitrary PHP.

Nothing was "hacked" at any single step. Every component behaved as documented. The vulnerability lived entirely in the gap between two components' idea of what a string is.

The lessons that transfer:

  • A validation check and the consumer of the validated data must operate on the same bytes. Any encoding, normalisation, transcoding or character-set conversion between the check and the use is a potential bypass. This is the same structural bug as Unicode normalisation bypasses in username checks, and as parser differentials in request smuggling.
  • Blocklists inherit the full weirdness of every layer beneath them. The PHP guard was correct about ASCII. It was never going to enumerate the Windows Best-Fit table.
  • Fixing a symptom leaves the shape in place. CVE-2012-1823 and CVE-2024-4577 are the same vulnerability, twelve years apart, because both fixes filtered the input instead of removing the ability to pass options at all.

The other one worth knowing: CVE-2023-22809

If you want to see the same class in a completely different context, look at sudoedit.

sudo -e (sudoedit) lets a user edit specific privileged files with their own editor, taken from SUDO_EDITOR, VISUAL or EDITOR. sudo tried to stop you smuggling extra arguments in those variables by checking for spaces and quotes.

It did not account for --.

EDITOR='vim -- /etc/sudoers'

sudo parsed that, appended the file it intended you to edit, and the resulting argument list gave the editor two files: yours and the one you were authorised for. Everything after -- is a file operand, so the "extra" path became an editable file with the RunAs user's privileges. Affected sudo 1.8.0 through 1.9.12p1; fixed in 1.9.12p2 by explicitly checking for --.

Same lesson: the check understood one syntax for "extra argument" and the parser understood two.

Finding it

Argument injection hides in code that looks correct, so grep alone is not enough. You need to grep and then read.

# Python
grep -rn "subprocess\.\(run\|call\|Popen\|check_output\)" --include="*.py" .

# Node
grep -rn "execFile\|spawn(" --include="*.js" --include="*.ts" .

# Go / Java / Ruby / PHP
grep -rn "exec.Command\|ProcessBuilder\|Open3\|proc_open\|escapeshellarg" .

At each hit, ask one question: can any user-controlled element of the argument array begin with -?

Note the trap in that last grep: PHP's escapeshellarg() is frequently cited as the fix and is not one. It correctly prevents shell metacharacter injection. A value of -o /var/www/shell.php survives escapeshellarg() intact, quoted, shell-safe, and still a flag.

Look especially at:

  • Filenames and paths from uploads, URL parameters or database records
  • Values passed to git, curl, tar, ssh, rsync, ffmpeg, convert, pandoc, wkhtmltopdf
  • Anything where a user-supplied string is spliced into an argv array before the fixed arguments
  • CI/CD pipelines, which are full of git clone $USER_SUPPLIED_URL

Fixing it

Three fixes, in order of strength.

1. The -- separator. Nearly every POSIX-conventional program treats -- as "no more options; everything after this is an operand."

subprocess.run(["convert", "--", user_filename, "output.png"], shell=False)

One token, class removed for that call. Verify the specific binary honours it. Most do, a few do not, and the ones that do not should be on your list of programs never to hand user input to.

2. Validate the shape, not the characters. If the value is a filename, require it to match ^[A-Za-z0-9_.-]{1,64}$ and not start with - or .. If it is an ID, require digits. Allowlists on structure beat blocklists on content, and unlike escaping they do not depend on the downstream parser's behaviour.

3. Do not pass user input as an argument at all. Write the upload to a path you generate, then pass your path. The user's filename becomes metadata you store in a database, not a token in an argument vector. This is the version that cannot be got around, and it is usually less code than the alternatives.

For CVE-2024-4577 specifically: update to PHP 8.3.8, 8.2.20 or 8.1.29 and above, and stop running PHP in CGI mode. If you cannot do either immediately, the practical stopgap is to block the request pattern at the edge and to stop exposing the PHP binary in a web-served directory, but treat that as buying days, not as a fix.

Detection

  • Requests where a parameter value begins with - or --, or contains %AD (raw or double-encoded). On most applications this is close to zero-volume in normal traffic, which makes it a high-signal rule.
  • Query strings containing allow_url_include, auto_prepend_file, -d+, or php://input. The CVE-2024-4577 exploitation signature.
  • Process telemetry: php-cgi.exe or php.exe spawning cmd.exe or powershell.exe; git spawning a shell; tar spawning anything.
  • Command-line auditing on Windows (Event ID 4688 with command line logging enabled) is what makes the process-lineage rules above possible at all. If it is off, turn it on before you need it.

Take this away

shell=False protects you from the shell. It does not protect you from the program.

The complete question for a code reviewer is not "can the user inject a command?" It is "can the user inject a flag?", and the cheapest answer to it is --.


Further reading

Was this useful?

Share

Tags

  • web security
  • cve analysis
  • secure code review
  • penetration testing

Comments

Loading comments…

Leave a comment

Comments are read and approved by hand before they appear, so yours will not show up straight away. Your email address is optional, is never published, and is only used if we need to reply to you directly.

0/5000

Related service

Web Application Testing

Manual, business-logic-aware testing of the applications your customers touch.

If you want to know whether what you have just read applies to your own systems, that is the engagement that answers it.