技能学习
Nov 08, 2026

一次文件上传漏洞的完整复盘:从 WebShell 入侵到纵深防御

11 月初,我们在一次常规检查中发现:头像上传目录里出现了本不该存在的 .php 文件,访问日志显示它们在两天前被多次请求且全部返回 200。这意味着攻击者已经通过文件上传功能拿到了 WebShell,并在服务器上执行过命令。本文把这次事件从发现、定性、应急到根治的全过程完整复盘一遍,希望对同样在运营带上传功能的站点的朋友有所帮助。

⚠️ 先说结论:这次入侵的根因只有一句话——上传校验信任了客户端传来的信息。浏览器说“我是图片”,代码就信了;文件名说“我叫 .jpg”,代码也信了。攻击者只需要把一句话木马的 MIME 类型改成 image/png,文件名改成 .php,就能绕过全部检查。

一、事件经过

  • 11 月 6–7 日:攻击者通过头像上传接口上传了多个伪装成图片的 PHP 文件,落盘在 uploads/avatars/ 目录下。
  • 11 月 7 日:访问日志中出现对这些 PHP 文件的多次请求,全部返回 200,说明 WebShell 已被成功访问并执行。
  • 随后:攻击者以 Web 服务器进程(www)的身份执行命令,读取了站外文件和一份本地数据库副本,并尝试建立持久化立足点。
  • 发现时:我们在检查上传目录时发现了异常的 .php 文件,随即启动应急处置。

需要坦白的一点是:日志只能证明“至少发生了这些”,不能证明“只发生了这些”。数据到底被拿走了多少,无法从现有日志里完全还原。这也是为什么事后我们把“轮换一切可能泄露的密钥”当作必做项,而不是可选项。

二、根因:代码信任了客户端

出问题的上传函数逻辑大致是这样的(已简化):

// ❌ 有问题的写法(示意)
$ext  = strtolower(pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION));
$mime = $_FILES['avatar']['type']; // 浏览器上报的 MIME

if (in_array($mime, ['image/jpeg', 'image/png', 'image/gif'])) {
    move_uploaded_file($_FILES['avatar']['tmp_name'],
        "uploads/avatars/{$name}.{$ext}");
}

这里有两个致命的信任假设:

  • $_FILES['avatar']['type'] 不是服务器检测的结果,而是浏览器在请求里自报的。攻击者用 curl 发请求时,想填什么就填什么,image/png 只是一行 -F 参数的事。
  • 扩展名取自用户上传的原始文件名。文件内容是 PHP 代码,文件名叫 shell.php,扩展名就是 php,校验完全不看文件里到底是什么。

更糟的是,上传目录在 Web 根目录下,且默认可执行 PHP。于是攻击链闭环了:上传 → 直接访问 URL → 代码执行。讽刺的是,站内另一个上传接口写得就是对的——用 finfo 检测真实类型、按真实类型定扩展名。同一份代码库,两种写法,安全水位取决于写那段代码的人当天有没有想起来。

三、攻击链还原

  1. 绕过校验:构造一个内容为 PHP 代码、MIME 声明为 image/png、文件名为 *.php 的请求,上传成功。
  2. 触发执行:直接访问 https://站点/uploads/avatars/xxx.php,Nginx 交给 PHP-FPM 执行,拿到命令执行能力。
  3. 横向读取:以 www 用户身份读取 Web 根目录之外的文件和本地数据库副本。
  4. 尝试持久化:事后排查发现同一时间段新增了几个可疑账号,服务器 SSH 授权文件里也多了一个陌生密钥——这些都按“已沦陷”处理,逐一核对、清理。

值得注意的是,攻击者还利用了另一个低 hanging fruit:站内存在可公开下载的带时间戳备份文件(.bak.时间戳),其中一份是配置文件的备份。Nginx 原来的规则只拦截以 .bak 结尾的文件名,带时间戳后缀的正好绕过。备份文件本身也是攻击面,这一点很容易被忽视。

四、应急处置:六步

发现问题后,我们按顺序做了六件事:

  1. 隔离后门:把上传目录下的全部 9 个可疑 PHP 文件移到 Web 根目录之外隔离保存(留作取证,附 SHA256 清单),原 URL 随即返回 404。其中一个其实是 PNG 图片被错命名成 .php,也一并隔离。
  2. 堵住上传漏洞:重写上传校验(见第五节),并在 Nginx 层禁止上传目录下执行 PHP。
  3. 清理公开备份:38 个 .bak / .yfbk 文件打包归档后从 Web 根目录清除;Nginx 规则收紧,带时间戳的备份和 chat_data.json 一律返回 404。
  4. 轮换密钥:数据库密码、应用加密密钥全部轮换;用旧密钥加密过的配置项重新加密并回环验证;旧数据库密码确认失效。同时提醒:以明文存放在库里的第三方 API 密钥和 SMTP 密码,必须去各平台重新签发,服务器端换不了。
  5. 排查持久化:全站扫可疑代码模式(eval、base64_decode($_GET…) 等)零命中;核对定时任务(仅宝塔证书续期)、管理员账号(仅 1 个);可疑账号与 SSH 密钥逐一登记,等站长确认后清理。
  6. 复测验证:伪装成图片的 PHP 文件上传被拒;真 PNG 即使文件名叫 .php 也会被按真实类型存为 .png;上传目录下放测试 PHP 文件返回 404,正常图片仍是 200;首页与核心功能正常。

