跳转到帖子

一次 PHP 文件上传接口的审计与加固:从后缀绕过到内容校验

精选回复

发布于

背景

最近看了一个业务里的头像上传接口,代码量不大,但问题比较典型:只校验了文件后缀,保存路径又在 Web 可访问目录下。实际审计中,文件上传往往不是单点问题,通常会和解析配置、文件名处理、MIME 判断、存储目录权限一起形成风险。

这篇记录一次常见 PHP 上传接口的审计思路和加固方式,重点放在可复现、可落地的检查项上。

技术分析

一个常见的上传接口可能长这样:

<?php
if ($_FILES['file']['error'] === 0) {
    $name = $_FILES['file']['name'];
    $tmp  = $_FILES['file']['tmp_name'];

    $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
    if (in_array($ext, ['jpg', 'png', 'gif'])) {
        move_uploaded_file($tmp, __DIR__ . '/uploads/' . $name);
        echo 'ok';
    } else {
        echo 'invalid file';
    }
}
?>

这段代码表面上限制了 jpg/png/gif,但风险点比较多:

  • 文件名不可控:直接使用用户上传文件名,可能造成覆盖、特殊字符处理异常,部分环境还可能触发路径问题。
  • 只看后缀:攻击者可以上传伪装图片,比如文件名是 shell.jpg,内容却不是图片。
  • 双后缀风险:如 a.php.jpg 在某些错误配置下可能被解析为 PHP。
  • Web 目录可访问:如果上传目录允许脚本执行,风险会被放大。
  • MIME 不可靠:$_FILES['file']['type'] 来自客户端请求头,不能作为安全依据。

操作步骤

1. 先确认 Web 服务器解析规则

上传漏洞能否形成实际危害,很大程度取决于 Web 服务器和 PHP 解析配置。建议先检查:

  • Nginx 是否存在不严谨的 PHP 匹配规则;
  • Apache 是否开启了危险的 AddType/AddHandler;
  • 上传目录是否允许执行脚本;
  • 是否存在历史兼容配置,比如把多个扩展名都交给 PHP 处理。

例如 Nginx 里不建议使用过宽的匹配:

location ~ \.php {
    fastcgi_pass 127.0.0.1:9000;
    include fastcgi_params;
}

更稳妥的做法是只匹配真实以 .php 结尾的文件,并配合 try_files:

