KHAGAN AYDIN

The PHP Bug That Gave Away Windows Servers in a Day

One hidden character in a web address was enough to run code on thousands of Windows servers. Here is how it worked.

· 7 min read

On June 6, 2024, the PHP team released a fix for a flaw called CVE-2024-4577. It was rated 9.8 out of 10, near the top of the scale. Within about a day, honeypots were seeing attacks. Within a few days, a ransomware group called TellYouThePass was using it to lock up servers and demand payment. By early July, the United States government had ordered federal agencies to patch it.

The flaw was found by a researcher named Orange Tsai at the security firm DEVCORE. What makes it worth studying is not that it was complicated. It was the opposite. It brought back a bug that had been fixed twelve years earlier, using a single character that almost nobody thinks about. If you run PHP on Windows, or you review code that does, this one is worth understanding in full.

The old bug it came from

To see the new flaw, you have to see the old one first.

There are a few ways to run PHP behind a web server. One of the oldest is called CGI. In this setup, each web request starts a small program, php-cgi.exe, and the web server hands it the details of the request.

Back in 2012, a bug called CVE-2012-1823 showed that the query string, the part of a web address after the question mark, was being passed to that program as if it were command-line options. Command-line options are the switches you type after a program’s name in a terminal, such as the -d in many tools. PHP’s command-line program accepts a -d switch that sets a configuration value. So an attacker who could smuggle a -d into the query string could change how PHP behaved, and from there, run their own code.

PHP fixed that in 2012. The fix was simple: if the query string started with a dash and had no equals sign, PHP refused to treat it as options. For twelve years, that was the end of it.

The one character that broke the fix

The 2012 fix looked for the normal dash, the one on your keyboard. In the computer’s character table, that dash is the value 0x2D.

There is another character that looks almost identical in many fonts, called the soft hyphen. It is the value 0xAD. It is meant to mark a spot where a word can break across two lines. Most of the time you never see it.

PHP’s 2012 fix checked for the normal dash, 0x2D. It did not check for the soft hyphen, 0xAD. On its own, that would not matter, because a soft hyphen is not a real dash and PHP’s options reader would ignore it.

Here is the part that turned a harmless gap into a critical flaw. Windows has a feature called “best-fit” character mapping. When Windows hands text to a program, it sometimes replaces an unusual character with a close-looking ordinary one. On the affected systems, Windows took the soft hyphen, 0xAD, and helpfully converted it into a normal dash, 0x2D.

So the order of events was:

  1. The attacker put a soft hyphen (0xAD) in the web address.
  2. PHP’s safety check looked for a normal dash (0x2D), did not find one, and let the request through.
  3. Windows then converted the soft hyphen into a normal dash before php-cgi.exe read it.
  4. php-cgi.exe now saw a real dash, and treated what followed as command-line options.

The guard checked the door. Windows opened a window right after.

What the attack looked like

An attacker did not need a password or an account. One web request was enough. Stripped to its core, the request looked like this:

POST /php-cgi/php-cgi.exe?%ADd+allow_url_include%3d1+%ADd+auto_prepend_file%3dphp://input HTTP/1.1
Host: victim.example
Content-Type: application/x-www-form-urlencoded

<?php system('whoami'); ?>

Every piece of that has a job. Let us read it in plain words.

  • %AD is the soft hyphen, written in the form web addresses use. This is the smuggled dash.
  • d after it makes -d, the switch that sets a PHP configuration value.
  • allow_url_include%3d1 means allow_url_include=1. This turns on a setting that lets PHP load code from places it normally would not.
  • The second %ADd sets another value: auto_prepend_file=php://input.
  • auto_prepend_file tells PHP to run a chosen file before the real page. php://input is not a file on disk. It is a name that means “the body of this request.”

Put together, the attacker told PHP: before you run anything else, run whatever is in the body of my request, as PHP code. The body held <?php system('whoami'); ?>, which runs a system command. Swap that one line for anything else, and the attacker runs that instead.

That is the whole attack. No break-in, no stolen key. The server was told to run the attacker’s code, and it did.

Who was exposed

The flaw only affected PHP on Windows, and only when PHP ran in that older CGI mode. That sounds narrow. It was not, for two reasons.

First, XAMPP. XAMPP is a popular bundle that installs Apache, PHP and a database together, used by countless developers and small sites on Windows. DEVCORE warned that XAMPP on Windows was vulnerable in its default setup on affected systems. Many people run it without changing a single setting.

Second, the scale. One scan service, Censys, found roughly 458,000 PHP servers on the internet that could be affected. A cloud security firm, Wiz, estimated that about a third of cloud environments had Windows machines running a vulnerable version of PHP. Even if those numbers are high, the real figure was large.

The systems named as open to code execution without any login were those set to the Traditional Chinese, Simplified Chinese or Japanese system locales, because of how their character tables behave. But the safe move for everyone was to assume exposure and patch, not to count on a locale.

How fast the attackers moved

This is the part worth remembering.

The fix came out on June 6, 2024. The Shadowserver Foundation, which runs sensor machines across the internet, saw attempts to exploit it within about 24 hours. A security firm, watchTowr, built a working attack quickly and called it a nasty bug with a very simple exploit.

Then the ransomware arrived. The group behind TellYouThePass used the flaw to drop a program onto servers that encrypted files and left a ransom note. They were doing this within days of the fix going public. People who waited a week to patch were not early. They were late.

How to close it

If you run PHP on Windows, here is the order of work.

Patch now. Upgrade to PHP 8.3.8, 8.2.20, 8.1.29 or newer. If you are on PHP 8.0, 7 or 5, there is no fix coming, because those versions are retired. You must move to a supported version.

Get off CGI. The deeper fix is to stop running PHP in CGI mode at all. Use a modern setup instead, such as PHP-FPM, FastCGI, or mod_php under Apache. These are faster as well as safer. CGI has been the source of this exact family of bug for over a decade.

If you cannot patch today, block the pattern. As a stopgap, you can add a web server rule that rejects requests carrying the soft hyphen trick. This is a bandage, not a cure, and it comes off the moment you patch. Here is the idea as an Apache rule:

# Stopgap only. Remove once PHP is patched and off CGI.
RewriteEngine On
RewriteCond %{QUERY_STRING} %AD [NC]
RewriteRule .* - [F]

Then look back. A server that was reachable and unpatched for even a day may already have been hit. Check your access logs for requests containing %AD aimed at php-cgi.exe. Check for files you did not create, scheduled tasks you did not add, and outbound connections you cannot explain. Patching closes the door. It does not remove someone who already walked in.

The lesson under the bug

It is tempting to file this under “a weird Windows quirk” and move on. That misses the point.

The 2012 fix was correct for what its authors could see. They checked for the dash that exists on a keyboard. The flaw was not in their logic. It was in an assumption: that the character PHP checked would be the same character the operating system later delivered. Windows quietly changed it in between, and the whole guard fell.

This is the shape of a great many security failures. Each part behaves sensibly on its own. The gap lives in the handoff between them, in what one layer assumes about another. It is the same lesson as HTTP Parameter Pollution, where the danger was not any single part but the disagreement between them.

So when you check your own systems, do not only ask whether each piece is correct. Ask what each piece assumes about the piece next to it, and what happens when that assumption is wrong. That question finds the bugs that a checklist never will.