Python 基础体系 · 第 74/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 安全工程:输入、注入、反序列化、依赖、Secret 和沙箱

安全工程不是在函数旁边加一个 try/except,而是要回答一个更基础的问题:

哪些数据可以影响程序的控制流、资源消耗、文件系统、网络、凭据和依赖环境?

在 Python 应用中,风险通常沿着一条数据流传播:

flowchart LR
    A[外部输入] --> B[解析]
    B --> C[规范化]
    C --> D[验证]
    D --> E[业务对象]
    E --> F[数据库/模板/命令/文件]
    F --> G[外部副作用]

    H[依赖声明] --> I[依赖解析]
    I --> J[构建与安装]
    J --> K[运行时导入]

    L[Secret] --> M[配置读取]
    M --> N[认证与签名]
    N --> G

每个箭头都可能改变安全边界:

  • 解析阶段可能触发反序列化;
  • 规范化不一致可能造成验证绕过;
  • 未验证的字符串可能进入 SQL、Shell 或模板;
  • 依赖解析可能把攻击者发布的包带入构建环境;
  • Secret 可能通过日志、异常、子进程环境或序列化文件泄露;
  • 运行用户输入时,所谓“沙箱”如果只限制了 Python 语法,而没有限制操作系统能力,通常并不成立。

下面以 Python 3.14 为范围,逐层建立这些机制之间的关系。


一、先建立安全模型:输入不是字符串,而是跨越信任边界的数据

1.1 输入的安全属性

输入是程序从自身控制范围之外获得的数据,例如:

  • HTTP 请求体、查询参数、请求头;
  • 消息队列消息;
  • 用户上传的文件;
  • 数据库中由其他系统写入的字段;
  • 环境变量和配置文件;
  • 插件、模型、脚本或第三方包;
  • 来自缓存、任务队列和序列化文件的数据。

输入是否“来自自己的系统”并不能直接说明它可信。更准确的判断方式是:

  1. 谁能修改它?
  2. 修改后是否经过认证?
  3. 是否经过完整性校验?
  4. 校验发生在使用之前还是之后?
  5. 即使内容合法,是否可能造成过大的 CPU、内存、磁盘或网络消耗?

例如,从内部数据库读取的字符串仍可能是用户最初提交的内容;从内部对象存储读取的 pickle 文件,仍可能已经被篡改。

可以将一个输入抽象为:

x=(v,o,i,r)x = (v, o, i, r)

其中:

  • vv 是数据值;
  • oo 是数据来源;
  • ii 是完整性状态;
  • rr 是资源规模,例如长度、嵌套深度和元素数量。

安全决策不能只检查 vv。同一个字节串:

  • 来自固定代码中的常量,可以直接解析;
  • 来自用户上传,即使内容相同,也必须经过更严格处理;
  • 带有有效签名,说明它未被篡改,但不一定说明当前用户有权使用它;
  • 长度为几百字节可能正常,长度为几百 GB 则是资源耗尽风险。

1.2 验证不是“看起来像数字”

输入验证至少包含四个层次:

  1. 语法验证:能否解析为预期格式;
  2. 类型验证:解析结果是否为预期类型;
  3. 语义验证:值是否满足业务规则;
  4. 资源验证:长度、数量、深度和时间是否可接受。

例如,订单数量的完整约束可能是:

valid(n)=(nZ)(1n1000)\text{valid}(n) = (n \in \mathbb{Z}) \land (1 \le n \le 1000)

只验证 int("10") 成功,不能说明输入合法,因为 0-110**1000000 都可能通过部分检查。

一个直接的 Python 示例:

from dataclasses import dataclass


@dataclass(frozen=True)
class OrderRequest:
    product_id: str
    quantity: int


def parse_order(data: dict) -> OrderRequest:
    if not isinstance(data, dict):
        raise ValueError("request must be an object")

    product_id = data.get("product_id")
    quantity = data.get("quantity")

    if not isinstance(product_id, str):
        raise ValueError("product_id must be a string")

    if not product_id or len(product_id) > 64:
        raise ValueError("invalid product_id")

    if not isinstance(quantity, int) or isinstance(quantity, bool):
        raise ValueError("quantity must be an integer")

    if not 1 <= quantity <= 1000:
        raise ValueError("quantity out of range")

    return OrderRequest(product_id=product_id, quantity=quantity)

