抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

0X01 漏洞概况

在于 ThinkPHP 模板引擎中,在加载模版解析变量时存在变量覆盖问题,而且程序没有对数据进行很好的过滤,最终导致文件包含漏洞 的产生。漏洞影响版本: 5.0.0<=ThinkPHP5<=5.0.185.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版本的安装:

  1. 下载git源码:Releases · top-think/think (github.com)

  2. 将源码放在phpstudy的www目录下

    php版本大于5.4

  3. composer安装

    1. composer版本1.8.5

    2. 更换composer镜像源

      1
      composer config -g repo.packagist composer https://mirrors.cloud.tencent.com/composer/
    3. composer安装

      1
      2
      cd thinkphp5.0.12
      composer install

5.0.18 版本的安装:

  1. phpstudy 新建一个网站

  2. 用phpstudy 里的composer,通过以下命令获取测试环境代码:

    1
    composer create-project --prefer-dist topthink/think=5.0.18 tpdemo
  3. composer.json 文件的 require 字段设置成如下

    1
    2
    3
    4
    "require": {
    "php": ">=5.6.0",
    "topthink/framework": "5.0.18"
    },
  4. tpdemo 目录下执行

    1
    composer update

0X02 PHP变量覆盖漏洞原理介绍

自定义的参数值替换原有变量值的情况称为变量覆盖漏洞。经常导致变量覆盖漏洞场景有:

  1. 开启了全局变量注册
  2. $$ 使用不当
  3. extract() 函数使用不当
  4. parse_str() 函数使用不当
  5. import_request_variables() 使用不当等。

全局变量注册

register_globals 在 PHP5.3 之前,默认开启;PHP5.3 默认关闭,PHP5.6 及 5.7 已经被移除.

由于本次漏洞分析,基于php5.4 以上版本,所以该方法不展开讲解

$$ 动态变量覆盖

在PHP中,$$被称为“可变变量”。可变变量的概念是将一个变量的值作为另一个变量的名字。

假设你有一个变量 $a,并且你希望动态地创建或访问一个以 $a 的值为名字的变量。这时就可以用到 $$

1
2
3
4
5
6
<?php
$a = "hello";
$$a = "world";

echo $hello; // 输出 "world"
?>

在上面的例子中:

  1. 首先,$a 被赋值为字符串 "hello"
  2. 接着,$$a 就等同于 $hello,因为 $a 的值是 "hello"
  3. 然后,我们给 $hello 赋值为 "world"
  4. 最后,输出 $hello 的值,结果是 "world"

通过这种方式,你可以动态地创建和访问变量。这种用法在需要根据变量名的值来生成新变量名的情况下非常有用,例如在处理动态数据或配置时。


实操一道题,来获取flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
foreach (array('_POST','_GET') as $_request)
{
foreach ($$_request as $_key=>$_value)
{
$$_key = $_value;
}
}
$id = isset($id) ? $id : 2;
if ($id == 1) {
include('./flag.php'); // 确保文件名正确
die();
}
?>

$_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
2
3
4
5
6
<?php
$a = array('nickname' => '1ndex');
extract($a);
echo $nickname;
?>
//运行结果为输出 1ndex

extract(array[,flag][,prefix])三个参数:

  • array
    一个关联数组(必需)。此函数会将键名当作变量名,值作为变量的值。 对每个键/值对都会在当前的符号表中建立变量,并受到 flagsprefix 参数的影响。
    必须使用关联数组,数字索引的数组将不会产生结果,除非用了 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 与其它任何标志结合使用。
  • 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
