Description
Initially reported privately, opened as issue per request of team as this is out of scope as a security issue:
Hi. According to our documentation:
https://www.php.net/manual/en/features.commandline.webserver.php
Warning This web server is designed to aid application development. It may also be useful for testing purposes or for application demonstrations that are run in controlled environments. It is not intended to be a full-featured web server. It should not be used on a public network.
Hence, this doesn't qualify as a security issue. Please create a regular issue for this.
Summary
A use-after-free in the built-in web server lets a single HTTP request corrupt heap memory. Sending the same header twice with different letter case frees a value that a second hash table still references, so apache_request_headers() returns freed memory and request shutdown frees it again. Confirmed on master with valgrind invalid read and write, and two observed server crashes.
Details
php_cli_server_client_save_header() in sapi/cli/php_cli_server.c:1694-1695 stores one value zval in two tables without a second reference:
zend_hash_update(&client->request.headers, lc_header_name, &tmp);
zend_hash_update(&client->request.headers_original_case, client->current_header_name, &tmp);
headers is created with the cli_header_value_dtor destructor, headers_original_case is created with no destructor, sapi/cli/php_cli_server.c:1391-1395, the comment there says the aliasing is safe because both tables die together.
That breaks when the same header arrives twice with different case. zend_hash_update() moves the value in without addref, Zend/zend_hash.c:871, and destroys the old value in place:
X-L: AAAA stores v1 under headers["x-l"] and headers_original_case["X-L"]
x-l: BBBB makes the lower case key collide, so zend_hash_update(headers, "x-l", ...) frees v1, while headers_original_case inserts under the new key "x-l" and leaves headers_original_case["X-L"] dangling
Sink one is PHP_FUNCTION(apache_request_headers) at sapi/cli/php_cli_server.c:404, which calls zend_array_dup() on headers_original_case, addrefs the freed string and returns it to userland. Sink two is request shutdown, which frees the dangling entry again.
PoC
Step 1, save as hdr.php in a web root, for example /tmp/poc:
<?php
$h = apache_request_headers();
echo bin2hex($h['X-L'] ?? 'none'), "\n";
Step 2, start the server:
php -S 127.0.0.1:8177 -t /tmp/poc
Step 3, send one mixed case duplicate:
import socket
s = socket.create_connection(("127.0.0.1", 8177))
s.sendall(b"GET /hdr.php HTTP/1.1\r\nHost: x\r\nConnection: close\r\n"
b"X-L: AAAAAAAA\r\nx-l: BBBBBBBB\r\n\r\n")
print(s.recv(4096))
Result: the body prints an empty value, the freed string's length has been clobbered. The control request with X-L twice in the same case correctly returns the comma joined AAAAAAAA, BBBBBBBB.
Under valgrind, USE_ZEND_ALLOC=0:
Invalid write of size 4
at zend_gc_addref
by zend_array_dup_value <- zend_array_dup <- zif_apache_request_headers (php_cli_server.c:404)
... block free'd at _zend_hash_add_or_update_i <- zend_hash_update
<- php_cli_server_client_save_header (php_cli_server.c:1694)
Repeated mixed case sequences crashed the server twice during testing, the double free at shutdown.
Impact
Use-after-free, CWE-416. The freed block is a malloc allocation whose size and content the attacker controls through the header value bytes. Demonstrated: stale read surfaced to userland, refcount write on freed memory, and process crash. The write primitive and double free make further exploitation plausible, that part is not demonstrated. The trigger request itself needs no script cooperation, only the observable output needs apache_request_headers() or getallheaders() in the page.
The built-in server is documented as development only, but it ships in the standard binary and gets exposed regularly.
CWE IDs: CWE-416
Solution
Do not share one reference between two tables. Either insert into headers_original_case with a real copy, ZVAL_COPY before the first update, or reorder so the aliasing table is updated before the table whose update can destroy the value:
zend_hash_update(&client->request.headers_original_case, client->current_header_name, &tmp);
zend_hash_update(&client->request.headers, lc_header_name, &tmp);
Reordering alone still leaves the case where both keys collide, for example X-L sent three times in mixed case, where the original case table would destroy a value the other table still names. The safe fix is the explicit second reference, or an update helper that keeps both tables keyed consistently. A NULL destructor on the second table is what created the trap, do not keep relying on it.
Internal Tracking ID: KF-202692827
PHP Version
PHP 8.7.0-dev (cli) (built: Sep 26 2026 REDACTED) (NTS)
Copyright © The PHP Group and Contributors
Zend Engine v4.7.0-dev, Copyright © Zend by Perforce
with Zend OPcache v8.7.0-dev, Copyright ©, by Zend by Perforce
Operating System
Fedora
Description
Initially reported privately, opened as issue per request of team as this is out of scope as a security issue:
Summary
A use-after-free in the built-in web server lets a single HTTP request corrupt heap memory. Sending the same header twice with different letter case frees a value that a second hash table still references, so
apache_request_headers()returns freed memory and request shutdown frees it again. Confirmed on master with valgrind invalid read and write, and two observed server crashes.Details
php_cli_server_client_save_header()insapi/cli/php_cli_server.c:1694-1695stores one value zval in two tables without a second reference:headersis created with thecli_header_value_dtordestructor,headers_original_caseis created with no destructor,sapi/cli/php_cli_server.c:1391-1395, the comment there says the aliasing is safe because both tables die together.That breaks when the same header arrives twice with different case.
zend_hash_update()moves the value in without addref,Zend/zend_hash.c:871, and destroys the old value in place:X-L: AAAAstoresv1underheaders["x-l"]andheaders_original_case["X-L"]x-l: BBBBmakes the lower case key collide, sozend_hash_update(headers, "x-l", ...)freesv1, whileheaders_original_caseinserts under the new key"x-l"and leavesheaders_original_case["X-L"]danglingSink one is
PHP_FUNCTION(apache_request_headers)atsapi/cli/php_cli_server.c:404, which callszend_array_dup()onheaders_original_case, addrefs the freed string and returns it to userland. Sink two is request shutdown, which frees the dangling entry again.PoC
Step 1, save as
hdr.phpin a web root, for example/tmp/poc:Step 2, start the server:
Step 3, send one mixed case duplicate:
Result: the body prints an empty value, the freed string's length has been clobbered. The control request with
X-Ltwice in the same case correctly returns the comma joinedAAAAAAAA, BBBBBBBB.Under valgrind,
USE_ZEND_ALLOC=0:Repeated mixed case sequences crashed the server twice during testing, the double free at shutdown.
Impact
Use-after-free, CWE-416. The freed block is a malloc allocation whose size and content the attacker controls through the header value bytes. Demonstrated: stale read surfaced to userland, refcount write on freed memory, and process crash. The write primitive and double free make further exploitation plausible, that part is not demonstrated. The trigger request itself needs no script cooperation, only the observable output needs
apache_request_headers()orgetallheaders()in the page.The built-in server is documented as development only, but it ships in the standard binary and gets exposed regularly.
CWE IDs: CWE-416
Solution
Do not share one reference between two tables. Either insert into
headers_original_casewith a real copy,ZVAL_COPYbefore the first update, or reorder so the aliasing table is updated before the table whose update can destroy the value:Reordering alone still leaves the case where both keys collide, for example
X-Lsent three times in mixed case, where the original case table would destroy a value the other table still names. The safe fix is the explicit second reference, or an update helper that keeps both tables keyed consistently. A NULL destructor on the second table is what created the trap, do not keep relying on it.Internal Tracking ID: KF-202692827
PHP Version
Operating System
Fedora