发布于6小时前6小时 背景 最近看了一个业务里的头像上传接口,代码量不大,但问题比较典型:只校验了文件后缀,保存路径又在 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 层禁止上传目录脚本执行,展示层做好静态资源隔离。 实际审计时,我通常会优先确认三个点:上传目录是否可执行、文件名是否可控、服务端是否做真实内容校验。只要这三点有两个没做好,基本就值得继续深入验证。 网络请求、日志与边界流量分析示意
4小时前4小时 这类接口审计时,重点不要放在“把黑名单补全”,而是把上传文件当成不可信数据,按“解析、存储、访问”三层隔离。单纯判断文件名后缀或 $_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。最后一项尤其重要:即使应用层判断失误,只要上传目录不能执行脚本,影响面会小很多。
创建帐户或登录后发表意见