A soft hyphen becomes a hyphen in three code pages out of fifteen

  • Yunik Maharjan
  • 16 min read

The whole of CVE-2024-4577 is one record in one Microsoft mapping table. PHP-CGI url-decodes the query string, checks the first character isn’t a hyphen, and lets %ad through as the byte 0xAD. Apache widens that byte to U+00AD, and Windows best-fits U+00AD down to a real hyphen for the child process’s ANSI argv.

The sanitisation guards a string the operating system has not produced yet. Two things follow that almost nobody writes down. The record is findable, and I’ll give you its line number. And the versions every write-up names as the fix — 8.3.8, 8.2.20, 8.1.29 — carry a patch its own author deleted eight days after it shipped.

How CVE-2024-4577 works: the hyphen PHP never sees

CVE-2024-4577 works because PHP-CGI tests the query string for a leading hyphen and Windows puts the hyphen there afterwards. The hole underneath is CVE-2012-1823. Under Apache with the CGI SAPI, a query string with no = in it reaches the binary as command-line arguments, so ?-s arrives as the -s option and prints the script’s source. PHP’s 2012 fix url-decoded that string and set skip_getopt = 1 on a leading -. That test still runs. On Windows it asks about the wrong string.

Orange Tsai of DEVCORE found the way round it, in DEVCORE’s alert of 6 June 2024. The PHP Group reproduced his report verbatim in GHSA-3qgc-jrrr-25jv: “However, if the attacker change the - to %ad, it bypasses the patch and inject -s to show the PHP source code”. %ad decodes to one byte, 0xAD, which is not 0x2D, so the hyphen test passes.

Four steps run in one direction, and only the first belongs to PHP. PHP’s CGI main reads 0xAD out of QUERY_STRING, which is not a hyphen, so option parsing stays on. Apache starts php-cgi.exe with a command line Windows holds as UTF-16, so that byte widens to the code point U+00AD. php-cgi.exe is an ANSI program, so its argv gets converted back down to the machine’s code page — DEVCORE names the ANSI side of the API, “such as GetCommandLineA” — by WideCharToMultiByte with CP_ACP. Best-Fit maps U+00AD to 0x2D, php_getopt reads -s, and no PHP code is left looking.

Run that chain backwards and it breaks on the very code page the bug works on, because line 149 of bestfit932.txt widens the byte 0xAD to U+FF6D, halfwidth katakana small yu. Apache is what makes U+00AD appear, since it widens request bytes by code-point value, and the Qi’anxin team who later bypassed this patch give the window in GHSA-p99j-rfp4-xqvq: “Apache limits our data range when expanding the input data to UTF-16. We can only look for wide characters that may be exploited in the range of 0x007f - 0x00ff”.

Tsai’s five-line C proof does not quite do what the transcript beneath it shows: the program is if (argc == 2) printf("[1] = %s\n", argv[1]);, and the transcript prints b'./test-cli.exe -s\r\n', a whole command line rather than that printf.

Two request shapes do the damage, and the second is the serious one because it reaches an exposed binary rather than a script.

text

http://server/index.php?%ads
http://server/php-cgi/php-cgi.exe?%add+allow_url_include%3don+-d+auto_prepend_file%3dphp%3a//input

What Windows Best-Fit conversion does to a character with no exact mapping

Best-Fit conversion replaces a character the target code page cannot represent with one that looks similar, and on Windows it’s what you get unless you ask for something else. It lives in WideCharToMultiByte, which turns UTF-16 into code-page bytes. Microsoft’s page for WideCharToMultiByte introduces dwFlags in a paragraph about speed and completeness: “The function performs more quickly when none of these flags is set. The application should specify WC_NO_BEST_FIT_CHARS and WC_COMPOSITECHECK … to retrieve all possible conversion results.” What it recommends is retrieving every conversion result, not validating anything. Zero flags is the quick path, the quick path is Best-Fit, and Microsoft’s own sample passes CP_ACP, 0.

