文件上传是Web应用中非常常见的功能——用户头像、文章配图、附件上传、相册等等,都需要文件上传。但文件上传也是Web应用中最容易被攻击的地方之一。

如果文件上传处理不当,攻击者可以上传恶意文件——比如PHP脚本(webshell)、病毒、恶意HTML/JS文件——然后通过访问这些文件,执行恶意代码,获取服务器权限,窃取数据,篡改网页,甚至控制整个服务器。

很多网站被黑,都是因为文件上传漏洞。攻击者上传一个webshell,然后通过webshell控制服务器,为所欲为。文件上传漏洞,是OWASP十大安全风险之一,也是PHP网站最常见的安全漏洞之一。

今天分享PHP文件上传的安全防护措施,帮你写出安全的文件上传代码,防止恶意文件上传攻击。

文件上传的攻击方式

了解攻击者如何利用文件上传漏洞,才能更好地防护。

1. 直接上传脚本文件 最直接的攻击方式,就是上传PHP脚本文件(如shell.php),然后通过URL访问这个文件,执行PHP代码。如果上传目录在Web根目录下,且没有禁止执行PHP,攻击者就能直接执行上传的脚本。

2. 文件名绕过 攻击者通过各种方式绕过文件类型验证:

  • 双扩展名:shell.php.jpg(有些服务器会解析.php)
  • 特殊字符:shell.php%00.jpg(空字节截断,老版本PHP)
  • 大小写:shell.Phpshell.pHp(Windows不区分大小写)
  • 末尾加点或空格:shell.php.shell.php (Windows会自动去掉)

3. MIME类型伪造 很多程序只验证$_FILES['file']['type'](MIME类型),但这个值是客户端发送的,可以伪造。攻击者可以把PHP文件的MIME类型改成image/jpeg,绕过验证。

4. 图片马 攻击者把PHP代码嵌入到图片文件中(在图片末尾追加PHP代码),文件头是正常的图片,文件内容包含PHP代码。如果服务器包含(include)这个文件,或者配置了把图片当PHP解析,就能执行恶意代码。

5. .htaccess攻击 攻击者上传.htaccess文件,修改服务器配置,比如把jpg文件当PHP解析,然后上传图片马,就能执行恶意代码。

6. 解析漏洞 利用服务器的解析漏洞,比如:

  • Apache:shell.php.jpg可能被解析为PHP
  • IIS:shell.asp;jpgshell.asp/
  • Nginx:shell.jpg%00.phpshell.jpg/.php

安全防护措施

针对以上攻击方式,我们需要多层防护,确保文件上传安全。

1. 验证文件类型

不要只靠扩展名或MIME类型,要多种方式结合验证:

验证扩展名:

$allowedExts = ['jpg', 'jpeg', 'png', 'gif'];
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExts)) {
    die("不允许的文件类型");
}

验证MIME类型(辅助):

$allowedMimes = ['image/jpeg', 'image/png', 'image/gif'];
if (!in_array($_FILES['file']['type'], $allowedMimes)) {
    die("不允许的文件类型");
}

验证文件内容(最可靠):finfogetimagesize验证文件实际内容:

// 用finfo验证MIME类型
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['file']['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, $allowedMimes)) {
    die("文件内容不合法");
}

// 图片文件用getimagesize验证
$imageInfo = @getimagesize($_FILES['file']['tmp_name']);
if ($imageInfo === false) {
    die("不是有效的图片文件");
}

2. 限制文件大小

限制上传文件的大小,避免大文件攻击和DoS攻击:

$maxSize = 2 * 1024 * 1024; // 2MB
if ($_FILES['file']['size'] > $maxSize) {
    die("文件太大,最大允许2MB");
}

同时在php.ini中配置:

upload_max_filesize = 2M
post_max_size = 8M

3. 重命名文件

不要用原始文件名,重新生成随机文件名,避免文件名攻击和覆盖:

// 生成随机文件名
$newFilename = uniqid('img_', true) . '.' . $ext;
// 或者用更随机的方式
$newFilename = md5(uniqid(mt_rand(), true)) . '.' . $ext;

不要用用户ID、时间戳等可预测的文件名,避免被猜到。

4. 安全存储位置

把上传文件存储在Web根目录之外: 这是最安全的方式,上传的文件不能通过URL直接访问,需要通过PHP脚本读取输出:

$uploadDir = '/var/www/uploads/'; // Web根目录之外
$savePath = $uploadDir . $newFilename;
move_uploaded_file($_FILES['file']['tmp_name'], $savePath);

// 访问时通过PHP脚本读取
// file.php?file=xxx.jpg
header("Content-Type: image/jpeg");
readfile($uploadDir . $_GET['file']);

如果必须存在Web根目录下:

  • 禁止上传目录执行PHP(在Nginx/Apache配置)
  • 上传目录不要有执行权限
  • 不要把上传目录作为include_path

5. 禁止执行权限

