本文的灵感来自于前不久swpu的一道ctf题目——You Think I Think,由于那周要参加省赛,所以没有好好看这些题目,现在回过头来看大佬的wp,感觉学到不少东西。

2018.02.26 来填坑了~~ 仔细想了想还是把名字改了,这种情况还是更适合叫文件包含而不是模板注入。

ThinkPHP 3

版本 thinkphp 3.2.3

由浅入深吧,先从tp3开始,测试代码如下:

1
2
3
4
5
6
7
8
9
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function test2() {
$path = I('p');
return $this->display($path);
}
}

假设要输出的模板文件是我们可控的,我们可以上传一个含有php代码的文件,然后将该文件的位置传到display中,tp会将其中的php代码解析执行,看起来很像文件包含

如果使用相对路径的话,默认的根目录为tp入口文件index.php所在的目录。

mark

当然使用绝对路径包含web目录之外的文件也是可以的

mark

如果想在tp模板里执行php代码有两种方法,见手册使用PHP代码

ps. 在测试的时候常用的一句话没有生效,不知道是什么原因。

下面我们就从代码的角度来分析一下这个漏洞。

首先来到ThinkPHP/Library/Think/Controller.php,我们调用了这里的display方法,这里又调用了ThinkPHP/Library/Think/View.phpdisplay方法。

1
2
3
4
5
6
7
8
9
10
11
public function display($templateFile='',$charset='',$contentType='',$content='',$prefix='') {
G('viewStartTime');
// 视图开始标签
Hook::listen('view_begin',$templateFile);
// 解析并获取模板内容
$content = $this->fetch($templateFile,$content,$prefix);
// 输出模板内容
$this->render($content,$charset,$contentType);
// 视图结束标签
Hook::listen('view_end');
}

跟进fetch方法

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
public function fetch($templateFile='',$content='',$prefix='') {
if(empty($content)) {
$templateFile = $this->parseTemplate($templateFile);
// 模板文件不存在直接返回
if(!is_file($templateFile)) E(L('_TEMPLATE_NOT_EXIST_').':'.$templateFile);
}else{
defined('THEME_PATH') or define('THEME_PATH', $this->getThemePath());
}
// 页面缓存
ob_start();
ob_implicit_flush(0);
if('php' == strtolower(C('TMPL_ENGINE_TYPE'))) { // 使用PHP原生模板
$_content = $content;
// 模板阵列变量分解成为独立变量
extract($this->tVar, EXTR_OVERWRITE);
// 直接载入PHP模板
empty($_content)?include $templateFile:eval('?>'.$_content);
}else{
// 视图解析标签
$params = array('var'=>$this->tVar,'file'=>$templateFile,'content'=>$content,'prefix'=>$prefix);
Hook::listen('view_parse',$params);
}
// 获取并清空缓存
$content = ob_get_clean();
// 内容过滤标签
Hook::listen('view_filter',$content);
// 输出模板文件
return $content;
}

先看一下parseTemplate

1
2
3
4
5
6
public function parseTemplate($template='') {
if(is_file($template)) {
return $template;
}
//省略部分
}

tp可能会出现模板注入的根源就在这里,只是简单检测传入的文件是否存在,是否为文件类型,就直接返回了。对文件内容、路径等都没有检测。这也是上面提到的那道ctf题目的出题思路。

剩下的模板编译、解析问题就不再多说了,反正手册上说支持php代码。。。

番外

在看代码的过程中看到了show方法

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 输出内容文本可以包括Html 并支持内容解析 ******
* @access protected
* @param string $content 输出内容
* @param string $charset 模板输出字符集
* @param string $contentType 输出类型
* @param string $prefix 模板缓存前缀
* @return mixed
*/
protected function show($content,$charset='',$contentType='',$prefix='') {
$this->view->display('',$charset,$contentType,$content,$prefix);
}

如果这个方法传入的内容可控的话,就可以直接执行php代码了,这个跟我之前了解到的模板注入比较像,上面那个怎么看怎么像文件包含。。。

ThinkPHP 5

版本 thinkphp 5.0.11

tp5相较于tp3有一些改变:

  1. 最明显的就是display变成了fetch,而show变成了display。。。果真是大改版233333。
  2. tp5的入口文件在public目录下
  3. 使用php代码的方式有一点小变化。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
namespace app\index\controller;
use think\Controller;
class Index extends Controller
{
public function test3() {
$path = input('p');
return $this->fetch($path);
}

public function test4() {
return $this->display('<?php phpinfo();?>');
}
}

tp5引入了很多先进的编程思想,因而文件之间的调用变得更加复杂,但是问题依然没有解决,tp3中出现的问题依然存在。具体的函数跟踪过程就不再多说了,tp5的debug模式可以清楚直观的展现出来,我们直接定位到关键代码。

首先是thinkphp\library\view\driver\Think.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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);
}

先是检测输入的模板文件名有没有后缀,如果没有就调用另一个fetch方法。

thinkphp\library\view\driver\Template.php

这里的fetch方法中加了一层缓存机制,所以代码看起来比较长,但是没有对模板内容有什么特别的操作,我们直接看parseTemplateFile方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private function parseTemplateFile($template)
{
if ('' == pathinfo($template, PATHINFO_EXTENSION)) {
if (strpos($template, '@')) {
list($module, $template) = explode('@', $template);
}
if (0 !== strpos($template, '/')) {
$template = str_replace(['/', ':'], $this->config['view_depr'], $template);
} else {
$template = str_replace(['/', ':'], $this->config['view_depr'], substr($template, 1));
}
/*省略部分*/
}

if (is_file($template)) {
// 记录模板文件的更新时间
$this->includeFile[$template] = filemtime($template);
return $template;
} else {
throw new TemplateNotFoundException('template not exists:' . $template, $template);
}
}

可以看到这里是做了一些处理的,将/替换为:,但是。。。这是在没有后缀的前提下啊,有后缀名的话直接来到最后了,啥也没干就给return了。。。

综上所述,虽然tp5走的流程比较复杂一点,但是。。。

Laravel

版本 laravel 5.5

由于接触laravel的时间不长,再加上它比较复杂,有些地方可能说的不那么准确,欢迎各位大佬指正。

之后有时间再详细写一写laravel运行的流程,安全处理等。

测试代码(配置路由等操作就省略了):

1
2
3
4
5
6
7
<?php
namespace App\Http\Controllers;
class IndexController extents Controller {
public function test() {
return view('welcome');
}
}

看测试代码就知道了,laravel并不存在上面的问题,我也没有找到类似tp中的show这样能够直接输出模板内容的函数。

下面开始代码分析,首先view是个助手函数,它实例化了一个ViewFactory对象并调用了make方法。

vendor/laravel/framework/src/illuminate/View/Factory.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function make($view, $data = [], $mergeData = [])
{
$path = $this->finder->find(
$view = $this->normalizeName($view)
);

// Next, we will create the view instance and call the view creator for the view
// which can set any data, etc. Then we will return the view instance back to
// the caller for rendering or performing other view manipulations on this.
$data = array_merge($mergeData, $this->parseData($data));

return tap($this->viewInstance($view, $path, $data), function ($view) {
$this->callCreator($view);
});
}

接着看normalizeName方法,它实际上是调用ViewName.php中的normalize方法。

vendor/laravel/framework/src/illuminate/View/ViewName.php

1
2
3
4
5
6
7
8
9
10
11
12
public static function normalize($name)
{
$delimiter = ViewFinderInterface::HINT_PATH_DELIMITER;

if (strpos($name, $delimiter) === false) {
return str_replace('/', '.', $name);
}

list($namespace, $name) = explode($delimiter, $name);

return $namespace.$delimiter.str_replace('/', '.', $name);
}

到这里已经很明显了,这个方法将/全部替换为.,不管是绝对路径还是相对路径都办法用了,我们能引用模板文件的目录就这样被“锁住”了。

其他的以后再补充,立个flag吧,之后补上Yii和CI框架的,希望别打自己的脸。。。

来填坑了,233。这两个框架接触的并不多,粗略的写一下吧。

Yii

测试代码

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

namespace app\controllers;

use Yii;
use yii\filters\AccessControl;
use yii\web\Controller;
use yii\web\Response;
use yii\filters\VerbFilter;

class SiteController extends Controller
{
public function actionTest() {
$temp = Yii::$app->request->get('t');
return $this->render($temp);
}
}

经过测试,yii框架同样存在这种包含问题,只不过不能使用绝对路径来包含项目外的文件,只能使用下面这个方法跨越目录。

mark

php代码执行自不必说,原生的php标签即可。

废话不多说,直接定位到关键代码

