漏洞根源在于未校验路径归属:os.path.join遇绝对路径会丢弃base_dir,导致绕过限制;应使用pathlib.path.resolve()结合is_relative_to()严格验证真实路径是否在白名单内。

send_file 或手动拼接路径读取文件,且未校验用户输入的真实路径归属,就是漏洞根源。必须在打开文件前确认路径落在白名单目录内,而不是靠过滤 .. 或正则替换。
为什么 os.path.join + 用户输入会失效?
当用户传入的路径是绝对路径(如 /etc/passwd 或 ../../etc/passwd),os.path.join(BASE_DIR, user_input) 会直接丢弃 BASE_DIR,返回用户指定的路径——这是 Python 的设计行为,不是 bug。攻击者只要提交以 / 开头的路径,就能完全绕过目录限制。
-
os.path.join("/var/www/uploads", "../../../etc/passwd")→"/var/www/uploads/../../../etc/passwd"(相对路径,后续可被realpath拦截) -
os.path.join("/var/www/uploads", "/etc/passwd")→"/etc/passwd"(绝对路径,BASE_DIR被无视)
所以不能依赖拼接后“看起来在目录里”,而要验证最终解析出的路径是否真属于授权范围。
用 pathlib.Path.resolve() 替代手工拼接
Python 3.6+ 推荐用 pathlib,它比 os.path 更直观、更安全:.resolve() 自动处理符号链接、..、.,并抛出 FileNotFoundError 或 PermissionError(如果路径不可达),天然防部分绕过。
- 先构造
Path(BASE_DIR) / user_input,再调用.resolve() - 检查
resolved_path.parent是否是safe_root的子目录(用.is_relative_to(safe_root)) - 必须捕获
FileNotFoundError:攻击者可能用不存在路径触发异常,但不应暴露系统结构
示例:
from pathlib import Path
BASE_DIR = Path("/var/www/uploads")
user_input = "../../../etc/passwd"
try:
full_path = (BASE_DIR / user_input).resolve()
if not full_path.is_relative_to(BASE_DIR):
raise PermissionError("Access denied")
return send_file(full_path)
except (FileNotFoundError, PermissionError):
abort(404)
Flask 中 send_from_directory 不等于万无一失
send_from_directory 确实做了基础校验,但它只检查路径字符串是否以目录开头,不调用 realpath 或 resolve。如果 BASE_DIR 下存在符号链接指向外部目录(比如 uploads → /etc),攻击者仍可通过 send_from_directory("/var/www/uploads", "uploads/passwd") 读取 /etc/passwd。
- 不要依赖
send_from_directory的默认行为,尤其当用户能控制BASE_DIR下的软链创建权限时 - 生产环境禁用用户创建符号链接;或改用
Path.resolve()+is_relative_to()做二次校验 - 若必须支持软链,需递归检查每一级父路径是否都在白名单内(即逐级
.parent到根,每层都is_relative_to)
Web 框架外的手动文件读取最容易漏防
Django 的 serve、Flask 的 send_from_directory、FastAPI 的 FileResponse 都内置了路径校验,但一旦你写 open(path)、Path(path).read_text() 或 send_file(path),这些防护就彻底失效——框架不会帮你做任何事。
- 所有手动文件操作前,必须走一遍
Path(path).resolve().is_relative_to(BASE_DIR) - URL 参数、表单字段、JSON body 里的路径字段,一律视为不可信输入,不做例外处理
- 特别注意日志导出、配置下载、静态资源预览等高频功能点,它们常被忽略校验
最危险的是“看起来安全”的场景:比如前端传 filename=report.pdf,后端拼成 os.path.join("reports/", filename) ——只要攻击者发 filename=%2e%2e%2fetc%2fpasswd(URL 解码后为 ../etc/passwd),就中招。
Python免费学习笔记(深入):立即使用
在学习笔记中,你将探索 Python 的核心概念和高级技巧!











