开发者 John Hawthorn 公开了 Ruby
on Rails
上的一个路径穿越与任意文件读取漏洞。

之前的文章我们根据源码的分析,弄清了Yii如何处理一次请求,以及根据解析的路由如何调用控制器中的action,那接下来好奇的可能就是,我在控制器action中执行了return $this->render('index'),那render这个方法是如何完成渲染视图文件的工作的?我们继续从源码入手。

视图名

图片 1

1、找到视图文件

先看我们在controller/action中视图渲染的调用:

public function actionIndex()
{
    //代码省略
    return $this->render('index',[
        'model'=>$model
    ]);
}

渲染视图时,可指定一个视图名或视图文件路径/别名,大多数情况下使用前者因为前者简洁灵活,
我们称用名字的视图为 视图名.

John 指出,Action View 中可能存在文件内容泄露漏洞。特制的 accept headers
并调用 render
file,可以导致目标服务器上的任意文件被渲染,从而泄露文件内容。控制器中受影响的代码如上图所示。

1.1、找到render方法

因为所有的控制器类都继承了yiiwebController,最终找到render位于yiiwebController的父类yiibaseController,看定义:

//yiibaseController
public function render($view, $params = [])
{
    $content = $this->getView()->render($view, $params, $this);
    return $this->renderContent($content);
}

可以看到真正的渲染操作并非在controller中处理,而是在view对象中完成的。这里只负责调用:

  • 把视图文件给我渲染好
  • 把渲染好的视图文件放进我定义的布局文件
  • 返回

视图名可以依据以下规则到对应的视图文件路径:

漏洞分析

在控制器中通过render file形式来渲染应用之外的视图,因此在
actionview-5.2.1/lib/action_view/renderer/template_renderer.rb:22
中会根据 options.key?(:file),调用find_file来寻找视图。

module ActionView
  class TemplateRenderer < AbstractRenderer #:nodoc:
    # Determine the template to be rendered using the given options.
      def determine_template(options)
        keys = options.has_key?(:locals) ? options[:locals].keys : []
        if options.key?(:body)
          ...
        elsif options.key?(:file)
          with_fallbacks { find_file(options[:file], nil, false, keys, @details) }
        ...
      end
end

find_file代码如下:

def find_file(name, prefixes = [], partial = false, keys = [], options = {})
    @view_paths.find_file(*args_for_lookup(name, prefixes, partial, keys, options))
end

用于生成查找文件参数的args_for_lookup函数,最终返回时会把 payload
保存在details[formats]中:

图片 2

它会执行位于
actionview-5.2.1/lib/action_view/path_set.rb 中的 @view_paths.find_file

class PathSet #:nodoc:
    def find_file(path, prefixes = [], *args)
      _find_all(path, prefixes, args, true).first || raise(MissingTemplate.new(self, path, prefixes, *args))
    end
    private
    # 注,这里的 args 即前面args_for_lookup生成的details
        def _find_all(path, prefixes, args, outside_app)
            prefixes = [prefixes] if String === prefixes
            prefixes.each do |prefix|
            paths.each do |resolver|
                if outside_app
                templates = resolver.find_all_anywhere(path, prefix, *args)
                else
                templates = resolver.find_all(path, prefix, *args)
                end
                return templates unless templates.empty?
            end
            end
            []
        end

因为视图在应用之外,所以 outside_app equals == True,并调用
find_all_anywhere:

def find_all_anywhere(name, prefix, partial = false, details = {}, key = nil, locals = [])
    cached(key, [name, prefix, partial], details, locals) do
    find_templates(name, prefix, partial, details, true)
    end
end

跳过cached部分,find_templates将根据选项查找要呈现的模板:

# An abstract class that implements a Resolver with path semantics.
class PathResolver < Resolver #:nodoc:
    EXTENSIONS = { locale: ".", formats: ".", variants: "+", handlers: "." }
    DEFAULT_PATTERN = ":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}"

    ...

    private
        def find_templates(name, prefix, partial, details, outside_app_allowed = false)
            path = Path.build(name, prefix, partial)
            # 注意 details 与 details[:formats] 的传入
            query(path, details, details[:formats], outside_app_allowed)
        end

        def query(path, details, formats, outside_app_allowed)
            query = build_query(path, details)
            template_paths = find_template_paths(query)
            ...
            end
        end

