路径遍历(Path Traversal)不需要复杂利用链,一个 file=../../etc/passwd 就足以读完服务器上的配置与密钥。它常出现在下载、预览、日志查看这类看起来无害的接口里,而 Nginx alias 配置不当又会让同一类问题在 Web 层复现。本文从载荷拆解到 Nginx 与代码双层校验,给出可直接落地的防御配置。
适用场景
- 存在按文件名读取的下载接口,如
download.php?file=report.pdf; - 图片、附件预览接口把用户传入的相对路径直接拼接进文件系统调用;
- 运维后台提供日志查看、配置导出功能,参数对应真实文件路径;
- 静态资源使用 Nginx
alias映射到 Web 根目录之外的存储目录; - 模板引擎、语言包加载、证书文件读取等场景接受外部传入的路径片段。
前置条件
- Nginx 1.18 及以上,可修改站点配置并 reload;
- 应用侧可改动文件读取逻辑(本文以 PHP 与 Java 为例);
- 已确认业务真实需要的文件集合,最好能改为白名单映射;
- 测试环境具备一组准备被读取的样本文件,禁止对生产环境做越权探测;
- 具备 curl 与浏览器开发者工具,用于构造编码变体。
原理说明
路径遍历的本质是「用户输入进入了文件系统路径构造」。当代码执行 readfile($base . $_GET['file']) 时,../ 会被操作系统解析为父目录,一层不够就多叠几层,直到抵达目标文件。绝对路径 /etc/passwd 更直接,只要拼接逻辑允许覆盖前缀就成立。
防护之所以经常失效,是因为过滤发生在解码之前。HTTP 请求到达应用时,路径可能经历 URL 解码、二次解码、Unicode 归一化多轮处理:%2e%2e%2f 解码后是 ../,%252e%252e%252f 二次解码后才成形,全角句点与超长 UTF-8 编码在部分语言中也会被归一化为点。只 str_replace('../','') 一次,等于只挡住了最朴素的一类。
Nginx 层还有独立的成因:alias 与 location 尾斜杠不一致时,URI 会被错误拼接;未开启规范化时连续斜杠、编码斜杠可能绕过 location 匹配。所以完整防御必须是两层:Web 服务器拦住明显载荷并统一路径形态,应用层用「解析真实路径 + 前缀校验」或「ID 到文件映射」做最终判定。
操作步骤
第一步:定位读取点并做基线测试
在测试环境用一组载荷确认哪些参数可控,重点看返回长度与状态码差异,而不是看是否报错:
BASE="https://test.example.com"
for p in "../../../../etc/passwd" "..%2f..%2f..%2fetc%2fpasswd" \
"%2e%2e%2f%2e%2e%2fetc%2fpasswd" "..%252f..%252fetc%252fpasswd" \
"....//....//etc/passwd" "..\\..\\windows\\win.ini"; do
printf "%-40s " "$p"
curl -s -o /tmp/out -w "%{http_code} %{size_download}\n" -m 5 \
--path-as-is "$BASE/download.php?file=$p"
done
grep -l "root:x:0:0" /tmp/out && echo "存在任意文件读取"
–path-as-is 是关键参数,否则 curl 会自行归一化 ../,测出来的结果与真实攻击不一致。返回 200 且体积明显大于正常响应,通常意味着读取成功。
第二步:修正 Nginx alias 与路径规范化
server {
merge_slashes on;
# 错误:location 无尾斜杠,alias 有尾斜杠
# location /files { alias /data/storage/; }
# 正确:两者尾斜杠一致,且只允许已知扩展名
location /files/ {
alias /data/storage/;
autoindex off;
location ~* \.(jpg|jpeg|png|gif|webp|pdf)$ {
expires 7d;
add_header Cache-Control "public";
}
# 未命中白名单的扩展名一律拒绝
location ~* \.(php|phtml|jsp|asp|aspx|sh|conf|env|sql)$ {
return 404;
}
try_files $uri =404;
}
# URI 中出现 .. 或编码后的点斜杠,直接 400
if ($request_uri ~* "(\.\.|%2e%2e|%252e)") {
return 400;
}
}
nginx -t && systemctl reload nginx
这条 if 只做 return,属于官方认可的安全用法。注意它拦的是原始 URI,因此能覆盖尚未解码的载荷;解码后的判断仍要交给应用层,不能只依赖 Nginx。
第三步:应用层改用白名单映射(首选方案)
最彻底的做法是让用户永远无法决定路径,只传标识符,由服务端查表得到真实文件:
<?php
// 用户只提交 id,服务端映射到固定目录下的文件
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id === null) {
http_response_code(400);
exit('bad request');
}
$map = [
101 => 'report/2026-q1.pdf',
102 => 'report/2026-q2.pdf',
];
if (!isset($map[$id])) {
http_response_code(404);
exit('not found');
}
$base = '/data/storage/';
$real = realpath($base . $map[$id]);
// 双重确认:解析后的真实路径必须以基目录开头
if ($real === false || strpos($real, realpath($base) . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(404);
exit('not found');
}
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . basename($real) . '"');
readfile($real);
如果业务确实需要动态文件名,就必须保留 realpath 加前缀比较这两步。只做 basename 会丢掉子目录结构,只做 str_replace 会漏掉编码变体,两者都不能单独作为防护。
第四步:Java 侧使用 normalize 加前缀校验
import java.nio.file.*;
public static Path safeResolve(Path baseDir, String userInput) throws IOException {
if (userInput == null || userInput.indexOf('\0') >= 0) {
throw new IllegalArgumentException("invalid name");
}
Path base = baseDir.toRealPath();
Path target = base.resolve(userInput).normalize();
// 归一化之后仍必须位于基目录之内
if (!target.startsWith(base)) {
throw new SecurityException("path traversal blocked");
}
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("not a regular file");
}
return target;
}
toRealPath() 会解析符号链接,NOFOLLOW_LINKS 则防止最终文件本身是软链。只有两步都做,才能同时挡住 ../ 与「先放一个软链再读」的组合手法。
第五步:用 WAF 规则做兜底拦截
# ModSecurity + OWASP CRS 已内置 930 系列规则,确认已启用
SecRuleEngine On
SecRule REQUEST_URI|ARGS|REQUEST_HEADERS "@rx (?i)(?:\.\./|%2e%2e%2f|%252e%252e)" \
"id:1009301,phase:2,deny,status:403,log,msg:'path traversal attempt'"
WAF 只能降低扫描噪音,不能替代代码修复。编码变体组合极多,任何正则都有绕过空间,把 WAF 当最后一道减速带而不是防线。
第六步:日志侧留痕与告警
拦截是否生效要看日志,而不是看配置。给下载接口单独定义日志格式,把原始 URI 与查询串都记下来,事后才能区分扫描噪音与真实利用尝试:
log_format download '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" uri="$uri" args="$args"';
server {
location /download/ {
access_log /var/log/nginx/download.log download;
proxy_pass http://127.0.0.1:9000;
}
}
# 每日统计含遍历特征的请求来源
grep -Ei '(\.\.|%2e%2e|%252e)' /var/log/nginx/download.log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20
日志不要落在公开可访问目录,查询串里可能带令牌,日志本身也会变成泄露点。放在 /var/log/nginx 下、权限设为 640,配合 logrotate 保留 90 天,既能满足溯源需求,也不会长期堆积占满磁盘。
配置验证
改完后用同一组载荷复测,期望全部返回 400、403 或 404,且响应体不含 root:x:0:0:
for p in "../../../../etc/passwd" "..%2f..%2f..%2fetc%2fpasswd" \
"..%252f..%252fetc%252fpasswd" "....//....//etc/passwd"; do
code=$(curl -s -o /tmp/o -w "%{http_code}" -m 5 --path-as-is \
"https://test.example.com/download.php?file=$p")
size=$(wc -c < /tmp/o)
echo "$p -> $code ${size}B"
grep -q "root:x:0:0" /tmp/o && echo " !! 仍可读取"
done
# 正常文件必须仍可下载
curl -sI "https://test.example.com/files/report/2026-q1.pdf" | head -n 3
# 期望:HTTP/2 200,Content-Type: application/pdf
- 所有遍历载荷返回 4xx,且响应体不含系统文件特征字符串;
- 正常文件下载返回 200,Content-Length 与源文件一致;
- 日志中出现拦截记录,字段包含原始 URI 与规则 ID,便于后续审计;
- 符号链接测试:在存储目录放一个指向 /etc 的软链,请求应被拒绝。
常见问题
Q1:已经过滤了 ../,为什么还能穿越?
因为过滤发生在解码之前。请求 %2e%2e%2f 时原始字符串里没有 ../,替换函数不会命中,而框架或 Web 服务器随后解码,路径就成形了。同类变体还有 ..%5c(反斜杠,Windows 有效)、....//(替换后重新拼出 ../)、超长 UTF-8 编码的全角点。正确做法是先把输入按目标文件系统的规则归一化,再用 realpath 解析并做前缀比较,而不是在字符串层面做删除。
Q2:realpath 校验通过了,为什么还是读到了越权文件?
多半是符号链接。攻击者先在可写目录里创建一个指向 /etc 的软链,此时 realpath 返回的是解析后的真实路径;如果校验用的是「原始拼接路径」而不是 realpath 的返回值,或者先校验后解析,就会漏过。正确顺序是先 realpath,再用返回值做前缀比较,最后用 NOFOLLOW_LINKS 打开。另外要确认基目录本身不是软链,否则前缀比较会以错误的基准进行。
Q3:Nginx 层已经拒绝了 ..,应用层还需要改吗?
需要。Nginx 只保护经过它的 HTTP 请求,任务队列、CLI 脚本、内部 RPC 调用同样会走到读取函数,这些路径不经过 Nginx;此外 proxy_pass 到上游、X-Accel-Redirect 内部跳转等场景中,URI 形态会变化,规则可能不覆盖。把 Nginx 当作第一层减速,把应用层的前缀校验当作必须项,两层同时存在才谈得上防御。
总结
路径遍历的修复重点不在「过滤危险字符」,而在「让用户无法决定路径」。优先级从高到低是:标识符映射、realpath 前缀校验、Nginx alias 与规范化加固、WAF 兜底。落地时先做基线测试确认读取点,再改代码,最后调整 Nginx 与规则,改完用编码变体复测一遍。留下测试脚本和拦截日志,下次接口迭代时可以直接回归,比事后排查省事得多。