符号链接是保存目标路径的文件系统对象。跟随链接时,系统按操作发生时的状态解析这段路径;链接并没有永久绑定目标 inode。这个区别解释了为什么符号链接可以跨文件系统、为什么会变成悬空链接,以及为什么保存的目标文本完全没变,最终访问对象却可能改变。理解链接对象与当前解析目标,是正确使用和诊断链接的基础。
坏掉的样子
相对目标以符号链接所在目录为基准,而不是以调用进程的当前工作目录为基准。因此移动链接可能改变其含义;移动或重命名目标,则可能让链接悬空。部署过程如果调整目录结构,却没有核对这些解析基准,熟悉的链接就可能悄悄访问另一个文件。仅看 readlink 打印的文本没有变化,无法证明对象仍相同。
不同存在性检查回答不同问题。跟随链接的操作可能报告目标缺失,而检查目录项本身的操作仍能找到链接。同样,宽松路径解析函数可以在最终目标不存在时返回绝对字符串。规范化后的路径不是可用文件当前存在的证明,更不是文件身份的永久凭据;诊断必须说明自己检查了链接对象,还是跟随之后的目标。
当其他参与者能够修改相关目录项时,先检查路径再打开,会留下检查与使用之间的时间窗口。给路径加引号可以保护 shell 参数解析,却不能冻结文件系统解析。普通 open 的 O_NOFOLLOW 拒绝最后一个组件是符号链接,不会自动禁止前面目录组件中的链接。把参数安全、路径安全和对象稳定性混为一谈,会造成错误边界。
快速验证
把脚本保存为 symlinks.py,在一次性 Linux 环境中运行 python3 symlinks.py。实测使用 Linux 6.12.76 和 Python 3.11.2。脚本创建无害临时文件,并主动改变自己的链接,因此结果是确定性的,不需要并发攻击者,也不访问无关路径:
from pathlib import Path
from tempfile import TemporaryDirectory
import errno,os
def read_no_follow(path):
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW)
try:return os.read(fd,100)
finally:os.close(fd)
with TemporaryDirectory(prefix='xbcatm-symlinks-') as directory:
root=Path(directory);first=root/'first';second=root/'second';first.mkdir();second.mkdir()
(first/'data').write_text('first');(second/'data').write_text('second')
link=first/'link';link.symlink_to('data')
assert os.readlink(link)=='data' and link.read_text()=='first'
assert link.lstat().st_ino!=link.stat().st_ino
moved=second/'link';link.rename(moved)
assert os.readlink(moved)=='data' and moved.read_text()=='second'
print('moving a relative symlink changes its target because resolution starts at its containing directory')
(second/'data').rename(second/'renamed')
assert moved.is_symlink() and os.path.lexists(moved) and not moved.exists()
assert moved.resolve(strict=False)==second/'data'
try:moved.resolve(strict=True)
except FileNotFoundError:pass
else:raise AssertionError('strict resolution unexpectedly found missing target')
moved.unlink();assert (second/'renamed').read_text()=='second'
print('dangling link exists as a link; permissive resolution can name a missing target; unlink removes only the link')
safe=root/'safe';outside=root/'outside';safe.write_bytes(b'approved');outside.write_bytes(b'replaced')
current=root/'current';current.symlink_to('safe');checked=current.resolve(strict=True)
assert checked==safe
opened=os.open(current,os.O_RDONLY)
try:
replacement=root/'next';replacement.symlink_to('outside');replacement.replace(current)
assert current.read_bytes()==b'replaced' and os.read(opened,100)==b'approved'
finally:os.close(opened)
print('a checked path string can resolve differently later; an already opened descriptor keeps the prior object')
try:read_no_follow(current)
except OSError as error:assert error.errno==errno.ELOOP,error
else:raise AssertionError('O_NOFOLLOW accepted a final-component symlink')
alias=root/'parent-alias';alias.symlink_to(first.name,target_is_directory=True)
assert read_no_follow(alias/'data')==b'first'
print('O_NOFOLLOW rejects the final symlink but still allows symlinks in earlier path components')
第一个目录中有名为 data 的文件,以及目标文本仅为 data 的链接。通过链接读取,得到 first。lstat 检查链接本身,stat 则跟随链接检查目标,样本确认两者具有不同 inode 身份。这两个结果用于说明它们确实是两个对象,而不是同一对象的两种显示名称。
把链接移到第二个目录后,readlink 仍然返回相同文本,但读取结果变成 second。此时目标是第二个目录里的 data 文件。实验没有通过改变进程工作目录解释结果,而是直接改变链接所在位置,因此明确展示相对路径解析基准。保留同一段目标文本,不意味着移动链接是没有语义变化的操作。
重命名第二个目标后,链接悬空。is_symlink 与 lexists 仍为真,跟随目标的 exists 则为假。Python 的宽松 resolve 返回预期但不存在的目标路径,严格模式抛出 FileNotFoundError。删除悬空链接后,已经重命名的目标仍然完好,因为 unlink 删除的是链接目录项,不会追着旧目标把它也删除。
随后,脚本把链接解析到无害的已认可文件,并打开它,再把链接替换为指向另一个样本文件的版本。新的路径读取返回 replaced,之前打开的描述符仍然返回 approved。先前检查没有约束后续查找;已有描述符则保留了先前对象。这个对照直接展示两个操作之间的对象选择差异,不依赖偶然碰上的竞争时序。
对最终链接使用 O_NOFOLLOW 打开,得到 ELOOP。但是用相同标志打开 parent-alias/data 时,如果 parent-alias 是目录符号链接,操作仍会成功。两个结果必须一起解释:只验证最后一段拒绝,很容易错误宣称该标志保护了整条路径,使所有中间目录中的链接都无法被跟随。
分别确定对象与遍历策略
问题涉及保存的链接文本时使用 readlink,涉及链接本身时使用 lstat,涉及当前目标时使用跟随操作,并记录诊断实际报告的是哪种身份。日志里的绝对解析路径有助于理解上下文,但仍然只是某一时刻的观察。之后目录项被替换时,这段字符串不会自动保留先前解析结果的对象关系。
如果操作必须限制在可信目录边界内,应围绕目录描述符及适合威胁模型的约束设计遍历。Linux 的 openat2 提供拒绝符号链接、限制在目录之下等解析策略,使用时需要核对精确语义和内核支持。单纯把可信前缀与外部输入拼接,并不能建立目录包含关系,更不能代替实际的内核解析约束。
打开描述符可以避免后续路径替换改变它引用的对象,却不会让对象内容不可修改,也不会证明最初打开已经获得授权。应验证选中的对象,并通过适当引用执行目标操作。路径选择保证、访问权限和内容一致性需要分别设计;把其中一项测试成功当成三项都成立,会留下实验并未覆盖的错误假设。
来源
- symlink(7),保存的目标字符串、相对解析与悬空链接,已核验 2026-09-12。
- open(2),O_NOFOLLOW 与路径变化后的描述符引用,已核验 2026-09-12。
- path_resolution(7),逐段路径遍历与目录权限,已核验 2026-09-12。
- openat2(2),由内核执行的受控路径解析约束,已核验 2026-09-12。