PHP 文件上传
上传头像、上传图片、上传文档——Web 应用几乎都有上传功能。但文件上传是高风险功能:历史上无数严重漏洞(网站被挂马、服务器被拿权限)都源于上传校验不严。这一章讲 PHP 上传的正确做法。
1. 上传表单
上传表单有两个必须属性:method=post 和 enctype=multipart/form-data。少了任一项 $_FILES 就是空:
<!-- 上传表单:必须 method=post 且 enctype=multipart/form-data -->
<form action="upload.php" method="post"
enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/*">
<button type="submit">上传</button>
</form>
<!-- 关键属性:
- method="post" 必须,文件不能放在 URL
- enctype="multipart" 必须,否则 $_FILES 是空
- accept="image/*" 仅建议过滤(可被绕过,后端必须再验证)
- 文件大小受 php.ini 限制
-->
<!-- php.ini 关键配置:
; upload_max_filesize = 2M 单文件最大
; post_max_size = 8M POST 总最大(必须 >= upload_max_filesize)
; max_file_uploads = 20 一次最多文件数
-->几个关键点:
- method="post":文件数据放在请求体,不能用 GET(URL 装不下)。
- enctype="multipart/form-data":让浏览器按多部分编码发送文件,这是上传的关键。
- accept 只是浏览器建议过滤,可被绕过——后端必须再校验。
- 文件大小受
php.ini限制,大文件需要调upload_max_filesize和post_max_size。
2. $_FILES:PHP 收集的上传信息
PHP 把上传的文件信息放到 $_FILES 超全局数组,里面有 5 个关键字段:
<?php
// $_FILES:PHP 自动收集的文件上传信息
echo "<pre>";
print_r($_FILES);
// 输出类似:
// Array
// (
// [avatar] => Array
// (
// [name] => photo.jpg 原始文件名
// [type] => image/jpeg 浏览器声明的 MIME
// [tmp_name] => /tmp/phpXXXX 临时文件路径
// [error] => 0 错误码(0 = 成功)
// [size] => 123456 字节数
// )
// )
// 关键字段:
$file = $_FILES["avatar"];
$originalName = $file["name"];
$tempPath = $file["tmp_name"];
$error = $file["error"];
$size = $file["size"];3. 错误码
error 字段告诉你上传是否成功以及失败原因:
<?php
// 错误码含义($_FILES["xxx"]["error"])
switch ($_FILES["avatar"]["error"]) {
case UPLOAD_ERR_OK: // 0 成功
break;
case UPLOAD_ERR_INI_SIZE: // 1 超过 php.ini 的 upload_max_filesize
case UPLOAD_ERR_FORM_SIZE: // 2 超过表单 MAX_FILE_SIZE
$msg = "文件太大";
break;
case UPLOAD_ERR_PARTIAL: // 3 只有部分上传
$msg = "上传中断,请重试";
break;
case UPLOAD_ERR_NO_FILE: // 4 没有文件
$msg = "请选择文件";
break;
case UPLOAD_ERR_NO_TMP_DIR: // 6 缺少临时目录
case UPLOAD_ERR_CANT_WRITE: // 7 写入失败
case UPLOAD_ERR_EXTENSION: // 8 被扩展名阻止
$msg = "服务器错误,请联系管理员";
break;
}UPLOAD_ERR_OK(0)是唯一表示成功的,其他都是错误。生产代码用 switch 把错误码转成友好提示。
4. 移动文件:move_uploaded_file
上传的文件先存到临时目录(tmp_name),请求结束后会被自动删除。你必须把它移到正式位置:
<?php
// move_uploaded_file:把临时文件移到正式位置
$uploadDir = __DIR__ . "/uploads/";
// 1. 目录不存在则创建
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// 2. 校验(下面会展开)
// ...
// 3. 生成安全文件名(不要用原始名!)
$ext = pathinfo($_FILES["avatar"]["name"], PATHINFO_EXTENSION);
$safeName = bin2hex(random_bytes(16)) . "." . $ext;
$dest = $uploadDir . $safeName;
// 4. 移动文件
if (move_uploaded_file($_FILES["avatar"]["tmp_name"], $dest)) {
echo "上传成功:" . $safeName;
} else {
echo "上传失败";
}
// ⚠️ 关键:用原始文件名会覆盖、可被注入路径、可执行文件被访问核心安全原则:
- 文件名用随机串:绝对不要用用户的原始文件名(可覆盖、可注入路径、可执行文件被访问)。
- 用 move_uploaded_file,不要用 rename 或 copy——前者会校验文件确实是上传的(HTTP POST 来的),防止路径穿越。
- 目录权限 0755:不要 0777。
- 保留扩展名用
pathinfo,不要直接拼字符串。
5. 三道安全校验(必须全做!)
<?php
// 三道校验:类型、大小、扩展名
$file = $_FILES["avatar"];
// 1. 错误码
if ($file["error"] !== UPLOAD_ERR_OK) {
die("上传错误");
}
// 2. 文件大小(也校验,不能只靠 php.ini)
$maxSize = 2 * 1024 * 1024; // 2MB
if ($file["size"] > $maxSize) {
die("文件不能超过 2MB");
}
// 3. 真实 MIME 类型(不要相信浏览器声明的 type!)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($file["tmp_name"]);
$allowed = ["image/jpeg", "image/png", "image/gif", "image/webp"];
if (!in_array($realMime, $allowed)) {
die("仅支持 JPG/PNG/GIF/WEBP");
}
// 4. 扩展名(基于 MIME 选安全的扩展名)
$ext = match($realMime) {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
default => die("不支持的类型")
};
// 5. 文件名:用随机名,保留扩展
$safeName = bin2hex(random_bytes(16)) . "." . $ext;三个核心校验,缺一不可:
- 错误码:UPLOAD_ERR_OK 才继续。
- 文件大小:服务端必须校验,前端隐藏字段会被改。
- 真实 MIME 类型:用
finfo读文件头部字节判断,不要相信$_FILES["type"](那是浏览器声明的,可伪造)。
典型攻击场景:用户把 shell.php 改名为 shell.jpg,浏览器声明的 type 是 image/jpeg。如果只看扩展名或浏览器 type,就会让 PHP 木马上传成功。用 finfo 才能识别它其实是 PHP 文件。
6. 多文件上传
表单字段名加 [],配合 multiple 属性,一次选多个文件:
<?php
// 多文件上传:表单用 name="files[]" (加 [])
// <input type="file" name="files[]" multiple>
$files = $_FILES["files"];
$count = count($files["name"]); // 文件数量
for ($i = 0; $i < $count; $i++) {
if ($files["error"][$i] !== UPLOAD_ERR_OK) continue;
$tmp = $files["tmp_name"][$i];
$size = $files["size"][$i];
$name = $files["name"][$i];
// 校验 + 移动(逻辑同单文件)
$safe = bin2hex(random_bytes(8));
move_uploaded_file($tmp, __DIR__ . "/uploads/" . $safe);
}
// $_FILES 结构在多文件时是"转置"的
// 每个字段(name/type/tmp_name/error/size)都是数组
// 需要用循环索引访问 $files["name"][$i] 而不是 $files[$i]["name"]注意:多文件时 $_FILES 的结构是转置的——每个字段(name、tmp_name 等)都是数组,需要按索引访问。这点容易让新手困惑,实际处理时通常会"重组"一下数组。
7. 路径安全
上传文件的存放和访问也要小心,防止目录穿越、防止文件被执行:
<?php
// 路径安全要点:
// 1. 上传目录放在 web 根外(理想)
// /var/www/uploads/ ✓
// /var/www/public/uploads/ ✗(可被直接访问)
// 然后用 PHP 读出来输出(更安全,可加权限检查)
// 2. 如必须放 web 根内:
// - 关闭该目录的 PHP 执行(.htaccess: php_flag engine off)
// - 文件名用随机串,不含用户输入
// - 校验真实 MIME,不能只看扩展
// 3. 用 .htaccess 阻止直接执行
// <FilesMatch "\.(php|phtml|php3|php4|php5)$">
// Require all denied
// </FilesMatch>
// 4. 文件下载用 readfile,不要用 include
header("Content-Type: application/octet-stream");
readfile($safePath);
// 5. 路径检查:realpath 防目录穿越
$real = realpath($uploadDir . "/" . $name);
if (!str_starts_with($real, $uploadDir)) {
die("非法路径"); // 防止 ../../etc/passwd 这种攻击
}8. 实战要点清单
- 上传目录放 web 根外:理想方案,通过 PHP 控制访问权限。
- 上传目录禁止执行 PHP:用 .htaccess 或 Nginx 配置。
- 文件名随机:bin2hex(random_bytes(16)) + 扩展名。
- 图片用 GD / Imagick 重编码:不仅校验,还重新生成图片(剔除嵌入的恶意代码)。
- 大文件分片上传:超过 50MB 用 JS 分片 + resumable.js,避免 PHP 超时。
- 云存储:生产环境推荐 S3/OSS/COS,不存本地服务器。
小结
这一章你掌握了 PHP 文件上传的完整流程:表单 enctype、$_FILES 结构、错误码、move_uploaded_file、安全校验、多文件、路径安全。下一篇安全实践——把前面所有安全要点系统化。
← 上一篇 PHP 数据库(PDO)
下一篇 PHP 安全实践 →