学完这篇,你能给站内任何一个上传入口配上一套「拦得住」的校验链:从 PHP 配置层的大小闸门,到代码层的 MIME 与扩展名双校验,再到存储层的执行隔离,让上传目录不再是提权入口。
第一步:先把 PHP 层面的闸门关小
上传能不能进来,第一道关卡不在你的代码里,而在 php.ini。打开配置文件确认三项:
upload_max_filesize = 2M
post_max_size = 8M
max_file_uploads = 5
post_max_size 必须大于 upload_max_filesize,因为 multipart 表单还有边界、字段名等开销,一般留 3~4 倍余量。改完重启 PHP-FPM 才生效,用 phpinfo() 或「后台 → 环境体检」类工具确认实际值。
同时在代码里做第二道判断,因为用户报错时要给出人话提示:
if ($_FILES['file']['error'] !== UPLOAD_ERR_OK) {
exit('上传失败,错误码:' . $_FILES['file']['error']);
}
if ($_FILES['file']['size'] > 2 * 1024 * 1024) {
exit('文件不能超过 2MB');
}
error 等于 1 表示超 upload_max_filesize,等于 2 表示超 post_max_size(此时 $_FILES 可能是空的,要先判断 isset)。
注意:表单里那个 <input type="hidden" name="MAX_FILE_SIZE"> 只是给浏览器的提示,客户端可以随意改,绝不能当作服务端校验依据。
第二步:类型校验只认服务端检测结果
$_FILES['file']['type'] 是浏览器上报的,改个包就能伪装成 image/jpeg。真正可信的是文件内容:
$allowMime = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['file']['tmp_name']);
if (!in_array($mime, $allowMime, true)) {
exit('只允许上传 JPG / PNG / GIF / WEBP 图片');
}
MIME 过关还不够,扩展名要用白名单映射,而不是相信用户传上来的文件名:
$extMap = ['image/jpeg' => 'jpg', 'image/png' => 'png',
'image/gif' => 'gif', 'image/webp' => 'webp'];
$ext = $extMap[$mime];
对图片再加一层「能不能被解析」的检测:getimagesize() 返回 false 就直接拒绝。一个改过后缀的 PHP 木马通常过不了这一步。更彻底的做法是重新采样保存(imagecreatefromjpeg → imagejpeg),把文件里夹带的 EXIF 注释、附加脚本一起洗掉。
注意:SVG 是纯 XML,内部可写 <script>,非常容易变成存储型 XSS。除非你确实需要矢量图,否则别放进白名单;确实要放,就用独立域名 + Content-Disposition: attachment 输出。
第三步:文件名和保存路径自己说了算
用户传的原始文件名一律不入库当路径用。正确姿势是:随机名 + 固定目录。
$name = bin2hex(random_bytes(8)) . '.' . $ext;
$dir = '/data/uploads/' . date('Ym') . '/';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$save = $dir . $name;
if (!is_uploaded_file($_FILES['file']['tmp_name'])) exit('非法请求');
if (!move_uploaded_file($_FILES['file']['tmp_name'], $save)) exit('保存失败');
is_uploaded_file() 和 move_uploaded_file() 这两个函数不要省,它们是 PHP 内建的「这是真上传来的临时文件」证明,能挡掉通过伪造 tmp_name 读取服务器任意文件的手法。原始文件名清洗后单独存进数据库,只在展示和下载时用,记得输出时转义。
第四步:让上传目录「只能读、不能跑」
这是最容易被忽略、后果最严重的一环。假设攻击者真把 shell.php 塞进了 /uploads/,只要这个目录能执行 PHP,服务器就交出去了。
Nginx 加一段:
location ^~ /uploads/ {
location ~* \.(php|php5|phtml|phar)$ { return 403; }
}
Apache 则在目录里放 .htaccess:
php_flag engine off
<FilesMatch "\.(php|php5|phtml|phar)$">
Require all denied
</FilesMatch>
更好的方案是把上传目录整个挪到站点根目录之外,用时由 PHP 读取后带合适响应头输出,同时补上 X-Content-Type-Options: nosniff,防止浏览器嗅探内容类型。
注意:目录权限给 0755、文件给 0644,不要出现 0777。上传目录不需要写权限以外的任何特权,也不该被 open_basedir 之外的程序访问到。
第五步:把配置项做成可维护的清单
单点校验容易在后续迭代中漏掉。建议把下面几项写进一个统一的 Upload::check() 方法,所有上传入口都调它:
- 大小上限(按业务分档:头像 2MB、附件 20MB)
- MIME 白名单 + 扩展名映射
- 图片二次解析校验
- 随机重命名 + 按月分目录
- 记录日志:上传者、原始名、大小、MIME、IP、时间
日志能帮你在出事时定位是谁、什么时候、传了什么。像 Clara BBS 这类系统把「上传文件类型白名单 + 图片二次校验 + 敏感路径拦截 + 安全响应头」做成了内置基线,自定义扩展时沿用同一套规则即可,不要在插件里另开一条绕过校验的通道。
小结
- PHP 层面先卡
upload_max_filesize / post_max_size,代码层再判 $_FILES['error'] 和 size;
- 类型校验只认
finfo 检测出的 MIME,$_FILES['type'] 完全不可信;
- 扩展名走 MIME→扩展名映射白名单,不要用用户提供的后缀;
- 图片用
getimagesize() 复检,必要时重新采样洗掉夹带内容;
- 文件名随机生成,必须用
is_uploaded_file() + move_uploaded_file() 落盘;
- 上传目录禁止执行脚本,权限收紧,响应头加
nosniff;
- 所有上传入口共用同一套校验方法,并留下审计日志。