build_query 后的值如下:
图片 3

利用../与前缀组合造成路径穿越,利用最后的{{完成闭合,经过
File.expand_path 解析后组成的 query 如下:

/etc/passwd{{},}{+{},}{.{raw,erb,html,builder,ruby,coffee,jbuilder},}

最后/etc/passwd被当成模板文件进行渲染,造成了任意文件读取。

该漏洞已被 CVE 收录,编号
CVE-2019-5418、CVE-2019-5419,目前补丁已经跟进:

禁止接受未注册的 mime
types:图片 4

详情查看漏洞报告:

(文/开源中国)    

1.2、获取视图对象

从render方法的设计可以看到Yii2.0中,控制器(C)和视图(V)是完全分离的,各司其职。看$this->getView():

//yiibaseController
public function getView()
{
    if ($this->_view === null) {
        $this->_view = Yii::$app->getView();
    }
    return $this->_view;
}

Yii::$app,就是我们new Application的时候保存的一个全局application变量,在构造方法中已经保存好了Yii::$app = $this;,可以查看yiibaseApplication __construct,不过多描述了。

这样我们知道了Yii::$app->getView(),getView应该位于yiiwebApplication,或者其父类中`:

//yiibaseApplication
public function getView()
{
    return $this->get('view');
}

get方法位于Application的父类yiidiServiceLocator,根据源码可以看到,是对当前对象中的两个成员变量$_components$_definitions做了判断,看是否有view这个组件的对象(实例),或者定义(['class'=>'yiiwebview']),有实例则直接返回,否则根据定义创建实例(createObject)返回。

【注】关于$_definitions变量的初始化,其实在new
Application的时候,在yiibaseApplication
__construct构造方法中,调用了preInit,而preInit方法中对核心组件(coreComponents)进行了初始化,这里就包括了'view' => ['class' => 'yiiwebView'],组件的初始化就是这些配置信息保存的过程,主要通过yiibaseObject中的构造方法,调用了Yii::configure($config),configure的过程就是对$_definitions $_components赋值的过程。

最终经过getView(),我们得到的就是一个yiiwebView object

视图名可省略文件扩展名,这种情况下使用 .php 作为扩展, 视图名 about
对应到 about.php 文件名;视图名以双斜杠 // 开头,对应的视图文件路径为
@app/views/ViewName, 也就是说视图文件在
yiibaseApplication::viewPath 路径下找, 例如 //site/about 对应到
@app/views/site/about.php。视图名以单斜杠/开始,视图文件路径以当前使用模块
的yiibaseModule::viewPath开始,
如果不存在模块,使用@app/views/ViewName开始,例如,如果当前模块为user,
/user/create 对应成@app/modules/user/views/user/create.php,
如果不在模块中,/user/create对应@app/views/user/create.php。如果
yiibaseView::context 渲染视图 并且上下文实现了
yiibaseViewContextInterface, 视图文件路径由上下文的
yiibaseViewContextInterface::getViewPath() 开始,
这种主要用在控制器和小部件中渲染视图,例如
如果上下文为控制器SiteController,site/about 对应到
@app/views/site/about.php。如果视图渲染另一个视图,包含另一个视图文件的目录以当前视图的文件路径开始,
例如被视图@app/views/post/index.php 渲染的 item 对应到
@app/views/post/item。根据以上规则,在控制器中
appcontrollersPostController 调用 $this->render,
实际上渲染@app/views/post/view.php 视图文件,当在该视图文件中调用
$this->render 会渲染@app/views/post/_overview.php 视图文件。

2、渲染视图文件

进入到yiiwebView中的render方法,位于其父类。

//yiibaseView
public function render($view, $params = [], $context = null)
{
    $viewFile = $this->findViewFile($view, $context);
    return $this->renderFile($viewFile, $params, $context);
}

通过代码可以看到,渲染视图文件的第一步就是找到这个视图文件在磁盘中的文件存储位置(真实路径)。从findViewFile方法寻找路径的过程,也可看出我们的$view参数支持的几种格式。

视图中访问数据

2.1、$view参数格式

源码不放了,大家对照yiibaseView中的function
findViewFile,或看这里

  1. 按照alias别名的方式
    检查$view中是否包含“@”,如果有说明是使用了别名的方法,直接通过Yii::getAlias($view)获取,因为形式是”@backend/views/site/index”这种格式,而在config文件夹下的bootstrap.php中已经对backend进行了setAlias,所以通过getAias简单处理就可返回真实路径了
  2. 以”//”开头的$view,如”//site/index”
    匹配到这种形式执行Yii::$app->getViewPath(),getViewPath方法事实上是:获取我们在配置文件中定义的basePath,再根据Yii2.0中约定的目录结构,加上”/views”返回
  3. 以”/”开头
    相当于绝对路径的方式,在当前控制器所在的module中进行寻找,这个controller对象的module成员保存的是当前控制器所在的module对象,这个保存是在createControllerByID中调用Yii::createObject()的时候进行赋值的。yiibaseController中的构造方法有一句:$this->module = $module;
  4. controller对象调用View进行视图渲染时,将对象自身传递给了view,也就是$context,如果这个控制器实现了ViewContextInterface接口(接口中就定义了一个getViewPath方法),那么直接调用控制器的getViewPath
  5. 最后一种情况就是根据当前视图文件的文件路径来返回视图路径(这种应该是在视图文件中渲染其他视图的情况)

【注】2与3其实都是调用yiibaseModule中的getViewPath,getViewPath中调用了getBasePath,区别在于2中用的是Yii::$app
这个对象,Application对象初始化的时候在preInit中已经对将配置文件中的basePath初始化并保存在了$_basePath中;3中getViewPath调用的发起者是controller实例所属的module对象,module对象创建的时候并未初始化$_basePath,所以可以看到getBasePaht中是使用反射的方法获取当前module对象对应文件的路径,进而返回basePath的

在视图中有两种方式访问数据:推送和拉取。

2.2、renderFile

renderFile的操作就比较简单了,根据视图文件的后缀来判断是否启用了相应的模板引擎,如Smarty、Twig等,如果都没有那就默认的php文件的方式(renderPhpFile)进行渲染,renderPhpFile的代码非常简单:

public function renderPhpFile($_file_, $_params_ = [])
{
    ob_start();
    ob_implicit_flush(false);
    //将我们render的参数数组extract为本地变量
    extract($_params_, EXTR_OVERWRITE);
    //require 我们的视图文件
    require($_file_);
    //清空并返回当前缓冲区的内容
    return ob_get_clean();
}

经过以上的分析,一个视图文件的渲染就完成了,而我们的Yii框架中大多使用了布局文件。所以回到yiibaseController的render方法中,视图文件渲染之后返回的是一个字符串,保存在了$content中,然后执行了return $this->renderContent($content),这其实就是把视图文件渲染后的结果放到布局中,这也是为什么我们的布局文件中会有这么一行代码:<?=$content?>。renderContent其实就是获取布局文件的路径然后在调用View中的renderFile方法,过程跟视图渲染的过程一样。

至此,Yii2.0视图渲染过程分析完毕。

【附】render渲染代码调用流程
图片 5

推送方式是通过视图渲染方法的第二个参数传递数据,数据格式应为名称-值的数组,
视图渲染时,调用PHP extract() 方法将该数组转换为视图可访问的变量。
例如,如下控制器的渲染视图代码推送2个变量到 report 视图:$foo = 1 和
$bar = 2。

echo $this->render('report', [ 'foo' => 1, 'bar' => 2,]);

拉取方式可让视图从yiibaseView视图组件或其他对象中主动获得数据,
在视图中使用如下表达式$this->context可获取到控制器ID,
可让你在report视图中获取控制器的任意属性或方法,如以下代码获取控制器ID。

The controller ID is: context->id ?>?>

推送方式让视图更少依赖上下文对象,是视图获取数据优先使用方式,
缺点是需要手动构建数组,有些繁琐,在不同地方渲染时容易出错。

视图间共享数据

yiibaseView视图组件提供yiibaseView::params参数属性来让不同视图共享数据。

例如在about视图中,可使用如下代码指定当前breadcrumbs的当前部分。

$this->params['breadcrumbs'][] = 'About Us';

在布局文件中,可使用依次加入到yiibaseView::params数组的值来
生成显示breadcrumbs:

 isset($this->params['breadcrumbs']) ? $this->params['breadcrumbs'] : [],]) ?>

布局

布局是一种特殊的视图,代表多个视图的公共部分,例如,大多数Web应用共享相同的页头和页尾,
在每个视图中重复相同的页头和页尾,更好的方式是将这些公共放到一个布局中,
渲染内容视图后在合适的地方嵌入到布局中。

创建布局

由于布局也是视图,它可像普通视图一样创建,布局默认存储在@app/views/layouts路径下,
模块中使用的布局应存储在yiibaseModule::basePath模块目录
下的views/layouts路径下,可配置yiibaseModule::layoutPath来自定义应用或模块的布局默认路径。

如下示例为一个布局大致内容,注意作为示例,简化了很多代码,
在实际中,你可能想添加更多内容,如头部标签,主菜单等。

beginPage() ?>   head() ?>beginBody() ?> My Company  © 2014 by My CompanyendBody() ?>endPage() ?>

如上所示,布局生成每个页面通用的HTML标签,在

标签中,打印$content变量,
$content变量代表当yiibaseController::render()控制器渲染方法调用时传递到布局的内容视图渲染结果。

大多数视图应调用上述代码中的如下方法,这些方法触发关于渲染过程的事件,
这样其他地方注册的脚本和标签会添加到这些方法调用的地方。

yiibaseView::beginPage(): 该方法应在布局的开始处调用,
它触发表明页面开始的 yiibaseView::EVENT_BEGIN_PAGE 事件。
yiibaseView::endPage(): 该方法应在布局的结尾处调用,
它触发表明页面结尾的 yiibaseView::EVENT_END_PAGE 时间。
yiiwebView::head(): 该方法应在HTML页面的标签中调用,
它生成一个占位符,在页面渲染结束时会被注册的头部HTML代码替换。
yiiwebView::beginBody(): 该方法应在标签的开始处调用, 它触发
yiiwebView::EVENT_BEGIN_BODY 事件并生成一个占位符,
会被注册的HTML代码在页面主体开始处替换。 yiiwebView::endBody():
该方法应在标签的结尾处调用, 它触发 yiiwebView::EVENT_END_BODY
事件并生成一个占位符, 会被注册的HTML代码在页面主体结尾处替换。

布局中访问数据

在布局中可访问两个预定义变量:$this 和
$content,前者对应和普通视图类似的yiibaseView 视图组件
后者包含调用yiibaseController::render()方法渲染内容视图的结果。

如果想在布局中访问其他数据,必须使用视图中访问数据一节介绍的拉取方式,
如果想从内容视图中传递数据到布局,可使用视图间共享数据一节中的方法。

使用布局

如控制器中渲染一节描述,当控制器调用yiibaseController::render()
方法渲染视图时,会同时使用布局到渲染结果中,默认会使用@app/views/layouts/main.php布局文件。

可配置yiibaseApplication::layout 或 yiibaseController::layout
使用其他布局文件,
前者管理所有控制器的布局,后者覆盖前者来控制单个控制器布局。
例如,如下代码使 post 控制器渲染视图时使用 @app/views/layouts/post.php
作为布局文件, 假如layout 属性没改变,控制器默认使用
@app/views/layouts/main.php 作为布局文件。

namespace appcontrollers;use yiiwebController;class PostController extends Controller{ public $layout = 'post'; // ...}

对于模块中的控制器,可配置模块的 yiibaseModule::layout
属性指定布局文件应用到模块的所有控制器。

由于layout
可在不同层级配置,在幕后Yii使用两步来决定控制器实际使用的布局。

第一步,它决定布局的值和上下文模块:

如果控制器的 yiibaseController::layout
属性不为空null,使用它作为布局的值, 控制器的
yiibaseController::module模块 作为上下文模块。如果
yiibaseController::layout 为空,从控制器的祖先模块 开始找
第一个yiibaseModule::layout
属性不为空的模块,使用该模块作为上下文模块,
并将它的yiibaseModule::layout 的值作为布局的值,
如果都没有找到,表示不使用布局。第二步,它决定第一步中布局的值和上下文模块对应到实际的布局文件,布局的值可为:

路径别名 (如 @app/views/layouts/main).绝对路径 :
布局的值以斜杠开始,在应用的[[yiibaseApplication::layoutPath|layout
path] 布局路径 中查找实际的布局文件,布局路径默认为
@app/views/layouts。相对路径 :
在上下文模块的yiibaseModule::layoutPath布局路径中查找实际的布局文件,
布局路径默认为yiibaseModule::basePath模块目录下的views/layouts
目录。布尔值 false: 不使用布局。布局的值没有包含文件扩展名,默认使用
.php作为扩展名。

嵌套布局

有时候你想嵌套一个布局到另一个,例如,在Web站点不同地方,想使用不同的布局,
同时这些布局共享相同的生成全局HTML5页面结构的基本布局,可以在子布局中调用
yiibaseView::beginContent() 和yiibaseView::endContent()
方法,如下所示:

beginContent('@app/views/layouts/base.php'); ?>...child layout content here...endContent(); ?>

如上所示,子布局内容应在 yiibaseView::beginContent() 和
yiibaseView::endContent() 方法之间,传给
yiibaseView::beginContent()
的参数指定父布局,父布局可为布局文件或别名。

使用以上方式可多层嵌套布局。

使用数据块

数据块可以在一个地方指定视图内容在另一个地方显示,通常和布局一起使用,
例如,可在内容视图中定义数据块在布局中显示它。

调用 yiibaseView::beginBlock() 和 yiibaseView::endBlock()
来定义数据块, 使用 $view->blocks[$blockID] 访问该数据块,其中
$blockID 为定义数据块时指定的唯一标识ID。

如下实例显示如何在内容视图中使用数据块让布局使用。

首先,在内容视图中定一个或多个数据块:

...beginBlock; ?>...content of block1...endBlock(); ?>...beginBlock; ?>...content of block3...endBlock(); ?>

然后,在布局视图中,数据块可用的话会渲染数据块,如果数据未定义则显示一些默认内容。

...blocks['block1'])): ?> blocks['block1'] ?> ... default content for block1 ......blocks['block2'])): ?> blocks['block2'] ?> ... default content for block2 ......blocks['block3'])): ?> blocks['block3'] ?> ... default content for block3 ......

使用视图组件

yiibaseView视图组件提供许多视图相关特性,可创建yiibaseView或它的子类实例来获取视图组件,
大多数情况下主要使用 view应用组件,可在应用配置中配置该组件, 如下所示:

[ // ... 'components' => [ 'view' => [ 'class' => 'appcomponentsView', ], // ... ],]

视图组件提供如下实用的视图相关特性,每项详情会在独立章节中介绍:

主题: 允许为你的Web站点开发和修改主题; 片段缓存:
允许你在Web页面中缓存片段; 客户脚本处理: 支持CSS 和 JavaScript
注册和渲染; 资源包处理: 支持 资源包的注册和渲染; 模板引擎:
允许你使用其他模板引擎,如 Twig, Smarty。

开发Web页面时,也可能频繁使用以下实用的小特性。

设置页面标题

每个Web页面应有一个标题,正常情况下标题的标签显示在 布局中,
但是实际上标题大多由内容视图而不是布局来决定,为解决这个问题,
yiiwebView 提供 yiiwebView::title
标题属性可让标题信息从内容视图传递到布局中。

为利用这个特性,在每个内容视图中设置页面标题,如下所示:

title = 'My page title';?>然后在视图中,确保在  段中有如下代码:

注册Meta元标签

Web页面通常需要生成各种元标签提供给不同的浏览器,如

中的页面标题,元标签通常在布局中生成。

如果想在内容视图中生成元标签,可在内容视图中调用yiiwebView::registerMetaTag()方法,如下所示:

registerMetaTag(['name' => 'keywords', 'content' => 'yii, framework, php']);?>

以上代码会在视图组件中注册一个 “keywords”
元标签,在布局渲染后会渲染该注册的元标签,
然后,如下HTML代码会插入到布局中调用yiiwebView::head()方法处:

注意如果多次调用 yiiwebView::registerMetaTag()
方法,它会注册多个元标签,注册时不会检查是否重复。

为确保每种元标签只有一个,可在调用方法时指定键作为第二个参数,
例如,如下代码注册两次 “description” 元标签,但是只会渲染第二个。

$this->registerMetaTag(['name' => 'description', 'content' => 'This is my cool website made with Yii!'], 'description');$this->registerMetaTag(['name' => 'description', 'content' => 'This website is about funny raccoons.'], 'description');

注册链接标签

和 Meta标签
类似,链接标签有时很实用,如自定义网站图标,指定Rss订阅,或授权OpenID到其他服务器。
可以和元标签相似的方式调用yiiwebView::registerLinkTag(),例如,在内容视图中注册链接标签如下所示:

$this->registerLinkTag([ 'title' => 'Live News for Yii', 'rel' => 'alternate', 'type' => 'application/rss+xml', 'href' => 'http://www.yiiframework.com/rss.xml/',]);

复制代码 代码如下:

和 yiiwebView::registerMetaTag() 类似,
调用yiiwebView::registerLinkTag() 指定键来避免生成重复链接标签。

视图事件

yiibaseView 视图组件会在视图渲染过程中触发几个事件,
可以在内容发送给终端用户前,响应这些事件来添加内容到视图中或调整渲染结果。

yiibaseView::EVENT_BEFORE_RENDER: 在控制器渲染文件开始时触发,
该事件可设置 yiibaseViewEvent::isValid 为 false 取消视图渲染。
yiibaseView::EVENT_AFTER_RENDER: 在布局中调用
yiibaseView::beginPage() 时触发,
该事件可获取yiibaseViewEvent::output的渲染结果,可修改该属性来修改渲染结果。
yiibaseView::EVENT_BEGIN_PAGE: 在布局调用
yiibaseView::beginPage() 时触发; yiibaseView::EVENT_END_PAGE:
在布局调用 yiibaseView::endPage() 是触发;
yiiwebView::EVENT_BEGIN_BODY: 在布局调用
yiiwebView::beginBody() 时触发; yiiwebView::EVENT_END_BODY:
在布局调用 yiiwebView::endBody() 时触发。

例如,如下代码将当前日期添加到页面结尾处:

Yii::$app->view->on(View::EVENT_END_BODY, function () { echo date;

渲染静态页面

静态页面指的是大部分内容为静态的不需要控制器传递动态数据的Web页面。

可将HTML代码放置在视图中,在控制器中使用以下代码输出静态页面:

public function actionAbout(){ return $this->render;}

如果Web站点包含很多静态页面,多次重复相似的代码显得很繁琐,
为解决这个问题,可以使用一个在控制器中称为 yiiwebViewAction
的独立操作。 例如:

namespace appcontrollers;use yiiwebController;class SiteController extends Controller{ public function actions() { return [ 'page' => [ 'class' => 'yiiwebViewAction', ], ]; }}

现在如果你在@app/views/site/pages目录下创建名为 about 的视图,
可通过如下rul显示该视图:

中 view 参数告知
yiiwebViewAction 操作请求哪个视图,然后操作在
@app/views/site/pages目录下寻找该视图,可配置
yiiwebViewAction::viewPrefix 修改搜索视图的目录。

最佳实践

视图负责将模型的数据展示用户想要的格式,总之,视图

应主要包含展示代码,如HTML, 和简单的PHP代码来控制、格式化和渲染数据;
不应包含执行数据查询代码,这种代码放在模型中;
应避免直接访问请求数据,如 $_GET, $_POST,这种应在控制器中执行,
如果需要请求数据,应由控制器推送到视图。
可读取模型属性,但不应修改它们。
为使模型更易于维护,避免创建太复杂或包含太多冗余代码的视图,可遵循以下方法达到这个目标:
使用 布局 来展示公共代码;
将复杂的视图分成几个小视图,可使用上面描述的渲染方法将这些小视图渲染并组装成大视图;
创建并使用 小部件 作为视图的数据块;
创建并使用助手类在视图中转换和格式化数据。