Skip to content

Fix GH-21368 crash: runtime orig_handler lookup in escape_if_undef#21710

Open
iliaal wants to merge 1 commit into
php:PHP-8.4from
iliaal:fix/gh21368-escape-if-undef-runtime-lookup
Open

Fix GH-21368 crash: runtime orig_handler lookup in escape_if_undef#21710
iliaal wants to merge 1 commit into
php:PHP-8.4from
iliaal:fix/gh21368-escape-if-undef-runtime-lookup

Conversation

@iliaal

@iliaal iliaal commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #21368.

@vibbow reported an access violation in zend_jit_escape_if_undef on
PHP 8.5.5 VS17 x64 NTS (Windows + IIS + FastCGI), crashing at the fix
I introduced in #21368:

FAILURE_BUCKET_ID: INVALID_POINTER_READ_c0000005_php8.dll!zend_jit_escape_if_undef
FAULTING_SOURCE_LINE_NUMBER: 7980

The crashing instruction is mov rcx, [rax+rcx*8+0xD0], reading 0xD0
from a heap address that isn't mapped. On 64-bit NTS that offset
corresponds to zend_op_array->reserved[zend_func_info_rid], which is
what ZEND_FUNC_INFO(op_array) expands to.

Root cause

#21368 dispatched to orig_handler via a compile-time load computed
from exit_info->op_array. That pointer is set at parent-trace compile
time from JIT_G(current_frame)->func, and can become stale by the time
a side trace compiles for that exit. Long-lived FastCGI workers with
many trace invalidation/recompile cycles match the observed failure
pattern.

Fix

Keep compile-time dispatch but resolve orig_handler against
jit->current_op_array instead of the parent's exit_info:

  • On the side-trace compile path (the crash path),
    zend_jit_trace_start already sets jit->current_op_array to
    trace_buffer->op_array, which is freshly captured for the current
    compilation and doesn't depend on the parent's stale pointer.
  • On the zend_jit_trace_exit_to_vm path, zend_jit_deoptimizer_start
    leaves current_op_array unset, so it is now set from
    exit_info->op_array there.

This drops the op_array parameter added to zend_jit_escape_if_undef
in #21368.

Verification

gh21267.phpt and gh21267_blacklist.phpt both pass. The original
infinite-loop fix is preserved.

Reproducer

I couldn't build a native Linux reproducer for the stale-pointer
scenario through synthetic stress. @vibbow can reproduce reliably on
IIS+FastCGI and will validate the fix once a Windows build is
available.

@dstogov

dstogov commented Apr 13, 2026

Copy link
Copy Markdown
Member

It would be great to understand where the failure comes from.
If we somehow have an inconsistent exit_info->op_array we may get troubles in other places.

As I see, JIT_G(current_frame) is always set during trace compilation (it can't be NULL) , so this part of deduction can not be the reason of the failure.

The fix generates more expensive JIT code that performs run-time resolution, I believe it should be possible to make this resolution at compile time.

@vibbow

vibbow commented Apr 16, 2026

Copy link
Copy Markdown

I can reproduce this bug consistently. If somone can help compile a Windows version, I can test this fix

@iliaal iliaal force-pushed the fix/gh21368-escape-if-undef-runtime-lookup branch from 4c71cf2 to 7c69d4b Compare April 17, 2026 11:19
@iliaal

iliaal commented Apr 17, 2026

Copy link
Copy Markdown
Contributor Author
  1. JIT_G(current_frame) is allocated at zend_jit_trace.c:1428 before any
    exit-point creation, so the NULL hypothesis in my PR description was
    wrong. Updated the description.

  2. The crash fits a stale exit_info->op_array, not a NULL one: rax+0xD0
    on an unmapped page, long-lived IIS+FastCGI worker. If this pointer
    can go stale, it's a broader invariant concern (zend_jit_dump_exit_info
    is the only other direct consumer today). I haven't yet pinned down
    the concrete path by which op_array is freed while traces referencing
    it survive.

  3. Reworked the fix to keep compile-time dispatch:

    • zend_jit_escape_if_undef now uses jit->current_op_array for the
      ZEND_FUNC_INFO lookup (back to your original approach).
    • On the side-trace compile path (the actual crash path),
      zend_jit_trace_start sets jit->current_op_array to
      trace_buffer->op_array, which is freshly captured for this
      compilation and doesn't rely on the parent's possibly stale
      exit_info.
    • On the zend_jit_trace_exit_to_vm path, zend_jit_deoptimizer_start
      leaves current_op_array unset, so I set it from exit_info->op_array
      before the deopt call.

PR updated.