这里特意排除了 bool。在 Python 中:

isinstance(True, int)  # True

如果业务要求“整数但不是布尔值”,就必须显式排除。这个例子说明,类型系统和业务约束并不是同一件事。

1.3 规范化必须先于比较

规范化是把多个表示形式转换为统一形式,例如:

  • Unicode 文本规范化;
  • 路径转换为绝对路径并解析符号链接;
  • URL 主机名和端口规范化;
  • 大小写、空白和编码处理;
  • 解析百分号编码。

常见错误是验证一个形式,使用另一个形式:

if ".." not in user_path:
    path = base_dir / user_path

这不能可靠阻止路径穿越,因为 .. 可能经过编码、符号链接或路径拼接后才产生实际效果。更合理的思路是:

  1. 将用户输入解析为路径;
  2. 计算目标路径;
  3. 解析目标路径;
  4. 检查目标是否仍位于允许目录内;
  5. 使用文件描述符或更严格的操作系统约束完成最终访问。
from pathlib import Path


def safe_path(base: Path, name: str) -> Path:
    base = base.resolve()
    candidate = (base / name).resolve()

    try:
        candidate.relative_to(base)
    except ValueError as exc:
        raise ValueError("path escapes base directory") from exc

    return candidate

这段代码可以防止一部分普通路径穿越,但不能自动解决所有竞态问题。若攻击者能在检查和打开之间替换符号链接,仍可能出现 TOCTOU(检查时间与使用时间不一致)问题。高风险场景应使用更底层的文件描述符 API、专用目录和操作系统权限,而不是只依赖字符串检查。


二、注入:把数据误当成了程序

2.1 注入的统一结构

注入发生在程序把“数据”拼接进另一种语言或解释器,并让数据改变了原本的语法或控制结构。

抽象地说,程序本来想执行:

P(c,x)P(c, x)

其中 cc 是固定程序结构,xx 是数据。但如果代码实际构造了:

P(c+ ⁣ ⁣+x)P(c \mathbin{+\!\!+} x)

那么 xx 就可能改变程序本身,而不只是作为参数存在。

常见注入目标包括:

  • SQL;
  • Shell;
  • Python 表达式或代码;
  • 模板语言;
  • LDAP、XPath、NoSQL 查询;
  • 日志格式;
  • 文件路径和 URL。

安全边界的核心原则是:

让结构由 API 或语法树决定,让输入只作为值传递。

2.2 SQL 注入:参数化不是简单转义

错误代码:

username = request.args["username"]

sql = f"SELECT id FROM users WHERE name = '{username}'"
cursor.execute(sql)

如果输入为:

' OR '1'='1

生成的 SQL 结构就被改变了。

正确做法是使用参数绑定:

username = request.args["username"]

cursor.execute(
    "SELECT id FROM users WHERE name = ?",
    (username,),
)

参数化查询的关键不是“把引号转义得更好”,而是数据库驱动把 SQL 模板和参数分开传输:

SQL 结构:SELECT id FROM users WHERE name = ?
参数值:  ["' OR '1'='1"]

此时输入中的引号只是数据,不再具有 SQL 语法意义。

但参数化不能解决所有问题。表名、列名和排序方向通常不是普通参数:

# 不能把列名作为普通值绑定
cursor.execute("SELECT ? FROM users", (column_name,))

如果需要动态选择列,应使用白名单:

ALLOWED_COLUMNS = {
    "name": "name",
    "created": "created_at",
}

column = ALLOWED_COLUMNS.get(requested_column)
if column is None:
    raise ValueError("unsupported sort column")

sql = f"SELECT id, name FROM users ORDER BY {column}"
cursor.execute(sql)

这里的 f-string 仍然存在,但插入的内容来自固定映射,而不是任意用户输入。

2.3 Shell 注入:优先不用 Shell

错误代码:

import subprocess

filename = input("file: ")
subprocess.run(f"cat {filename}", shell=True)

用户输入可能改变命令结构,例如加入命令分隔符、重定向或命令替换。

如果只是读取文件,应直接使用 Python:

from pathlib import Path

content = Path(filename).read_text(encoding="utf-8")

如果确实需要调用外部程序,应传递参数序列,并保持 shell=False

import subprocess

result = subprocess.run(
    ["/usr/bin/grep", "-n", pattern, filename],
    capture_output=True,
    text=True,
    timeout=5,
    check=True,
)
print(result.stdout)

subprocess.run() 是 Python 文档推荐的常用入口;传递参数序列比拼接命令字符串更容易保持参数边界,check=True 能把非零退出码转换为异常,timeout 则限制等待时间。(docs.python.org)

这里仍需验证:

  • pattern 是否允许换行或极端长度;
  • filename 是否位于允许目录;
  • 外部程序是否可能读取 Secret;
  • 子进程的环境变量是否过多;
  • 输出是否可能撑爆内存;
  • 外部程序是否继承了不应继承的标准输入、文件描述符和权限。

env 不传时,子进程通常继承当前进程环境;如果环境中含有凭据,就可能扩大泄露面。可以显式构造最小环境:

import os
import subprocess

env = {
    "PATH": "/usr/bin:/bin",
    "LANG": "C.UTF-8",
}

subprocess.run(
    ["/usr/bin/tool", "--input", filename],
    env=env,
    stdin=subprocess.DEVNULL,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True,
    timeout=5,
    check=True,
)

2.4 evalexec 和“限制 globals”不是沙箱

以下代码不是安全的输入计算器:

expression = input("expression: ")
result = eval(expression, {"__builtins__": {}}, {})

问题在于,Python 对象模型非常强大。即使移除了部分名称,攻击者仍可能通过对象关系、类型信息或实现细节恢复能力。更重要的是,eval 的设计目标就是执行 Python 表达式,而不是解析不可信数据。

如果需求只是解析 Python 字面量,可使用 ast.literal_eval

from ast import literal_eval

value = literal_eval("[1, 2, {'name': 'Ada'}]")
print(value)

literal_eval 只适用于有限的字面量结构,不支持函数调用、属性访问和任意表达式。但“不会执行任意代码”不等于“没有拒绝服务风险”:极深嵌套、超大整数或超长输入仍可能消耗大量资源。因此,在调用前仍应限制输入字节数,并在需要时限制嵌套深度和容器规模。


三、反序列化:pickle 不只是数据格式

3.1 序列化、复制和反序列化的区别

序列化是把对象结构转换成字节或文本表示;反序列化是从表示恢复对象结构。

import pickle

data = {"name": "Ada", "roles": ["admin"]}
blob = pickle.dumps(data)
restored = pickle.loads(blob)

Python 的 copy.copy()copy.deepcopy() 也与对象图有关,但它们不是安全边界:

  • 浅拷贝只复制最外层对象;
  • 深拷贝递归复制对象图;
  • 深拷贝可能调用对象定义的特殊协议;
  • pickle 需要恢复更完整的 Python 对象结构,可能导入模块、重建类实例并调用还原逻辑。

例如:

import copy

inner = []
original = [inner]

shallow = copy.copy(original)
deep = copy.deepcopy(original)

shallow.append("outer")
inner.append("shared")

print(original)  # [['shared']]
print(shallow)   # [['shared'], 'outer']
print(deep)      # [[]]

shalloworiginal 共享内部列表,deep 则创建了独立的内部列表。这里的“复制”讨论的是内存对象图;“序列化”讨论的是跨时间或跨进程传输对象表示。两者都不能把不可信对象自动变成安全对象。

3.2 为什么 pickle.loads() 能执行代码

Python 3.14 文档明确警告:pickle 不安全,恶意 pickle 数据可以在反序列化过程中执行任意代码;不应反序列化来自不可信来源或可能被篡改的数据。(docs.python.org)

一个演示性例子:

import pickle
import os


class RunCommand:
    def __reduce__(self):
        return os.system, ("echo side effect",)