<?php
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
<?php if ($_SERVER["REQUEST_METHOD"] == “POST”) { ?>

<?php

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
    输入的字符串。如果 strURL 传递入的查询字符串(query string),则将它解析为变量并设置到当前作用域(如果提供了 array 则会设置到该数组里)
    注意,php >= 7.2,如果不设置该参数,该函数将失效
  • array
    一个数组,如果设置该参数, 变量将会以数组元素的形式存入到这个数组,作为替代
1
2
3
4
5
6
7
8
<?php
$str = "first=value&arr[]=foo+bar&arr[]=baz";

parse_str($str, $output);
echo $output['first']; // value
echo $output['arr'][0]; // foo bar
echo $output['arr'][1]; // baz
?>

题目:

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
    指定导入哪些变量为全局变量,可以用字母 GPC 分别表示导入 GETPOSTCookie 中的变量。这些字母不区分大小写,所以你可以使用 gpc 的任何组合。POST 包含了通过 POST 方法上传的文件信息。注意这些字母的顺序,当使用 gp 时,POST 变量将使用相同的名字覆盖 GET 变量。任何 GPC 以外的字母都将被忽略

  • prefix
    $prefix 变量名的前缀,置于所有被导入到全局作用域的变量之前。所以如果你有个名为 userid 的 GET 变量,同时提供了 pref_ 作为前缀,那么你将获得一个名为 $pref_userid 的全局变量。虽然 prefix 参数是可选的,但如果不指定前缀,或者指定一个空字符串作为前缀,你将获得一个 E_NOTICE 级别的错误。

    1
    2
    3
    4
    5
    6
    7
    <?php
    // 此处将导入 GET 和 POST 变量
    // 使用 runoob_ 作为前缀
    import_request_variables("gP", "runoob_");

    echo $runoob_foo;
    ?>
  • 修复

    1. 在php.ini文件中设置register_globals=OFF

    2. 使用原始变量数组,如$_POST,$_GET等数组变量进行操作

    3. 不使用foreach语句来遍历$_GET变量,而改用[(index)]来指定

    4. 验证变量是否存在,注册变量前先判断变量是否存在

题目:

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设置

  1. 配置

    端口为 thinkphp 的web 端口

  2. 在phpstorm中打开小虫子监听,在浏览器中把插件,选成绿色虫子状态,并刷新页面。

  3. 刷新页面后,会跳转到phpstorm页面,并显示如下页面。

  4. 调试成功

  5. 找到漏洞函数/漏洞点,调出它的调用链,然后进一步分析

    1. 在漏洞点处打断点

    2. xdebug helper,浏览器页面刷新,跳转到phpstorm 进入动态调试

    3. 按绿色小箭头,让程序正常运行,会在断点处停下,这样就可以找到调用链了。然后分析下去。

调试过程中总是中断

在调试的过程中,总是中断并且报500的错误,原因是 apache 默认的连接时间过短,调试时超过了默认的连接时间。

解决方案:修改phpstudy的apache配置,并重启apache。

1
2
FcgidIOTimeout 3000  
FcgidConnectTimeout 3000

配置文件里不能有中文

0X04 补丁分析

0X05 漏洞分析

调用链

漏洞点

漏洞成因是由于**flags 参数默认为 EXTR_OVERWRITE,即如果有冲突,覆盖已有的变量**

extract(array[,flag][,prefix])

  • array
    一个关联数组(必需)。此函数会将键名当作变量名,值作为变量的值。 对每个键/值对都会在当前的符号表中建立变量,并受到 flagsprefix 参数的影响。
    必须使用关联数组,数字索引的数组将不会产生结果,除非用了 EXTR_PREFIX_ALL 或者 EXTR_PREFIX_INVALID
  • flags
    对待非法/数字和冲突的键名的方法将根据取出标记 flags 参数决定。可以是以下值之一:
    • EXTR_OVERWRITE
      如果有冲突,覆盖已有的变量。(默认)

安全的做法是确定register_globals=OFF后,在调用extract()时使用EXTR_SKIP保证已有变量不会被覆盖。

1
2
3
4
5
6
7
8
9
10
public function read($cacheFile, $vars = [])
{
$this->cacheFile = $cacheFile;
if (!empty($vars) && is_array($vars)) {
// 模板阵列变量分解成为独立变量
extract($vars, EXTR_OVERWRITE);
}
//载入模版缓存文件
include $this->cacheFile;
}

这里调用了 extract($vars, EXTR_OVERWRITE);采用的是覆盖已有变量的方式。可以通过在$vars里面传数据的方式,来覆盖cacheFile这个变量。


index文件修改

需要在源文件的基础上,修改 application/index/controller/Index.php 为视图模板。并且增加一个文件 application/index/view/index/index.html(内容可为空,只需有这个文件即可)

输出替换 - ThinkPHP官方手册

1
2
3
4
5
6
7
8
9
10
11
<?php
namespace app\index\controller;
use think\Controller;
class Index extends Controller
{
public function index()
{
$this->assign(request()->get());
return $this->fetch(); // 当前模块/默认视图目录/当前控制器(小写)/当前操作(小写).html
}
}

漏洞跟进

断点打在 $this->assign(request()->get()); 然后点 step into ,一步步跟进,看参数都经过了哪些处理。

application/index/controller/Index.php 传参点跟进。

1
2
3
4
5
public function index()
{
$this->assign(request()->get());
return $this->fetch(); // 当前模块/默认视图目录/当前控制器(小写)/当前操作(小写).html
}

$this->assign(request()->get());

  • request()->get():获取当前 HTTP 请求的所有 GET 参数,并返回一个数组。
  • $this->assign():通常用于将数据分配到视图。在 ThinkPHP 中,assign 方法将数据分配到模板变量,使它们可以在视图模板中使用。

return $this->fetch();

  • $this->fetch():渲染并返回视图。根据注释,它会默认加载当前模块的视图目录下,当前控制器和当前操作对应的模板文件。

跟进fetch函数

1
2
3
4
protected function fetch($template = '', $vars = [], $replace = [], $config = [])
{
return $this->view->fetch($template, $vars, $replace, $config);
}

fetch 需要传入 template 模板信息和模板变量(即当前模块/默认视图目录/当前控制器(小写)/当前操作(小写).html application/index/view/index/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function fetch($template = '', $vars = [], $replace = [], $config = [], $renderContent = false)
{
......
// 渲染输出
try {
$method = $renderContent ? 'display' : 'fetch';
// 允许用户自定义模板的字符串替换
$replace = array_merge($this->replace, $replace, (array) $this->engine->config('tpl_replace_string'));
$this->engine->config('tpl_replace_string', $replace);
$this->engine->$method($template, $vars, $config);
} catch (\Exception $e) {
ob_end_clean();
throw $e;
}
.....
}
1
$method = $renderContent ? 'display' : 'fetch';

根据 $renderContent 的值选择调用 display 还是 fetch 方法。display 用于直接输出内容,而 fetch 用于返回渲染后的内容。

1
$this->engine->$method($template, $vars, $config);

调用模板引擎的 fetchdisplay 方法进行渲染。


从调用链里,看到是调用的fetch方法。跟进引擎中的think\view\driver\Think->fetch()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function fetch($template, $data = [], $config = [])
{
if ('' == pathinfo($template, PATHINFO_EXTENSION)) {
// 获取模板文件名
$template = $this->parseTemplate($template);
}
// 模板不存在 抛出异常
if (!is_file($template)) {
throw new TemplateNotFoundException('template not exists:' . $template, $template);
}
// 记录视图信息
App::$debug && Log::record('[ VIEW ] ' . $template . ' [ ' . var_export(array_keys($data), true) . ' ]', 'info');
$this->template->fetch($template, $data, $config);
}

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
public function fetch($template, $vars = [], $config = [])
{
if ($vars) {
$this->data = $vars;
}
if ($config) {
$this->config($config);
}
if (!empty($this->config['cache_id']) && $this->config['display_cache']) {
// 读取渲染缓存
$cacheContent = Cache::get($this->config['cache_id']);
if (false !== $cacheContent) {
echo $cacheContent;
return;
}
}
$template = $this->parseTemplateFile($template);
if ($template) {
$cacheFile = $this->config['cache_path'] . $this->config['cache_prefix'] . md5($this->config['layout_name'] . $template) . '.' . ltrim($this->config['cache_suffix'], '.');
if (!$this->checkCache($cacheFile)) {
// 缓存无效 重新模板编译
$content = file_get_contents($template);
$this->compiler($content, $cacheFile);
}
// 页面缓存
ob_start();
ob_implicit_flush(0);
// 读取编译存储
$this->storage->read($cacheFile, $this->data);
// 获取并清空缓存
$content = ob_get_clean();
if (!empty($this->config['cache_id']) && $this->config['display_cache']) {
// 缓存页面输出
Cache::set($this->config['cache_id'], $content, $this->config['cache_time']);
}
echo $content;
}
}

1
2
3
4
5
6
7
8
9
10
public function read($cacheFile, $vars = [])
{
if (!empty($vars) && is_array($vars)) {
// 模板阵列变量分解成为独立变量
extract($vars, EXTR_OVERWRITE);
}
//载入模版缓存文件
include $cacheFile;
}


调用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代码即可。然后保存为图片格式。

0X08 修复方案

0X09 参考文章

PHP 变量覆盖漏洞 - 1ndex- - 博客园 (cnblogs.com)

评论