ThinkPHP5 文件包含漏洞

tp5在调用模板函数assign和fetch时会触发严重的文件包含漏洞, 本文从源码角度追溯漏洞成因。

模板函数

  • assign(): 给模板变量赋值,然后接收两个参数,一个是要显示在模板中的变量名,另一个是要传入的变量的值。
  • fetch(): 获取模板文件, 渲染模板(assign赋值的)变量, 输出模板。
    对于这两个函数的更多内容请自行查阅文档, 本文以分析漏洞为主。

漏洞分析

下面的内容需要读者清楚一些tp5的目录结构, 如有需要请自行查阅文档。我们首先在application/index/Controller/index.php中给出漏洞代码:

<?php
namespace app\index\controller;
use think\Controller;
class Index extends Controller
{
    public function index()
    {
        $this->assign(request()->get());
        return $this->fetch();
    }
}
?>

上面的代码存在严重的文件包含漏洞, 我们首先跟进fetch函数:
Controller.php

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

调用了$this->view的fetch()方法, 继续跟进:
View.php

public function fetch($template = '', $vars = [], $replace = [], $config = [], $renderContent = false)
    {
        // 模板变量
        $vars = array_merge(self::$var, $this->data, $vars);

        // 页面缓存
        ob_start();
        ob_implicit_flush(0);

        // 渲染输出
        $method = $renderContent ? 'display' : 'fetch';
        $this->engine->$method($template, $vars, $config); //*******
 
        // 获取并清空缓存
        $content = ob_get_clean();
        // 内容过滤标签
        Hook::listen('view_filter', $content);
        // 允许用户自定义模板的字符串替换
        $replace = array_merge($this->replace, $replace);
        if (!empty($replace)) {
            $content = strtr($content, $replace);
        }
        return $content;
    }

请看加星号的程序this->engine->method(template,vars, config);,这里的method是一个变量, 它的值由renderContent来定, 因为renderContent默认是false(自己向上跟一下函数调用时的传值过程),所以method可以控制为fetch, 这里相当于调用了this->engine->fetch函数, 下面我们需要直到$this->engine是谁的对象, 那就看一下该类的构造器函数:

 public function __construct($engine = [], $replace = [])
    {
        // 初始化模板引擎
        $this->engine((array) $engine);
        // 基础替换字符串
        $request = Request::instance();
        $base    = $request->root();
        $root    = strpos($base, '.') ? ltrim(dirname($base), DS) : $base;
        if ('' != $root) {
            $root = '/' . ltrim($root, '/');
        }
        $baseReplace = [
            '__ROOT__'   => $root,
            '__URL__'    => $base . '/' . $request->module() . '/' . Loader::parseName($request->controller()),
            '__STATIC__' => $root . '/static',
            '__CSS__'    => $root . '/static/css',
            '__JS__'     => $root . '/static/js',
        ];
        $this->replace = array_merge($baseReplace, (array) $replace);
    }

看第一句, 调用了 this->engine((array)engine)函数, 传入的$engine默认为空, 跟一下该函数:

    public function engine($options = [])
    {
        if (is_string($options)) {
            $type    = $options;
            $options = [];
        } else {
            $type = !empty($options['type']) ? $options['type'] : 'Think'; //$options['type']为空时, type为Think
        }

        $class = false !== strpos($type, '\\') ? $type : '\\think\\view\\driver\\' . ucfirst($type);//为class赋值,$type中不包含‘\\’时 为后者 
        if (isset($options['type'])) {
            unset($options['type']);
        }
        $this->engine = new $class($options);
        return $this;
    }

程序很简单, 看一下注释应该就能理解, 记得上面在构造器中调用该函数时传入了一个空值吧(默认), 当options为空时, Type为Think,class为'\think\view\driver\Think', this->engine是class的对象。回到fetch函数中, $this->engine->fetch调用了Think类中的fetch, 跟进
\think\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);
    }

最后一句this->template->fetch(template, data,config);, 在跟一下, 马上就胜利了, 坚持一下:

    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($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;
        }
    }

程序中标星号的代码this->storage->read(cacheFile, this->data), 调用了read函数, 和分析this->engine一样的步骤, 我们分析出$this->storage是\think\template\driver\File的对象:
\think\template\driver\File.php:

   public function read($cacheFile, $vars = [])
    {
        if (!empty($vars) && is_array($vars)) {
            // 模板阵列变量分解成为独立变量
            extract($vars, EXTR_OVERWRITE);//******
        }
        //载入模版缓存文件
        include $cacheFile;//*****
    }

很明显了吧, 包含extract($vars, EXTR_OVERWRITE);变量覆盖函数, 和include文件包含函数, 我们只需要对vars赋值, 使其包含cacheFile变量, 就可以造成任意文件包含。
下面的问题就是怎么对vars赋值, 这需要我们回过头来分析一下assign函数,
controller.php

   protected function assign($name, $value = '')
    {
        $this->view->assign($name, $value);
    }

继续跟进
View.php

    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的值保存到this->data中, 然后在回过头来看一下View.php中的fetch函数, 其中有一句代码 `vars = array_merge(self::var,this->data, vars);` 它将this->data的值加入到vars中了, 在后面调用Think.php中的fetch将vars传进去, 构成文件包含。
下面进行一波漏洞展示:
代码还是上面的代码, 我们在public目录下面写一个1.txt, 然后进行包含, 目录结构如下:

image.png

我们在浏览中去访问,将内容显示出来
image.png

willphp

理解了tp5的文件包含漏洞,willphp的就很好理解了, 原理几乎一样, 所以我们介绍快一点,先看一下目录结构:


image.png

app文件夹下包含了MVC需要的所有文件, 我们还是将漏洞代码显示在index控制器中:


image.png

assign函数和tp5没有区别,就是将传入的变量加入到$vars中,然后通过fetch函数的层层调用将原变量覆盖构成文件包含,就不多说了。return view();和tp5中的fetch是一样的, 我们跟进一下:

function view($file = '', $vars = []) {
    return \wiphp\View::fetch($file, $vars);
}

调用view中的fetch,继续跟进,

        if (!empty($vars)) self::$vars = array_merge(self::$vars, $vars);   
        $path = __MODULE__;
        if ($file == '') {
            $file = __ACTION__;
        } elseif (strpos($file, ':')) {
            list($path, $file) = explode(':', $file);
        } elseif (strpos($file, '/')) {
            $path = '';
        }
        if ($path == '') {
            $viewfile = THEME_PATH.'/'.$file.'.html';
        } else {
            $path = strtolower($path);
            $viewfile = THEME_PATH.'/'.$path.'/'.$file.'.html';
        }   
        if (file_exists($viewfile)) {
            array_walk_recursive(self::$vars, 'self::parseVars'); //处理输出
            define('__RUNTIME__', round((microtime(true) - START_TIME) , 4));   
            Template::render($viewfile, self::$vars); //********************
        } else {
            App::halt($file.' 模板文件不存在。');
        }
    }   

看第一句代码将assign赋值的变量加入到了vars中了,这个给后面的变量覆盖提供了可能, 看星号标记部分,只要viewfile存在,就会调用Template::render(viewfile, self::vars), 我们看一下这个函数

public static function render($viewfile, $vars = []) {
        $shtml_open = Config::get('shtml_open', 'app');
        $shtml_time = Config::get('shtml_time', 'app');     
        if (!$shtml_open || basename($viewfile) == 'jump.shtml') {
            self::renderTo($viewfile, $vars);  //**************************
        } else {
            $params = http_build_query(I());
            $sfile = md5(__MODULE__.basename($viewfile).$params).'.shtml';
            $sfile = PATH_SHTML.'/'.$sfile;
            $ntime = time();
            $shtml_time = max(10, $shtml_time);
            if (is_file($sfile) && filemtime($sfile) > ($ntime - $shtml_time)) {
                include $sfile;
            } else {
                ob_start();
                self::renderTo($viewfile, $vars);  
                $content = ob_get_contents();
                file_put_contents($sfile, $content);
            }
        }
    }   

看星号部分,嗲用了renderto函数,再跟进一下

    public static function renderTo($viewfile, $vars = []) {
        $m = strtolower(__MODULE__);
        $cfile = 'view-'.$m.'_'.basename($viewfile).'.php';
        if (basename($viewfile) == 'jump.html') {
            $cfile = 'view-jump.html.php';
        }
        $cfile = PATH_VIEWC.'/'.$cfile;
        if (APP_DEBUG || !file_exists($cfile) || filemtime($cfile) < filemtime($viewfile)) {
            $strs = self::compile(file_get_contents($viewfile), $vars);
            file_put_contents($cfile, $strs);
        }
        extract($vars);
        include $cfile;
    }

最后两句就是漏洞代码, 我们只要将cfile变量的值覆盖就好了。
下面看一道题:
[HXBCTF 2021]easywill

image.png

明确告诉使用了willphp,那么考虑使用文件包含漏洞,本来想使用data伪协议构成命令执行,但是没成功, 应该是后台配置没有开, 那么我们考虑使用日志包含,成功了:
image.png

网上有些大神使用pearcmd.php,这个后面可以研究一下,但是这里不能用,可能是因为buu的环境设置有问题(我用的buu做的题),这个pearcmd.php与data伪协议类似,只有配置了这样的系统环境才能用的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,509评论 6 504
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,806评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,875评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,441评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,488评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,365评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,190评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,062评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,500评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,706评论 3 335
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,834评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,559评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,167评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,779评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,912评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,958评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,779评论 2 354

推荐阅读更多精彩内容