payload = pickle.dumps(RunCommand())
pickle.loads(payload)

__reduce__() 描述对象如何被重建。攻击者可以让重建过程调用一个函数,而不是简单创建普通字典。这个例子会产生外部副作用,因此不应在生产环境运行。

危险点不在于“pickle 文件是二进制”,也不在于“文件扩展名是 .pkl”,而在于:

  1. pickle 允许表达丰富的 Python 对象;
  2. 反序列化需要执行还原协议;
  3. 还原协议可以引用可调用对象;
  4. 因此字节流本身可以携带控制流。

Python 3.14 的默认 pickle protocol 是 5。协议 5 主要改善大块数据和带外缓冲区传输,并没有改变 pickle 的不安全性质。(docs.python.org)

3.3 签名能解决什么,不能解决什么

如果业务必须使用 pickle,例如同一组织内部的短期缓存,可以对字节流做完整性保护:

import hashlib
import hmac
import pickle


def seal(obj: object, key: bytes) -> bytes:
    payload = pickle.dumps(obj, protocol=5)
    tag = hmac.new(key, payload, hashlib.sha256).digest()
    return tag + payload


def open_sealed(blob: bytes, key: bytes) -> object:
    tag, payload = blob[:32], blob[32:]
    expected = hmac.new(key, payload, hashlib.sha256).digest()

    if not hmac.compare_digest(tag, expected):
        raise ValueError("invalid signature")

    return pickle.loads(payload)

这可以阻止不知道密钥的攻击者篡改数据,但有三个边界:

  • 如果签名密钥泄露,攻击者可以生成恶意但有效的 pickle;
  • 签名只说明完整性,不说明对象是否适合当前业务;
  • 如果签名来源本身不可信,验证签名仍可能放行恶意对象。

因此,签名不能把 pickle 变成通用的不可信输入格式。Python 文档也将 HMAC 描述为防篡改手段,并建议在处理不可信数据时考虑 JSON 等更安全的格式。(docs.python.org)

3.4 不可信数据应使用数据格式和模式校验

对外接口通常可以采用 JSON 加显式模式验证:

import json


def parse_payload(raw: bytes) -> dict:
    if len(raw) > 1_000_000:
        raise ValueError("payload too large")

    value = json.loads(raw)

    if not isinstance(value, dict):
        raise ValueError("payload must be an object")

    if set(value) - {"name", "age"}:
        raise ValueError("unknown field")

    if not isinstance(value.get("name"), str):
        raise ValueError("name must be a string")

    age = value.get("age")
    if not isinstance(age, int) or isinstance(age, bool) or not 0 <= age <= 150:
        raise ValueError("invalid age")

    return value

JSON 反序列化本身不会像 pickle 一样恢复任意 Python 类并执行还原函数,但 JSON 仍可能造成:

  • 超大输入导致内存压力;
  • 深层嵌套导致递归或解析成本过高;
  • 数值精度和类型差异;
  • 业务层面的权限绕过。

所以安全边界不是“换成 JSON 就完成了”,而是“使用不会携带任意控制流的数据格式,再进行类型、语义和资源验证”。


四、依赖安全:版本解析是供应链的一部分

4.1 分发包、导入包和依赖树

Python 依赖至少涉及三种不同概念:

  • 分发包:安装工具获取的项目,例如某个 wheel 或 source distribution;
  • 导入包:代码中执行 import 时使用的模块名称;
  • 依赖树:直接依赖及其递归依赖组成的有向图。

因此,下面两个名字可能不同:

[project]
dependencies = ["beautifulsoup4"]
import bs4

安全审查时不能只搜索 import,也不能只看 requirements.txt。需要同时检查:

  • pyproject.toml
  • 构建后生成的元数据;
  • lock 文件;
  • 直接 URL;
  • 私有索引;
  • extras 和环境标记;
  • 构建依赖;
  • CI/CD 安装命令。

PyPA 将包元数据、依赖说明、安装元数据、仓库接口和可复现环境分别列为规范领域;这些规范用于工具之间的互操作,而不是某一个安装工具的私有约定。(packaging.python.org)

4.2 版本约束表达的是候选集合,不是最终版本

依赖声明:

[project]
requires-python = ">=3.14"
dependencies = [
    "httpx>=0.27,<1",
    "pydantic~=2.8",
]

可以形式化为候选集合:

C={vv 满足所有版本约束和环境条件}C = \{v \mid v \text{ 满足所有版本约束和环境条件}\}

例如:

pydantic~=2.8

大致等价于:

pydantic>=2.8, pydantic==2.*

而:

pydantic~=2.8.3

大致等价于:

pydantic>=2.8.3, pydantic==2.8.*

兼容发布运算符 ~= 的上界由版本段数决定;版本规范还规定了 ==!=、大小比较和通配符等语义。(packaging.python.org)

环境标记会进一步缩小候选集合:

dependencies = [
    "pywin32; sys_platform == 'win32'",
    "uvloop; sys_platform == 'linux'",
]

标记表达式在目标环境中求值为真时才纳入依赖;求值为假时应忽略该依赖。(packaging.python.org)

因此,锁定 Linux x86_64 环境得到的依赖集合,不必然适用于 Windows、macOS、ARM 或另一 Python 小版本。一个安全的 lock 不只是“列出包名和版本”,还需要记录适用的 Python 版本、平台、架构、extras、环境标记和分发文件信息。

4.3 Lock 与哈希解决的是不同问题

版本约束解决“允许哪些版本”。

Lock解决“这次解析最终选择了哪些版本及其依赖关系”。

哈希解决“下载到的分发文件是否就是预期文件”。

这三个层次不能互相替代:

>=2.0,<3       允许候选版本集合
lock           选择 2.7.4 及其完整依赖树
hash           校验具体 wheel/sdist 的内容

如果只有:

some-package>=2,<3

不同时间、不同平台或不同索引可能得到不同结果。

如果只有:

some-package==2.7.4

也未必能保证:

  • 传输的文件未被替换;
  • 所有传递依赖固定;
  • 安装的是同一种 wheel;
  • 当前平台与锁定环境一致。

当前 PyPA 的 pylock.toml 规范定义了 pylock.tomlpylock.<name>.toml 的命名形式,并要求锁文件记录格式版本、创建工具和可安装包集合等信息。(packaging.python.org) 但规范定义格式并不等于所有安装工具都以完全相同的方式生成和消费它;工程上仍应确认所用工具的兼容范围。

4.4 供应链攻击发生在安装阶段和运行阶段

依赖风险不止是“某个版本存在漏洞”,还包括:

  1. 名称混淆:把内部包名、导入名或常见拼写发布到公共索引;
  2. 传递依赖:直接依赖本身可信,但其依赖树中出现恶意或脆弱组件;
  3. 构建脚本:source distribution 或构建后端在构建期间执行代码;
  4. 恶意升级:新版本功能正常,却增加数据窃取、环境探测或凭据读取;
  5. 索引配置错误:公共索引与私有索引混用,导致错误来源优先;
  6. 缓存污染:构建缓存或镜像缓存保留了错误文件;
  7. 依赖漂移:开发环境、CI 和生产环境解析出不同树。

因此,依赖安装本身应被视为执行外部代码的操作,而不是简单下载文件。构建环境应使用最小权限、隔离网络、固定索引、审计安装日志,并在安装后记录实际分发包和文件哈希。

一个基本的验证流程可以是:

python3.14 -m venv .venv
. .venv/bin/activate

python -m pip install --upgrade pip
python -m pip install --require-hashes -r requirements.txt
python -m pip check
python -m pip list --format=json

每一步的意义不同:

  • python -m venv 确保 pip 对应当前解释器;
  • --require-hashes 要求要求文件中的候选分发文件带哈希;
  • pip check 检查已安装分发包之间的依赖兼容性;
  • pip list 记录实际安装结果。

虚拟环境提供的是隔离的 Python 安装路径和包集合,不是安全沙箱;它不会阻止包访问文件系统、网络或当前用户可访问的 Secret。PyPA 将虚拟环境作为安装元数据规范的一部分,但隔离依赖与隔离权限是两个不同问题。(docs.python.org)


五、Secret:不是“字符串怎么存”,而是“能力如何流动”

5.1 Secret 的定义

Secret 是一旦泄露就可能授予访问能力的数据,例如:

  • API token;
  • 数据库密码;
  • 云平台访问密钥;
  • JWT 签名密钥;
  • webhook 签名密钥;
  • 加密密钥;
  • 私有包索引凭据;
  • OAuth client secret。

Secret 和普通配置的区别不在于类型,而在于后果。数据库地址泄露通常是信息暴露;数据库密码泄露则可能直接改变系统状态。

5.2 生成 Secret 必须使用密码学随机源

不要使用:

import random

token = str(random.randint(100000, 999999))

random 面向模拟和一般随机数,不适合生成认证令牌。Python 的 secrets 模块使用操作系统提供的高质量随机源,用于密码、认证信息和安全令牌。(docs.python.org)

import secrets

reset_token = secrets.token_urlsafe(32)
print(reset_token)

这里的 32 表示 32 个随机字节,即 256 位随机性;编码后的字符串长度会更长。比较 Secret 时可以使用:

import secrets

if secrets.compare_digest(provided_token, expected_token):
    ...

compare_digest 用于降低普通字符串比较暴露时序差异的风险,但它不能修复弱随机数、错误的令牌生命周期或权限模型。(docs.python.org)

密码则不应以可恢复形式存储。密码认证通常应使用专门的、带盐的慢速密码哈希方案,而不是直接保存明文、可解密密文或普通 SHA-256 摘要。

5.3 Secret 的生命周期

一个 Secret 至少经历以下状态:

stateDiagram-v2
    [*] --> Generated
    Generated --> Stored: 写入 Secret 管理系统
    Stored --> Injected: 运行时注入
    Injected --> Used: 完成认证/签名
    Used --> Rotated: 到期或主动轮换
    Rotated --> Revoked: 旧凭据失效
    Used --> Leaked: 日志/异常/进程/备份泄露
    Leaked --> Revoked
    Revoked --> [*]

风险点不只在“源代码有没有密码”:

  • 命令行参数可能出现在进程列表;
  • 环境变量可能被子进程继承;
  • 异常对象和调试日志可能包含请求头;
  • 序列化缓存可能把配置对象一并保存;
  • CI 日志可能打印安装 URL 中的 token;
  • core dump、崩溃报告和备份可能包含内存中的 Secret;
  • 依赖包可以读取当前用户有权访问的环境变量。

例如,不应这样记录请求:

logger.info("request headers=%r", dict(request.headers))

应该在记录前做字段级脱敏:

SENSITIVE_HEADERS = {"authorization", "cookie", "x-api-key"}


def safe_headers(headers: dict[str, str]) -> dict[str, str]:
    return {
        key: "***REDACTED***" if key.lower() in SENSITIVE_HEADERS else value
        for key, value in headers.items()
    }

但脱敏仍有边界:如果业务字段本身包含密码,单纯按字段名过滤不够。更可靠的策略是定义可记录字段,而不是记录全部字段后尝试删除敏感字段。


六、沙箱:限制代码能力,而不是隐藏几个名字

6.1 Python 内部沙箱的根本边界

沙箱是一个限制不可信代码能力的执行环境。有效沙箱必须限制至少一部分:

  • 文件系统;
  • 网络;
  • 进程创建;
  • CPU 时间;
  • 内存;
  • 系统调用;
  • 用户和组权限;
  • 文件描述符;
  • 进程间通信;
  • Secret 和宿主环境。

以下做法不能构成可靠沙箱:

safe_globals = {"__builtins__": {}}
exec(user_code, safe_globals, {})

原因是 Python 运行时本身提供了动态对象模型、反射、异常机制、类和模块关系。删除一个名称空间中的名称,不等于删除解释器、进程和操作系统的能力。

同样,以下方案也不应被当作安全边界:

  • 禁用 import
  • 只允许 AST 中出现若干节点;
  • 删除 __builtins__
  • 通过正则表达式过滤 ossubprocess
  • 把代码放入线程;
  • 捕获异常后继续执行。

线程共享同一进程地址空间,无法提供独立的内存和权限边界;异常捕获也不能撤销已经发生的文件写入、网络请求或进程创建。

6.2 可接受的执行架构

对不可信代码,更合理的结构是:

sequenceDiagram
    participant API as 主服务
    participant Q as 任务队列
    participant W as 隔离 Worker
    participant OS as 操作系统/容器
    participant S as 结果存储

    API->>Q: 写入任务与受限输入
    Q->>W: 领取任务
    W->>OS: 低权限、限时、限资源执行
    OS-->>W: stdout/stderr/退出码
    W->>S: 仅写入结构化结果
    S-->>API: 返回结果或错误

关键点是:

  1. 主服务不直接执行用户代码;
  2. Worker 使用独立进程;
  3. Worker 运行在独立容器、虚拟机或其他操作系统隔离机制中;
  4. 使用非特权用户;
  5. 只挂载必要目录;
  6. 默认禁止或严格限制出网;
  7. 不注入主服务环境变量;
  8. 设置 CPU、内存、进程数、文件大小和执行时间限制;
  9. 对输出做大小限制;
  10. 超时后杀死进程并确认子进程树已退出。

subprocess.run(timeout=...) 能限制等待外部进程完成的时间,超时后会终止并等待子进程;但这只是 Python 层面的进程管理能力,不等于完整沙箱,也不自动限制子进程创建孙进程、访问文件系统或读取网络。(docs.python.org)

一个只适合展示“边界意识”的 Worker 调用示例:

import subprocess
import sys


def run_worker(source_file: str) -> str:
    result = subprocess.run(
        [sys.executable, "-I", source_file],
        stdin=subprocess.DEVNULL,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        timeout=2,
        check=False,
        env={
            "PATH": "/usr/bin:/bin",
            "PYTHONNOUSERSITE": "1",
        },
    )

    if result.returncode != 0:
        raise RuntimeError(f"worker failed: {result.stderr[:2000]}")

    if len(result.stdout) > 64 * 1024:
        raise RuntimeError("worker output too large")

    return result.stdout

这里:

  • sys.executable 保证使用当前 Python 解释器;
  • -I 启用隔离模式的一部分行为,减少用户环境影响;
  • stdin=DEVNULL 避免等待交互输入;
  • env 只传递最小环境;
  • timeout 防止无限等待;
  • 输出截断避免日志或内存被异常放大。

但即使如此,如果 source_file 与主服务共享相同用户权限,攻击者仍可能读取主服务可访问的文件。因此生产系统还需要容器或虚拟机级别的权限隔离,以及操作系统和平台层面的资源控制。


七、把几个主题串成一条防御链

真实漏洞往往不是单一 API 造成的,而是多个边界连续失守:

# 反例:输入 -> pickle -> eval -> shell
obj = pickle.loads(request.body)
command = eval(obj["command"])
subprocess.run(command, shell=True)

这段代码同时违反了四个边界:

  1. 不可信输入进入 pickle 反序列化;
  2. 反序列化结果未验证类型和结构;
  3. 数据进入 eval,重新获得 Python 控制流;
  4. 结果进入 Shell,进一步获得操作系统控制流。

修复不能只把 shell=True 改成 shell=False,因为前面的任意代码执行已经足够造成危害。正确的重构方向是:

import json
import subprocess


def run_action(raw: bytes) -> str:
    if len(raw) > 16_384:
        raise ValueError("request too large")

    payload = json.loads(raw)

    if not isinstance(payload, dict):
        raise ValueError("object required")

    action = payload.get("action")
    filename = payload.get("filename")

    allowed_actions = {
        "list": ["/usr/bin/ls", "-l"],
    }

    if action != "list":
        raise ValueError("unsupported action")

    if not isinstance(filename, str) or len(filename) > 256:
        raise ValueError("invalid filename")

    # 实际系统还需要进行路径规范化和目录边界检查
    result = subprocess.run(
        allowed_actions[action] + [filename],
        shell=False,
        capture_output=True,
        text=True,
        timeout=3,
        check=True,
        env={"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"},
    )

    return result.stdout

这个版本仍不是完整的生产级文件浏览器,但它体现了正确的数据流:

JSON 数据
  -> 类型和长度验证
  -> 固定动作白名单
  -> 参数序列
  -> 非 Shell 执行
  -> 超时、输出和环境限制

安全性来自结构被固定,而不是来自对输入字符串做越来越复杂的过滤。


八、诊断安全问题时要看失败路径

8.1 看到异常不代表风险被阻止

反序列化失败时,除了 pickle.UnpicklingError,还可能出现 AttributeErrorEOFErrorImportErrorIndexError 等异常。(docs.python.org) 因此不要只捕获一个异常来判断“输入安全”:

try:
    value = pickle.loads(blob)
except pickle.UnpicklingError:
    ...

更重要的是,恶意 payload 可能在抛出异常前已经执行副作用。异常只说明最后一步失败,不说明之前没有发生文件写入、网络请求或命令执行。

8.2 依赖安装成功不代表供应链可信

安装成功只能说明解析器找到了满足约束的候选并完成安装。诊断时还应回答:

  • 实际使用了哪个索引?
  • 选择了哪个版本和分发文件?
  • 是否有 source distribution 构建?
  • lock 是否覆盖当前平台?
  • 是否验证了哈希?
  • 是否存在未声明的直接 URL?
  • 构建工具版本是否固定?
  • CI 与生产是否使用同一锁定结果?

8.3 超时不代表进程树已经清理

主进程超时退出后,外部程序可能已经创建了子进程。生产 Worker 必须设计进程组、容器生命周期和强制清理策略,而不能只依赖一个 Python TimeoutExpired 异常。

8.4 Secret 泄露要按路径排查

排查泄露时不应只搜索 Git 仓库,还应检查:

源代码
提交历史
CI 日志
镜像层
构建缓存
进程参数
环境变量
异常报告
访问日志
对象存储备份
数据库导出
pickle/cache 文件
依赖安装配置

一旦确认泄露,正确动作不是“删除日志中的一行”,而是:

  1. 立即撤销或轮换凭据;
  2. 判断攻击者可能获得的权限;
  3. 检查使用记录;
  4. 清理传播副本;
  5. 修复产生泄露的日志、配置或构建流程;
  6. 为新凭据设置最小权限和过期时间。

九、规范保证、实现行为与工程建议必须分开

最后需要区分三类结论。

规范保证

例如:

  • 版本说明符中 ~===!= 等运算符有定义;
  • 环境标记表达式决定依赖是否适用于当前环境;
  • pylock.toml 规范定义了锁文件命名和字段语义;
  • Python 文档明确说明 pickle 不安全。

这些内容可以作为跨工具理解的基础,但不代表每个工具支持全部功能。

常见实现行为

例如:

  • 安装工具通常会解析传递依赖;
  • wheel 与 source distribution 的构建路径不同;
  • subprocess.run() 通常适合简单子进程调用;
  • 虚拟环境隔离包安装位置。

这些行为应结合具体工具、平台和版本验证。

工程建议

例如:

  • 对外输入使用 JSON 加模式校验;
  • SQL 使用参数绑定;
  • 默认不使用 shell=True
  • 不对不可信数据调用 pickle.loads()
  • 锁定依赖并验证分发文件哈希;
  • Secret 使用专门管理系统并支持轮换;
  • 不把 Python 进程内的名称过滤当成沙箱;
  • 不可信代码放到低权限、限资源的操作系统隔离环境。

这些不是一个 API 能自动完成的功能,而是对数据流、权限和失败路径的整体设计。

Python 安全工程的核心可以压缩成一条规则:

数据进入系统后,必须始终保持“数据”的身份;只有经过明确、固定、可审计的边界,才允许它成为查询参数、命令参数、对象结构、依赖文件或执行任务。

一旦数据被拼接成程序、恢复成任意对象、解析成可执行依赖、注入成环境能力,安全边界就已经发生了变化。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。