Python 的 O_NOFOLLOW 为什么挡不住中间目录的符号链接?

作者:Elton Pang,Linkward 维护者。

假设一个 Python 服务需要读取 /srv/myapp/config/token。你希望读取这个路径下的文件,并且拒绝符号链接把读取操作引向别处。

很多人会先想到:

python 复制代码
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)

这个标志很有用,但它只约束路径的最后一个分量。如果 token 是符号链接,打开会失败;如果 config 是符号链接,而它指向的目录中有一个普通的 token 文件,打开仍然可能成功。

这里的差别在于:我们通常说"一个文件路径",内核实际处理的却是一连串目录项。

我把逐级拒绝符号链接,以及对已打开文件进行策略检查的逻辑,封装成了一个小型 Python 库:Linkward。它支持 Linux 和 macOS、Python 3.9 及以上版本,没有运行时第三方依赖。本文用一个本地例子说明它处理的问题,以及它的边界。

先运行一个例子

下面的代码只使用自己创建的临时文件,不访问系统凭据。可以在 Linux 或 macOS 上复现本文对应的版本:

bash 复制代码
python -m pip install linkward==0.1.1

把以下内容保存为 demo_linkward.py,然后运行 python demo_linkward.py:

python 复制代码
import os
import tempfile
from pathlib import Path

from linkward import SymlinkRejected, safe_open


with tempfile.TemporaryDirectory(prefix="linkward-demo-") as temporary:
    # 这里只规范化程序自行创建的临时根目录。
    # macOS 的临时目录路径可能经过 /var 等系统符号链接。
    root = Path(temporary).resolve(strict=True)

    normal = root / "normal.conf"
    normal.write_bytes(b"expected configuration\n")
    assert safe_open(normal) == b"expected configuration\n"

    attacker = root / "attacker"
    attacker.mkdir()
    (attacker / "token").write_bytes(b"attacker-controlled data\n")

    # 最后一个分量 token 是普通文件;中间的 config 才是符号链接。
    (root / "config").symlink_to(attacker, target_is_directory=True)
    target = root / "config" / "token"

    ordinary = target.read_bytes()
    print("ordinary open:", ordinary)

    fd = os.open(target, os.O_RDONLY | os.O_NOFOLLOW)
    try:
        leaf_only = os.read(fd, 4096)
        print("leaf-only O_NOFOLLOW:", leaf_only)
    finally:
        os.close(fd)

    assert ordinary == leaf_only == b"attacker-controlled data\n"

    try:
        safe_open(target)
    except SymlinkRejected:
        print("linkward: rejected a symlink in the path")
    else:
        raise AssertionError("expected the symlink to be rejected")

输出为:

text 复制代码
ordinary open: b'attacker-controlled data\n'
leaf-only O_NOFOLLOW: b'attacker-controlled data\n'
linkward: rejected a symlink in the path

第二行就是容易漏掉的情况:O_NOFOLLOW 按照设计工作了,只是它保护的最后一个分量并不是符号链接。

这个例子展示了一个静态的目录链接。仓库还提供了完整演示脚本,包括"先检查路径,再替换目录,最后打开"的情形。替换发生在明确安排的步骤之间,用来复现有问题的操作顺序;它不是依靠线程调度运气的竞态成功率测试。

为什么先检查整条路径还不够?

另一种写法是先调用 lstat() 检查路径分量,或者先用 resolve() 获得没有符号链接的路径,再调用 open()。

问题是,检查和打开是两个时刻。只要攻击者能修改相关目录,就可能在检查后替换目录项。一次检查证明的是当时观察到的状态,后面的 open() 仍然会重新解析路径。

Linkward 使用目录文件描述符把打开操作逐级衔接起来。处理 /srv/myapp/config/token 时,基本步骤是:

  1. 打开根目录,得到目录描述符。
  2. 相对于上一步的目录描述符打开下一级目录,并同时要求 O_DIRECTORY 和 O_NOFOLLOW。
  3. 重复这一过程,直到父目录。
  4. 相对于父目录描述符,以 O_NOFOLLOW 打开最后的文件。
  5. 对这个文件描述符执行策略检查,并从同一个描述符读取内容。

这样,每一次打开都要求当前分量本身不是符号链接。文件打开后,后续检查和读取也不会再次按路径查找另一个文件。

这也带来一个需要理解的行为:文件或目录打开之后,即使它被改名,描述符仍然关联着已经选中的对象。Linkward 不保证读到"读取结束那一刻,这个路径名对应的对象",也不提供目录范围约束。

放进应用时,策略要明确

Linkward 的包名和导入名是 linkward,读取函数叫 safe_open(),返回值是 bytes。

