概念定义
文件路径相对于应用预期的某个根。穿越输入改变有效根:../../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()