Overview
| Field |
Value |
| Target |
php @ 38956a3 |
| Defect type |
ZEND_UNREACHABLE (=assert(0)) violation → SIGABRT (debug build only) |
| Crash site |
zend_get_type_by_const@Zend/zend_API.c:126 — default: ZEND_UNREACHABLE(); (← zend_zval_value_name ← zend_fetch_dimension_address_read@zend_execute.c:3206) |
| Reproduction |
reproduced (php --enable-debug + ASan, same commit) |
| Latest release |
compiled out in release: NDEBUG turns UNREACHABLE into a no-op (see Impact) |
| PoC |
04_3e4b59e314ffb841.php (256 bytes) |
Full Output (debug-build reproduction)
Running the PoC on a --enable-debug + ASan php (CLI) terminates as follows.
Deprecated: Non-canonical cast (integer) is deprecated ...
Warning: Octal escape sequence overflow \400 is greater than \377 ...
Deprecated: The backtick (`) operator is deprecated ...
Warning: String offset cast occurred ...
php: Zend/zend_API.c:126: const char *zend_get_type_by_const(int): Assertion `0' failed.
==ABORTING (SIGABRT)
Root Cause
In an array-offset read of the form $expr[$offset], when the container is not an array/string/object the read path enters the error-reporting branch at zend_execute.c:3206, which calls zend_zval_value_name(container). That function in turn calls zend_get_type_by_const(Z_TYPE_P(container)), and because the container the PoC builds carries a type tag that the switch does not enumerate, control reaches default: ZEND_UNREACHABLE() at zend_API.c:126.
Call site (Zend/zend_execute.c:3206) — the warning path when the container is not an array:
3202 zend_error(E_WARNING, "Cannot use %s as array", zend_zval_type_name(container));
3203 }
3204 if (!is_list && type != BP_VAR_IS) {
3205 zend_error(E_WARNING, "Trying to access array offset on %s",
>> 3206 zend_zval_value_name(container));
3207 }
3208 ZVAL_NULL(result);
3209 }
3210 }
zend_zval_value_name (Zend/zend_API.c:147) — forwards to zend_get_type_by_const for the type name:
143 } else if (Z_TYPE_P(arg) == IS_TRUE) {
144 return "true";
145 }
146
>> 147 return zend_get_type_by_const(Z_TYPE_P(arg));
148 }
149
150 ZEND_API const char *zend_zval_type_name(const zval *arg)
151 {
zend_get_type_by_const (Zend/zend_API.c:126) — any type not in the switch falls to default: ZEND_UNREACHABLE():
116 case IS_ITERABLE:
117 return "iterable";
118 case IS_ARRAY:
119 return "array";
120 case IS_VOID:
121 return "void";
122 case IS_MIXED:
123 return "mixed";
124 case _IS_NUMBER:
125 return "int|float";
>> 126 default: ZEND_UNREACHABLE();
127 }
128 }
129 /* }}} */
130
131 ZEND_API const char *zend_zval_value_name(const zval *arg)
132 {
133 ZVAL_DEREF(arg);
134
135 if (Z_ISUNDEF_P(arg)) {
136 return "null";
- Broken invariant:
zend_get_type_by_const/zend_zval_value_name assume the input zval's type tag is one of the values the switch enumerates. That assumption relies on the upstream contract that the caller (zend_fetch_dimension_address_read) only ever passes containers of valid type.
- The PoC breaks that contract by letting a container with no valid type tag — a temporary zval — flow into the 3206 error path. So the defect is not the name-lookup functions but the upstream code (operand creation / temporary-stack management) that let an abnormally-typed container reach this path. The crash site is the symptom; the defect is upstream.
Trigger
PoC (04_3e4b59e314ffb841.php, 256 bytes), verbatim:
<?php switch ( sprintf ( true , 'b' ) [ 2.2 ] = "\143" ) { } assert ( $ { function ( ) { } } [ function ( ) : void { try { $ { '1!' } [ ( integer ) fn & ( ) =>ire ( ( object ) fn & ( ) => re + "ss\n" ) ] ; } finally { r ?-> db ; } } ] ) ; `\400\40\ \u` ;
- The input is a collection of syntactically accepted but semantically extreme nested expressions. During execution these warnings print first: non-canonical cast, octal overflow
\400 > \377, deprecated backtick, and String offset cast occurred.
- The crash-driving construct is an array-offset read of the form
<temporary-expr>[<offset>]. Because the VM handler is ZEND_FETCH_DIM_R_SPEC_TMPVAR_TMP_HANDLER, the container reaching the read is a temporary computed result (TMPVAR), not a variable. Candidates in the PoC:
sprintf(true,'b')[2.2] — a float offset read on a function return value (a temporary string) → matches the String offset cast occurred warning.
${ function(){} }[ ... ] / ${'1!'}[ ... ] — offset access on a container built via variable-variable (${expr}), where the expression does not reduce to a normal variable name.
- The offset expression itself mixes casts, arrow functions, references (
&), and undefined symbols: ( integer ) fn & ( ) => ..., ( object ) fn & ( ) => re + "ss\n".
- The trigger is the evaluation of these nested expressions producing a temporary zval with no valid type tag as the container, which then flows into
zend_fetch_dimension_address_read's error path (3206). Which subexpression produces the final tainted container can be pinned down in gdb by breaking on container at 3206 and inspecting Z_TYPE_P.
Impact
- Debug build (
--enable-debug): ZEND_UNREACHABLE is abort(), so this is a DoS (crash).
- Release build (NDEBUG):
ZEND_UNREACHABLE becomes __builtin_unreachable(). Because the optimizer assumes the branch is unreachable, actually reaching it is undefined behavior (a mis-taken branch or a bad memory access). The real release impact would need a separate release ASan build to assess.
- In short, what is confirmed is a debug-only unreachable violation (a missing type mapping, or an unnormalized type flowing in). A release memory vulnerability is undetermined, but the fact that a
ZEND_UNREACHABLE is actually reachable is itself an engine-invariant bug worth reporting upstream.
Reproduction
git clone https://github.com/php/php-src.git php-src && cd php-src
git checkout 38956a3 && ./buildconf --force
CC=clang CFLAGS="-g -O1 -fsanitize=address -fno-omit-frame-pointer" LDFLAGS="-fsanitize=address" \
./configure --disable-all --enable-cli --enable-debug --enable-tokenizer --enable-mbstring
make -j$(nproc)
USE_ZEND_ALLOC=0 ASAN_OPTIONS='symbolize=1:handle_segv=1' sapi/cli/php pocs/04_3e4b59e314ffb841.php
--enable-debug is required. Without it the UNREACHABLE is compiled out and the issue does not reproduce.
Overview
php@38956a3ZEND_UNREACHABLE(=assert(0)) violation → SIGABRT (debug build only)zend_get_type_by_const@Zend/zend_API.c:126—default: ZEND_UNREACHABLE();(←zend_zval_value_name←zend_fetch_dimension_address_read@zend_execute.c:3206)--enable-debug+ ASan, same commit)04_3e4b59e314ffb841.php(256 bytes)Full Output (debug-build reproduction)
Running the PoC on a
--enable-debug+ ASan php (CLI) terminates as follows.Root Cause
In an array-offset read of the form
$expr[$offset], when the container is not an array/string/object the read path enters the error-reporting branch atzend_execute.c:3206, which callszend_zval_value_name(container). That function in turn callszend_get_type_by_const(Z_TYPE_P(container)), and because the container the PoC builds carries a type tag that the switch does not enumerate, control reachesdefault: ZEND_UNREACHABLE()atzend_API.c:126.Call site (
Zend/zend_execute.c:3206) — the warning path when the container is not an array:zend_zval_value_name(Zend/zend_API.c:147) — forwards tozend_get_type_by_constfor the type name:zend_get_type_by_const(Zend/zend_API.c:126) — any type not in the switch falls todefault: ZEND_UNREACHABLE():zend_get_type_by_const/zend_zval_value_nameassume the input zval's type tag is one of the values the switch enumerates. That assumption relies on the upstream contract that the caller (zend_fetch_dimension_address_read) only ever passes containers of valid type.Trigger
PoC (
04_3e4b59e314ffb841.php, 256 bytes), verbatim:\400 > \377, deprecated backtick, andString offset cast occurred.<temporary-expr>[<offset>]. Because the VM handler isZEND_FETCH_DIM_R_SPEC_TMPVAR_TMP_HANDLER, the container reaching the read is a temporary computed result (TMPVAR), not a variable. Candidates in the PoC:sprintf(true,'b')[2.2]— a float offset read on a function return value (a temporary string) → matches theString offset cast occurredwarning.${ function(){} }[ ... ]/${'1!'}[ ... ]— offset access on a container built via variable-variable (${expr}), where the expression does not reduce to a normal variable name.&), and undefined symbols:( integer ) fn & ( ) => ...,( object ) fn & ( ) => re + "ss\n".zend_fetch_dimension_address_read's error path (3206). Which subexpression produces the final tainted container can be pinned down in gdb by breaking oncontainerat 3206 and inspectingZ_TYPE_P.Impact
--enable-debug):ZEND_UNREACHABLEisabort(), so this is a DoS (crash).ZEND_UNREACHABLEbecomes__builtin_unreachable(). Because the optimizer assumes the branch is unreachable, actually reaching it is undefined behavior (a mis-taken branch or a bad memory access). The real release impact would need a separate release ASan build to assess.ZEND_UNREACHABLEis actually reachable is itself an engine-invariant bug worth reporting upstream.Reproduction
--enable-debugis required. Without it the UNREACHABLE is compiled out and the issue does not reproduce.