PHP 文件上传
The $_FILES structure, move_uploaded_file, type and size checks, and securing the upload directory.
Key PointsThe original name and MIME come from the client and are untrusted.
The form
An upload form needs enctype="multipart/form-data", otherwise nothing is sent.
HTML
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="file" name="avatar">
<input type="submit" value="上传">
</form>$_FILES structure
| Key | Meaning |
|---|---|
| name | 客户端原始文件名(可伪造) |
| type | 客户端声明的 MIME(可伪造) |
| tmp_name | 服务器上的临时文件路径 |
| error | 错误码,0 表示成功 |
| size | 字节数 |
PHP
<?php
// 假设上传了一个 20480 字节的 photo.png
$f = $_FILES["avatar"] ?? null;
if ($f === null) {
exit("没有文件");
}
echo "原始名: " . $f["name"], "\n";
echo "大小: " . $f["size"] . " 字节\n";
echo "客户端声明类型: " . $f["type"], "\n";
echo "错误码: " . $f["error"], "\n";
echo "扩展名: " . strtolower(pathinfo($f["name"], PATHINFO_EXTENSION)), "\n";Output
原始名: photo.png
大小: 20480 字节
客户端声明类型: image/png
错误码: 0
扩展名: pngtype is guessed by the browser from the extension and is untrustworthy. Rename the file and it says image/png. Detect the real type with finfo_file() or getimagesize().Error codes
| Code | Constant | Meaning |
|---|---|---|
| 0 | UPLOAD_ERR_OK | 成功 |
| 1 | UPLOAD_ERR_INI_SIZE | 超过 upload_max_filesize |
| 2 | UPLOAD_ERR_FORM_SIZE | 超过表单 MAX_FILE_SIZE |
| 3 | UPLOAD_ERR_PARTIAL | 只上传了一部分 |
| 4 | UPLOAD_ERR_NO_FILE | 没有选择文件 |
| 6 | UPLOAD_ERR_NO_TMP_DIR | 缺临时目录 |
| 7 | UPLOAD_ERR_CANT_WRITE | 写磁盘失败 |
| 8 | UPLOAD_ERR_EXTENSION | 被扩展中断 |
The full handler
PHP
<?php
declare(strict_types=1);
function handleUpload(array $file, string $dir): array
{
if (($file["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return ["ok" => false, "msg" => "上传失败,错误码 " . $file["error"]];
}
if ($file["size"] > 2 * 1024 * 1024) {
return ["ok" => false, "msg" => "文件不能超过 2MB"];
}
$allowed = ["image/jpeg", "image/png", "image/gif"];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file["tmp_name"]);
if (!in_array($mime, $allowed, true)) {
return ["ok" => false, "msg" => "只允许 JPG/PNG/GIF,实际是 $mime"];
}
if (@getimagesize($file["tmp_name"]) === false) {
return ["ok" => false, "msg" => "不是有效图片"];
}
$ext = ["image/jpeg" => "jpg", "image/png" => "png", "image/gif" => "gif"][$mime];
$name = bin2hex(random_bytes(8)) . "." . $ext;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
if (!move_uploaded_file($file["tmp_name"], $dir . "/" . $name)) {
return ["ok" => false, "msg" => "保存失败"];
}
return ["ok" => true, "name" => $name, "mime" => $mime, "size" => $file["size"]];
}
$r = handleUpload(
["name" => "photo.png", "type" => "image/png", "tmp_name" => "/tmp/phpXXXX", "error" => 0, "size" => 20480],
"/var/www/uploads"
);
print_r($r);Output
Array
(
[ok] => 1
[name] => 3f2a9c1b7d4e5a60.png
[mime] => image/png
[size] => 20480
)Regenerating the name with random_bytes discards the user's input entirely — killing traversal, encoding issues and collisions at once.
Security checklist
- **Never trust
$file["type"]** —— usefinfo_fileorgetimagesize - Rename the file —— keeping the original name hands over path control
- Disable PHP in the upload dir ——
deny allfor.php - Cap size and count —— ini settings
- Store outside the webroot —— serve through PHP for authorisation
"PHP executable in the upload directory" is historically the worst hole. Even with strict image checks, one bypass yields a shell. This matters more than every other check.
Multiple files
PHP
<?php
// <input type="file" name="pics[]" multiple>
$files = $_FILES["pics"] ?? null;
if ($files === null) {
exit("没有文件");
}
// 把「按字段组织」重排成「按文件组织」
$list = [];
foreach ($files["name"] as $i => $name) {
$list[] = [
"name" => $name,
"type" => $files["type"][$i],
"tmp_name" => $files["tmp_name"][$i],
"error" => $files["error"][$i],
"size" => $files["size"][$i],
];
}
echo count($list) . " 个文件\n";
foreach ($list as $f) {
echo $f["name"] . " " . $f["size"] . "\n";
}Output
2 个文件
a.png 10240
b.jpg 30720For multiple files $_FILES is field-first: name is an array, type too. Reshape to file-first before looping.
表单
上传表单必须 enctype="multipart/form-data",否则文件根本不会传上来。
HTML
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="file" name="avatar">
<input type="submit" value="上传">
</form>$_FILES 的结构
| 键 | 含义 |
|---|---|
| name | 客户端原始文件名(可伪造) |
| type | 客户端声明的 MIME(可伪造) |
| tmp_name | 服务器上的临时文件路径 |
| error | 错误码,0 表示成功 |
| size | 字节数 |
PHP
<?php
// 假设上传了一个 20480 字节的 photo.png
$f = $_FILES["avatar"] ?? null;
if ($f === null) {
exit("没有文件");
}
echo "原始名: " . $f["name"], "\n";
echo "大小: " . $f["size"] . " 字节\n";
echo "客户端声明类型: " . $f["type"], "\n";
echo "错误码: " . $f["error"], "\n";
echo "扩展名: " . strtolower(pathinfo($f["name"], PATHINFO_EXTENSION)), "\n";Output
原始名: photo.png
大小: 20480 字节
客户端声明类型: image/png
错误码: 0
扩展名: pngtype 是浏览器根据扩展名猜的,完全不可信。改个扩展名就能让它变成 image/png。真实类型要用 finfo_file() 或 getimagesize() 检测。错误码
| 码 | 常量 | 含义 |
|---|---|---|
| 0 | UPLOAD_ERR_OK | 成功 |
| 1 | UPLOAD_ERR_INI_SIZE | 超过 upload_max_filesize |
| 2 | UPLOAD_ERR_FORM_SIZE | 超过表单 MAX_FILE_SIZE |
| 3 | UPLOAD_ERR_PARTIAL | 只上传了一部分 |
| 4 | UPLOAD_ERR_NO_FILE | 没有选择文件 |
| 6 | UPLOAD_ERR_NO_TMP_DIR | 缺临时目录 |
| 7 | UPLOAD_ERR_CANT_WRITE | 写磁盘失败 |
| 8 | UPLOAD_ERR_EXTENSION | 被扩展中断 |
完整的处理流程
PHP
<?php
declare(strict_types=1);
function handleUpload(array $file, string $dir): array
{
if (($file["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return ["ok" => false, "msg" => "上传失败,错误码 " . $file["error"]];
}
if ($file["size"] > 2 * 1024 * 1024) {
return ["ok" => false, "msg" => "文件不能超过 2MB"];
}
$allowed = ["image/jpeg", "image/png", "image/gif"];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file["tmp_name"]);
if (!in_array($mime, $allowed, true)) {
return ["ok" => false, "msg" => "只允许 JPG/PNG/GIF,实际是 $mime"];
}
if (@getimagesize($file["tmp_name"]) === false) {
return ["ok" => false, "msg" => "不是有效图片"];
}
$ext = ["image/jpeg" => "jpg", "image/png" => "png", "image/gif" => "gif"][$mime];
$name = bin2hex(random_bytes(8)) . "." . $ext;
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
if (!move_uploaded_file($file["tmp_name"], $dir . "/" . $name)) {
return ["ok" => false, "msg" => "保存失败"];
}
return ["ok" => true, "name" => $name, "mime" => $mime, "size" => $file["size"]];
}
$r = handleUpload(
["name" => "photo.png", "type" => "image/png", "tmp_name" => "/tmp/phpXXXX", "error" => 0, "size" => 20480],
"/var/www/uploads"
);
print_r($r);Output
Array
(
[ok] => 1
[name] => 3f2a9c1b7d4e5a60.png
[mime] => image/png
[size] => 20480
)文件名用 random_bytes 重新生成,完全丢弃用户提供的名字。这同时解决了三个问题:路径穿越、中文名乱码、同名覆盖。
安全要点
- **别用
$file["type"]判断类型** —— 用finfo_file或getimagesize - 重命名文件 —— 保留用户原名等于把路径控制权交出去
- 上传目录禁止执行 PHP —— nginx/apache 里加
location ~ \.php$ { deny all; } - 限制大小与数量 ——
upload_max_filesize、post_max_size、max_file_uploads - 存到 Web 根之外 —— 需要访问时用 PHP 转发,便于鉴权
「上传目录能执行 PHP」是历史上被打得最惨的一类漏洞。哪怕图片校验做得很严,只要目录里能跑
.php,一个绕过就能拿到 shell。这一条比其它所有校验都重要。多文件上传
PHP
<?php
// <input type="file" name="pics[]" multiple>
$files = $_FILES["pics"] ?? null;
if ($files === null) {
exit("没有文件");
}
// 把「按字段组织」重排成「按文件组织」
$list = [];
foreach ($files["name"] as $i => $name) {
$list[] = [
"name" => $name,
"type" => $files["type"][$i],
"tmp_name" => $files["tmp_name"][$i],
"error" => $files["error"][$i],
"size" => $files["size"][$i],
];
}
echo count($list) . " 个文件\n";
foreach ($list as $f) {
echo $f["name"] . " " . $f["size"] . "\n";
}Output
2 个文件
a.png 10240
b.jpg 30720$_FILES 多文件时的结构是「字段优先」的:name 是数组,type 也是数组。先重排成「文件优先」再循环,逻辑会清楚很多。