/* We can't use trace_escape() because opcode handler may be overridden by JIT */
zend_jit_op_array_trace_extension *jit_extension =
(zend_jit_op_array_trace_extension*)ZEND_FUNC_INFO(op_array);
(zend_jit_op_array_trace_extension*)ZEND_FUNC_INFO(jit->current_op_array);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure jit->current_op_array is always initialized?
It seems, it may be NULL on the following patch - zend_jit_trace_exit() -> zend_jit_trace_hot_side() -> zend_jit_blacklist_trace_exit() -> zend_jit_trace_exit_to_vm() - > zend_jit_trace_deoptimization() -> zend_jit_escape_if_undef().

May be I'm wrong...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exit_info.op_array is written in one place, zend_jit_trace_get_exit_point (trace.c:197), and the surrounding code at trace.c:146-164 assigns op_array and stack_size together: JIT_G(current_frame) non-NULL gives a real op_array with some stack_size; the else branch sets both op_array = NULL and stack_size = 0 in the same step.

So on your path, ctx.current_op_array == NULL only when exit_info.stack_size == 0, which means zend_jit_trace_deoptimization loops over zero entries, check2 stays at -1, and zend_jit_escape_if_undef at trace.c:3606 isn't called. The NULL never reaches ZEND_FUNC_INFO(jit->current_op_array).

Can add ZEND_ASSERT(exit_info[exit_num].op_array || exit_info[exit_num].stack_size == 0) to make it explicit.

@iluuu1994

Copy link
Copy Markdown
Member

Should the original fix be reverted until we fully understand the issue? I don't have the time to look at the problem, and also don't have a Windows setup. Otherwise we'll miss the next release cycle (I don't feel comfortable merging this without an RC-phase).

@iliaal

iliaal commented Apr 24, 2026

Copy link
Copy Markdown
Contributor Author

Possibly, same as you I cannot reproduce the issue due to lack of windows env, fix is made based on code analysis. Ultimately be nice to have windows build the reporter could try, maybe revert from 8.5/8.4 keep in master? And then try fully patched version for 8.4/8.5 back port after current release is out?

@shivammathur

Copy link
Copy Markdown
Member

@iliaal

jit->current_op_array may not be stale on the side-trace compile path after this PR, but the remaining compile-time ZEND_OP_TRACE_INFO((opline - 1), offset)->orig_handler lookup still seems unsafe there. Would using zend_jit_orig_opline_handler(jit) after jit_LOAD_IP_ADDR(jit, opline - 1) be safer?

@iliaal

iliaal commented May 2, 2026

Copy link
Copy Markdown
Contributor Author

Yes, (opline - 1) has the same compile-time staleness exposure. Before another rework, the blocker is repro: none of us with patches in flight can hit this, only vibbow can, and there's no Windows build of any patch for them to test. Two rounds so far on code reasoning alone, and a third would be the same. @dstogov, given that: (a) switch to runtime resolution now and get a Windows build to vibbow for validation, (b) hold while we dig into why exit_info goes stale in the first place, or (c) revert GH-21368 from 8.4/8.5 per @iluuu1994's RC concern, keep the fix in master, and land a Windows-validated patch later. Compile-time vs runtime is downstream of which option you pick.

@shivammathur

Copy link
Copy Markdown
Member

@iliaal

I’ve built Windows binaries for this PR here:
https://github.com/php/php-windows-builder/actions/runs/25253712864
You can find them in artifacts.zip.

@iliaal

iliaal commented May 2, 2026

Copy link
Copy Markdown
Contributor Author

@iliaal

I’ve built Windows binaries for this PR here: https://github.com/php/php-windows-builder/actions/runs/25253712864 You can find them in artifacts.zip.

Oh, that's awesome thank you! I'll ask the original bug reporter to try, hopefully good news. Just to confirm these are based on latest state of the PR?

@shivammathur

Copy link
Copy Markdown
Member

@iliaal

Oh, that's awesome thank you! I'll ask the original bug reporter to try, hopefully good news. Just to confirm these are based on latest state of the PR?

Yes, it is based on the latest state of the PR.

@iliaal

iliaal commented May 2, 2026

Copy link
Copy Markdown
Contributor Author

I can reproduce this bug consistently. If somone can help compile a Windows version, I can test this fix