The warning lives elsewhere, in the row for the flag that switches Best-Fit off: “For strings that require validation, such as file, resource, and user names, the application should always use the WC_NO_BEST_FIT_CHARS flag. … For example, the symbol for ‘∞’ (infinity) maps to 8 (eight) in some code pages.” Hold on to infinity mapping to eight; it comes back when PHP tries to fix this. That row is the third and last in the dwFlags table, and the warning is its second paragraph — roughly 1,050 words into a 2,440-word page, by the rendered <main> element on 29 October 2026.

Which code page, though? The one CP_ACP names: the machine’s ANSI code page, which Microsoft’s GetACP page warns “can be different on different computers, or can be changed for a single computer, leading to data corruption”. A security property set by a registry value is one you read rather than assume.

Which Windows code pages are affected by CVE-2024-4577?

Three of the fifteen ANSI code pages Microsoft publishes best-fit tables for, and in all three the exposure is one character. PHP’s advisory sets two conditions: Best-Fit behaviour, and “A code point smaller than 0xFF that maps to 0x2D”, looser than the 0x7F–0xFF Apache allows. It names only PHP-CGI on Windows “whose region are from Japan, China, Hong Kong, Macao, Singapore and Taiwan are affected.” Six regions, three code pages: 932, 936, 950. Condition two is checkable. Almost nobody checks it.

The Unicode Consortium’s WindowsBestFit directory holds Microsoft’s own tables, one per ANSI code page, and its readme says these are the files that “include ‘best fit’ behavior”. Twenty lines of Python parse the WCTABLE block of each, the UTF-16-to-bytes direction this attack travels in, and keep every record whose target byte is 0x2D.

python

import re, sys

def wctable(path):
    rows, inside = [], False
    for line in open(path, encoding="latin-1"):
        if line.startswith("WCTABLE"):
            inside = True
            continue
        if inside and re.match(r"^(MBTABLE|DBCSTABLE|ENDCODEPAGE)", line):
            break
        m = re.match(r"^(0x[0-9a-f]{4})\s+(0x[0-9a-f]+)", line, re.I)
        if inside and m:
            rows.append((int(m.group(1), 16), int(m.group(2), 16)))
    return rows

for path in sys.argv[1:]:
    rows = wctable(path)
    hits = [(s, t) for s, t in rows if t == 0x2D]
    low = [(s, t) for s, t in hits if s <= 0xFF and s != 0x2D]
    ad = [t for s, t in rows if s == 0xAD]
    print(f"{path}  records={len(rows)}  ->0x2D={len(hits)}  "
          f"<=U+00FF={len(low)}  U+00AD->{hex(ad[0]) if ad else 'no record'}")

In bestfit932.txt the survivor is one record, 0x00ad 0x002d ; Soft Hyphen (>u2010), at line 10,146 of the 19,493 wc -l reports. The WCTABLE header is at 10,004, line 10,005 is blank, record 1 sits on 10,006, so the soft hyphen is record 141 of 9,486. 10,146 / 19,493 puts it 52.0% down the file.

Records targeting byte 0x2D in each WCTABLE block, on 29 October 2026. The last column drops U+002D and decides exposure.

Code page U+00AD gives Records giving 0x2D Of those, ≤ U+00FF
932, 936, 950 0x2D 2 1
949 Korean 0xA1A9 1 0
1361 Johab 0xD939 1 0
1252 Western European 0xAD 15 0
1250, 1251, 1253, 1254 0xAD 15 to 18 0
1255 to 1258 0xAD 2 0
874 Thai nothing, so ? 2 0

Korean 949 is the control that proves this is about the table rather than the region: same double-byte family as 932, same Best-Fit behaviour, and it sends the soft hyphen to the double-byte 0xA1A9 at line 17,899 instead of to 0x2D. Windows-1252 maps it back to 0xAD at line 413, so it round-trips. Thai 874 has no U+00AD record at all, so under its CPINFO 1 0x3f 0x003f line a soft hyphen arrives as a question mark. Those rows are why Korea and Western Europe and Thailand are off the list, for this character. DEVCORE says of other locales that it “is currently not possible to completely enumerate and eliminate all potential exploitation scenarios”.

