别等被拖库才想起堵这两个洞
个人站长常把精力放在「怎么让搜索引擎多收录」,却往往忽略了最基础的一件事——服务器上那些看似无害的配置,可能正把你的文件系统整个暴露在公网上。在历年泄露事件的复盘里,目录遍历(Path Traversal) 与 任意文件上传 / 执行 长期占据前两位,前者让你服务器上的配置、备份、密钥被人直接下载,后者则让攻击者在你站里塞进一个 webshell,从此服务器易主。
这篇文章不讲抽象原理,而是从 Nginx 与 PHP 的实际配置出发,把这两类漏洞的成因、真实利用方式,以及一层层的收口方案讲清楚。所有示例都可以直接对照本站的配置检查一遍。
目录遍历是怎么发生的
目录遍历的本质是:用户可控的路径里包含了 ../ 这样的向上跳转序列,程序或 Web 服务器没有做规范化和边界校验,最终读到了预期目录之外的文件。最典型的两种触发方式:
其一:Nginx alias 配错,少写斜杠
这是最隐蔽也最常见的一类。假设你有这样一段配置,想把 /static/ 映射到磁盘上的某个目录:
location /static {
alias /var/www/site/assets/;
}注意 location /static 后面没有斜杠,而 alias 的目标路径以斜杠结尾。这会导致请求 /static../etc/passwd 时,Nginx 把 /static 替换成 /var/www/site/assets/,拼接后变成 /var/www/site/assets/../etc/passwd,规范化后指向 /var/www/site/assets/etc/passwd——进而可以一路 ../ 跳到系统敏感文件。正确写法是让 location 也带斜杠:
location /static/ {
alias /var/www/site/assets/;
}location 与 alias 都带斜杠时,Nginx 的匹配是对齐的,/static/foo.js 会准确映射到 /var/www/site/assets/foo.js。这是 Nginx 官方文档里反复强调的规则,也是无数老站翻车的源头。
其二:应用层自己拼路径,没做规范化
如果下载功能是这样写的:
<?php
$file = $_GET['file'];
readfile('/var/www/downloads/' . $file);
?>攻击者传 ?file=../../../../etc/passwd 就能读到系统文件,传 ?file=../../config/database.php 就能拿走数据库密码。防御的核心是:拿到路径后先用 realpath() 规范化,再断言它确实落在允许的目录内。
<?php
$base = realpath('/var/www/downloads');
$target = realpath($base . '/' . basename($_GET['file']));
if ($target === false || strpos($target, $base . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(403);
exit('forbidden');
}
readfile($target);
?>basename() 首先剥掉路径里的目录部分,只留文件名,从源头消除 ../;realpath() 处理软链接与相对路径;strpos(...) !== 0 保证最终路径确实以白名单目录开头。三重保险,缺一不可。
目录列表(autoindex)——不请自来的「文件管理器」
如果某个目录没有索引页,而 Nginx 又开着自动目录列表,访问者会直接看到目录下所有文件名列表,配合上面的遍历洞,等于把整个服务器的目录结构画了张地图。默认情况下 autoindex 是 off 的,但很多人为了图方便打开过:
location /uploads/ {
autoindex on; # 危险!别在生产开
}排查自己有没有中招很简单,直接 curl 一下目录本身,看返回里有没有一堆 <a href= 的文件链接:
curl -sI https://example.com/uploads/ | head -5
curl -s https://example.com/uploads/ | grep -c 'href='如果第二个命令返回的数字远大于 0,说明目录被公开列出来了,应立即在对应 location 里写 autoindex off;,或者干脆返回 404。更彻底的做法是在全局 server 块里显式写 autoindex off;,把默认值画成明文,防止将来某个子配置又打开。
文件上传漏洞——从「能传」到「能执行」
文件上传本身不可怕,可怕的是上传目录能被当作脚本执行。攻击者传一个 shell.php,里面塞进执行任意命令的代码,然后访问它——服务器就归他了。防护要从「传什么」和「怎么存」两个层面发力。
第一层:限制可执行的目录
上传目录里的文件本应只被读取(图片、附件),绝不该被 PHP 解析。所以在上传目录的 location 里,明确告诉 PHP-FPM「这里的东西不要当脚本跑」:
location ^~ /uploads/ {
# 上传目录禁止执行任何脚本
location ~* \.(php|php5|php7|phtml|pl|py|cgi|sh)$ {
deny all;
}
# 只允许读取静态资源
try_files $uri =404;
}对于 Nginx + PHP-FPM 的架构,更稳妥的是在 location ~ \.php$ 这个总的 PHP 处理块里,先判断请求的路径是否落在上传目录,是则直接拒绝:
location ~ \.php$ {
# 上传目录下的 php 一律不交给 FPM
if ($uri ~* "^/uploads/") {
return 403;
}
fastcgi_pass unix:/run/php/php-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}注意 if 在 location 里被称作「邪恶的 if」,但这里只做 return,属于官方推荐的少数安全用法之一。原则是:在「邪恶的 if」里只写 return 或 rewrite ... last,绝不写 proxy_pass 或 fastcgi_pass,否则会有隐蔽的作用域问题。
第二层:应用层校验(不可只靠前端)
前端的 accept 限制和 JS 校验只是用户体验,攻击者用 curl 直接 POST 就能绕过。服务端必须做三件事:
- 白名单扩展名:只允许
jpg/jpeg/png/gif/webp等明确列举的类型,绝不用黑名单(黑名单永远漏)。 - 校验 MIME 与文件头:读取文件前几个字节的魔数,确认真的是图片,而不是改了扩展名的 PHP。用
finfo_file()或getimagesize()都行。 - 重命名文件:上传后一律用随机串重命名,保留原始扩展名。随机名能防止攻击者猜到路径,也避免
shell.php.jpg这种双扩展名绕过。
<?php
$allow = ['jpg','jpeg','png','gif','webp'];
$ext = strtolower(pathinfo($_FILES['f']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allow, true)) { exit('type not allowed'); }
$info = @getimagesize($_FILES['f']['tmp_name']);
if ($info === false) { exit('not an image'); }
$new = bin2hex(random_bytes(16)) . '.' . $ext;
move_uploaded_file($_FILES['f']['tmp_name'], '/var/www/uploads/' . $new);
?>第三层:open_basedir 收口
即便某一处漏了,PHP 的 open_basedir 还能把整个 PHP 进程的视野锁在允许的目录内,它读写不到 /etc、/root 这类敏感路径。在 PHP-FPM 的池配置里加上:
php_admin_value[open_basedir] = /var/www/site:/tmp注意 open_basedir 用的是前缀匹配,多个目录用 : 分隔。它会让 realpath() 等函数在访问越界路径时返回 false 而不是报错崩溃,所以是防止遍历的兜底手段,但不能替代应用层的白名单校验。
一条自查命令,把常见洞扫一遍
下面这几条 curl 可以当作上线前的例行体检,逐条看返回值是否符合预期(都应该是 403 或 404):
# 1. 探目录列表
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/uploads/
# 2. 探路径穿越(应 400/403/404,不能是 200)
curl -s -o /dev/null -w '%{http_code}\n' --path-as-is https://example.com/static/../../../etc/passwd
# 3. 探敏感文件(应 403/404)
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/.env
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/robots.txt.bak
# 4. 探备份/配置泄露
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/www.zip
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/database.sql--path-as-is 是 curl 的关键参数:默认 curl 会在发出请求前把 ../ 规范化掉,导致你测不到真实的遍历行为;加上它才能把原始的 ../ 原样发出去。这一点很多人不知道,测了半天以为没洞,其实是 curl 自己帮你「修」了路径。
Nginx 层面的其它几处常见泄露点
除了遍历和上传,Nginx 配置里还有几个「默认就很危险」的地方,顺手一起排查掉。第一是隐藏文件的暴露:站点根目录下往往躺着 .env、.git/、.htaccess、.user.ini 这类文件,如果没被拦截,任何人加上路径就能直接下载。.git/ 尤其致命——攻击者拿到整个 Git 仓库后,可以回溯出你历史上提交过的所有密码、密钥和配置文件。收口的写法很统一:
location ~ /\. {
deny all;
return 404;
}用正则匹配所有以点开头的路径,deny all 拒绝后返回 404(而不是 403,403 反而会暴露「这里有东西」的事实)。第二是备份与源码包的暴露:备份习惯把 www.zip、database.sql、index.php.bak 放在站点目录下,这些文件一旦被扫描器命中,等于把源码和数据库白送。正确做法是把备份放在 Web 根目录之外,比如 /root/backups/ 或 /var/backups/,从物理上避免被 HTTP 访问到。
第三是 try_files 的兜底逻辑。不少配置为了做伪静态,写了 try_files $uri $uri/ /index.php?$args;,这是合理的;但如果写成 try_files $uri $uri/ /index.php$uri; 又配合了不当的 alias,就可能让不存在的路径被当作参数透传给 PHP,进而引发变量覆盖类问题。原则是:兜底只兜到已知的入口文件,绝不把用户可控的原始路径拼接进文件系统路径。
验证响应头,确认防护真的生效
配置改完不能只看一眼就完事,必须用请求验证。除了前面用 curl 探路径,还要看响应头里是否泄露了过多信息:
curl -sI https://example.com/ | grep -iE 'server|x-powered-by'如果返回里出现了 Server: nginx/1.18.0 和 X-Powered-By: PHP/7.4.3,说明你的版本号正明明白白告诉攻击者「我跑的是哪个版本,去查这个版本的 CVE 吧」。关闭方式是在 Nginx 里加:
server_tokens off;
fastcgi_hide_header X-Powered-By;server_tokens off 把 Server 头里的版本号抹掉,只留 nginx;fastcgi_hide_header X-Powered-By 把 PHP 自报的版本也藏起来。这一步不解决漏洞本身,但能大幅提高自动化扫描的成本——很多批量扫描器直接按版本号匹配攻击脚本,藏住版本能滤掉一大批无差别的撞库。
上线前检查清单
- 所有
location与对应alias的斜杠是否对齐(要么都带,要么都不带)。 - 全局
autoindex off;,并逐个确认没有子配置把它打开。 - 上传目录已禁止 PHP 执行(
location里deny all或总 PHP 块里return 403)。 - 应用层上传做了扩展名白名单 + 文件头校验 + 随机重命名。
- PHP-FPM 池配置了
open_basedir,锁死 PHP 可访问的目录范围。 - 站点根目录下没有
.env、*.sql、*.zip、.git/等可直接访问的敏感文件;有则用location ~ /\.统一拒绝。 - 用带
--path-as-is的 curl 实测过遍历,返回非 200。
安全不是一次性的工作,而是每次改配置、加功能时顺手多问一句「这个路径可被用户控制吗」。把上面这几条固化成自查流程,你的站就挡住了绝大多数自动化扫描器的试探。毕竟对个人站长来说,服务器被拖的那一刻,损失的不只是流量,还有多年积累的信任。