五、修复后的代码

核心原则只有两条:只信服务器自己检测到的文件类型;扩展名由检测结果决定,而不是由用户决定。

// ✅ 修复后的写法(示意)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime  = finfo_file($finfo, $tmpPath); // 读文件头,服务器端检测

$allowed = [
    'image/jpeg' => 'jpg',
    'image/png'  => 'png',
    'image/webp' => 'webp',
    'image/gif'  => 'gif',
];
if (!isset($allowed[$mime])) {
    throw new Exception('不支持的文件类型');
}
if (getimagesize($tmpPath) === false) {
    throw new Exception('不是有效的图片文件');
}
$ext = $allowed[$mime]; // 扩展名由真实类型决定
move_uploaded_file($tmpPath, "uploads/avatars/{$name}.{$ext}");

只修应用层还不够。上传目录本身要默认不可执行,Nginx 层再加一道:

# 上传目录下禁止执行 PHP
location ^~ /uploads/ {
    location ~ \.php$ { deny all; }
}

这两层是互补的:应用层校验挡住“存错文件”,Nginx 规则兜底“万一存进去了也执行不了”。任何一层单独存在都不够——这正是纵深防御的意思。

六、纵深防御清单

如果你也在运营带上传功能的站点,可以照这张清单自查一遍:

  • 校验文件内容,不校验客户端说法:用 finfo 读文件头、getimagesize 二次确认;永远不要用 $_FILES['type'] 和原始文件名扩展名做安全判断。
  • 扩展名由服务端决定:按检测到的真实 MIME 生成扩展名,文件名随机化,不保留用户上传的原始文件名。
  • 上传目录默认不可执行:Web 服务器层禁止上传目录执行脚本;更彻底的做法是把上传文件存到 Web 根目录之外,通过程序代理读取。
  • 上传与主站分离:条件允许时用独立域名或对象存储存放用户上传内容,与主站 Cookie、权限隔离。
  • 限制大小与数量:限制单文件大小、总容量与上传频率,避免存储耗尽与批量探测。
  • 备份文件不进 Web 根目录:备份统一存到站外或不可访问的目录;Nginx 规则要覆盖 .bak.时间戳 这类变体后缀,不只拦截 .bak 结尾。
  • 密钥与凭据轮换成流程:一旦怀疑泄露,数据库密码、加密密钥、第三方 API Key、SMTP 密码全部轮换;明文存放的密钥要推动改成加密存储或环境变量。
  • 最小权限运行:PHP-FPM / Web 进程只读它必须读的目录,数据库账号只给它必须有的权限,www 用户不该读到站外敏感文件。
  • 日志与告警:对上传目录出现可执行文件、异常时间段新增账号、授权文件变更这类事件做监控,发现窗口越短,损失越小。

七、经验总结

  1. 永远不要信任客户端传来的任何安全相关信息。MIME 类型、文件名、扩展名都是攻击者可以任意填写的请求参数,不是事实。
  2. 上传目录默认就是不可信区域。把它当作“可能已经有恶意文件”的目录来设计:不可执行、不可列目录、与主站隔离。
  3. 同一类功能,全站只应有一种写法。这次站内一个上传接口是安全的,另一个是有漏洞的。把上传收敛成一个统一的、经过审计的公共函数,比逐个修补可靠得多。
  4. 备份文件也是攻击面。一个带时间戳的 .bak 就能泄露整站配置,备份策略要和上传策略一样被当作安全项来评审。
  5. 应急处置的顺序很重要:先隔离、再堵漏、再清理暴露面、再轮换密钥、最后排查持久化。每一步都要验证——“我以为修好了”是最危险的状态。

这次事件没有造成可确认的资金损失,但它提醒我们:很多安全事件不是因为攻击者多高明,而是因为某个看似无害的“小功能”从一开始就没按安全的方式写。如果你运营的站点也有文件上传功能,不妨现在就去看一眼它的校验逻辑——用的到底是服务器检测到的真实类型,还是浏览器自报的类型。

评论区

0 条评论 · 评论需审核通过后显示

加载评论中...

发表评论

0/2000

← 返回
🔍
客服图标
微信客服图标
AI 助手
×

微信客服

微信二维码