location ~ \.php$ {
    try_files $uri =404;
    fastcgi_pass 127.0.0.1:9000;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

2. 上传目录禁止脚本执行

这是非常关键的一层防护。即使业务代码校验有疏漏,上传目录也不应该具备脚本执行能力。

Nginx 可以单独限制上传目录:

location ^~ /uploads/ {
    default_type application/octet-stream;
    add_header X-Content-Type-Options nosniff;
}

location ~ ^/uploads/.*\.php$ {
    return 403;
}

Apache 可在上传目录放置配置,前提是允许对应目录配置生效:

<Directory "/var/www/html/uploads">
    php_admin_flag engine off
    Options -ExecCGI
    RemoveHandler .php .phtml .php3 .php4 .php5 .phar
    RemoveType .php .phtml .php3 .php4 .php5 .phar
</Directory>

3. 服务端重新生成文件名

不要信任原始文件名。建议使用随机值或业务 ID 生成文件名,并且只保留经过白名单验证的扩展名。

这样可以避免覆盖、特殊字符、路径穿越以及日志污染等问题。

4. 同时校验扩展名、真实 MIME、文件头与图片尺寸

单独一种校验方式都不够稳。比较实用的组合是:

  • 扩展名白名单;
  • finfo_file 获取服务端识别的 MIME;
  • getimagesize 判断图片结构;
  • 限制文件大小;
  • 必要时重编码图片,去除附带的异常内容。

代码示例

下面是一个相对稳妥的 PHP 图片上传处理示例。它不是完整框架代码,但核心思路可以直接迁移到实际项目里。

<?php
function uploadImage(array $file): array
{
    if (!isset($file['error']) || is_array($file['error'])) {
        return ['ok' => false, 'msg' => 'invalid upload parameter'];
    }

    if ($file['error'] !== UPLOAD_ERR_OK) {
        return ['ok' => false, 'msg' => 'upload failed'];
    }

    // 限制大小:2MB
    if ($file['size'] > 2 * 1024 * 1024) {
        return ['ok' => false, 'msg' => 'file too large'];
    }

    if (!is_uploaded_file($file['tmp_name'])) {
        return ['ok' => false, 'msg' => 'not uploaded file'];
    }

    $originName = $file['name'] ?? '';
    $ext = strtolower(pathinfo($originName, PATHINFO_EXTENSION));

    $allowExt = [
        'jpg'  => 'image/jpeg',
        'jpeg' => 'image/jpeg',
        'png'  => 'image/png',
        'gif'  => 'image/gif',
    ];

    if (!isset($allowExt[$ext])) {
        return ['ok' => false, 'msg' => 'extension not allowed'];
    }

    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime = $finfo->file($file['tmp_name']);

    if ($mime !== $allowExt[$ext]) {
        return ['ok' => false, 'msg' => 'mime mismatch'];
    }

    $imgInfo = @getimagesize($file['tmp_name']);
    if ($imgInfo === false) {
        return ['ok' => false, 'msg' => 'not a valid image'];
    }

    // 简单限制图片尺寸,防止异常大图消耗资源
    [$width, $height] = $imgInfo;
    if ($width < 1 || $height < 1 || $width > 5000 || $height > 5000) {
        return ['ok' => false, 'msg' => 'invalid image size'];
    }

    $uploadDir = __DIR__ . '/uploads';
    if (!is_dir($uploadDir)) {
        mkdir($uploadDir, 0755, true);
    }

    // 服务端生成文件名,不使用用户原始文件名
    $newName = bin2hex(random_bytes(16)) . '.' . $ext;
    $target = $uploadDir . '/' . $newName;

    if (!move_uploaded_file($file['tmp_name'], $target)) {
        return ['ok' => false, 'msg' => 'save failed'];
    }

    chmod($target, 0644);

    return [
        'ok' => true,
        'msg' => 'success',
        'path' => '/uploads/' . $newName,
    ];
}

$result = uploadImage($_FILES['file'] ?? []);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($result, JSON_UNESCAPED_UNICODE);
?>

进一步加固:图片重编码

如果业务只需要展示图片,建议在通过校验后进行重编码。例如使用 GD 或 Imagick 重新生成图片。这样可以降低图片尾部混入异常内容、EXIF 中携带异常数据等风险。

以 JPEG 为例,可以重新输出:

<?php
function reEncodeJpeg(string $src, string $dst): bool
{
    $image = @imagecreatefromjpeg($src);
    if (!$image) {
        return false;
    }

    $ok = imagejpeg($image, $dst, 90);
    imagedestroy($image);
    return $ok;
}

注意,重编码会影响图片质量和元数据,需要结合业务需求决定是否保留 EXIF。

注意事项

  • 不要依赖客户端 MIME:$_FILES['file']['type'] 可以被随意伪造。
  • 不要只做前端限制:前端限制只能改善体验,不能作为安全边界。
  • 上传目录建议独立域名:静态资源域与主站域隔离,可以降低 Cookie、同源策略相关风险。
  • 文件存储尽量放在 Web 根目录外:通过受控下载接口读取文件更安全。
  • 限制文件数量和频率:上传接口容易被滥用为存储资源消耗点。
  • 记录审计日志:至少记录上传用户、IP、文件大小、最终文件名、识别 MIME、上传时间。
  • 关注解析链路:CDN、对象存储、反向代理、Web Server 任一层配置异常,都可能改变最终风险。

总结

文件上传安全不能只看一段业务代码。比较稳的思路是:业务层做白名单和内容校验,存储层重新命名并限制权限,Web Server 层禁止上传目录脚本执行,展示层做好静态资源隔离。

实际审计时,我通常会优先确认三个点:上传目录是否可执行、文件名是否可控、服务端是否做真实内容校验。只要这三点有两个没做好,基本就值得继续深入验证。

网络流量与边界分析示意图
网络请求、日志与边界流量分析示意

这类接口审计时,重点不要放在“把黑名单补全”,而是把上传文件当成不可信数据,按“解析、存储、访问”三层隔离。单纯判断文件名后缀或 $_FILES['type'] 都比较容易被绕过:双后缀、大小写、尾随空格、伪造 MIME,以及图片文件尾部拼接 PHP 代码都可能绕过表面检查。

建议至少检查下面几个点:

  • 只允许业务真正需要的后缀,并将后缀转成小写后与白名单比较;不要使用黑名单。
  • 使用 finfo_file() 读取实际文件内容,但不要把 MIME 判断当成最终结论。
  • 图片上传还应使用 getimagesize(),并通过 GD/Imagick 重新编码后保存,避免保留原始文件中的附加数据。
  • 保存路径必须在 Web 根目录之外,文件名由服务端随机生成,不能采用用户提供的文件名。
  • 下载时通过 PHP 读取文件并设置固定的 Content-Type,不要让上传目录直接解析脚本。
  • 限制请求体大小、单文件大小、图片尺寸和解压文件数量,避免借上传接口做磁盘或内存消耗。

一个只允许图片的处理流程可以类似这样:

<?php
$upload = $_FILES['file'] ?? null;

if (!$upload || $upload['error'] !== UPLOAD_ERR_OK) {
    http_response_code(400);
    exit('upload failed');
}

$maxSize = 5 * 1024 * 1024;
if ($upload['size'] <= 0 || $upload['size'] > $maxSize) {
    http_response_code(413);
    exit('file too large');
}

$tmp = $upload['tmp_name'];

// 确认确实是 PHP 创建的上传临时文件
if (!is_uploaded_file($tmp)) {
    http_response_code(400);
    exit('invalid upload');
}

// 不信任客户端传入的 name/type
$allowed = [
    'image/jpeg' => 'jpg',
    'image/png'  => 'png',
    'image/gif'  => 'gif',
];

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($tmp);

if (!isset($allowed[$mime])) {
    http_response_code(415);
    exit('unsupported file type');
}

// 对图片做结构检查,而不是只看 MIME
$size = @getimagesize($tmp);
if ($size === false || $size[0] > 5000 || $size[1] > 5000) {
    http_response_code(415);
    exit('invalid image');
}

$dir = '/srv/app-data/uploads';
if (!is_dir($dir) && !mkdir($dir, 0700, true)) {
    throw new RuntimeException('cannot create upload directory');
}

$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$target = $dir . DIRECTORY_SEPARATOR . $name;

// 重新解码并编码,避免直接保存用户提供的原始图片
$image = @imagecreatefromstring(file_get_contents($tmp));
if ($image === false) {
    http_response_code(415);
    exit('invalid image data');
}

$ok = match ($mime) {
    'image/jpeg' => imagejpeg($image, $target, 90),
    'image/png'  => imagepng($image, $target, 6),
    'image/gif'  => imagegif($image, $target),
    default      => false,
};

imagedestroy($image);

if (!$ok) {
    @unlink($target);
    http_response_code(500);
    exit('save failed');
}

chmod($target, 0600);

// 数据库中保存 $name 或文件 ID,不保存用户原始路径
echo json_encode(['id' => $name], JSON_UNESCAPED_SLASHES);

如果业务不是图片,而是 PDF、Office 或压缩包,不建议套用图片校验逻辑。应分别使用对应解析器做结构验证,并考虑宏、外链、压缩炸弹和解析器漏洞;无法安全验证的格式最好不要接受。

Web 服务器侧也要做兜底。例如 Nginx 不要给上传目录配置 PHP 解析能力:

location ^~ /uploads/ {
    types { }
    default_type application/octet-stream;
    try_files $uri =404;

    # 禁止浏览器把上传内容当页面执行
    add_header X-Content-Type-Options nosniff always;
    add_header Content-Disposition "attachment" always;
}

更稳妥的方式是上传目录完全位于 root 外,通过鉴权下载接口输出文件。若历史原因必须放在站点目录下,Apache 也应明确禁用脚本处理,并且不要只依赖目录中的 .htaccess,主配置应同步限制:

<Directory "/var/www/app/uploads">
    Options -ExecCGI
    AllowOverride None
    <FilesMatch "\.(php|phtml|phar|php[0-9]*)$">
        Require all denied
    </FilesMatch>
</Directory>

审计时可以重点回归这些用例:a.php.jpg、a.jpg.php、大写后缀、后缀尾随空格、伪造 Content-Type、图片尾部追加脚本、路径穿越文件名,以及直接访问上传后的 URL。最后一项尤其重要:即使应用层判断失误,只要上传目录不能执行脚本,影响面会小很多。

创建帐户或登录后发表意见

最近浏览 0

  • 没有会员查看此页面。