对于一个已经部署好的凭据文件,可以这样读取。示例假设 /srv/myapp/token 是你配置的绝对路径,并且每一级目录都没有符号链接:

python 复制代码
import os
from pathlib import Path

from linkward import safe_open

token = safe_open(
    Path("/srv/myapp/token"),
    require_owner=os.geteuid(),
    require_mode=0o600,
    max_bytes=64 * 1024,
)

这里明确要求文件属于当前进程的有效用户,普通权限位恰好为 0600,读取结果不超过 64 KiB。检查失败会抛出 SafeOpenError 的子类;应用可以让启动失败,或者在统一错误处理处记录不含凭据的诊断信息。

所有者和权限检查默认没有启用,需要调用方根据部署方式设置。默认会要求普通文件,大小上限为 1 MiB。权限位检查并不检查 ACL,也不证明内容可信。

路径必须是绝对文本路径或返回文本的 PathLike。Linkward 不接受相对路径,也拒绝输入文本中的 .、.. 分量和末尾斜杠。检查针对 os.fspath() 返回的文本;Path 对象可能已经移除了部分 . 或末尾斜杠。Linkward 不会替调用方解析符号链接。

在 macOS 上,/etc、/var、/tmp 常常涉及系统符号链接。应用应在可信的部署配置中选择合适的实际路径,例如系统允许时使用 /private/etc/...。不要为了让不可信路径通过检查,就先对它调用 resolve() 或 realpath():这些操作会跟随链接,之后的打开又可能遇到已经变化的路径。

成功读到文件,不等于获得了内容快照

这是我希望使用者在采用前认真评估的部分。

Linkward 在读取前后调用 fstat(),比较文件标识、大小和时间戳,并检查读取长度。这能发现一部分并发修改,但不能证明内容在整个读取过程中始终不变。包括 mmap 写入在内的并发修改,可能没有留下可区分的元数据;一次成功的读取也可能包含不同时刻的字节。

因此,它适合处理的需求是:在打开文件时拒绝整条路径中的符号链接,并对实际读取的描述符执行指定检查。

采用时还需要满足应用自身的信任条件:

  • 攻击者如果能直接写入普通文件,拒绝符号链接不能让内容变得可信。
  • 硬链接、挂载点变化、拥有更高权限的攻击者等问题,不在符号链接保证范围内。
  • 逐级打开目录不构成沙箱,也不保证已经打开的目录始终位于某个路径边界内。
  • 需要内容一致性、真实性或隔离保证时,应另外设计相应的发布、完整性验证或隔离机制。

完整行为和限制记录在 README 和 SECURITY.md。

想听到什么反馈

安全路径处理已经有不少已有工作,例如 pathrs 和 google/safeopen。Linkward 选择的是一个较小的 Python API:拒绝每一级符号链接,再检查实际读取的文件描述符。

如果你维护 Python CLI、服务配置加载器或凭据读取代码,欢迎用你自己的部署场景评估它。我尤其希望听到两个问题的反馈:哪些现有读取逻辑确实需要这个保证,以及这个 API 的限制是否说得足够清楚。

项目目前处于早期阶段,使用 MIT 许可证。源码、测试和演示在 GitHub,安装包在 PyPI。普通使用问题可以提交 issue;疑似安全漏洞请通过 GitHub 私密漏洞报告 联系,避免先公开敏感细节。

演示代码已针对 Linkward 0.1.1 运行验证。

相关推荐
Generalzy2 小时前
Qt 桌面开发入门:PySide6 学习路线、架构原理与技术选型
笔记·python·学习
Mr.D学长2 小时前
毕业设计项目 基于机器视觉的车牌识别系统
python·毕业设计·毕设
云和数据.ChenGuang2 小时前
AI 工程师相关就业岗位(细分,偏工程落地,由底层到上层)
服务器·开发语言·人工智能·python
Web3&Basketball2 小时前
litellm 做 Agent 成本监控:3 个账单维度实战
python·大模型·agent·可观测性·litellm·成本控制·llm运维
Mr.D学长3 小时前
毕业设计项目 基于python的答题卡识别评分系统
python·毕业设计·毕设
云和数据.ChenGuang3 小时前
LangChain 实战指南:从入门到构建智能应用
开发语言·人工智能·python·langchain
青少儿编程课堂3 小时前
蓝桥杯·全国青少年信息素养大赛·电子学会C/C++考级:拓扑排序课程表排课解析
c++·python·算法·bfs·信息学竞赛
const_qiu3 小时前
第三章 04:3.6 基础调用链的构建
开发语言·python
向上的车轮3 小时前
Python 大数据处理不慢:Polars + DuckDB + Arrow 实战指南
开发语言·python