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           一次最多文件数
-->

几个关键点:

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 "上传失败";
}

// ⚠️ 关键:用原始文件名会覆盖、可被注入路径、可执行文件被访问

核心安全原则:

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;

三个核心校验,缺一不可:

典型攻击场景:用户把 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. 实战要点清单

小结

这一章你掌握了 PHP 文件上传的完整流程:表单 enctype、$_FILES 结构、错误码、move_uploaded_file、安全校验、多文件、路径安全。下一篇安全实践——把前面所有安全要点系统化。

← 上一篇 PHP 数据库(PDO)

下一篇 PHP 安全实践

✈️💬