PHP research · Part 1 of 3. Series index · Next: application code
Source: Patch-gap analysis · minimal UAF verifier · session-chain verifier · lab setup
The failure occurs during cleanup. The outer parser has released its bookkeeping, but it has not finished running destructors. One destructor calls back into parsing. In the reviewed builds, that nested parser can inherit the context whose memory was just freed.
We reproduced this previously reported path on PHP 8.4.27 and 8.5.12, both of which contained the October 21 PHP2Shell fixes. ASAN recorded the invalid read. A separate stock-allocator experiment reached a fixed command through session decoding, with additional configuration, gadget, placement, and address requirements.
Follow what survives.
Separate the lifetime of parser bookkeeping from the execution of delayed object destruction.
The outer parser records references in bookkeeping allocations and retains values for delayed cleanup.
Recorded evidence ↗Reduced motion: select steps to change the diagram. Symbolic objects; observed results keep their recorded conditions.
Transcript and recorded profiles
- One active parsing context. The outer parser records references in bookkeeping allocations and retains values for delayed cleanup. Evidence
- A separate cleanup rail. Objects awaiting destruction remain part of the operation. Destroying them can invoke user code. Evidence
- Release the reference table. var_destroy() frees the bookkeeping chain first. The dashed outline marks released storage, not a live allocation. Evidence
- The destructor still executes. The reviewed destructor branch does not hold serialize_lock. The outer unserialize level is still active. Evidence
- Nested parsing selects a context. The destructor re-enters parsing. The positive path can reuse stale bookkeeping; the locked callback control obtains a fresh context. Evidence
- Freeze at the observed access. ASAN recorded an invalid read in the named instrumented builds. This trace alone does not establish command execution. Evidence
- Destructor path: ASAN invalid read. The reviewed destructor walk left serialize_lock zero while the outer unserialize level remained active. Evidence
- Locked callback control: Required stale state absent. The tested wakeup and unserialize callbacks held the lock. Nested parsing obtained a fresh context. Evidence
Current upstream status (November 2, 2026): PHP closed the main var_destroy() report after an October 30 source change. The related SPL entry-point report and proposed change remain open. The results below are for the named October 21 release builds; we have not tested a later release with these changes.
PHP's October 21 releases—8.2.35, 8.3.36, 8.4.27, and 8.5.12—fixed CVE-2026-109618, the OOM corruption issue, and CVE-2026-109619, the stale global-state issue. PHP's release notice names the affected web runtimes and fixed versions.
The release fixes and the result below concern different paths. The question is which state can reach var_destroy(), and whether a callback can reuse that state after cleanup has started.
The older bug underneath the result
The var_destroy() re-entry use-after-free was already public. Dinosn published it in May 2026, alongside Calif's related MAD Bugs research. ACD421 had also published the four-stage composition that delivered it through upload-progress session decoding.
Our result is a set of tested profiles on specific builds, with recorded controls. The prior-art review documents the earlier bug, composition, and disable_functions work.
Cleanup can call back into the parser
The lock changes the nested context
Fresh context. The reviewed wakeup and unserialize branches hold the serialization lock. The tested nested calls obtain a fresh context; the required stale-context state was absent.
All recorded cases
- Locked callback: Fresh context. The reviewed wakeup and unserialize branches hold the serialization lock. The tested nested calls obtain a fresh context; the required stale-context state was absent.
- Destructor control: ASAN invalid read. The reviewed destructor walk leaves the lock zero. The positive control re-enters the outer context after its bookkeeping was freed.
PHP's unserializer maintains bookkeeping for the values it creates. It also keeps a list of values that require delayed cleanup.
In the tested path, var_destroy() frees the bookkeeping chain before it finishes processing that delayed list. Destroying an object on the list can invoke its __destruct() method.
If that destructor calls unserialize() again, PHP must decide whether to create a fresh parsing context or reuse the current one.
The relevant state is BG(serialize_lock). The __wakeup() and __unserialize() branches hold that lock around their calls. The destructor branch in the reviewed builds does not. With an active outer unserialize level and the lock still zero, the nested parser can reuse bookkeeping that the outer cleanup has already freed.
The stale reference crosses a lifetime boundary: an active unserialize level survives the allocation that backed its reference table. Re-entry turns that ordering error into an invalid access.
The instrumented builds reported an eight-byte read from freed memory at var_unserializer.c:906. The recorded stack passes through var_destroy() and the session decoder. The patch-gap account includes the source path and ASAN trace.
The session decoder supplies the entry point
The request handler does not need an explicit unserialize() call.
The delivery path uses a session-format mismatch:
- →Upload progress writes a record using a configured serializer such as
php_serialize. - →The application switches to the legacy
phphandler beforesession_start(). - →The legacy decoder interprets the stored bytes using a different format.
- →Decoding can create an object whose destructor later re-enters the parser.
The application or a loaded library must still supply the required destructor. Removing an explicit parser call from the request handler does not remove that condition.
This is an unusual configuration. PHP's October 23 advisory for CVE-2026-115560 requires php_binary or php_serialize in server configuration, a runtime switch to php before session_start(), and enabled upload progress. The advisory does not describe every PHP deployment as exposed to this path.
An ASAN report establishes the memory error
Two experiments, two proof boundaries
Memory error. The instrumented run records an invalid read from freed bookkeeping. This establishes the memory error in that execution.
All recorded cases
- ASAN observation: Memory error. The instrumented run records an invalid read from freed bookkeeping. This establishes the memory error in that execution.
- Stock allocator lab: Separate command receipt. A separate profile also needs a suitable gadget, memory placement, and an address source. The fixed-command receipt establishes that tested result.
- Wrong-address control: No proof file. The recorded wrong-address control produces no proof file under the same lab configuration. The absence of a receipt is attached to this control.
The ASAN trace establishes an invalid access in that execution. Command execution required a different experiment, using the stock Zend allocator and a separately checked success condition.
The next lab step used PHP's normal Zend allocator. After cleanup freed the bookkeeping allocation, the destructor allocated replacement data of the required size. The nested parser then interpreted reclaimed bytes as engine structures.
In the tested profile, that path reached a forged object handler and called the native zif_system function. The fixed command wrote evidence identifying the web-server user:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
A response body was not the success condition. The request could terminate after the forged call. The proof file, removed before each attempt, was the recorded evidence.
The controls mattered as much as the positive result. Moving the target function address by 0x1000 produced no proof. Removing the runtime handler switch or disabling the upload-progress writer also stopped the tested delivery. These controls are recorded with the session-decoder results.
The hardened run tested a specific boundary
One container disabled system, exec, shell_exec, and the other listed command functions. The probe confirmed that function_exists('system') returned false and a normal call failed.
The forged native call still reached zif_system.
The path did not use normal userland function lookup. It called an address through a forged engine handler. The wrong-address control then failed under the same configuration.
That result establishes a limit of the setting in this tested memory-corruption chain. It does not establish a new bypass technique or a result across all PHP builds. The full configuration and controls are in the hardened-target record.
Address discovery remained part of the problem
The remaining problem was address discovery. Correct object layout alone could not provide a valid native call target.
Some profiles obtained addresses through lab inspection. A later arm64 profile found a usable address by searching the limited window in that environment, without reading /proc for the carrier at fire time.
The session-delivery runs recorded success after 351 attempts on 8.5.12 and 307 attempts on 8.4.27.
Those are results for the stated arm64 configuration. They do not establish the same method on native x86-64 with a larger randomization space. The amd64 result under Rosetta used explicit addresses and must remain labelled as emulated coverage.
The constants also depend on the request shape. A direct request, a streaming-upload race, and an application-specific profile can place the carrier at different locations.
Result and release boundary
The October patches closed the OOM and stale-state paths named in their advisories. The separate, previously public destructor re-entry path remained reachable in the reviewed October 21 builds. Under the lab's configuration and gadget conditions, it reached the fixed command on 8.4.27 and 8.5.12.
The required serializer switch, destructor, memory placement, and address source define the result. Upstream source work after those releases changes the current status, as noted above. Part 2 tests which application code supplied the required conditions.
The source review, controlled reproductions, and stated limits in this investigation are also part of our security research and application testing work.