LOCKSTEP

PHP2Shell: two stale pointers in PHP’s upload-progress code

We received technical details on PHP2Shell out of band ahead of PHP’s public advisory. In PHP 8.4.4, the session upload-progress handler caches two raw zval * pointers into HashTable bucket arrays (arData) that can be freed before subsequent multipart events dereference them.

PHP notice

01 / EXPERT ANALYSIS

PHP caches direct bucket pointers during multipart parsing

As PHP parses a multipart/form-data request, the RFC 1867 session callback tracks byte counts through two direct zval * pointers on php_session_rfc1867_progress. Neither pointer holds a reference to the backing zend_array or tracks reallocations of its arData buffer.

/* ext/session/php_session.h, PHP 8.4.4 — relevant members */typedef struct _php_session_rfc1867_progress {    /* ... */    zval data;    zval *post_bytes_processed;    zval *current_file_bytes_processed;    zval current_file;} php_session_rfc1867_progress;

zend_hash_str_find() returns the memory address of a zval slot inside a HashTable's contiguous arData allocation. Caching that return value across lifecycle events is only valid if the owning zend_array stays alive and its arData buffer is never reallocated or freed.

The out-of-band disclosure tracks the stale progress->post_bytes_processed pointer as CVE-2026-109619 and the stale progress->current_file_bytes_processed pointer as CVE-2026-109618.

PHP structure

02 / EXPERT ANALYSIS

The outer pointer persists across file boundaries

During MULTIPART_EVENT_FILE_START, the callback initializes the tracking arrays, inserts the counter keys, and caches the resulting bucket addresses. The scope of each lookup dictates the lifetime of the cached pointer.

/* ext/session/session.c, PHP 8.4.4 — relevant statements */if (Z_ISUNDEF(progress->data)) {    array_init(&progress->data);    add_assoc_long_ex(&progress->data, "bytes_processed",        sizeof("bytes_processed") - 1, data->post_bytes_processed);    progress->post_bytes_processed = zend_hash_str_find(        Z_ARRVAL(progress->data), "bytes_processed",        sizeof("bytes_processed") - 1);}array_init(&progress->current_file);add_assoc_long_ex(&progress->current_file, "bytes_processed",    sizeof("bytes_processed") - 1, 0);add_next_index_zval(&progress->files, &progress->current_file);progress->current_file_bytes_processed = zend_hash_str_find(    Z_ARRVAL(progress->current_file), "bytes_processed",    sizeof("bytes_processed") - 1);

On the first MULTIPART_EVENT_FILE_START event, PHP initializes progress->data and resolves progress->post_bytes_processed. Because that block is guarded by if (Z_ISUNDEF(progress->data)), progress->post_bytes_processed is never looked up again for the remainder of the request. Each subsequent FILE_START event allocates a fresh progress->current_file array, appends it to progress->files, and updates progress->current_file_bytes_processed to point into the new file array's arData buffer.

Subsequent MULTIPART_EVENT_FILE_DATA and MULTIPART_EVENT_FILE_END events write directly through both cached pointers without re-querying the HashTables.

PHP callback · PHP file-data handler

03 / EXPERT ANALYSIS

A session update can free both bucket allocations

Two C struct fields hold raw zval * pointers into HashTable arData buffers while aliasing arrays lack the corresponding reference counts. During FILE_START, PHP inserts progress->files into progress->data["files"], and inserts progress->current_file into progress->files[n]. Both add_assoc_zval_ex() and add_next_index_zval() copy the zval into the destination HashTable bucket without incrementing the underlying zend_array reference count. The C struct fields and the HashTable buckets now alias the same array headers at refcount 1.

/* FILE_START: uncounted aliases are created */array_init(&progress->files);            /* files zend_array: refcount 1 */add_assoc_zval_ex(&progress->data, "files",    sizeof("files") - 1, &progress->files);                                         /* data["files"] copies the zval; refcount stays 1 */ array_init(&progress->current_file);     /* current_file zend_array: refcount 1 */add_next_index_zval(&progress->files,    &progress->current_file);            /* files[0] copies the zval; refcount stays 1 */ /* php_session_track_init(): destroy prior decoded $_SESSION tree */if (!Z_ISUNDEF(PS(http_session_vars))) {    zval_ptr_dtor(&PS(http_session_vars));} /* php_session_rfc1867_update(): re-initialize and overwrite $_SESSION entry */php_session_initialize();Z_TRY_ADDREF(progress->data);zend_hash_update(Z_ARRVAL_P(sess_var), progress->key.s,    &progress->data);

Both cached zval * pointers dangle when php_session_rfc1867_update() flushes progress to storage:

  1. Outer pointer (progress->post_bytes_processed): Calling php_session_initialize() invokes php_session_track_init() to destroy the prior PS(http_session_vars) tree and unserializes the session payload from the underlying handler into $_SESSION. When zend_hash_update() installs progress->data into $_SESSION[progress->key.s]—or when a subsequent array growth or copy-on-write separation reallocates progress->data's internal arData buffer—the original bucket array backing progress->data is released. Because progress->post_bytes_processed was cached once during the initial FILE_START and points directly into that initial arData allocation, freeing or reallocating progress->data->arData leaves progress->post_bytes_processed pointing at freed memory.

  2. Inner pointer (progress->current_file_bytes_processed): When php_session_track_init() and zend_hash_update() tear down the existing $_SESSION[progress->key.s] entry, zval_ptr_dtor() destroys the replaced array and decrements the reference counts of its elements. Because progress->files and progress->current_file were inserted via ZVAL_COPY_VALUE without Z_TRY_ADDREF(), tearing down the prior array drops the nested array refcounts from 1 to 0. Zend frees the current_file zend_array and its arData buffer while the C struct fields progress->files, progress->current_file, and progress->current_file_bytes_processed still hold raw pointers to the freed allocation.

Moment C slot and HashTable bucket Array refcount Consequence
After FILE_START progress->current_file and files[0] alias the same zend_array; post_bytes_processed and current_file_bytes_processed point into live arData buffers 1 C struct pointers do not own a reference to the backing arData allocations.
During php_session_rfc1867_update() php_session_initialize() and zend_hash_update() replace or reallocate the session arrays; zval_ptr_dtor() runs on the displaced entries 1 → 0 Both the outer arData buffer (via reallocation/replacement) and the inner current_file arData buffer (via uncounted alias destruction) are freed.
Next multipart event post_bytes_processed and current_file_bytes_processed retain the old bucket addresses Freed Z_LVAL_P() reads and writes directly into freed heap chunks.

PHP callback · HashTable insertion · Association helper · Session initialization · Update function

04 / EXPERT ANALYSIS

Subsequent events write through the dangling pointers

When the next multipart chunk arrives, PHP does not call zend_hash_str_find(). It dereferences both saved zval * addresses directly via Z_LVAL_P().

/* php_session_rfc1867_update() — ext/session/session.c:3188 */if (Z_LVAL_P(progress->post_bytes_processed) < progress->next_update) {    return;} /* MULTIPART_EVENT_FILE_DATA — ext/session/session.c:3355-3357 */Z_LVAL_P(progress->current_file_bytes_processed) =    data->offset + data->length;Z_LVAL_P(progress->post_bytes_processed) =    data->post_bytes_processed;php_session_rfc1867_update(progress, 0); /* MULTIPART_EVENT_FILE_END — ext/session/session.c:3375 */Z_LVAL_P(progress->post_bytes_processed) =    data->post_bytes_processed;

MULTIPART_EVENT_FILE_DATA overwrites the 64-bit integer payload (zend_long) at both addresses before invoking php_session_rfc1867_update(), which reads progress->post_bytes_processed to check the update threshold. MULTIPART_EVENT_FILE_END performs an additional unconditional store through progress->post_bytes_processed.

In the reported exploit, the attacker grooms the heap using t[] multipart form fields so a newly allocated packed array reclaims the freed arData size class. When FILE_DATA or FILE_END updates the byte counters, the write corrupts an element inside the attacker's packed array. The exploit chains this 8-byte write primitive to forge a Zend closure structure and invoke system().

PHP counter accesses · PHP update function

05 / EXPERT ANALYSIS

Detecting the stale dereference in Lockstep

Lockstep intercepts the bug at the first load or store to the freed arData buffer. When PHP is built with Lockstep's tracked allocator bindings (USE_ZEND_ALLOC=0 and USE_TRACKED_ALLOC=1), Zend retains its internal allocation bookkeeping and request-bound cleanup list, but routes underlying malloc, realloc, and free calls through Lockstep. Lockstep assigns a generation tag to every heap allocation and transitions freed chunks to a quarantine tag upon release. Every pointer dereference verifies the pointer's tag against the target allocation's shadow memory before executing the load or store.

PHP:  _efree(arData_buffer)         → tracked_free → Lockstep free → shadow tag set to quarantinePHP:  Z_LVAL_P(saved_bucket_pointer) = new_value         → checked store → pointer tag ≠ quarantine → UseAfterFree

This configuration disables Zend's internal slab arena and rejects custom allocator hooks that would otherwise hide sub-page chunk reuse from the underlying heap tracker.

Zend tracked allocator · Lockstep allocator binding: crates/lockstep-alloc/src/ffi.rs · Lockstep heap: crates/lockstep-runtime/src/heap.rs · Native access check: crates/lockstep-runtime/src/check.rs · Lockstep PHP setup: fixtures/php-8.4.4/build.sh

06 / EXPERT ANALYSIS

Verifying allocator enforcement on PHP 8.4.4

We linked a test fixture directly against unmodified PHP 8.4.4 Zend allocator source and executed it under Lockstep’s x86-64 process runner. Standard _emalloc, _erealloc, _efree, and request-shutdown cleanup passes completed without fault. With tag enforcement active, an out-of-bounds write one byte past a 17-byte _emalloc buffer trapped with TagMismatch; a write through a pointer passed to _efree trapped with UseAfterFree; and a write to a request-bound allocation after Zend shutdown cleanup trapped with UseAfterFree. Running the same binary with --no-tags allowed all three corruptions to succeed and exit with status 3.

When Z_LVAL_P() dereferences a dangling bucket pointer after _efree(), the pointer's original allocation tag fails to match the quarantine tag in shadow memory, halting execution before the store can corrupt a reclaimed heap chunk.

Fixture case Full hardening Heap tags disabled (--no-tags)
Valid Zend allocation and cleanup Completes —
Write at offset 17 of a 17-byte _emalloc TagMismatch Invalid write completes (exit 3)
Write after _efree UseAfterFree Invalid write completes (exit 3)
Write after request shutdown cleanup UseAfterFree Invalid write completes (exit 3)

Zend test fixture: fixtures/php-8.4.4/zend_alloc_uaf_fixture.c · Zend test harness: tests/zend_alloc_regression.rs · Local test receipt: receipts/php-8.4.4-zend-alloc-uaf.json