作者: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 时,基本步骤是:
- 打开根目录,得到目录描述符。
- 相对于上一步的目录描述符打开下一级目录,并同时要求
O_DIRECTORY和O_NOFOLLOW。 - 重复这一过程,直到父目录。
- 相对于父目录描述符,以
O_NOFOLLOW打开最后的文件。 - 对这个文件描述符执行策略检查,并从同一个描述符读取内容。
这样,每一次打开都要求当前分量本身不是符号链接。文件打开后,后续检查和读取也不会再次按路径查找另一个文件。
这也带来一个需要理解的行为:文件或目录打开之后,即使它被改名,描述符仍然关联着已经选中的对象。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 运行验证。