Skip to content

Latent memory leak in ext/ffi:zend_ffi_make_pointer_type() when validate_vla fails #24530

Open

Description

@matyapiro31

Description

Summary

zend_ffi_make_pointer_type() in ext/ffi/ffi.c allocates type via
pemalloc() (line 6358) before calling zend_ffi_validate_vla(). If
validation fails the function calls zend_ffi_cleanup_dcl(dcl) and
LONGJMP() without freeing type, leaking sizeof(zend_ffi_type) bytes.

Impact

Currently unreachable in practice. make_pointer_type is only called
from the pointer grammar rule, which runs before
array_or_function_declarators; thus dcl->type never carries
ZEND_FFI_ATTR_VLA at that point and validate_vla always succeeds.

However the pattern is inconsistent with the sibling functions
zend_ffi_make_array_type() and zend_ffi_make_func_type(), which both
pemalloc after all validation. A future refactor could accidentally
make the failure path reachable.

Fix

Either add a pefree before LONGJMP:

if (zend_ffi_validate_vla(ZEND_FFI_TYPE(dcl->type)) == FAILURE) {
    pefree(type, FFI_G(persistent));  // add this
    zend_ffi_cleanup_dcl(dcl);
    LONGJMP(FFI_G(bailout), FAILURE);
}

PHP Version

PHP 8.5.12 (cli) (built: Oct 21 2026 22:48:15) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.5.12, Copyright (c) Zend Technologies
    with Zend OPcache v8.5.12, Copyright (c), by Zend Technologies

Operating System

Windows 11 26H2

Activity

  1. ndossche commented on Oct 22, 2026

    @ndossche
    Member

    Feel free to open a PR!

  2. matyapiro31 commented on Oct 22, 2026

    @matyapiro31
    Author

    Correction: the leak is reachable. Claude Code discovered new way.

    Claude only analyzed the zend_ffi_validate_vla() failure path (unreachable). The same function has a second bailout after the allocation: zend_ffi_finalize_type().

    void zend_ffi_make_pointer_type(zend_ffi_dcl *dcl)
    {
    	zend_ffi_type *type = pemalloc(sizeof(zend_ffi_type), FFI_G(persistent)); // allocated first
    	...
    	zend_ffi_finalize_type(dcl);   // can call zend_ffi_parser_error()

    Claude overlooked last time that zend_ffi_parser_error() always ends in LONGJMP(FFI_G(bailout), FAILURE). It is called from zend_ffi_finalize_type() for unsupported specifier combinations. The generated parser (parse_pointer()) does not finalize the base type before calling zend_ffi_make_pointer_type(). When dcl->type is still unresolved at the first *, finalize_type() bails out with type allocated and not yet owned by anything.

    Reproducer

    <?php
    FFI::cdef("double _Complex *x;");   // FFI\ParserException: Unsupported type _Complex at line 1

    Measured on PHP 8.5.12 (NTS, release build Windows 11 26H2). Claude called FFI::cdef() 20000 times in a loop and compared memory_get_usage() before and after:

    declaration growth after 20000 calls
    double _Complex *x; 1,920,000 bytes (96 B/call)
    double _Complex **x; 1,920,000 bytes (bails at the first *)
    double _Complex x; (no pointer, control) 0
    int *x; (success, control) 0

    On a debug build this is reported as a Zend MM leak at request end. With ffi.preload the allocation is persistent, so it is not reclaimed.

    Why the originally proposed pefree() is not enough

    Adding pefree(type, ...) before the LONGJMP in the validate_vla branch does not fix this. The bailout happens inside zend_ffi_finalize_type(), where type is not accessible.

    Proposed fix

    Allocate only after all checks have passed. This matches zend_ffi_make_array_type() and zend_ffi_make_func_type(), and needs no pefree().

     void zend_ffi_make_pointer_type(zend_ffi_dcl *dcl)
     {
    -	zend_ffi_type *type = pemalloc(sizeof(zend_ffi_type), FFI_G(persistent));
    -	type->kind = ZEND_FFI_TYPE_POINTER;
    -	type->attr = FFI_G(default_type_attr) | (dcl->attr & ZEND_FFI_POINTER_ATTRS);
    -	type->size = sizeof(void*);
    -	type->align = _Alignof(void*);
    +	zend_ffi_type *type;
    +
     	zend_ffi_finalize_type(dcl);
     	if (zend_ffi_validate_vla(ZEND_FFI_TYPE(dcl->type)) == FAILURE) {
     		zend_ffi_cleanup_dcl(dcl);
     		LONGJMP(FFI_G(bailout), FAILURE);
     	}
    +
    +	type = pemalloc(sizeof(zend_ffi_type), FFI_G(persistent));
    +	type->kind = ZEND_FFI_TYPE_POINTER;
    +	type->attr = FFI_G(default_type_attr) | (dcl->attr & ZEND_FFI_POINTER_ATTRS);
    +	type->size = sizeof(void*);
    +	type->align = _Alignof(void*);
     	type->pointer.type = dcl->type;

    type->attr depends only on dcl->attr and FFI_G(default_type_attr). zend_ffi_finalize_type() modifies neither of them, so moving it earlier doesn't change its behavior.

    Safety check

    Statically checked these vulnerabilities. But I have not compiled patched version yet.

    • Double free: none. type is not linked into dcl->type or any hash table, so zend_ffi_cleanup_dcl() never sees it.
    • Use-after-free: none. type is a local that has not been published yet.
    • Threads (ZTS): FFI_G is per-thread and the parser state is thread-local. type is not shared before it is stored in dcl->type.

    I don't have enough development environment today, so I'll create PR later.

  3. ndossche commented on Oct 22, 2026

    @ndossche
    Member

    Proposal sounds good

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions