符号链接保存路径,不永久绑定目标身份
符号链接是保存目标路径的文件系统对象。跟随链接时,系统按操作发生时的状态解析这段路径;链接并没有永久绑定目标 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 身份。这两个结果用于说明它们确实是两个对象,而不是同一对象的两种显示名称。 ...