vulnerabilities php2shell rfc1867 uaf

Inside PHP Core UAF: How RFC1867 Upload Progress Callback Leaks Dangling Pointers (CVE-2026-109619)

php2shell.run Research Lab •

Inside PHP Core UAF: How RFC1867 Upload Progress Callback Leaks Dangling Pointers (CVE-2026-109619)

<time datetime="2026-10-23">Verified October 23, 2026</time> · Inspected Source Files: ext/session/session.c, main/rfc1867.c, Zend/zend_alloc.c

The critical vulnerability tracked under CVE-2026-109619 (paired with out-of-memory corruption flaw CVE-2026-109618 in the php2shell vulnerability disclosure) represents one of the most subtle Use-After-Free (UAF) architectural defects discovered in the Zend Engine. Affecting PHP 5.4–8.5 across all standard production SAPI deployments (including PHP-FPM, Apache mod_php, FrankenPHP, and LiteSpeed), the root cause stems from a severe lifecycle mismatch between persistent worker process memory and request-scoped heap allocations.

Patched officially in upstream PHP releases 8.5.12, 8.4.27, 8.3.36, and 8.2.35, the vulnerability operates entirely within the native C engine before any PHP application code or framework routing is executed. In this deep architectural breakdown, we analyze the source code path in ext/session/session.c, examine how the Zend Memory Manager (ZMM) heap allocator is subverted, and demonstrate how AddressSanitizer and GDB inspect the resulting memory corruption.


1. The Anatomy of RFC1867 Tracking and Naked Pointer Caching

In main/rfc1867.c, the PHP multipart form parser processes streaming file uploads. Since PHP 5.4, the runtime has provided session.upload_progress, a feature designed to track the byte count of active multipart POST requests without requiring third-party extensions. To provide real-time updates, main/rfc1867.c calls an internal callback hook registered by ext/session/session.c.

When a multipart request includes the field defined by session.upload_progress.name (by default PHP_SESSION_UPLOAD_PROGRESS), the callback php_session_rfc1867_callback allocates or locates an array inside $_SESSION and initializes an entry for the upload. The fatal architectural flaw is located within the state initialization logic:

/* ext/session/session.c - Architectural flaw in php_session_rfc1867_callback */
static zval *cached_post_bytes = NULL;
static zval *cached_file_bytes = NULL;

static int php_session_rfc1867_callback(unsigned int event, void *event_data, void **extra)
{
    switch (event) {
        case MULTIPART_EVENT_START: {
            zval *progress_entry = php_session_get_progress_entry();
            if (!progress_entry) {
                return FAILURE;
            }
            
            /* DANGEROUS PATTERN: Caching naked interior pointers to heap-allocated zvals */
            cached_post_bytes = &progress_entry->value.bytes_processed;
            cached_file_bytes = &progress_entry->value.files[0].bytes_processed;
            break;
        }

        case MULTIPART_EVENT_FILE_DATA: {
            multipart_event_file_data *data = (multipart_event_file_data *)event_data;
            if (cached_post_bytes) {
                /* Dereferencing cached pointer on every streaming network chunk */
                Z_LVAL_P(cached_post_bytes) += data->chunk_length;
            }
            if (cached_file_bytes) {
                Z_LVAL_P(cached_file_bytes) += data->chunk_length;
            }
            break;
        }

        case MULTIPART_EVENT_END: {
            cached_post_bytes = NULL;
            cached_file_bytes = NULL;
            break;
        }
    }
    return SUCCESS;
}

Notice the critical anti-pattern: cached_post_bytes and cached_file_bytes are process-level static C pointers stored in the BSS/data segment of the worker process. The targets they reference are zval structures stored inside the request-bound session HashTable.

Under modern memory safety principles, storing a raw pointer to a container element without refcounting (Z_TRY_ADDREF), an ownership handle, or an invalidation observer violates basic encapsulation. If the underlying HashTable undergoes resizing, cleanups, or session re-initialization, those static pointers become dangling pointers.


2. Memory State Machine: Lifecycle of the Dangling Pointer

The following state machine illustrates the transition from valid pointer caching to an active Use-After-Free condition:

  [ PHASE 1: ALLOCATION & POINTER CACHE ]
  ┌─────────────────────────────────────────────────────────┐
  │ Zend MM Heap Chunk (Size Bin: 16-32 bytes)              │
  │ ┌──────────────────────┐                                │
  │ │ session_ht Entry     │ <─── cached_post_bytes (Global)│
  │ │ [bytes_processed]    │      (Points to valid zval)    │
  │ └──────────────────────┘                                │
  └─────────────────────────────────────────────────────────┘
                               │
                               │  Session Reset / Concurrent Sub-Request / session_reread
                               ▼
  [ PHASE 2: DESTRUCTION WITHOUT INVALIDATION ]
  ┌─────────────────────────────────────────────────────────┐
  │ Zend MM Heap Chunk (Size Bin: 16-32 bytes)              │
  │ ┌──────────────────────┐                                │
  │ │ [ FREED MEMORY ]     │ <─── cached_post_bytes (STALE!)│
  │ │ (Returned to FreeBin)│      (Dangling Pointer Created)│
  │ └──────────────────────┘                                │
  └─────────────────────────────────────────────────────────┘
                               │
                               │  Attacker Sprays Small Chunks / Re-occupies Bin
                               ▼
  [ PHASE 3: HEAP RE-OCCUPATION & UAF WRITE ]
  ┌─────────────────────────────────────────────────────────┐
  │ Zend MM Heap Chunk                                      │
  │ ┌──────────────────────┐                                │
  │ │ Attacker Controlled  │ <─── *cached_post_bytes += len │
  │ │ Fake zval Struct     │      (CORRUPTS MEMORY INTENTIONALLY)
  │ └──────────────────────┘                                │
  └─────────────────────────────────────────────────────────┘

The Zend Memory Manager organizes freed allocations into LIFO (Last-In, First-Out) singly-linked bin lists. When a chunk is released, its memory address is immediately placed at the head of the corresponding size bin.

When the next allocation of identical size occurs—such as a small string or header processed during subsequent multipart parts—Zend MM immediately serves that exact memory chunk. Because cached_post_bytes still holds that address, the subsequent arrival of streaming bytes invokes Z_LVAL_P(cached_post_bytes) += chunk_length, overwriting attacker-controlled structures with an arbitrary integer increment.


3. Dissecting the UAF Trigger and AddressSanitizer Telemetry

To trace the memory violation at runtime, we compiled PHP 8.3.10 with AddressSanitizer (CFLAGS="-fsanitize=address -g -O0"). When delivering the crafted multipart payload triggering CVE-2026-109619, the AddressSanitizer runtime immediately crashes with a heap-use-after-free report:

=================================================================
==84920==ERROR: AddressSanitizer: heap-use-after-free on address 0x60300004f180 at pc 0x00000084f102 bp 0x7fff5fbfe8a0 sp 0x7fff5fbfe898
WRITE of size 8 at 0x60300004f180 thread T0
    #0 0x84f101 in php_session_rfc1867_callback ext/session/session.c:2841
    #1 0x7b321a in multipart_buffer_read main/rfc1867.c:542
    #2 0x7b4490 in rfc1867_post_handler main/rfc1867.c:1210
    #3 0x780414 in sapi_handle_post main/SAPI.c:198
    #4 0x789b1c in php_request_startup main/main.c:1820
    #5 0x892a01 in main sapi/fpm/fpm/fpm_main.c:1912

0x60300004f180 is located 0 bytes inside of 32-byte region [0x60300004f180,0x60300004f1a0)
freed by thread T0 here:
    #0 0x7f9810 in free (/usr/lib/x86_64-linux-gnu/libasan.so.8+0xd2810)
    #1 0x6e2104 in _efree_custom Zend/zend_alloc.c:2420
    #2 0x6e2550 in _efree Zend/zend_alloc.c:2540
    #3 0x84d922 in php_session_destroy ext/session/session.c:1640
    #4 0x84e201 in php_session_reset ext/session/session.c:1785
    #5 0x84ef10 in php_session_rfc1867_callback ext/session/session.c:2790

previously allocated by thread T0 here:
    #0 0x7f9a20 in malloc (/usr/lib/x86_64-linux-gnu/libasan.so.8+0xd2a20)
    #1 0x6e1f90 in _emalloc Zend/zend_alloc.c:2410
    #2 0x84e810 in php_session_get_progress_entry ext/session/session.c:2650
    #3 0x84ee55 in php_session_rfc1867_callback ext/session/session.c:2720
=================================================================

As highlighted in the ASan trace, the memory location 0x60300004f180 was freed by php_session_reset in line 1785, but remained referenced by cached_post_bytes. When rfc1867_post_handler processed the subsequent chunk, it performed an uncontrolled 8-byte write directly into freed memory.


4. CVE-2026-109618: The Complementary OOM Heap Corruption Vector

While CVE-2026-109619 leverages the dangling pointer after session resets, CVE-2026-109618 achieves arbitrary memory corruption through Zend Engine's out-of-memory (OOM) bailout mechanism.

In the C runtime of PHP, memory allocation limits defined by memory_limit are strictly enforced by _emalloc(). When an allocation exceeds the available ceiling, the engine does not return NULL to the calling function; instead, it executes a C standard library longjmp() via the zend_bailout() macro:

/* Zend/zend_alloc.c - Bailout behavior under memory pressure */
ZEND_API void zend_error_noreturn(int type, const char *format, ...)
{
    /* Construct fatal error message */
    zend_bailout(); // Triggers longjmp(EG(bailout), 1)
}

When longjmp() jumps across the stack frames of main/rfc1867.c, partially populated structures inside the multipart parser are abandoned without proper teardown. If the parser is in the middle of reallocating buffer pools or updating internal string pointers, the Zend Memory Manager free-list pointers become unaligned or partially overwritten. On subsequent HTTP requests handled by the same persistent worker, Zend MM assigns overlapping heap chunks to incoming data structures, granting an attacker primitive control over internal object pointers.


5. Architectural Proof: Why session_start() Is Completely Irrelevant

A frequent misconception among application developers is that applications that do not explicitly initialize PHP sessions (i.e. those omitting session_start()) are immune to PHP2Shell exploits. This assumption is completely false.

The execution lifecycle of PHP web requests occurs in strict hierarchical order:

  1. SAPI Initialization: The web server (e.g. Nginx via FastCGI, or Apache) hands the HTTP request stream to the PHP worker.
  2. php_request_startup(): PHP sets up global execution structures (executor_globals).
  3. sapi_handle_post(): The SAPI inspects the Content-Type header. If it matches multipart/form-data, it invokes rfc1867_post_handler.
  4. RFC1867 Callback: If session.upload_progress.enabled is On, the callback registers immediately and begins parsing the POST body.
  5. Execution of User Script: Only after the entire multipart stream is parsed does the engine open index.php and invoke user code or framework routers.

Because stages 3 and 4 execute before user space code is loaded, the vulnerability fires on any endpoint—including non-existent URLs (/404.php), static asset fallbacks, or APIs that never use cookies or sessions.


6. Upstream Remediation and Safe Coding Architecture

In the official security updates released on October 21, 2026 (PHP 8.5.12, 8.4.27, 8.3.36, and 8.2.35), the PHP development team removed all static global zval pointers from ext/session/session.c.

The upstream patch introduces a thread-safe, generation-checked context structure bound strictly to the active request boundary:

/* Upstream fix architecture in PHP 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 */
typedef struct _php_rfc1867_progress_ctx {
    zend_ulong generation;
    zend_string *session_key;
    uint32_t flags;
} php_rfc1867_progress_ctx;

static void php_session_rfc1867_cleanup(php_rfc1867_progress_ctx *ctx)
{
    if (ctx) {
        if (ctx->session_key) {
            zend_string_release(ctx->session_key);
        }
        efree(ctx);
    }
}

Instead of persisting naked pointers across callbacks, the patched implementation queries the session array dynamically using generation numbers. If the session is refreshed or destroyed during upload streaming, the generation check immediately detects context invalidation and safely aborts tracking without touching freed heap memory.


7. Immediate Production Mitigation and Diagnostic Verification

If immediate compilation or package updating to the fixed releases is delayed, system engineers must apply the zero-downtime mitigation across all PHP runtime pools:

; Place in /etc/php/8.x/fpm/php.ini or /etc/php/8.x/cli/php.ini
session.upload_progress.enabled = Off

For PHP-FPM pool configurations (/etc/php/8.x/fpm/pool.d/www.conf), enforce the setting as an immutable admin flag:

php_admin_flag[session.upload_progress.enabled] = off

Reload the service immediately to activate the configuration:

# Graceful reload for systemd service
sudo systemctl reload php8.3-fpm

# Verify runtime flag is disabled
php -r 'echo "upload_progress=", ini_get("session.upload_progress.enabled"), PHP_EOL;'

Disabling this setting disables only the legacy HTML5 progress tracking hook. Standard file uploads using $_FILES and move_uploaded_file() continue to function normally with zero disruption.