Skip to content

Delayed fetch miscompile #23979

Description

@rlaaudrb1104

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.

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