vendor\yiisoft\yii2\base\View.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
32
33
protected function findViewFile($view, $context = null)
{
if (strncmp($view, '@', 1) === 0) {
// e.g. "@app/views/main"
$file = Yii::getAlias($view);
} elseif (strncmp($view, '//', 2) === 0) {
// e.g. "//layouts/main"
$file = Yii::$app->getViewPath() . DIRECTORY_SEPARATOR . ltrim($view, '/');
} elseif (strncmp($view, '/', 1) === 0) {
// e.g. "/site/index"
if (Yii::$app->controller !== null) {
$file = Yii::$app->controller->module->getViewPath() . DIRECTORY_SEPARATOR . ltrim($view, '/');
} else {
throw new InvalidCallException("Unable to locate view file for view '$view': no active controller.");
}
} elseif ($context instanceof ViewContextInterface) {
$file = $context->getViewPath() . DIRECTORY_SEPARATOR . $view;
} elseif (($currentViewFile = $this->getViewFile()) !== false) {
$file = dirname($currentViewFile) . DIRECTORY_SEPARATOR . $view;
} else {
throw new InvalidCallException("Unable to resolve view file for view '$view': no active view context.");
}

if (pathinfo($file, PATHINFO_EXTENSION) !== '') {
return $file;
}
$path = $file . '.' . $this->defaultExtension;
if ($this->defaultExtension !== 'php' && !is_file($path)) {
$path = $file . '.php';
}

return $path;
}

yii有多种方式来获取视图的路径,具体可以看手册

mark

从代码中我们可以看到无论哪一种方法,都没有对视图名的合法性做检验,只是简单的限制了一下视图的路径,这也就是可以使用目录穿越而不能使用绝对路径包含得原因。

CI

测试代码

1
2
3
4
5
6
7
8
9
10
<?php
defined('BASEPATH') OR exit('No direct script access allowed');

class Welcome extends CI_Controller {
public function index()
{
$temp = $this->input->get('t');
$this->load->view($temp);
}
}

ci框架与yii的情况差不多,不能用绝对路径包含,可以用目录跨越。

还是直接看关键代码

system/core/Loader.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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
protected function _ci_load($_ci_data)
{
// Set the default data variables
foreach (array('_ci_view', '_ci_vars', '_ci_path', '_ci_return') as $_ci_val)
{
$$_ci_val = isset($_ci_data[$_ci_val]) ? $_ci_data[$_ci_val] : FALSE;
}

$file_exists = FALSE;

// Set the path to the requested file
if (is_string($_ci_path) && $_ci_path !== '')
{
$_ci_x = explode('/', $_ci_path);
$_ci_file = end($_ci_x);
}
else
{
$_ci_ext = pathinfo($_ci_view, PATHINFO_EXTENSION);
$_ci_file = ($_ci_ext === '') ? $_ci_view.'.php' : $_ci_view;

foreach ($this->_ci_view_paths as $_ci_view_file => $cascade)
{
//输入的模板名先和模板目录拼接,导致不能使用绝对路径
if (file_exists($_ci_view_file.$_ci_file))
{
$_ci_path = $_ci_view_file.$_ci_file;
$file_exists = TRUE;
break;
}

if ( ! $cascade)
{
break;
}
}
}

if ( ! $file_exists && ! file_exists($_ci_path))
{
show_error('Unable to load the requested file: '.$_ci_file);
}

// This allows anything loaded using $this->load (views, files, etc.)
// to become accessible from within the Controller and Model functions.
$_ci_CI =& get_instance();
foreach (get_object_vars($_ci_CI) as $_ci_key => $_ci_var)
{
if ( ! isset($this->$_ci_key))
{
$this->$_ci_key =& $_ci_CI->$_ci_key;
}
}

/*
* Extract and cache variables
*
* You can either set variables using the dedicated $this->load->vars()
* function or via the second parameter of this function. We'll merge
* the two types and cache them so that views that are embedded within
* other views can have access to these variables.
*/
empty($_ci_vars) OR $this->_ci_cached_vars = array_merge($this->_ci_cached_vars, $_ci_vars);
extract($this->_ci_cached_vars); //这里可能存在变量覆盖,还没想出怎么搞事情

ob_start();
// If the PHP installation does not support short tags we'll
// do a little string replacement, changing the short tags
// to standard PHP echo statements.
if ( ! is_php('5.4') && ! ini_get('short_open_tag') && config_item('rewrite_short_tags') === TRUE)
{
echo eval('?>'.preg_replace('/;*\s*\?>/', '; ?>', str_replace('<?=', '<?php echo ', file_get_contents($_ci_path))));
}
else
{
//这里应该是四个框架中最直观的一个,就是文件包含
include($_ci_path); // include() vs include_once() allows for multiple views with the same name
}
/*省略部分*/
return $this;
}

函数比较长,删除了一部分。可以看到ci框架同样没有对模板路径做检测,只是限制了一下范围。

最后

几个框架看下来,只有laravel真正处理了这个问题。对于然而框架只是一种简化开发的工具,我们并不能将应用安全全部交给框架来处理。当然这个问题在实际中也并不常见,防御的话也很简单,只需将./\等字符过滤即可。