Python 沙箱逃逸技术全解——从关键字绕过到栈帧劫持
前言
Python 沙箱逃逸是 CTF 和真实渗透中的经典考点。题目通常提供一个受限的 Python 执行环境——禁用了 os、system、import 等危险功能,或通过 proot/chroot 隔离文件系统。攻击者需要利用 Python 语言特性绕过这些限制,读取 flag 或执行命令。
本文从最基础的字符串绕过到生成器栈帧逃逸再到 pth 文件劫持,系统地覆盖 Python 沙箱逃逸的完整知识体系。
一、传统逃逸——字符串与关键字绕过
1.1 os 关键字被 ban
如果黑名单禁止了字符串 os,核心思路是**让 os 这个字符串”变形”,执行时再”还原”**。
方法一:字符串反转
1 | __import__('so'[::-1]).system('ls') |
方法二:字符串拼接
1 | a = 'o' |
方法三:全角字符
利用 Python 对 Unicode 的宽松处理,用全角字符替代半角字符,绕过基于 ASCII 的关键词匹配。
方法四:编码绕过(chr + 列表)
1 | import timeit |
原理: timeit.timeit 第一个参数可以是一个可执行的代码字符串(不是 lambda),内部通过 exec() 执行。
1.2 模块/方法被 ban——import 关键字的替代
方法一:__import__ 内置函数
1 | import math |
__import__ 是 import 关键字的底层实现,在 __builtins__ 中直接可用。
方法二:importlib.import_module
1 | import importlib |
方法三:恢复 sys.modules
sys.modules 是一个字典,记录了所有已加载的模块。沙箱可能这么做:
1 | sys.modules['os'] = 'not allowed' # 污染 os 模块名 |
绕过:先删除缓存再导入
1 | sys.modules['os'] = 'not allowed' # 沙箱的限制 |
更直接的方式——直接从缓存拿:
1 | print(sys.modules['os'].environ['GZCTF_FLAG']) |
如果 os 在沙箱启动前已经被导入过(大概率),sys.modules 里就有现成的,不需要重新 import。
方法四:getattr 拿 __builtins__
当 SSTI 链式调用不可用时:
1 | import os |
builtinsvs__builtins__: 无本质区别,都是内建模块。builtins是 Python 3 的标准写法,__builtins__是 Python 2 遗留的别名。注意:
__import__是builtins中的一个函数,不是import关键字。两者不同。
方法五:reload 重载
有些沙箱禁止 import site 但 site 模块中已经 import os 了。可以直接 reload:
1 | import site |
方法六:从 warnings 模块拿 __builtins__
与 SSTI 类似,warnings 模块内部引用了 __builtins__:
1 | import warnings |
二、程序终止的绕过——breakpoint() 调试器跳转
2.1 场景
沙箱代码结构:
1 | def sandbox(user_code): |
用户代码先执行,然后 exit(0) 终止程序。print(flag) 在 exit 后面,正常流程永远到不了。
2.2 breakpoint() 基础
breakpoint() 是 Python 3.7+ 内置的调试器入口。程序执行到 breakpoint() 时会进入 pdb(Python Debugger) 交互模式:
| 命令 | 功能 |
|---|---|
n (next) |
执行下一行(不进入函数内部) |
s (step) |
进入函数内部 |
c (continue) |
继续运行直到下一个断点 |
q (quit) |
退出调试模式 |
p <变量> |
打印变量值 |
j <行号> (jump) |
跳转到指定行号(仅限同一函数内) |
2.3 Python 3.14+ 新特性——commands 参数
Python 3.14 起,breakpoint() 支持 commands 参数,以编程方式传入 pdb 命令,无需人工交互:
1 | # 自动执行:3 次 next → 跳过 exit 行 → 3 次 next → 打印 flag |
关键:j (jump) 命令
只能在同一个函数内部跳转。跳转目标必须是当前函数内有效的行号。通常的用法是跳过
exit(0)那一行,让程序继续执行后面的print(flag)。
操作流程:
1 | 1. n × N → 逐步执行,定位到 exit(0) 之前的行 |
三、栈帧逃逸——生成器 gi_frame 的核心利用
这是目前最强大的 Python 沙箱逃逸技术。核心思路:通过生成器的 gi_frame 属性拿到调用栈帧,然后沿着 f_back 链向上追溯,访问上级作用域中的变量。
3.1 生成器基础
生成器是一种特殊的迭代器,用 yield 关键字定义:
1 | def f(): |
生成器表达式是生成器函数的简写:
1 | a = (i + 1 for i in range(100)) |
嵌套生成器表达式:
1 | f = ( |
3.2 生成器的关键属性
| 属性 | 含义 |
|---|---|
gi_code |
生成器对应的 code 对象 |
gi_frame |
生成器对应的栈帧(frame)对象 |
gi_running |
生成器是否正在执行(yield 暂停时为 0) |
gi_yieldfrom |
如果正从另一个生成器 yield 值,则为该生成器的引用 |
gi_frame.f_locals |
当前帧的局部变量字典 |
gi_frame.f_globals |
当前帧的全局变量字典 |
gi_frame.f_back |
上一级栈帧(调用者) |
gi_frame.f_code.co_name |
当前帧的函数名 |
gi_frame.f_lineno |
当前暂停的行号 |
gi_frame 是最关键的属性——它是一个 frame 对象,记录了生成器暂停时的执行位置、局部变量、指令指针、代码对象等核心数据。
1 | def my_generator(): |
3.3 逃逸原理——Payload 一(嵌套函数版)
1 | s3cret = "this is flag" |
栈帧链分析(Payload 一):
1 | f() 的 gi_frame → Frame-f (co_name='f') |
关键细节——为什么 f_back 不是 <listcomp>?
直觉上,[x for x in g] 触发了 next(g),那 <listcomp> 帧应该出现在 Frame-f 的上方。但事实并非如此。原因在于 f_back 是在创建生成器的那一刻绑定的,而不是在迭代时绑定。
逐步还原 g = f() 这行代码执行瞬间发生的事:
1 | Python 创建生成器 g = f() 的瞬间: |
然后执行 frame = [x for x in g][0] 时:
1 | 列表推导式触发 next(g) 的瞬间: |
一句话总结: <listcomp> 压在了 Frame-waff 上面(成为 waff 的 f_back),但 Frame-f 的 f_back 仍然是 Frame-waff。因为 <listcomp> 调用的是 g.__next__()(触发 f 执行),而不是直接调用 f()。
这就是 Payload 一需要 f_back × 3 才能到全局的原因:f → waff → <listcomp> → <module>(或 f → waff → <module>,取决于 <listcomp> 是否在全局直接调用)。
3.4 逃逸原理——Payload 二(直接版,L3HCTF 2024)
这道题出自 L3HCTF 2024,核心思路和 Payload 一相同,但生成器在模块级创建,导致 f_back 链完全不同。
原始 payload:
1 | def fake_int(i): |
展开后的等价代码(方便理解各阶段 a 的类型变化):
1 | def fake_int(i): |
关键:变量 a 的类型在运行过程中变化了三次:
| 阶段 | a 的值 |
type(a) |
说明 |
|---|---|---|---|
| 创建生成器时 | 生成器对象 | <class 'generator'> |
a = my_generator() |
| 生成器内部执行时 | 生成器对象(自身引用) | <class 'generator'> |
yield 时 a 还是生成器,所以 a.gi_frame 指向自身 |
| 遍历完成后 | 栈帧对象 | <class 'frame'> |
a = [x for x in a][0] 覆盖了 a |
这里 a.gi_frame 取的是生成器自身的帧(my_generator),因为 yield 那一刻 a 还是指向这个生成器对象。
栈帧链分析(Payload 二):
1 | a.gi_frame → Frame-gen (my_generator) |
逐步还原 Payload 二的 f_back 绑定过程:
1 | 创建生成器 a = my_generator() 的瞬间: |
注意: 此时
Frame-<module>的f_back被临时替换为Frame-listcomp(因为列表推导式是触发执行的上下文)。这就是为什么a.gi_frame.f_back.f_back拿到的是<listcomp>而不是exec。
为什么 Payload 一和 Payload 二的 f_back 链不同?
根本原因:生成器在哪里被创建,f_back 就在哪里绑定。 生成器的 f_back 链是一个”快照”——在 生成器 = 生成器函数() 执行的那一刻拍下来的,之后无论谁调用 next() 都不会改变。
| Payload 一(嵌套版) | Payload 二(模块级版) | |
|---|---|---|
| 结构 | waff() 内部嵌套 f(),有中间函数层 |
my_generator() 直接在模块级定义和调用 |
| 生成器创建时 | g = f() 在 waff() 内部 → f_back = waff 帧 |
a = my_generator() 在模块级 → f_back = 主模块帧 |
| next 触发时 | <listcomp> 压在 waff 帧之上 |
<listcomp> 压在主模块帧之上 |
| listcomp 能插入生成器和它的 f_back 之间吗? | 不能。 f → waff 在创建时已定型,<listcomp> 只能压在 waff 上面,无法插到 f 和 waff 之间 |
不能。 gen → <module> 已定型,<listcomp> 压在 <module> 上面 |
| 拿 flag 需要 | f_back × 3 或 × 4 | f_back × 1(模块级变量)或 f_back × 4(exec 内变量) |
Payload 一的关键在于嵌套关系 waff → f: 在创建生成器 g = f() 时,栈帧关系 f → waff 就已经确定了。后面的 <listcomp> 无论怎么操作,都只能出现在 waff 的上面,永远插不到 f 和 waff 之间。这个嵌套结构是一道”防火墙”,把 listcomp 挡在了外面。
Payload 二没有嵌套: my_generator 直接在模块级创建,f_back = <module>。listcomp 压在 <module> 上面后,gen.f_back.f_back 刚好命中 listcomp。结构更扁平,但链更长。
核心总结:生成器的 f_back 链取决于”生成器在哪里被创建”,而不是”生成器在哪里被迭代”。
3.5 next 被 ban 时的绕过
1 | a = f() # 生成器已创建 |
原理:
1 | # [x for x in a][0] 等价于: |
3.6 精简版 Payload
1 | {}[[*((l:=[]).append(i.gi_frame.f_back.f_back.f_builtins['open']('/flag').read() for i in l) or l[0])][0]] |
逐层拆解:
1 | {}[ ... ][0] |
执行流程:
l := []→ 创建空列表l,返回l.append(...)→ 创建一个生成器放入l,.append()返回NoneNone or l[0]→None为假,短路到l[0],即刚刚添加的生成器[*..., l[0]]→ 列表只有一个元素(生成器)...[0]→ 取出生成器- 生成器执行:
i.gi_frame.f_back.f_back.f_builtins['open']('/flag').read()→ 读 flag
四、pth 文件劫持——突破文件系统隔离
4.1 场景
沙箱使用 proot 进行文件系统隔离,但 site-packages 目录没有被隔离(仍指向宿主机真实目录)。
launcher.py(沙箱启动器):
1 | def run_sandbox(script_name): |
server.py(后端):
1 | import os |
4.2 漏洞分析
| 目录 | 是否隔离 |
|---|---|
/app (用户代码) |
隔离到 ./jail_root |
/bin, /usr, /lib 等 |
不隔离,映射真实目录 |
/flag |
未映射,沙箱内不可见 |
关键发现:
- flag 在宿主机根目录
/flag /flag没有被-b映射到沙箱内- 但
/usr/lib/python3.10/site-packages/指向真实系统目录,读写都会落在宿主机上
4.3 攻击流程
Step 1:上传一个包含后门的 .pth 文件
文件名:/usr/lib/python3.10/site-packages/evil.pth
内容:
1 | import evil |
为什么不直接把后门代码写在
.pth里?Python 启动流程:
1
2
3
4
5 1. Python 启动
2. 加载基础模块
3. 执行 .pth 文件 ← 此时 stdout 可能还没准备好!
4. 初始化标准输出
5. 用户代码运行在阶段 3 执行 print/open 可能会因 stdout 未初始化而异常。所以用一个
import evil来延迟执行——等 Python 完全初始化后,evil.py的导入自然就完成了。
Step 2:上传后门模块 pwn.py
路径:/usr/lib/python3.10/site-packages/pwn.py
1 | import os |
Step 3:触发
用户提交执行任意 Python 文件,Python 启动时自动加载 evil.pth → import evil → 后门执行 → 读取宿主机 /flag。
4.4 .pth 执行机制回顾
Python 启动时,site.py 会遍历 site-packages 目录下所有 .pth 文件:
| 行类型 | 行为 |
|---|---|
以 # 开头 |
忽略(注释) |
以 import 开头 |
exec() 执行 |
| 其他 | 当作路径加入 sys.path |
五、好用的 Payload 合集
5.1 flag 在全局变量
方法一:直接拿 globals
1 | g = [globals][0]() |
注意:第一个
globals是全角字符,可能用于绕过关键字检测。
方法二:通过 __main__ 模块
1 | import __main__ |
沙箱代码如果是在模块级别 exec 的,变量会落在 __main__ 的命名空间。
方法三:线程栈帧遍历
1 | import sys |
原理: sys._current_frames() 返回所有线程的当前栈帧。遍历主线程的所有栈帧,逐个检查局部和全局变量空间。
5.2 flag 在环境变量
1 | import os |
5.3 flag 在文件——绕过 open/read 被 ban
方法一:pathlib.Path.read_text()
1 | from pathlib import Path |
pathlib 是标准库,它的文件操作不经过 open() 的钩子。
方法二:urllib + file:// 协议
1 | import urllib.request |
方法三:linecache 按行读取
1 | import linecache |
linecache.getline 内部虽然会调用 open,但它在 C 层面实现,有时能绕过 Python 层面的 hook。
方法四:从 linecache 反向拿 __builtins__ 中的 open
1 | import linecache |
思路: 先 import 一个”无害”的模块(linecache),然后沿着它的 __globals__ 链拿到 __builtins__,再从 __builtins__ 中取出真正的 open 函数。
六、防御与总结
6.1 沙箱逃逸的核心武器
| 技术 | 核心思想 | 适用场景 |
|---|---|---|
| 字符串变形 | 绕过关键字黑名单 | 轻度沙箱 |
sys.modules 恢复 |
利用缓存绕过模块禁用 | import 被 ban |
breakpoint() 跳转 |
跳过 exit/return 语句 | Python 3.14+ |
生成器 gi_frame |
沿栈帧链访问上级作用域 | 中高度沙箱 |
| pth 文件劫持 | 利用文件系统隔离不完整 | proot/chroot 场景 |
6.2 加固建议
- 不要用黑名单做沙箱——用白名单或真正的 OS 级隔离(Docker + seccomp)
- 清理
sys.modules——不仅是禁用,要彻底删除危险模块 - 用
exec(code, {'__builtins__': safe_builtins})——传入受限的全局命名空间 - 禁用
sys._current_frames()——阻止栈帧遍历 - 设置 Python 启动参数 ——
python -S禁用 site-packages 自动加载,python -I隔离模式 - 监控
site-packages写入 —— 对.pth文件的创建配置告警