一、文件上传是自建站最容易被忽视的入口
自建站的安全模型和成品 CMS 不一样:成品程序有成千上万人盯着、漏洞被发现得快、补丁也出得快;而自己写的上传功能,往往只有你一个人看过代码,而且是在「先能跑起来」的心态下写出来的。
上传功能的危险在于它能做到两件事:把攻击者的文件放到服务器上、并且让浏览器去执行它。前者是「写文件」,后者是「执行」。很多站只防了第一步(检查后缀名),没有意识到第二步才是真正致命的部分——一个 .jpg 后缀里装着 PHP 代码,只要能被某条规则解析成 PHP,站点就交出去了。
下面按「攻击者会怎么试」的顺序,把上传漏洞的五个层次拆开讲,每一层给出可落地的防护。
二、第一层:只检查后缀名,等于没检查
最常见的写法是这样的:
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (in_array($ext, ['jpg', 'png', 'gif'])) {
move_uploaded_file($_FILES['file']['tmp_name'], $dir . $_FILES['file']['name']);
}这段代码有四个问题,每一个都能单独被绕过。
问题一:文件名直接来自客户端。$_FILES['name'] 完全由请求方决定,可以是 ../../etc/cron.d/x,也可以是 shell.php.jpg,还可以带空字节(老版本 PHP)。正确做法是自己生成文件名,用哈希或自增 ID 加固定后缀,原始文件名只存进数据库当展示用。
问题二:后缀名不等于真实类型。攻击者可以把 shell.php 改名成 a.jpg 上传。必须读文件内容判断类型,PHP 用 finfo_file:
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['file']['tmp_name']);
$allow = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!in_array($mime, $allow, true)) {
die('type not allowed');
}注意必须传 true 做严格比较,否则字符串与数字的松散比较可能出意外。
问题三:不检查是否真的是文件。要用 is_uploaded_file() 校验临时路径是不是 PHP 上传机制生成的,防止被构造的路径伪造。
问题四:目录可执行。即使文件是真正的 JPG,只要它落在能被 PHP 解析的目录里,配合 Apache 的多后缀解析(a.php.jpg 在某些配置下会被当 PHP 执行),一样能触发。见第四层。
三、第二层:图片二次渲染与尺寸限制
如果站点只接受图片,最彻底的防护是「重新生成一张图」:用 GD 或 ImageMagick 读入原图,按要求缩放,再写出新文件。原文件的字节流完全不进入最终落盘的内容,任何藏在 EXIF、注释段、IDAT 块里的 PHP 代码都会被清洗掉。
$img = @imagecreatefromjpeg($tmp);
if (!$img) { die('not a valid jpeg'); }
$w = imagesx($img); $h = imagesy($img);
if ($w < 10 || $h < 10 || $w > 6000 || $h > 6000) { die('size out of range'); }
$out = $store . '/' . $newname . '.jpg';
imagejpeg($img, $out, 85);
imagedestroy($img);这段代码同时解决了三个问题:验证了文件真的是可解码的图片、限制了像素尺寸(防 decompression bomb:一个几十 KB 的 PNG 解压后可能占几 GB 内存,直接把 PHP 进程撑死)、并让输出文件完全由服务器生成。
顺带一个容易忽略的细节:上传完成后要检查 $_FILES['file']['error'],不能只看 is_uploaded_file。UPLOAD_ERR_INI_SIZE 表示超过 upload_max_filesize,UPLOAD_ERR_PARTIAL 表示传输中断,把这两个错误当成成功处理,会得到一堆 0 字节的坏文件。
四、第三层:存储目录与执行权限隔离
前两层解决「文件内容」,这一层解决「文件在哪里」。原则只有一条:上传目录不解释任何脚本,并且不给任何额外权限。
Nginx 的写法是把上传目录单独拦掉:
location ^~ /uploads/ {
location ~* \.(php|php5|phtml|phar|pl|py|cgi)$ {
deny all;
return 403;
}
# 关闭脚本执行,只当静态文件
try_files $uri =404;
add_header Content-Disposition "attachment" always;
add_header X-Content-Type-Options "nosniff" always;
}三个头的作用要说清楚:nosniff 让浏览器不猜测 MIME 类型,即使有人上传了一个内容是 HTML 的 .jpg,浏览器也不会当 HTML 渲染(这能防掉大部分存储型 XSS);Content-Disposition: attachment 让所有文件下载而不是内联打开,代价是图片不能直接显示了,所以通常只对非图片目录加,或者按后缀分开处理。
Apache 则要在上传目录放一个 .htaccess:
php_flag engine off
Options -Indexes -ExecCGI
AddType text/plain .php .phtml .php5
Require all denied
Require all granted更彻底的做法是把上传文件放到 Web 根目录之外,通过一个 PHP 脚本按权限读取并输出(/file?id=123)。这样攻击者即使上传了 PHP,也没有任何 URL 能直接访问到它。
五、第四层:可执行位与文件权限
Web 服务进程(www-data / nobody)对上传目录只需要写权限,不需要执行权限。Linux 上目录的「执行」位含义是「可以进入并访问其中的文件」,所以完全没有 x 位会连文件都读不了,正确做法是目录 755,文件 644,并且保证 Web 用户不是文件的所有者、也没有 chmod 的能力。
find /home/wwwroot/site/uploads -type d -exec chmod 755 {} \;
find /home/wwwroot/site/uploads -type f -exec chmod 644 {} \;
find /home/wwwroot/site/uploads -name "*.php*" -delete最后一条是应急手段,也适合放进每天的巡检脚本:上传目录里出现任何 .php 文件都是异常信号。
另外要检查 PHP 的危险配置。以下几项在生产环境必须关掉或收紧:
upload_tmp_dir 指向一个独立目录而不是系统 /tmp,避免与别的程序互相干扰;open_basedir 限定到站点目录,防止脚本读写别处文件;allow_url_include = Off(这条默认已关,但要确认);expose_php = Off 隐藏版本号。
六、第五层:防刷与容量控制
安全之外,上传还要防资源滥用。一个不做任何限制的上传接口,可以在几分钟内被灌满磁盘,让整个站点挂掉。
四道限制缺一不可:Nginx 的 client_max_body_size 5m 拦超大请求;PHP 的 upload_max_filesize 与 post_max_size 要比 Nginx 略小或相等,不能出现「Nginx 放行但 PHP 拒收」的错配;应用层按用户 ID 做频次限制(每分钟 10 次);磁盘层面加监控,使用率超 80% 告警,并给上传目录单独挂一个分区,防止写满影响系统盘。
如果站点允许游客上传(比如评论附图),还要加一条:上传成功的文件在人工或自动审核通过前不对外公开访问,用一个 status = pending 字段控制,避免被当成免费图床。
七、上传后的清理与对账
被挂马之后,只删掉那个 PHP 文件是不够的——攻击者往往同时上传了多个文件、修改了 .htaccess、甚至留了定时任务。上传功能的收尾工作应该包括:
第一,每天对账:数据库里引用的图片数量与上传目录里的文件数量,差异超过 10% 就告警。大量对不上的文件通常意味着有人在批量上传。
第二,每周扫描上传目录里出现的可疑特征:eval、base64_decode、assert、system 等字符串出现在非 .php 文件里,也要报。
第三,检查 Web 根目录下所有 .htaccess 与 .user.ini 的修改时间,这两类文件被改是典型的持久化手法。
find /home/wwwroot -name ".htaccess" -newermt "-7 days" -ls
find /home/wwwroot -name "*.php" -newermt "-24 hours" -ls正常运营的站点,24 小时内不应该出现新的 .php 文件(除非你刚发了版本)。这条告警极其有效,几乎能第一时间发现入侵。
八、从日志里发现上传攻击:三个必看的信号
防护做得再好,也需要知道有没有人在试。上传攻击在访问日志里留下的痕迹很有规律,三个信号足以覆盖大多数情况。
第一个信号是同一个 IP 在短时间内对上传接口发出大量 POST。正常用户一天上传几张图,攻击者往往几秒钟发几十个请求,试图用不同的后缀和内容碰运气。用 Nginx 日志按分钟聚合就能看出来:
awk '{print $1, $4, $6, $7, $9}' /var/log/nginx/access.log \
| grep '/upload' | awk '{print $1}' | sort | uniq -c | sort -rn | head -20输出里排在前面、数量远超常人的 IP 就是可疑对象。注意要先确认 /upload 是真实的接口路径,别把静态资源的日志混进来。
第二个信号是上传接口返回 200 但响应体很小。真正的图片处理成功会返回文件地址和尺寸信息,而被 WAF 或类型校验拦下的请求,往往返回一段简短的错误页。统计「200 且长度小于 500 字节」的请求数量,突然升高就说明有批量尝试。
第三个信号是上传目录里出现「后缀正常但内容异常」的文件。用一条命令列出最近 24 小时内新写入、体积异常小的文件,这类文件常常是上传的 webshell 或探测文件:
find /home/wwwroot/site/uploads -type f -newermt "-24 hours" -size -2k -ls发现信号之后的处置顺序是:先封 IP(iptables 或 fail2ban 的 recidive 规则),再扫描上传目录,然后检查 .htaccess 与定时任务有没有被改,最后复盘是哪一个校验环节漏了。别一上来就删文件,那样会丢掉追踪线索。
如果想更主动一些,可以给上传接口加一条简单的蜜罐规则:任何请求里出现 .php、.phtml、.phar 后缀的尝试,一律返回 200 空响应并记录到单独的日志文件。攻击者看到 200 会继续尝试,而你除了拿到完整的攻击载荷之外,还顺手延迟了对方的进度。
九、小结:五层防线,缺一层都不算安全
回到开头那句话:上传危险是因为它能「写入并执行」。要打断这个链条,五层防线分别对应:重命名文件(去掉攻击者对路径和名字的控制)、校验真实 MIME 与二次渲染(去掉对内容的控制)、隔离存储目录(去掉对执行环境的控制)、收紧权限与危险函数(缩小可利用面)、限流与对账(缩短被发现的时间)。
最省钱的一次改造是把「原始文件名直接拼路径」改成「服务端生成哈希文件名 + finfo 校验类型 + 上传目录 deny 掉 php 后缀」。这三行改动能挡掉线上 95% 的脚本上传尝试,剩下的靠巡检脚本兜底。上传功能不是写一次就完事的代码,它是一个需要每天看一眼的运维对象。