@vibbow Can you please try the builds shivammathur prepared (https://github.com/php/php-windows-builder/actions/runs/25253712864 ) if that fixes the issue. As you can tell from the convo, none of us are able to reproduce the issue.

@vibbow

vibbow commented May 2, 2026

Copy link
Copy Markdown

@iliaal @shivammathur The origional crash is happened on php 8.5.5

Today I tested with PHP 8.4.20 and PHP 8.4.21-dev (link provided), none of them crashed.

And PHP 8.5.5 is still crashed.

Can you made a php 8.5.6-dev build so I can test with it?

iliaal referenced this pull request May 12, 2026
…#21368)

When the JIT defers the IS_UNDEF check for FETCH_OBJ_R to the result
type guard, the deoptimization escape path dispatches to opline->handler
via the trace_escape stub. If opline->handler has been overwritten with
JIT code (e.g. a function entry trace), this creates an infinite loop.

Fix by dispatching to the original VM handler (orig_handler from the
trace extension) instead of going through the trace_escape stub. This
avoids the extra IS_UNDEF guard on every property read while correctly
handling the rare IS_UNDEF case during deoptimization.

Also set current_op_array in zend_jit_trace_exit_to_vm so that the
blacklisted exit deoptimizer can resolve orig_handler, covering the
case where side trace compilation is exhausted.

Closes GH-21368.
@iliaal iliaal force-pushed the fix/gh21368-escape-if-undef-runtime-lookup branch from 7c69d4b to 0777c51 Compare May 12, 2026 13:06
zend_jit_escape_if_undef received an op_array pointer captured at
parent-trace compile time. That pointer can go stale by the time a
side trace compiles for the exit. Drop the parameter and read
jit->current_op_array instead; zend_jit_trace_start sets it for
trace compilation, and zend_jit_trace_exit_to_vm now seeds it from
exit_info->op_array so the deoptimizer path has it too.

Closes phpGH-21710
@iliaal iliaal force-pushed the fix/gh21368-escape-if-undef-runtime-lookup branch from 0777c51 to 3079a72 Compare May 13, 2026 16:37
@vibbow

vibbow commented Jun 16, 2026

Copy link
Copy Markdown

Today I upgrade to php 8.5.7, and still get this issue.

And this time I created a full process crash dump. Hope this can help with solve this issue:
http://static.vsean.net/crashdumps/php-cgi.exe.4368.zip

Following is the crash dump analysis:

0:000> !analyze -v
................................................................
.....................
*******************************************************************************
*                                                                             *
*                        Exception Analysis                                   *
*                                                                             *
*******************************************************************************


KEY_VALUES_STRING: 1

    Key  : AV.Type
    Value: Read

    Key  : Analysis.CPU.mSec
    Value: 406

    Key  : Analysis.Elapsed.mSec
    Value: 1384

    Key  : Analysis.IO.Other.Mb
    Value: 0

    Key  : Analysis.IO.Read.Mb
    Value: 1

    Key  : Analysis.IO.Write.Mb
    Value: 0

    Key  : Analysis.Init.CPU.mSec
    Value: 265

    Key  : Analysis.Init.Elapsed.mSec
    Value: 3388

    Key  : Analysis.Memory.CommitPeak.Mb
    Value: 143

    Key  : Analysis.Version.DbgEng
    Value: 10.0.29547.1002

    Key  : Analysis.Version.Description
    Value: 10.2602.27.2 amd64fre

    Key  : Analysis.Version.Ext
    Value: 1.2602.27.2

    Key  : Failure.Bucket
    Value: INVALID_POINTER_READ_c0000005_php8.dll!zend_jit_escape_if_undef

    Key  : Failure.Exception.Code
    Value: 0xc0000005

    Key  : Failure.Exception.IP.Address
    Value: 0x7ff8f3272ac3

    Key  : Failure.Exception.IP.Module
    Value: php8

    Key  : Failure.Exception.IP.Offset
    Value: 0x382ac3

    Key  : Failure.Hash
    Value: {975c7f30-0c3e-ea22-ef42-5cbfda68e2ac}

    Key  : Failure.ProblemClass.Primary
    Value: INVALID_POINTER_READ

    Key  : Faulting.IP.Type
    Value: Paged

    Key  : Timeline.OS.Boot.DeltaSec
    Value: 572529

    Key  : Timeline.Process.Start.DeltaSec
    Value: 6

    Key  : WER.OS.Branch
    Value: ge_release

    Key  : WER.OS.Version
    Value: 10.0.26100.1

    Key  : WER.Process.Version
    Value: 8.5.7.0


FILE_IN_CAB:  php-cgi.exe.4024.dmp

NTGLOBALFLAG:  0

APPLICATION_VERIFIER_FLAGS:  0

CONTEXT:  (.ecxr)
rax=000002158ebe6498 rbx=0000100002407a98 rcx=0000000000000000
rdx=000000000000000f rsi=0000001df3ff06c0 rdi=0000000080000800
rip=00007ff8f3272ac3 rsp=0000001df3ff03e0 rbp=0000000000000006
 r8=0000000000000000  r9=000002158c179240 r10=0000000000000000
r11=0000001df3ff02f0 r12=0000100002c16338 r13=0000100002407a98
r14=0000001df3ff06c0 r15=0000000080000800
iopl=0         nv up ei pl zr na pe nc
cs=0033  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010246
php8!jit_CONST_FUNC+0x8 [inlined in php8!zend_jit_escape_if_undef+0x103]:
00007ff8`f3272ac3 488b8cc8d0000000 mov     rcx,qword ptr [rax+rcx*8+0D0h] ds:00000215`8ebe6568=????????????????
Resetting default scope

EXCEPTION_RECORD:  (.exr -1)
ExceptionAddress: 00007ff8f3272ac3 (php8!jit_CONST_FUNC+0x0000000000000008)
   ExceptionCode: c0000005 (Access violation)
  ExceptionFlags: 00000000
NumberParameters: 2
   Parameter[0]: 0000000000000000
   Parameter[1]: 000002158ebe6568
Attempt to read from address 000002158ebe6568

PROCESS_NAME:  php-cgi.exe

READ_ADDRESS:  000002158ebe6568 

ERROR_CODE: (NTSTATUS) 0xc0000005 - 0x%p            0x%p                    %s

EXCEPTION_CODE_STR:  c0000005

EXCEPTION_PARAMETER1:  0000000000000000

EXCEPTION_PARAMETER2:  000002158ebe6568

STACK_TEXT:  
(Inline Function) --------`--------     : --------`-------- --------`-------- --------`-------- --------`-------- : php8!jit_CONST_FUNC+0x8
0000001d`f3ff03e0 00007ff8`f32a6563     : 00001000`02c16e83 00000000`00000001 00000000`00000000 0000001d`f3ff7060 : php8!zend_jit_escape_if_undef+0x103
0000001d`f3ff0420 00007ff8`f32a8135     : 00000000`00000001 00001000`02c16338 00001000`02c16e78 00000000`00000001 : php8!zend_jit_trace_deoptimization+0x4a3
0000001d`f3ff04c0 00007ff8`f32ba335     : 00001000`00000020 00000000`00003a80 000001f0`00000000 00000000`00000005 : php8!zend_jit_trace+0x8b5
0000001d`f3ff0e00 00007ff8`f32baaae     : 00000000`00000022 0000001d`f3ff7020 00000000`00000022 00000000`00000000 : php8!zend_jit_compile_side_trace+0x185
0000001d`f3ff6fb0 00007ff8`f325d886     : 00001000`008b1588 00000000`00000000 00000000`00000000 0000001d`f3ffb140 : php8!zend_jit_trace_hot_side+0x4de
0000001d`f3ffb080 00001000`20000574     : 00001000`00000034 0000001d`0000008d 0000001d`f3ffb1a0 00000215`8c013c00 : php8!zend_jit_trace_exit+0x6a6
0000001d`f3ffb120 00000000`00000000     : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : 0x00001000`20000574


STACK_COMMAND: ~0s; .ecxr ; kb

IP_IN_PAGED_CODE: 
php8!zend_jit_escape_if_undef+103 [C:\Users\runneradmin\AppData\Local\Temp\php-4e72ff57-341f-4859-9ceb-59956af1c221\config\vs17\x64\php-8.5.7\ext\opcache\jit\zend_jit_ir.c @ 7982]
00007ff8`f3272ac3 488b8cc8d0000000 mov     rcx,qword ptr [rax+rcx*8+0D0h]

FAULTING_SOURCE_LINE:  C:\Users\runneradmin\AppData\Local\Temp\php-4e72ff57-341f-4859-9ceb-59956af1c221\config\vs17\x64\php-8.5.7\ext\opcache\jit\zend_jit_ir.c

FAULTING_SOURCE_FILE:  C:\Users\runneradmin\AppData\Local\Temp\php-4e72ff57-341f-4859-9ceb-59956af1c221\config\vs17\x64\php-8.5.7\ext\opcache\jit\zend_jit_ir.c

FAULTING_SOURCE_LINE_NUMBER:  7982

FAULTING_SOURCE_CODE:  
   575: #else
   576: 	ir_ref proto = 0;
   577: #endif
   578: 
>  579: 	return jit_CONST_FUNC_PROTO(jit, addr, proto);
   580: }
   581: 
   582: static ir_ref jit_CONST_OPCODE_HANDLER_FUNC(zend_jit_ctx *jit, zend_vm_opcode_handler_t handler)
   583: {
   584: 	return jit_CONST_FUNC(jit, (uintptr_t)handler, IR_FASTCALL_FUNC);


SYMBOL_NAME:  php8!zend_jit_escape_if_undef+103

MODULE_NAME: php8

IMAGE_NAME:  php8.dll

FAILURE_BUCKET_ID:  INVALID_POINTER_READ_c0000005_php8.dll!zend_jit_escape_if_undef

OS_VERSION:  10.0.26100.1

BUILDLAB_STR:  ge_release

OSPLATFORM_TYPE:  x64

OSNAME:  Windows 10

IMAGE_VERSION:  8.5.7.0

FAILURE_ID_HASH:  {975c7f30-0c3e-ea22-ef42-5cbfda68e2ac}

Followup:     MachineOwner
---------

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants