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;
}
请看加星号的程序method(
vars,
method是一个变量, 它的值由renderContent来定, 因为renderContent默认是false(自己向上跟一下函数调用时的传值过程),所以
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);
}
看第一句, 调用了 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;
}
程序很简单, 看一下注释应该就能理解, 记得上面在构造器中调用该函数时传入了一个空值吧(默认), 当class为'\think\view\driver\Think',
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);
}
最后一句template,
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;
}
}
程序中标星号的代码cacheFile,
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;
}
有了, 将value的值保存到
vars = array_merge(self::
this->data,
this->data的值加入到
vars传进去, 构成文件包含。
下面进行一波漏洞展示:
代码还是上面的代码, 我们在public目录下面写一个1.txt, 然后进行包含, 目录结构如下:
我们在浏览中去访问,将内容显示出来
willphp
理解了tp5的文件包含漏洞,willphp的就很好理解了, 原理几乎一样, 所以我们介绍快一点,先看一下目录结构:
app文件夹下包含了MVC需要的所有文件, 我们还是将漏洞代码显示在index控制器中:
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赋值的变量加入到了viewfile存在,就会调用Template::render(
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
明确告诉使用了willphp,那么考虑使用文件包含漏洞,本来想使用data伪协议构成命令执行,但是没成功, 应该是后台配置没有开, 那么我们考虑使用日志包含,成功了:
网上有些大神使用pearcmd.php,这个后面可以研究一下,但是这里不能用,可能是因为buu的环境设置有问题(我用的buu做的题),这个pearcmd.php与data伪协议类似,只有配置了这样的系统环境才能用的。