作者:温景行-749|安全工程师|8 年,专注安全加固与风险防范 栈:Spring Boot 3.2.4、Spring Security 6.3、Nginx 1.25、Go 1.21(对比实现)、Linux 5.15 / Windows Server 2019(跨平台对照) 结论先行:「文件名里不要出现
..」是一个黑名单思维,而文件读取的安全决策必须建立在「规范化之后的真实路径」上。校验的是字符串,OS 打开的是路径——两者之间隔了一次解析,这条缝就是所有穿越绕过的位置。
一、现象与背景:一个「只读自己目录」的下载接口,把 .env 拖走了
上个月给一套做 B 端 SaaS 的客户做接口安全审计。目标是一个通用的文件下载接口:
GET /api/v1/files/download?file=<文件名>
业务定位是「让用户下载自己上传的头像、发票、合同」,后端把文件存到 /data/uploads/ 下,按「当前登录用户 + 文件名」取文件。安全同学很自信:「我们校验了文件名,.. 和 / 都不让过。」
我做了三件事:
- 对 40 个带「文件名 / 路径类参数」的接口逐一跑「换值」测试(
..、....//、%2e%2e%2f、绝对路径、反斜杠各来一遍); - 命中的 9 个接口里,我重点打这个下载接口;
- 用一行 curl 把
/data/uploads/../../../.env的内容原样吐了出来(已脱敏为 [已脱敏]),里面躺着生产 DB 密码、对象存储 AK/SK、Redis 密码。
影响面:1 套生产 DB 凭据 + 1 组对象存储密钥 + 1 个 Redis 密码,全部从一个本该「只读自己目录」的接口里拖走。更扎心的是:这套代码上线 11 个月,WAF 一条穿越告警都没报过——因为它拦的是「字符串里有没有 ..」,而我发的 ....//、%252e%252e%252f 在它眼里都是「合法字符」。
二、根因:校验与打开之间,隔了一次「路径解析」
先还原他们最典型的写法(八成团队都这么写):
String name = req.getParameter("file");
if (name.contains("..") || name.contains("/") || name.contains("\\")) {
return 403; // ← 黑名单校验的是「原始字符串」
}
File f = new File("/data/uploads/" + name);
return new FileInputStream(f); // ← OS 打开的是「解析之后的路径」
问题不在黑名单不够全,而在校验的对象和打开的对象不是同一个东西:
- 黑名单校验的是「字符序列」:
....//里没有连续的两个点加斜杠(....//拆开是..+..+//),contains("..")命不中,contains("/")也命不中(因为中间是//但被..隔开了)。WAF 常见规则是「把../替换成空再放行」,于是....//→../又活了——这是经典的双重过滤绕过。 - 真正决定读哪个文件的是 OS 的解析:
/data/uploads/..%2f..%2f..%2f.env经 URL 解码、路径规范化后,落在文件系统上就是/data/.env。攻击者控制的是字符串,OS 控制的是解析,两者之间那次urldecode + canonicalize就是漏洞。
这和 SSRF 里「你拦的是解析之前的字符串,攻击者打的是解析之后的连接」是同一个结构——check 和 use 之间插了一次解析,黑名单永远追不上编码变体,因为变体是无穷的。
三、具体做法:从黑名单换到「白名单映射 + 规范化边界」
核心原则一句话:永远不要接受「路径」,只接受「标识符」,并让服务端把标识符翻译成路径。
方案 A(首选):用 ID 映射,不接受文件名
把「用户能下载什么」从「他传的文件名」改成「他拥有的资源 ID」:
// 1) 数据库:user_assets(id, owner_id, bucket, object_key, size, ...)
// 2) 下载时只认 id,路径由服务端拼
@GET
public Response download(@QueryParam("id") long id, @Context AuthUser u) {
Asset a = assetRepo.findByIdAndOwner(id, u.id())
.orElseThrow(() -> new ForbiddenException()); // ← 归属校验,不是字符串校验
// object_key 是服务端入库时生成的 UUID,不含任何用户输入
return stream(bucketClient.get(a.bucket(), a.objectKey()));
}
好处:id 是数字,contains("..") 这种问题根本不存在;归属校验是「数据库里的 owner_id = 当前用户」,是关系校验而非文本校验。
方案 B(次选):必须用文件名时,规范化后判「是否在基目录内」
Path base = Path.of("/data/uploads").toRealPath(); // ← 先规范化基目录本身(解析 symlink)
Path target = base.resolve(userFileName).normalize();
if (!target.startsWith(base)) { // ← 判「规范化后」的前缀
return 403;
}
FileInputStream in = new FileInputStream(target.toFile());
Go 的等价写法(注意 filepath.Clean 只规范化、不解析 symlink,需要再 os.Stat 兜底):
base, _ := filepath.Abs("/data/uploads")
target := filepath.Clean(filepath.Join(base, fileName))
if target != base && !strings.HasPrefix(target, base+string(os.PathSeparator)) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
Nginx 侧:别让 location 直接把参数映射到文件系统
# 反例:$arg_file 直接拼进文件系统路径,等于把解析权交给客户端
location ~ ^/files/download {
root /data;
try_files /uploads/$arg_file =403; # ← 危险:$arg_file 可含 ..
}
# 正解:Nginx 只回 302 指回应用,真正的取文件在应用层做 ID→路径
四、踩过的坑与反直觉之处
....//是专门打「WAF 单层替换」的:WAF 把../替换成空,....//就还原成../。任何「替换一次」的黑名单都会被这种「多一层」绕过。- URL 双编码
%252e%252e%252f:应用做一次解码得到%2e%2e%2f(安全),若中间还有一层(Nginx)再解码一次,就成了../。几层解码,就要考虑几次还原。 - 反斜杠
\在 Windows 上等价于/:..\..\..\..\etc\passwd在 Linux 无害、在 Windows 致命。跨平台部署时黑名单「只查/」会漏。 - 符号链接是隐藏通道:
/data/uploads/link -> /etc,resolve + startsWith能挡住..,但挡不住「一个指向外部的软链」。所以基目录本身也要toRealPath(),必要时对 target 也做toRealPath()再判前缀。 - 「我用了白名单扩展名(.jpg/.pdf)」不等于安全:扩展名白名单只约束「读出来的文件长什么样」,不约束「读的是哪个文件」;配合 Content-Type 嗅探才勉强收口,但仍不如 ID 映射干净。
- 绝对路径入参要单独处理:
file=/etc/passwd这种,resolve会直接覆盖基目录(base.resolve("/etc/passwd")=/etc/passwd),所以判前缀前必须确认入参是相对路径。
五、结论与可复用清单
把「文件名安全」从黑名单换到「标识符 + 规范化边界」,五条落地:
- 优先 ID 映射:客户端只传资源 ID,路径由服务端按
owner_id归属校验后生成;文本校验降级为兜底。 - 必须用文件名时:
base.toRealPath()→resolve(name).normalize()→startsWith(base),三步缺一不可。 - 跨平台:同时处理
/与\;Windows 上反斜杠是真实分隔符。 - 考虑解码层数:每多一层代理/WAF,就多一层「还原后穿越」的可能,黑名单在 N 层解码下必然失效。
- 软链与绝对路径:对 target 也做 realpath,并显式拒绝绝对路径入参。
一句话小结:「校验」要校在「解析之后、打开之前」的那个真实路径上;只要安全决策基于客户端给的原始字符串,无论黑名单写多长,都只是在跟无穷编码变体赛跑。