本文的灵感来自于前不久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所在的目录。
当然使用绝对路径包含web目录之外的文件也是可以的
如果想在tp模板里执行php代码有两种方法,见手册使用PHP代码
ps. 在测试的时候常用的一句话没有生效,不知道是什么原因。
下面我们就从代码的角度来分析一下这个漏洞。
首先来到ThinkPHP/Library/Think/Controller.php
,我们调用了这里的display
方法,这里又调用了ThinkPHP/Library/Think/View.php
的display
方法。
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' ))) { $_content = $content ; extract ($this ->tVar, EXTR_OVERWRITE); 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 protected function show ($content ,$charset ='' ,$contentType ='' ,$prefix ='' ) { $this ->view->display ('' ,$charset ,$contentType ,$content ,$prefix ); }
如果这个方法传入的内容可控的话,就可以直接执行php代码了,这个跟我之前了解到的模板注入比较像,上面那个怎么看怎么像文件包含。。。
ThinkPHP 5 版本 thinkphp 5.0.11
tp5相较于tp3有一些改变:
最明显的就是display
变成了fetch
,而show
变成了display
。。。果真是大改版233333。
tp5的入口文件在public
目录下 。
使用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 ) ); $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框架同样存在这种包含问题,只不过不能使用绝对路径来包含项目外的文件,只能使用下面这个方法跨越目录。
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 ) { $file = Yii ::getAlias ($view ); } elseif (strncmp ($view , '//' , 2 ) === 0 ) { $file = Yii ::$app ->getViewPath () . DIRECTORY_SEPARATOR . ltrim ($view , '/' ); } elseif (strncmp ($view , '/' , 1 ) === 0 ) { 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有多种方式来获取视图的路径,具体可以看手册
从代码中我们可以看到无论哪一种方法,都没有对视图名的合法性做检验,只是简单的限制了一下视图的路径,这也就是可以使用目录穿越而不能使用绝对路径包含得原因。
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 ) { 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 ; 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 ); } $_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 ; } } empty ($_ci_vars ) OR $this ->_ci_cached_vars = array_merge ($this ->_ci_cached_vars, $_ci_vars ); extract ($this ->_ci_cached_vars); ob_start (); 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 ); } return $this ; }
函数比较长,删除了一部分。可以看到ci框架同样没有对模板路径做检测,只是限制了一下范围。
最后 几个框架看下来,只有laravel 真正处理了这个问题。对于然而框架只是一种简化开发的工具,我们并不能将应用安全全部交给框架来处理。当然这个问题在实际中也并不常见,防御的话也很简单,只需将./\
等字符过滤即可。