← 中文资源

简体中文版本

防符号链接逃逸的目录遍历

遍历目录时不跟随指向根目录之外的符号链接,记录被跳过的条目并限制递归深度。

阅读英文原文

功能说明

打包、扫描、清理这类运维脚本经常需要遍历目录。如果直接跟随符号链接,攻击者可以在上传目录里放一个指向系统配置的链接,脚本就会读取或删除根目录之外的文件。这个遍历器先把根目录解析成真实路径,再逐个检查子项,凡是指向根目录之外或指向目录的链接一律跳过并记录。

返回两个列表:可安全处理的文件相对路径,以及被跳过的条目。跳过原因包括损坏的链接、越界链接、目录链接和超过深度限制的子目录。调用方可以据此打日志,而不是静默忽略。

关键代码

root_path 使用 resolve(strict=True) 获取真实路径,防止根本身就是链接。遍历用显式栈代替递归,避免深目录触发递归上限。entry.is_symlink 先判断链接,再解析目标并检查 is_relative_to 是否仍在根内;只有目标在根内且是普通文件才纳入结果。普通目录和文件都使用 follow_symlinks=False,确保链接不会被当作真实目录展开。

使用注意

目录链接被刻意跳过,这是防止循环和越界的保守策略;需要跟随根内目录链接时应额外记录访问过的真实路径。is_relative_to 在 Python 3.9 及以上可用。文件系统在检查与使用之间可能被替换,构成竞态,高安全场景应结合打开文件描述符与 nofollow 标志。深度限制过小会跳过合法的深层目录,需要按业务调整。返回的是字符串路径,跨平台请统一用 as_posix 或 Path 处理。

验证方式

在示例所在目录运行会打印文件数量和前五个相对路径,以及被跳过的条目数量。可以手动创建一个指向系统目录的链接,确认它出现在跳过列表中而不是文件列表中;再创建一个指向同目录文件的链接,确认它被当作普通文件收入结果。把深度限制改为零,所有子目录都应被跳过。

代码示例

import os
from pathlib import Path


def walk_confined(root: str, *, max_depth: int = 32) -> tuple[list[str], list[str]]:
    root_path = Path(root).resolve(strict=True)
    if not root_path.is_dir():
        raise NotADirectoryError(root)
    files: list[str] = []
    skipped: list[str] = []
    stack: list[tuple[Path, int]] = [(root_path, 0)]
    while stack:
        directory, depth = stack.pop()
        if depth >= max_depth:
            skipped.append(str(directory.relative_to(root_path)))
            continue
        with os.scandir(directory) as entries:
            ordered = sorted(entries, key=lambda entry: entry.name)
        for entry in ordered:
            entry_path = Path(entry.path)
            if entry.is_symlink():
                try:
                    target = entry_path.resolve(strict=True)
                except OSError:
                    skipped.append(str(entry_path.relative_to(root_path)))
                    continue
                if target.is_relative_to(root_path) and target.is_file():
                    files.append(str(entry_path.relative_to(root_path)))
                else:
                    skipped.append(str(entry_path.relative_to(root_path)))
            elif entry.is_dir(follow_symlinks=False):
                stack.append((entry_path, depth + 1))
            elif entry.is_file(follow_symlinks=False):
                files.append(str(entry_path.relative_to(root_path)))
    return sorted(files), sorted(skipped)


def main() -> None:
    root = Path(__file__).resolve().parent
    files, skipped = walk_confined(str(root), max_depth=2)
    print(f"files: {len(files)}")
    for name in files[:5]:
        print("  ", Path(name).as_posix())
    print(f"skipped: {len(skipped)}")


if __name__ == "__main__":
    main()