Windows-1252 has more hyphen-producing records than the Japanese code page and is still not exploitable, which inverts the usual reading of this bug. Fifteen records against two. Seven and a half times as many, and 14 of the 15 sit above U+00FF: U+2010, U+2011, U+2212, the fullwidth hyphen U+FF0D and ten box-drawing characters. The code page is not what keeps them out; the byte range the web server hands over is, and no request byte widens to U+2010 through Apache. The affected-locale list is a property of a 0x7F–0xFF window meeting a 9,486-record table, not of Japan.

Why 8.3.8, 8.2.20 and 8.1.29 were not the version that ended it

All three carry a fix PHP deleted eight days after shipping them. The first attempt, commit 93826731 by Niels Dossche on 17 May 2024, is candid about its own design: “The original code is error-prone due to the ‘best fit mapping’ that happens with the argument parsing but not with the query string.” It calls WideCharToMultiByte with dwFlags set to 0. That reproduces Best-Fit on purpose, so PHP sees the byte Windows is about to produce. Banning characters was never available. Remember infinity mapping to eight: 932 alone runs 9,486 records, and the only authority on what U+00AD becomes is the machine it becomes it on.

Dossche also rules out the fix PHP would ship four months later. Always checking for cgi || fastcgi “breaks real-world use-cases”, and the case named in the comment above is a Linux hashbang: #!/php-cgi -d option=value.

PHP 8.3.8, 8.2.20 and 8.1.29 shipped on 06 Jun 2024, each CGI changelog carrying the same line about GHSA-3qgc-jrrr-25jv. Then a second character turned up. HuanGMz, s0duku and m4yfly of the TianGong Team at Qi’anxin filed it as CVE-2024-8926.

In many code pages, Windows converts some wide characters to “, such as 0xa8 in 437 code page.

Windows strips the quotation marks around the parameter and -s survives. Their advisory is blunt about how reachable that is: the bypass wants “a non-standard configuration of Windows codepages, only obtainable through the registry by pointing the ACP codepage to an OEM codepage”, which it calls “unlikely to occur in a real environment”. I could not check the quote-stripping step the way I checked the soft hyphen, because bestfit437.txt returns a 404. It rests on their word.

Dossche’s answer, on 14 June 2024, was to delete his own month-old patch. Commit 4b9cd27f is +8 −18, and its message is the most useful thing written about this CVE: “It’s no use trying to work around whatever the operating system and Apache do because we’ll be fighting that until eternity.” It ends hedged, and the hedge is worth keeping: “This is a BC break, but this seems to be the only way to get rid of this class of issues.” The emulation went and skip_getopt = cgi || fastcgi; replaced it under an #ifdef PHP_WIN32. The 14 June is an author date; GitHub shows the commit landing on 23 September, because security fixes get merged at release.

So the versions in every write-up of CVE-2024-4577 are those of a patch its own author deleted.

The replacement reached users in 8.3.12, 8.2.24 and 8.1.30 on 26 Sep 2024. Count the gap from the printed dates: 24 days left in June, 31 in July, 31 in August, 26 in September, which is 112. The release train gives it again on the same page: 8.3.9 on 04 Jul, 8.3.10 on 01 Aug, 8.3.11 on 29 Aug, 8.3.12 on 26 Sep, each 28 days after the last. 4 × 28 = 112.

Check the ANSI code page, not the console code page

chcp reports the console OEM code page, a different setting from the ANSI code page this bug runs on. Microsoft’s chcp documentation says so in one line: “chcp displays the number of the active console code page”. Its example prints Active code page: 437, which tells you nothing about your exposure. The Qi’anxin advisory draws the same split: “Windows code pages are divided into ANSI and OEM code pages, where ANSI is used for GUI programs and OEM for console programs.”

php-src’s regression test for this CVE doesn’t go near chcp. The SKIPIF block of sapi/cgi/tests/ghsa-3qgc-jrrr-25jv.phpt reads the registry, and that is the authoritative answer.

powershell

# the setting that decides condition two; what php-src's own .phpt test reads
Get-ItemPropertyValue HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage ACP

# second quirk: this prints cli no matter how your web server runs PHP
php -r "echo PHP_SAPI;"