在服务器配置中,禁止上传目录执行脚本:

Nginx配置:

location ^~ /uploads/ {
    location ~ \.php$ {
        deny all;  # 禁止访问php文件
    }
}

Apache配置(.htaccess):

<Files ~ "\.php$">
    Order Allow,Deny
    Deny from all
</Files>

注意:不要允许用户上传.htaccess文件!

6. 图片处理(重新生成图片)

对于图片上传,用GD或ImageMagick重新处理图片,生成新的图片文件,可以清除图片中嵌入的恶意代码:

$src = imagecreatefromjpeg($_FILES['file']['tmp_name']);
$dst = imagecreatetruecolor($width, $height);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $width, $height, $origWidth, $origHeight);
imagejpeg($dst, $savePath, 90);
imagedestroy($src);
imagedestroy($dst);

重新生成的图片,只保留图片像素数据,清除了嵌入的PHP代码,非常安全。

7. 检查上传错误

if ($_FILES['file']['error'] !== UPLOAD_ERR_OK) {
    switch ($_FILES['file']['error']) {
        case UPLOAD_ERR_INI_SIZE:
        case UPLOAD_ERR_FORM_SIZE:
            die("文件太大");
        case UPLOAD_ERR_PARTIAL:
            die("文件只有部分被上传");
        case UPLOAD_ERR_NO_FILE:
            die("没有文件被上传");
        default:
            die("上传失败");
    }
}

8. 防XSS

如果要显示用户上传的文件名,一定要转义,防止XSS:

echo htmlspecialchars($_FILES['file']['name'], ENT_QUOTES, 'UTF-8');

9. 用isuploadedfile和moveuploadedfile

if (is_uploaded_file($_FILES['file']['tmp_name'])) {
    move_uploaded_file($_FILES['file']['tmp_name'], $savePath);
} else {
    die("非法上传");
}

不要用copy()rename(),要用moveuploadedfile(),它会检查文件是否是合法的上传文件。

完整的安全上传示例

<?php
// 配置
$allowedExts = ['jpg', 'jpeg', 'png', 'gif'];
$allowedMimes = ['image/jpeg', 'image/png', 'image/gif'];
$maxSize = 2 * 1024 * 1024; // 2MB
$uploadDir = '/var/www/uploads/'; // Web根目录之外

// 检查上传错误
if ($_FILES['file']['error'] !== UPLOAD_ERR_OK) {
    die("上传失败");
}

// 检查文件大小
if ($_FILES['file']['size'] > $maxSize) {
    die("文件太大");
}

// 验证扩展名
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExts)) {
    die("不允许的文件类型");
}

// 验证MIME类型
if (!in_array($_FILES['file']['type'], $allowedMimes)) {
    die("不允许的文件类型");
}

// 验证文件内容(finfo)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['file']['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, $allowedMimes)) {
    die("文件内容不合法");
}

// 验证图片(如果是图片)
$imageInfo = @getimagesize($_FILES['file']['tmp_name']);
if ($imageInfo === false) {
    die("不是有效的图片");
}

// 生成随机文件名
$newFilename = md5(uniqid(mt_rand(), true)) . '.' . $ext;
$savePath = $uploadDir . $newFilename;

// 移动文件
if (move_uploaded_file($_FILES['file']['tmp_name'], $savePath)) {
    // 可选:重新处理图片,清除恶意代码
    // ...
    
    echo "上传成功:" . $newFilename;
} else {
    die("上传失败");
}

其他注意事项

  1. 不要相信任何客户端验证:JavaScript验证只是为了用户体验,服务端一定要重新验证
  2. 不要显示上传路径:不要把上传文件的服务器路径暴露给用户
  3. 上传目录不要有执行权限:Linux下chmod 644文件,755目录,不要777
  4. 定期清理上传目录:删除不需要的文件,避免磁盘占满
  5. 病毒扫描:如果允许上传非图片文件(如doc、pdf、zip),建议用杀毒软件扫描
  6. CDN存储:可以把上传文件存到云存储(如阿里云OSS、腾讯云COS),更安全更省心
  7. HTTPS:上传用HTTPS,防止中间人攻击
  8. CSRF防护:上传表单加CSRF Token,防止CSRF攻击

总结

文件上传是Web应用中最容易被攻击的地方,一定要做好安全防护。

核心防护措施:

  1. 验证文件类型(扩展名+MIME+文件内容,多重验证)
  2. 限制文件大小
  3. 重命名文件(随机文件名)
  4. 存储在Web根目录之外(最安全)
  5. 禁止上传目录执行脚本
  6. 图片重新处理(清除恶意代码)
  7. 检查上传错误
  8. 用moveuploadedfile
  9. 防XSS(输出转义)

安全是一个系统工程,不要只靠一层防护,要多层防护,纵深防御。即使某一层被绕过,还有其他层保护。

希望这篇文章能帮你写出安全的文件上传代码,防止恶意文件上传攻击。