php框架中模板的包含问题

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

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

ThinkPHP 3

版本 thinkphp 3.2.3

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

<?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方法。

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方法

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

public function parseTemplate($template='') {
        if(is_file($template)) {
            return $template;
        }
  //省略部分
}

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

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

番外

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

/**
     * 输出内容文本可以包括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代码的方式有一点小变化。

测试代码如下:

<?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

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方法:

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运行的流程,安全处理等。

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

<?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

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

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

测试代码

<?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

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

测试代码

<?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

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

添加新评论