One registry read resolves both. An ACP of 932, 936 or 950 puts you in CVE-2024-4577 below 8.3.8; an ACP aimed at an OEM page such as 437 puts you in CVE-2024-8926 even on 8.3.8, the configuration its finders call unlikely. Read the SAPI through a request, never a command line. And I have no Windows host with a Japanese ANSI code page to hand, so all of this comes from tables and commits and one .phpt file, not a machine I ran it on.

A PHP version number is not a vulnerability status

A PHP version number answers a question no attacker asks. Debian’s security tracker marks php8.2, php7.4 and php7.3 <not-affected> with the note (Windows-specific), so a scanner flagging a Debian host on PHP 8.2.7 is wrong about the package and wrong about the platform. The error runs the other way wherever a distribution backports, and Red Hat’s page on backporting says “just looking at the version number of a package will not tell them if they are vulnerable or not”, and scanners judging on version alone throw false positives for that reason.

But the version string is not the weakest instrument here. CVE-2024-4577 carries a CVSS v3.1 base score of 9.8 with the vector AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H, assigned identically by the NVD and by the PHP Group as CNA, and that vector encodes none of the three preconditions: Windows, the CGI SAPI under Apache, and an ANSI code page of 932, 936 or 950. FastCGI belongs to the fix, not the bug: it appears in skip_getopt = cgi || fastcgi and in neither description. On CVE-2024-8926 the two disagreed outright: PHP scored it 8.1 with AC:H, the NVD 8.8 with AC:L/PR:L, from one set of facts.

Turning off expose_php isn’t a mitigation, and I’d push back on anyone who logged it as one. Its default is 1, and it’s php.ini-only. An unconfigured PHP does advertise its version in a response header. Removing that header hides it from your own inventory and from nobody who matters, because this exploit never reads it. Derate the finding either way, at one code page in fifteen or at all fifteen. The recommendation holds, because exposure turns on whether php-cgi.exe is reachable and the locale only decides whether today’s character works.

Stop shipping php-cgi.exe before you argue about the version

Stop shipping php-cgi.exe, because since 8.1.30, 8.2.24 and 8.3.12 the Windows build will not read its own options on the path that matters. The change is narrower than “arguments are ignored”, and 4b9cd27f shows where it sits.

c

 	if((query_string = getenv("QUERY_STRING")) != NULL && strchr(query_string, '=') == NULL) {
+#ifdef PHP_WIN32
+		skip_getopt = cgi || fastcgi;
+#else
 		unsigned char *p;

The skip fires only when QUERY_STRING is set and carries no =, and only on Windows. Run php-cgi.exe -d foo=bar script.php from a command prompt with nothing in QUERY_STRING and the if is never entered, so -d is parsed as it always was. The break costs you the web-server path, the one you cared about. What CGI still offers over PHP-FPM is a binary in the document root.

Our threshold has no version number in it. If php-cgi.exe is reachable by URL path, the version stops being a control and the SAPI has to change; on a Linux PHP-FPM host this CVE is not on the list and we wouldn’t spend an hour on it. DEVCORE’s own mitigation, three Apache lines that reject a query string starting %ad, is the thing I would not rely on, and DEVCORE offers it only to people who can’t upgrade. It blocks one character out of a set this article has just enumerated.

None of this is theoretical. The bodies that said so deserve naming. CISA added CVE-2024-4577 to its Known Exploited Vulnerabilities Catalog on 12 June 2024, with a remediation date of 3 July 2024 — 21 days — and it records "knownRansomwareCampaignUse": "Known". Cisco Talos, in a post by Chetan Raghuprasad on 6 March 2025, says it found an attacker using it for initial access against organisations in Japan from January 2025. Neither publishes a victim count, so neither could I.

Weighing a SAPI migration against another quarter of scanner tickets is where our software development work starts. When a 9.8 is gated by three preconditions, the order to work a backlog in is EPSS over CVSS when the backlog is four hundred, and what happens once nobody patches a branch at all is the end-of-life cliff.

Tell us what’s broken.

Twenty minutes, no deck. We’ll tell you straight whether this is work we should be doing, and if it isn’t, we usually know who should.

We come back within one business day.