本文目录导读:

- 第一层:前端与请求层拦截(最弱,但能过滤无效请求)
- 第二层:服务端核心校验(最关键,必须执行)
- 第三层:文件重命名与服务端配置(防御纵深)
- 第四层:内容安全检测(高级防御)
- 第五层:代码逻辑与错误处理
- 总结:必须遵守的 3个绝对禁止清单
- 代码示例(最小安全实现)
- 补充:如果必须提供图片预览(下载)
在 PHP 项目中,文件上传是最危险的功能之一,因为它直接允许用户向服务器写入文件,如果处理不当,攻击者可以上传恶意脚本(Webshell)并直接控制服务器。
要保障文件上传安全,必须采取多层防御策略,不要仅依赖单一方法,以下是核心的安全防护方案,按优先级和逻辑顺序排列:
第一层:前端与请求层拦截(最弱,但能过滤无效请求)
- 限制文件大小:
- 在
php.ini中设置upload_max_filesize和post_max_size。 - 在
HTML表单中设置MAX_FILE_SIZE(仅提示作用)。 - 在
PHP端使用$_FILES['file']['size']强制校验。
- 在
- 校验 MIME 类型(不可信):
$_FILES['file']['type']和$_FILES['file']['error']是由客户端(浏览器)提供的,可以被伪造,只能作为初步筛选,不能作为最终判断。
第二层:服务端核心校验(最关键,必须执行)
这是安全的基石,所有上传的请求必须通过以下检查:
使用 finfo 读取真实文件头(MIME),不要依赖后缀名
不要相信文件后缀或浏览器声明的 MIME,必须检查文件的(Magic Bytes)。
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['file']['tmp_name']);
finfo_close($finfo);
$allowed_mimes = ['image/jpeg', 'image/png', 'image/gif'];
if (!in_array($mime, $allowed_mimes)) {
die('文件类型不允许');
}
白名单校验扩展名
即使 MIME 正确,也要将文件扩展名限制在白名单内,并完全忽略用户提供的文件名。
$extension = pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION);
$extension = strtolower($extension); //转为小写
$allowed_extensions = ['jpg', 'jpeg', 'png', 'gif'];
if (!in_array($extension, $allowed_extensions)) {
die('非法扩展名');
}
强隔离存储目录(核心中的核心)
绝对不要将上传的文件存放在 Web 文档根目录(如 wwwroot 或 public)下。
- 正确做法:将文件上传到
Web根目录之外的目录(/var/www/uploads/或项目外的storage/)。 - 原因:即使攻击者上传了 PHP 文件,由于无法通过 HTTP 访问到该路径,脚本不会被解析执行。
- 如果必须存放于 Web 目录,请通过 PHP 脚本(如
readfile())下载,并关闭该目录的执行权限。
第三层:文件重命名与服务端配置(防御纵深)
重命名文件(不可预测)
永远不要使用用户上传的原始文件名保存,使用随机字符串或时间戳重命名,并禁止文件后缀为可执行程序(.php, .phtml, .php5, .asp 等)。
$new_name = bin2hex(random_bytes(16)) . '.' . $extension; // 生成随机名字 $destination = __DIR__ . '/../../uploads/' . $new_name; // 存放到根目录外 move_uploaded_file($_FILES['file']['tmp_name'], $destination);
禁用上传目录的执行权限(Apache/Nginx)
- Apache:在上传目录放置
.htaccess文件,内容为php_flag engine off或RemoveHandler .php .phtml .php5,更彻底的是直接使用<Directory>指令禁止 ExecCGI。 - Nginx:在
location块中显式禁止 PHP 解析:location ~* /uploads/.*\.(php|php5|phtml)$ { deny all; }
开启 open_basedir (PHP配置)
在 php.ini 中设置:
open_basedir = /var/www/html:/tmp
这会限制 PHP 访问文件系统的范围,即使上传了 Webshell,也无法读取服务器上的敏感文件。
第四层:内容安全检测(高级防御)
-
图片二次渲染(重编码): 使用 GD 库或 Imagick 重新生成图片,丢弃原有文件中的元数据和恶意代码。
$image = imagecreatefromjpeg($tmp_name); imagejpeg($image, $destination, 90); imagedestroy($image);
注意:对于 GIF/PNG,需特别注意二次渲染是否会破坏文件;如果追求严谨,不要使用
Imagick处理不可信文件,曾有历史漏洞(如 ImageTragick),建议使用 GD 库并限制尺寸。 -
病毒扫描: 调用
clamav等命令行工具对上传的文件进行扫描(注意资源开销)。
第五层:代码逻辑与错误处理
-
使用
is_uploaded_file()和move_uploaded_file(): 绝不要使用copy()或rename()来处理上传文件,只有move_uploaded_file()能确保文件是通过 HTTP POST 上传的,防止本地文件包含攻击。 -
检查
$_FILES['file']['error']: 必须等于UPLOAD_ERR_OK(值为 0),检查tmp_name是否为空。 -
防止路径穿越: 通过随机重命名,你已绕过了路径穿越的风险,如果你坚持使用自定义文件名,必须过滤 、 等字符,并只取
basename()。
必须遵守的 3个绝对禁止清单
- 禁止 信任客户端提供的任何数据(
name,type,size)。 - 禁止 将文件存放在 Web 根目录内。
- 禁止 使用原始文件名或用户可控的路径拼接保存文件。
代码示例(最小安全实现)
<?php
// 1. 定义白名单
$allowed_ext = ['jpg', 'jpeg', 'png', 'gif'];
$allowed_mime = ['image/jpeg', 'image/png', 'image/gif'];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) {
// 检查错误
if ($_FILES['file']['error'] !== UPLOAD_ERR_OK) {
die('上传失败,错误码:' . $_FILES['file']['error']);
}
// 获取真实 MIME
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['file']['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, $allowed_mime)) {
die('非法文件类型');
}
// 提取真实扩展名(并转为小写)
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed_ext)) {
die('非法文件扩展名');
}
// 生成随机文件名,禁止原始文件名
$new_name = bin2hex(random_bytes(16)) . '.' . $ext;
// 目标目录在 Web 根之外(/var/www/private_uploads/)
// 注意:确保该目录有写入权限
$upload_dir = __DIR__ . '/../../storage/';
if (!is_dir($upload_dir)) {
mkdir($upload_dir, 0755, true);
}
$destination = $upload_dir . $new_name;
// 移动文件
if (move_uploaded_file($_FILES['file']['tmp_name'], $destination)) {
echo '上传成功:' . $new_name;
// 记录文件路径到数据库(不要拼接用户输入)
} else {
die('文件保存失败,请检查目录权限');
}
}
?>
补充:如果必须提供图片预览(下载)
如果文件存储在了 Web 根目录之外,需要通过 PHP 输出图片,必须设置正确的 Content-Type,并使用 readfile(),同时输出时不执行任何代码。
$file_path = $upload_dir . 'example.jpg';
if (file_exists($file_path)) {
header('Content-Type: image/jpeg');
header('Content-Length: ' . filesize($file_path));
readfile($file_path);
exit;
}
注意:文件上传安全是动态对抗的过程,务必定期更新 PHP 版本(修复解析绕过漏洞),并关注 OWASP 发布的最新文件上传攻击手法的防护指南。