The nuclei template for CVE-2012-1823 still gets hits in 2026. PHP shipped one patch for it on May 3, 2012, and a second patch for the same primitive on June 6, 2024, after Orange Tsai of DEVCORE found a Windows-specific bypass. The two patches sit in the same function in the same file, twelve years apart, and they take opposite shapes: the 2012 patch added a filter to argv parsing, and the 2024 patch removed argv parsing entirely on Windows. PHP-CGI's interpretation of the CGI specification was that when the query string contains no =, its bytes become argv; after twelve years of trying to make that interpretation safe, the project gave up on Windows and stopped implementing it.
CVE-2012-1823: The 2024 Patch Removed The Feature The 2012 Patch Filtered
patterns
cve
proof of concept
PHP-CGI's argv comes from the URL
sapi/cgi/cgi_main.c is the entry point for php-cgi.exe, the FastCGI binary, and the SAPI Apache uses when configured Action application/x-httpd-php /cgi-bin/php-cgi. Like every PHP entry point, it accepts options through php_getopt. The flag set is what you would expect from a command-line tool: -c <ini path>, -n to ignore the system ini, -d <directive=value> to define an ini directive on the fly. None of those flags are meaningful for a network-facing handler. The CGI binary accepts them anyway because the same source file produces the standalone interactive PHP-CGI executable.
The trouble is what happens when the CGI binary is invoked by a web server. Apache's CGI module, IIS's FastCGI module, and every other CGI host pass the request's query string as argv to the script under one specific condition: when the query string contains no = sign. This is RFC 3875 §4.4, the "indexed query" convention from the era of <ISINDEX> HTML tags, where a server-side script could be invoked with a URL like /script?one+two+three and would receive ["script", "one", "two", "three"] in argv. PHP-CGI took the convention literally: argv comes from the URL.
Pre-2012 cgi_main.c trusted argv. The query string -d+allow_url_include%3Don+-d+auto_prepend_file%3Dphp%3A//input decodes to -d allow_url_include=on -d auto_prepend_file=php://input, with no top-level =. The CGI host sees no =, splits on +, passes the resulting array as argv. PHP-CGI's php_getopt reads -d flags out of argv and applies them to the runtime configuration. auto_prepend_file=php://input tells the runtime to read the request body as PHP source and prepend it to whatever script the CGI invocation was supposed to run. The request body, in the disclosed exploit, is <?php phpinfo();?>. The runtime executes it.
The full exploit is one HTTP request:
POST /index.php?-d+allow_url_include%3Don+-d+auto_prepend_file%3Dphp%3A//input HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
Content-Length: 28
<?php system($_GET["c"]);?>No authentication. No prerequisite. No interaction with index.php itself; the file does not have to exist. The CGI handler is the entire attack surface.
The 2012 patch was a filter
De Eindbazen, a Dutch CTF team, found this bug on January 13, 2012 while compromising the scoreboard at Nullcon HackIM. They reported it to [email protected] on January 17 with a patch attached. PHP's internal bug tracker (#61910) was accidentally flipped to public on May 3, 2012; someone mirrored it on Reddit; the project shipped PHP 5.3.12 and 5.4.2 the same day.
The 5.3.12 fix added a check in cgi_main.c. The dispatch into php_getopt got guarded:
if ((query_string = getenv("QUERY_STRING")) != NULL && strchr(query_string, '=') == NULL) {
char *decoded_query_string = strdup(query_string);
php_url_decode(decoded_query_string, strlen(decoded_query_string));
if (*decoded_query_string == '-') {
skip_getopt = 1;
}
free(decoded_query_string);
}The reading: if the query string has no = (the only case where it ever reaches argv), and it begins with - after URL decoding (the only character php_getopt recognizes as the start of a flag), do not invoke php_getopt at all. The argv injection still happened at the web-server level. The PHP runtime stopped reading argv as flags.
This was a filter, not a redesign. The CGI convention was preserved: query strings without = still became argv. PHP just stopped honoring --prefixed argv. The fix was small enough to backport and effective enough that PHP shipped variants of it across the 5.3.13 / 5.4.3 cycle to close CVE-2012-2311 (an incomplete fix that left the bug reachable when php-cgi was invoked through a wrapper shell script) and CVE-2012-2335 / CVE-2012-2336 (additional argument-handling edges). For Linux deployments, the filter held. CVE-2012-1823 went onto CISA's Known Exploited Vulnerabilities catalog in March 2022 with a backfill date and has remained there. The exploit became one of the most cited unauthenticated PHP RCE primitives in modern security history.
The Windows code path executed the same filter. For twelve years, that was assumed to be enough.
The 2024 bypass was character encoding
On May 7, 2024, Orange Tsai of DEVCORE reported a bypass of the 2012 patch to [email protected]. The mechanism is not a flaw in PHP's filter. It is a property of how Windows constructs argv for processes spawned via CreateProcessA.
When a Windows web server (typically IIS via the FastCGI module, or XAMPP-on-Windows shipping Apache with mod_cgi) invokes php-cgi.exe, Windows constructs the child process's argv by passing the command line through WideCharToMultiByte against the active ANSI code page. In Simplified Chinese, Japanese, and Traditional Chinese locales (code pages 936, 932, and 950), Windows applies "Best-Fit" mapping: a per-codepage table that converts Unicode characters with no exact ANSI representation to a "best guess" ASCII equivalent. The soft hyphen U+00AD has no representation in cp936 or cp932 or cp950. Windows' best guess is -, ASCII 0x2D.
The bypass:
POST /index.php?%ADd+allow_url_include%3Don+%ADd+auto_prepend_file%3Dphp%3A//input HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
Content-Length: 28
<?php system($_GET["c"]);?>PHP's 2012 filter URL-decodes the query string. After decode, the leading byte is 0xAD, not - (0x2D). The filter does not match. skip_getopt stays at 0. PHP's cgi_main.c proceeds to call php_getopt(argc, argv, ...). The argv it receives, however, is the argv Windows constructed: Best-Fit has already turned 0xAD into -. php_getopt reads -d allow_url_include=on -d auto_prepend_file=php://input. The runtime executes the POST body.
PHP's filter and Windows' character-encoding shim run in different processes. The filter inspects the query string PHP sees. The argv php_getopt reads is the argv Windows produced. They are not the same bytes.
DEVCORE's advisory was published June 6, 2024. PHP shipped fixes the same day in 8.1.29, 8.2.20, and 8.3.8. CVE-2024-4577 was assigned. Within twelve hours, watchTowr Labs published a working PoC. Within a week, mass exploitation was visible across Windows + IIS deployments in CJK locales, particularly in Taiwan, where cp950 is the default. CVE-2024-4577 was added to KEV.
The 2024 fix removed the feature on Windows
The patch is in the same cgi_main.c block that holds the 2012 filter. Both fixes are visible in the current source tree:
if ((query_string = getenv("QUERY_STRING")) != NULL && strchr(query_string, '=') == NULL) {
#ifdef PHP_WIN32
skip_getopt = cgi || fastcgi;
#else
unsigned char *p;
char *decoded_query_string = strdup(query_string);
php_url_decode(decoded_query_string, strlen(decoded_query_string));
for (p = (unsigned char *)decoded_query_string; *p && *p <= ' '; p++) {
/* skip all leading spaces */
}
if (*p == '-') {
skip_getopt = 1;
}
free(decoded_query_string);
#endif
}The #else branch is the 2012 fix, refactored once to skip leading whitespace. The #ifdef PHP_WIN32 branch is the 2024 fix. It is shorter than the 2012 fix because it does not have to inspect the query string at all. On Windows in CGI or FastCGI mode, when the query string lacks an =, php_getopt is unconditionally skipped. The decoded byte does not matter. The encoding does not matter. The locale does not matter. PHP-CGI on Windows no longer reads argv from the URL.
The 2012 fix preserved the feature and added a check. The 2024 fix removed the feature. PHP did not write a smarter check because the 2024 bypass proved that no check inside the PHP process is reliable: Windows can rewrite argv between the check and the read. The only path that survives is the path that does not check.
Twelve years. Two CVEs. Both KEV.
PHP-CGI is a design-debt-driver in the precise sense the pattern names: the same component produced the same bug class fourteen years apart, with two different reachability mechanisms (a missing filter, then a filter running in the wrong process), and the patch cadence has not converged. Six CVEs in the family across the 5.3.12 / 5.4.2 / 5.3.13 / 5.4.3 cycle in 2012, then a single bypass of the consolidated fix that lay dormant in the Windows code path for twelve years before Orange Tsai surfaced it.
It is also an unpatchable primitive in the sense the pattern's hook describes: the bug class is the design's purpose. PHP-CGI's job is to invoke the PHP runtime with CGI's argv conventions. The convention RFC 3875 §4.4 codified is that the query string can become argv. PHP took the convention literally. Twelve years of patches around the convention closed instances. The 2024 fix did not close another instance; it removed PHP's participation in the convention on the platform where the convention could not be implemented safely. The Linux path still preserves the feature. The Windows path no longer does. This is the same shape as CLFS in the Windows kernel, at the user-mode HTTP layer instead of the kernel-mode driver layer: a component whose declared job is the same shape as the bug class the patches keep closing.
The advisory text obscures the move. PHP's GHSA-3qgc-jrrr-25jv reads "PHP CGI Argument Injection Vulnerability" and frames the fix as a tightening of the leading-character check. Read the patch and the description differs from the code. The Windows branch is not a tighter check. There is no check on the Windows branch at all. The administrator following PHP's advisory who reads "fix" reads "the check now catches Best-Fit encoded inputs." The code says "the check is gone."
The 2012 patch documented an assumption that the query string PHP read and the argv PHP received were the same bytes. They were on Linux. They were not on Windows. The 2024 patch is the project conceding the assumption was always wrong on Windows, and that no filter inside the PHP process can compensate for what kernel32!CreateProcessA does to argv before PHP runs. Twelve years between the two admissions. The KEV catalog now lists both CVEs. The 2012 entry was added in March 2022, ten years after disclosure, when CISA started backfilling. The 2024 entry was added within weeks. Both are still being actively scanned. The nuclei template that triggered this analysis is one of dozens that fire daily against public IPv4. PHP-CGI on Windows in a CJK locale, unpatched since June 2024, remains as exploitable as PHP-CGI on Linux was in May 2012. Same primitive. Same exploit shape. Different reachability bug.
The 2012 fix added a filter. The 2024 fix removed the feature it was filtering.