0X01 漏洞概况
在于 ThinkPHP 模板引擎中,在加载模版解析变量时存在变量覆盖问题,而且程序没有对数据进行很好的过滤,最终导致文件包含漏洞 的产生。漏洞影响版本: 5.0.0<=ThinkPHP5<=5.0.18 、5.1.0<=ThinkPHP<=5.1.10。
实验环境
ThinkPHP-Vuln/ThinkPHP5/ThinkPHP5漏洞分析之文件包含7.md at master · Mochazz/ThinkPHP-Vuln (github.com)
版本:thinkphp5.0.0~5.0.18 皆可
5.0.12版本的安装:
将源码放在phpstudy的www目录下
php版本大于5.4
composer安装
composer版本1.8.5
更换composer镜像源
1
composer config -g repo.packagist composer https://mirrors.cloud.tencent.com/composer/
composer安装
1
2cd thinkphp5.0.12
composer install
5.0.18 版本的安装:
phpstudy 新建一个网站
用phpstudy 里的composer,通过以下命令获取测试环境代码:
1
composer create-project --prefer-dist topthink/think=5.0.18 tpdemo
将 composer.json 文件的 require 字段设置成如下
1
2
3
4"require": {
"php": ">=5.6.0",
"topthink/framework": "5.0.18"
},在
tpdemo
目录下执行1
composer update
0X02 PHP变量覆盖漏洞原理介绍
自定义的参数值替换原有变量值的情况称为变量覆盖漏洞。经常导致变量覆盖漏洞场景有:
- 开启了全局变量注册
$$
使用不当extract()
函数使用不当parse_str()
函数使用不当import_request_variables()
使用不当等。
全局变量注册
register_globals
在 PHP5.3 之前,默认开启;PHP5.3 默认关闭,PHP5.6 及 5.7 已经被移除.
由于本次漏洞分析,基于php5.4 以上版本,所以该方法不展开讲解
$$ 动态变量覆盖
在PHP中,$$
被称为“可变变量”。可变变量的概念是将一个变量的值作为另一个变量的名字。
假设你有一个变量 $a
,并且你希望动态地创建或访问一个以 $a
的值为名字的变量。这时就可以用到 $$
。
1 |
|
在上面的例子中:
- 首先,
$a
被赋值为字符串"hello"
。 - 接着,
$$a
就等同于$hello
,因为$a
的值是"hello"
。 - 然后,我们给
$hello
赋值为"world"
。 - 最后,输出
$hello
的值,结果是"world"
。
通过这种方式,你可以动态地创建和访问变量。这种用法在需要根据变量名的值来生成新变量名的情况下非常有用,例如在处理动态数据或配置时。
实操一道题,来获取flag
1 |
|
$_key=>$_value
是一个键值对,=>
符号在PHP中用于关联数组中的键和值的分隔,左侧是键,右侧是值。
外层 foreach
迭代,第一次 $_request
的值是 '_POST'
,在第二次迭代中,$_request
的值是 '_GET'
。
内层 foreach
迭代,会遍历 $_POST
数组的所有键值对,然后遍历 $_GET
数组的键值对。并且动态创建变量。
假设
$_POST
包含以下数据:
1
2
3
4 $_POST = array(
"name" => "John",
"age" => 30
);并且
$_GET
包含以下数据:
1
2
3
4 $_GET = array(
"city" => "New York",
"country" => "USA"
);执行这段代码后,将会动态创建以下变量:
1
2
3
4 $name = "John";
$age = 30;
$city = "New York";
$country = "USA";
接下来是检查id。如果变量 $id
已设置(即已经定义并且不是 null
),那么 $id
保持原值。如果变量 $id
未设置(即未定义或是 null
),那么 $id
被赋值为 2
。
if ($id == 1)
检查变量 $id
是否等于 1
。如果条件为真,执行包含文件和终止脚本的操作。
include
语句:include('./flag.php')
在包含文件 flag.php
的内容。如果文件路径正确且文件存在,其内容将被执行。(在当前目录下)
die()
函数:die()
终止脚本的执行。这样在包含文件后,不会继续执行后续代码。
分析代码可知,如果我们想要得到flag。直接 get/post
传参 id=1
即可,当执行 $$_key = $_value;
时,$_key="id"
,$$_key
等价于 $id
,就终结果也就是 $id = 1
,定义了 id
参数,并赋值为 1
,也就导致了包含flag
文件
extract()
函数使用不当
这种类型的变量覆盖,就是此次分析的thinkphp5.0 文件包含漏洞中,涉及到的漏洞原理
extract()
函数从数组中将变量导入到当前的变量表。该函数使用数组键名作为变量名,使用数组键值作为变量值。
1 |
|
extract(array[,flag][,prefix])
三个参数:
- array
一个关联数组(必需)。此函数会将键名当作变量名,值作为变量的值。 对每个键/值对都会在当前的符号表中建立变量,并受到flags
和prefix
参数的影响。
必须使用关联数组,数字索引的数组将不会产生结果,除非用了EXTR_PREFIX_ALL
或者EXTR_PREFIX_INVALID
。 - flags
对待非法/数字和冲突的键名的方法将根据取出标记flags
参数决定。可以是以下值之一:- EXTR_OVERWRITE
如果有冲突,覆盖已有的变量。(默认) - EXTR_SKIP
如果有冲突,不覆盖已有的变量。 - EXTR_PREFIX_SAME
如果有冲突,在变量名前加上前缀prefix
。 - EXTR_PREFIX_ALL
给所有变量名加上前缀 prefix。 - EXTR_PREFIX_INVALID
仅在非法/数字的变量名前加上前缀prefix
。 - EXTR_IF_EXISTS
仅在当前符号表中已有同名变量时,覆盖它们的值。其它的都不处理。 举个例子,以下情况非常有用:定义一些有效变量,然后从$_REQUEST
中仅导入这些已定义的变量。 - EXTR_PREFIX_IF_EXISTS
仅在当前符号表中已有同名变量时,建立附加了前缀的变量名,其它的都不处理。 - EXTR_REFS
将变量作为引用提取。这有力地表明了导入的变量仍然引用了array
参数的值。可以单独使用这个标志或者在flags
中用OR
与其它任何标志结合使用。
- EXTR_OVERWRITE
- prefix
注意 prefix 仅在 flags 的值是 EXTR_PREFIX_SAME,EXTR_PREFIX_ALL,EXTR_PREFIX_INVALID 或 EXTR_PREFIX_IF_EXISTS 时需要。 如果附加了前缀后的结果不是合法的变量名,将不会导入到符号表中。前缀和数组键名之间会自动加上一个下划线。
flags
参数默认为 EXTR_OVERWRITE
,即如果有冲突,覆盖已有的变量
获取flag,题目1:
1
2
3
4
5
6
7
8
9
10
extract($_GET);
if(isset($mypwd))
{
if($mypwd==$pwd)
{
include("./flag");
}
}
extract($_GET);
默认为EXTR_OVERWRITE
,冲突时覆盖已有变量。通过GET进行传参。代码的逻辑是,如果设置了$mypwd,且 $mypwd==$pwd 时,则可以获取flag。所以$_get部分获取到的是一个键值对。例如:mypwd=>1, pwd =>1 . extract($GET); 的结果是$mypwd=1, $pwd=1. . 即GET传入pwd=1&mypwd=1
.题目2:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 if ($_SERVER["REQUEST_METHOD"] == “POST”) {
extract($_POST);
if ($pass == $thepassword_123) {
<div class=”alert alert-success”>
<code><?php echo $theflag; ?></code>
</div>
<?php } ?>
<?php } ?>需要构造POST传参,为满足$pass == $thepassword_123。可以传:
pass=1&thepassword_123=1
. (只要使使$pass == $thepassword_123
即可得到flag)
parse_str()
函数使用不当
parse_str()
函数把字符串解析成多个变量。其作用就是解析字符串并注册成变量,在注册变量之前不会验证当前变量是否存在,所以直接覆盖掉已有变量。当 magic_quotes_gpc = On
,那么在 parse_str()
解析之前,变量会被 addslashes()
转换(也就是会被转义,加反斜线)。
parse_str(string[,array])
,两个参数分别为:
- string
输入的字符串。如果str
是URL
传递入的查询字符串(query string
),则将它解析为变量并设置到当前作用域(如果提供了array
则会设置到该数组里)
注意,php >= 7.2
,如果不设置该参数,该函数将失效 - array
一个数组,如果设置该参数, 变量将会以数组元素的形式存入到这个数组,作为替代
1 |
|
题目:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 php
error_reporting(0);
if (empty($_GET['id'])) {
show_source(__FILE__);
die();
} else {
include(‘. / flag.php’);
$a = “https: //www.cnblogs.com/wjrblogs/”;
$id = $_GET['id'];
@parse_str($id);
if ($a[0] != ‘QNKCDZO’ && md5($a[0]) == md5(‘QNKCDZO’)) {
echo $flag;
} else {
exit(‘其实很简单其实并不难!’);
}
}首先检查 URL 中是否没有传递
id
参数,或者传递的id
参数是一个空值。如果是的话,显示文件的源码。
@
符号用于抑制任何可能的错误或警告消息。@parse_str($id);
会对传入的内容进行处理,将字符串解析为变量。PHP Hash比较存在缺陷 ,它把每一个以”0E”开头的哈希值都解释为0,所以如果两个不同的密码经过哈希以后,其哈希值都是以”0E”开头的,那么PHP将会认为他们相同,都是0。而这里的 md5(‘QNKCDZO’) 的结果是 0e830400451993494058024219903391 。所以payload为 ?id=a[0]=s878926199a 。
import_request_variables()
使用不当
版本要求:PHP 4 >= 4.1.0, PHP 5 < 5.4.0
1 | bool import_request_variables ( string $types [, string $prefix ] ) |
string
指定导入哪些变量为全局变量,可以用字母G
、P
和C
分别表示导入GET
、POST
和Cookie
中的变量。这些字母不区分大小写,所以你可以使用g
、p
和c
的任何组合。POST
包含了通过POST
方法上传的文件信息。注意这些字母的顺序,当使用gp
时,POST
变量将使用相同的名字覆盖GET
变量。任何GPC
以外的字母都将被忽略prefix
$prefix
变量名的前缀,置于所有被导入到全局作用域的变量之前。所以如果你有个名为 userid 的 GET 变量,同时提供了 pref_ 作为前缀,那么你将获得一个名为 $pref_userid 的全局变量。虽然 prefix 参数是可选的,但如果不指定前缀,或者指定一个空字符串作为前缀,你将获得一个 E_NOTICE 级别的错误。1
2
3
4
5
6
7
// 此处将导入 GET 和 POST 变量
// 使用 runoob_ 作为前缀
import_request_variables("gP", "runoob_");
echo $runoob_foo;
修复
在php.ini文件中设置register_globals=OFF
使用原始变量数组,如
$_POST
,$_GET
等数组变量进行操作不使用foreach语句来遍历$_GET变量,而改用[(index)]来指定
验证变量是否存在,注册变量前先判断变量是否存在
题目:
1
2
3
4
5
6
7
8 php
$auth = '0';
import_request_variables('G');
if ($auth == 1) {
echo "登陆成功";
} else {
echo "登陆失败";
}
import_request_variables('G');
:
- 这是一个将 GET 请求参数导入到全局命名空间中的函数。
'G'
表示只导入 GET 请求参数。- 这意味着,如果 URL 中包含
auth
参数,例如example.com/script.php?auth=1
,该参数的值将覆盖现有的$auth
变量。所以直接传参
?auth=1
0X03 动态调试
phpstudy
在phpstudy中,勾选xdebug的PHP扩展选项
xdebug helper 插件
在浏览器应用商店里安装xdebug helper插件,并设置IDE key
phpstorm设置
配置
端口为 thinkphp 的web 端口
在phpstorm中打开小虫子监听,在浏览器中把插件,选成绿色虫子状态,并刷新页面。
刷新页面后,会跳转到phpstorm页面,并显示如下页面。
调试成功
找到漏洞函数/漏洞点,调出它的调用链,然后进一步分析
在漏洞点处打断点
xdebug helper,浏览器页面刷新,跳转到phpstorm 进入动态调试
按绿色小箭头,让程序正常运行,会在断点处停下,这样就可以找到调用链了。然后分析下去。
调试过程中总是中断
在调试的过程中,总是中断并且报500的错误,原因是 apache 默认的连接时间过短,调试时超过了默认的连接时间。
解决方案:修改phpstudy的apache配置,并重启apache。
1 | FcgidIOTimeout 3000 |
配置文件里不能有中文
0X04 补丁分析
0X05 漏洞分析
调用链
漏洞点
漏洞成因是由于**flags
参数默认为 EXTR_OVERWRITE
,即如果有冲突,覆盖已有的变量**
extract(array[,flag][,prefix])
:
- array
一个关联数组(必需)。此函数会将键名当作变量名,值作为变量的值。 对每个键/值对都会在当前的符号表中建立变量,并受到flags
和prefix
参数的影响。
必须使用关联数组,数字索引的数组将不会产生结果,除非用了EXTR_PREFIX_ALL
或者EXTR_PREFIX_INVALID
。 - flags
对待非法/数字和冲突的键名的方法将根据取出标记flags
参数决定。可以是以下值之一:- EXTR_OVERWRITE
如果有冲突,覆盖已有的变量。(默认)
- EXTR_OVERWRITE
安全的做法是确定register_globals=OFF后,在调用extract()时使用EXTR_SKIP保证已有变量不会被覆盖。
1 | public function read($cacheFile, $vars = []) |
这里调用了 extract($vars, EXTR_OVERWRITE);
采用的是覆盖已有变量的方式。可以通过在$vars
里面传数据的方式,来覆盖cacheFile
这个变量。
index文件修改
需要在源文件的基础上,修改 application/index/controller/Index.php
为视图模板。并且增加一个文件 application/index/view/index/index.html
(内容可为空,只需有这个文件即可)
1 |
|
漏洞跟进
断点打在
$this->assign(request()->get());
然后点step into
,一步步跟进,看参数都经过了哪些处理。
从 application/index/controller/Index.php
传参点跟进。
1 | public function index() |
$this->assign(request()->get());
request()->get()
:获取当前 HTTP 请求的所有 GET 参数,并返回一个数组。$this->assign()
:通常用于将数据分配到视图。在 ThinkPHP 中,assign
方法将数据分配到模板变量,使它们可以在视图模板中使用。
return $this->fetch();
$this->fetch()
:渲染并返回视图。根据注释,它会默认加载当前模块的视图目录下,当前控制器和当前操作对应的模板文件。
跟进fetch
函数
1 | protected function fetch($template = '', $vars = [], $replace = [], $config = []) |
fetch 需要传入 template 模板信息和模板变量(即当前模块/默认视图目录/当前控制器(小写)/当前操作(小写).html application/index/view/index/index.html
)
1 | public function fetch($template = '', $vars = [], $replace = [], $config = [], $renderContent = false) |
1 | $method = $renderContent ? 'display' : 'fetch'; |
根据 $renderContent
的值选择调用 display
还是 fetch
方法。display
用于直接输出内容,而 fetch
用于返回渲染后的内容。
1 | $this->engine->$method($template, $vars, $config); |
调用模板引擎的 fetch
或 display
方法进行渲染。
从调用链里,看到是调用的fetch方法。跟进引擎中的think\view\driver\Think->fetch()
。
1 | public function fetch($template, $data = [], $config = []) |
1 | public function fetch($template, $vars = [], $config = []) |
1 | public function read($cacheFile, $vars = []) |
调用request函数的get对象,来进行传参。传入的数据,送进
assign
进行处理。assign
方法通常用于 MVC 框架中的控制器(如 ThinkPHP),将数据分配到视图中,使其可以在模板中使用数据。
1
2
3
4
5
6
7
8
9 >public function assign($name, $value = '')
>{
if (is_array($name)) {
$this->data = array_merge($this->data, $name);
} else {
$this->data[$name] = $value;
}
return $this;
>}**
$name
**:
- 可以是一个字符串或一个数组。如果是字符串,则表示要分配到视图中的单个变量名;如果是数组,则表示要分配到视图中的多个变量名和值的键值对。
**
$value
**:
- 这是可选参数,默认为空字符串。仅当
$name
是字符串时才使用,表示要分配到视图中的变量的值。
- 如果name是数组,执行
array_merge
操作,将$name
中的所有键值对合并到$this->data
中。如果$name
不是数组(即是字符串),则将$value
赋值给$this->data
中对应的键$name
。例如:
1
2
3
4 $this->data = ['a' => 1, 'b' => 2];
$name = ['b' => 3, 'c' => 4];
//执行 array_merge 操作
// 结果:$this->data = ['a' => 1, 'b' => 3, 'c' => 4];将
$value
赋值给$this->data
中对应的键$name
。
1
2
3 $name = 'd';
$value = 5;
// 结果:$this->data['d'] = 5;
0X06 POC 构造
0X07 漏洞复现
在public文件夹下,放进去一个图片马(模拟文件上传)访问/public/?cacheFile=xxxx.xxx
可触发漏洞。文件包含漏洞,上传的文件,不论什么后缀,都会被解析。(解析为php文件)
图片马的制作:
用010 editor,直接在文件后面加入php代码即可。然后保存为图片格式。