← 中文资源

简体中文版本

路径穿越

路径穿越用构造的相对段——点号序列与绝对路径——逃出预定目录,在允许根之外读写文件。

阅读英文原文

概念定义

文件路径相对于应用预期的某个根。穿越输入改变有效根:../../etc/passwd 直接走出去。缺陷在于信任字符串拼接,而操作系统会先解析导航段。

工作机制

攻击向量包括点号序列、在校验后才解码的百分号或 unicode 编码、完全无视基目录的绝对路径,以及允许树内指向树外的符号链接。健壮做法是 realpath 解析全路径后断言它以“规范根加分隔符”开头;根必须是文件时连根本身也拒绝。

使用场景

每个接受文件名、路径或归档成员的端点:下载、头像、文档生成、插件加载。尽可能由服务端按 id 生成文件名,把客户端提供的名字当标签而不是位置。

常见误区

对输入剥一遍 ../ 不够——嵌套编码会在事后还原序列。不带分隔符的前缀比较会把 safe_dir-evil 误判为在 safe_dir 内。上传或解压可在树内造出符号链接被后续请求跟随——解压应限制在不跟随链接的沙箱。Windows 还多了备用数据流与保留设备名。zip-slip 就是归档抽取变体。

代码示例

from pathlib import Path


class UnsafePathError(ValueError):
    pass


def safe_join(root: str | Path, user_path: str) -> Path:
    if not isinstance(user_path, str) or not user_path.strip():
        raise UnsafePathError("path must be a non-empty string")
    if "\x00" in user_path:
        raise UnsafePathError("path must not contain NUL bytes")

    candidate = Path(user_path)
    if candidate.is_absolute() or candidate.drive:
        raise UnsafePathError("absolute paths are not allowed")

    base = Path(root).resolve()
    target = (base / candidate).resolve()
    try:
        target.relative_to(base)
    except ValueError as error:
        raise UnsafePathError(f"path escapes root: {user_path!r}") from error
    return target


def main() -> None:
    root = Path.cwd() / "public"
    for value in ["reports/2026/q1.csv", "../secrets.env", "/etc/passwd", "a/../../b"]:
        try:
            print(value, "->", safe_join(root, value))
        except UnsafePathError as error:
            print(value, "-> rejected:", error)


if __name__ == "__main__":
    main()
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()