文件路径遍历漏洞的本质,是程序把用户可控的路径片段拼接到文件系统操作中,却没有验证最终路径是否落在允许目录内。修复不能只靠替换 ../,因为攻击者可以使用绝对路径、符号链接、Unicode 编码、双写以及路径分隔符差异绕过。本文从路径归一化、沙盒隔离和白名单映射三个层面拆解防御方式,并给出可直接参考的代码实现。

为什么只过滤 ../ 解决不了路径遍历
许多文件下载、图片查看、模板加载接口会接收一个文件名或相对路径,例如 file=report.pdf。服务端拿到这个值后,通常拼接到固定目录后面,再交给文件读取函数。问题在于,文件系统路径解析规则比开发者预期的复杂得多。Unix 系统把 ../ 解释为上一级目录,Windows 同时接受 ../ 和 ..\,URL 解码阶段又可能先产生 %2e%2e%2f,再被应用层还原成 ../。
下面这段 Flask 代码是典型的漏洞写法:
from flask import Flask, request, send_file
app = Flask(__name__)
@app.route('/download')
def download():
filename = request.args.get('file')
base = '/var/www/uploads/'
full_path = base + filename
return send_file(full_path)
攻击者可以请求 /download?file=../../etc/passwd,最终读取到系统账户文件。即使开发者在拼接前做了 replace('../', ''),也可以用 ....// 这样的输入绕过:替换掉中间的 ../ 后,剩下的两端刚好又组成 ../。如果只检查字符串是否包含 ../,绝对路径 /etc/passwd 或 Windows 路径 C:\Windows\System32\config\SAM 也能直接逃出上传目录。
更隐蔽的绕过发生在符号链接和编码差异中。上传目录里如果存在一个指向 /etc 的软链接,那么即使路径校验确认最终路径带有正确前缀,打开文件时内核仍会跟随链接跳出目录。编码方面,服务端可能先做 URL 解码,再过滤 ../;但如果框架或反向代理已经解码一次,应用层又解码一次,就会产生双重解码问题。因此,路径遍历防御必须建立在路径规范化和访问边界控制之上,而不是只依赖字符串黑名单。
沙盒机制:先划边界,再谈文件访问
沙盒的核心思想是让进程看不到或访问不到敏感目录。即使路径拼接逻辑出错,攻击者能够影响的文件范围也被操作系统限制在指定目录内。Linux 下常见的技术包括 chroot、Linux namespaces、seccomp 以及容器运行时。chroot 会改变进程看到的根目录,把 /var/www/uploads 变成新的 /。不过 chroot 本身并不是完整的安全边界,具有 CAP_SYS_CHROOT 或 root 权限的进程有机会逃逸,因此需要配合降权、只读挂载和 seccomp 一起使用。
更细粒度的做法是使用文件描述符相对打开。Linux 提供的 openat 系列函数允许调用方传入一个目录文件描述符,相对路径只能在该目录下解析。Python 标准库的 os.open 可以传递 dir_fd 参数,示例代码如下:
import os
BASE_DIR = '/var/www/uploads'
base_fd = os.open(BASE_DIR, os.O_RDONLY | os.O_DIRECTORY)
def safe_open(filename: str):
filename = filename.lstrip('/')
if filename.startswith('..'):
raise ValueError('blocked')
fd = os.open(filename, os.O_RDONLY, dir_fd=base_fd)
return os.fdopen(fd, 'rb')
这段代码先把用户输入去掉开头斜杠,再拒绝以 .. 开头的路径,最后通过 dir_fd 在 base_fd 指向的目录内打开文件。它比简单字符串拼接更安全,但仍不是银弹。符号链接仍可能让打开结果跳出目录,如果需要更严格的禁止逃逸,可以使用 Linux 5.6 以后提供的 openat2 系统调用,并设置 RESOLVE_BENEATH 标志,让内核对路径解析过程中的每一级都进行边界检查。
在生产环境中,容器沙盒是一种更省心的方案。例如将文件服务运行在 Docker 容器中,只把上传目录挂载进去,系统目录不挂载或只读挂载;再配合非 root 用户、只读文件系统、禁止特权提升和 seccomp 策略,可以把路径遍历的影响降到最低。需要注意的是,容器共享宿主机内核,本身也不是绝对隔离,但作为纵深防御的一层非常有效。
白名单校验:让用户输入无法直接触达文件系统
白名单的思路比黑名单更可靠:用户不应该直接提交一个真实文件路径,而应该提交一个间接标识,例如文件 ID、哈希值或经过服务端生成的令牌。服务端根据这个标识查询映射表,得到真实的存储文件名或路径。这样攻击者即使知道系统里有哪些文件,也无法通过修改参数读取其他文件,因为参数根本不参与路径拼接。
最简单的白名单实现是使用随机的文件 ID。上传文件时生成一段不可猜测的字符串,比如 a3f9c2d1b8e04f7a,把它和真实存储路径记录到数据库或配置中。下载接口只接收 file_id,不接受文件名:
import os
FILE_STORE = {
'a3f9c2d1b8e04f7a': 'report-2024.pdf',
'7b1e9c0d5a284f3c': 'avatar.png',
}
def download(file_id: str):
filename = FILE_STORE.get(file_id)
if filename is None:
raise ValueError('unknown file')
full_path = os.path.join('/var/www/uploads', filename)
return open(full_path, 'rb')
如果业务确实需要用户直接传文件名,白名单校验至少要包含三层:字符集、扩展名和解析后目录。字符集规则可以只允许字母、数字、下划线、点号和短横线;扩展名规则只允许必要的类型,例如 .pdf、.png、.jpg;目录规则要求规范化后的真实路径必须仍以允许目录开头。下面是一段 Python 示例:
import os
import re
BASE = '/var/www/uploads'
def resolve_safe(filename: str):
if not re.fullmatch(r'[A-Za-z0-9._-]+', filename):
raise ValueError('invalid characters')
if not filename.lower().endswith(('.pdf', '.png', '.jpg')):
raise ValueError('invalid extension')
abs_base = os.path.realpath(BASE)
abs_target = os.path.realpath(os.path.join(abs_base, filename))
if not abs_target.startswith(abs_base + os.sep):
raise ValueError('path traversal detected')
return abs_target
这段代码中的正则表达式只允许安全字符,扩展名使用白名单,并且通过 realpath 解析出最终绝对路径后再次检查目录前缀。realpath 会跟随符号链接,因此如果上传目录内可能存在符号链接,应在上传阶段就禁止符号链接,或者使用 lstat 逐级检查路径组件,确保没有链接指向目录外。
组合防御与回归测试
单独的沙盒或白名单都可能存在盲点。沙盒没有做好时,白名单可能因路径规范化错误被绕过;白名单只校验文件名时,符号链接又可能突破沙盒。因此真正健壮的实现应当同时使用两者:对外只暴露文件 ID,服务端查表得到文件名,再做白名单校验,最后在受限目录下打开文件。容器或系统级沙盒作为底线,防止应用层校验出现未知缺陷时直接暴露系统文件。
测试时不应只验证正常下载,还要把常见攻击载荷放进回归用例。可以准备下面这些输入:
../../etc/passwd ..\..\windows\system32\config\sam %2e%2e%2f%2e%2e%2fetc%2fpasswd ....//....//etc/passwd /var/www/uploads/../../etc/shadow
这些 payload 分别覆盖 Unix 目录穿越、Windows 反斜杠穿越、URL 编码、双写替换绕过和绝对路径前缀绕过。测试时确认每个输入都被拒绝,并且返回统一的错误信息,不暴露实际文件系统路径。日志中可以记录完整的攻击输入和判定结果,但不要把真实文件路径回显给客户端。
最后还要注意文件上传环节本身。如果攻击者能够上传一个符号链接,后续下载时即使有白名单也可能跟随链接读取外部文件。上传时应使用随机文件名保存,忽略客户端提供的文件名;存储目录中不创建符号链接;如果有压缩包解压功能,必须在解压后逐条校验每个文件的真实路径。只有把上传、存储、读取三个环节都控制住,才算真正解决了路径遍历漏洞。