11 月初,我们在一次常规检查中发现:头像上传目录里出现了本不该存在的 .php 文件,访问日志显示它们在两天前被多次请求且全部返回 200。这意味着攻击者已经通过文件上传功能拿到了 WebShell,并在服务器上执行过命令。本文把这次事件从发现、定性、应急到根治的全过程完整复盘一遍,希望对同样在运营带上传功能的站点的朋友有所帮助。
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 检测真实类型、按真实类型定扩展名。同一份代码库,两种写法,安全水位取决于写那段代码的人当天有没有想起来。
三、攻击链还原
- 绕过校验:构造一个内容为 PHP 代码、MIME 声明为
image/png、文件名为*.php的请求,上传成功。 - 触发执行:直接访问
https://站点/uploads/avatars/xxx.php,Nginx 交给 PHP-FPM 执行,拿到命令执行能力。 - 横向读取:以
www用户身份读取 Web 根目录之外的文件和本地数据库副本。 - 尝试持久化:事后排查发现同一时间段新增了几个可疑账号,服务器 SSH 授权文件里也多了一个陌生密钥——这些都按“已沦陷”处理,逐一核对、清理。
值得注意的是,攻击者还利用了另一个低 hanging fruit:站内存在可公开下载的带时间戳备份文件(.bak.时间戳),其中一份是配置文件的备份。Nginx 原来的规则只拦截以 .bak 结尾的文件名,带时间戳后缀的正好绕过。备份文件本身也是攻击面,这一点很容易被忽视。
四、应急处置:六步
发现问题后,我们按顺序做了六件事:
- 隔离后门:把上传目录下的全部 9 个可疑 PHP 文件移到 Web 根目录之外隔离保存(留作取证,附 SHA256 清单),原 URL 随即返回 404。其中一个其实是 PNG 图片被错命名成
.php,也一并隔离。 - 堵住上传漏洞:重写上传校验(见第五节),并在 Nginx 层禁止上传目录下执行 PHP。
- 清理公开备份:38 个
.bak/.yfbk文件打包归档后从 Web 根目录清除;Nginx 规则收紧,带时间戳的备份和chat_data.json一律返回 404。 - 轮换密钥:数据库密码、应用加密密钥全部轮换;用旧密钥加密过的配置项重新加密并回环验证;旧数据库密码确认失效。同时提醒:以明文存放在库里的第三方 API 密钥和 SMTP 密码,必须去各平台重新签发,服务器端换不了。
- 排查持久化:全站扫可疑代码模式(
eval、base64_decode($_GET…)等)零命中;核对定时任务(仅宝塔证书续期)、管理员账号(仅 1 个);可疑账号与 SSH 密钥逐一登记,等站长确认后清理。 - 复测验证:伪装成图片的 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用户不该读到站外敏感文件。 - 日志与告警:对上传目录出现可执行文件、异常时间段新增账号、授权文件变更这类事件做监控,发现窗口越短,损失越小。
七、经验总结
- 永远不要信任客户端传来的任何安全相关信息。MIME 类型、文件名、扩展名都是攻击者可以任意填写的请求参数,不是事实。
- 上传目录默认就是不可信区域。把它当作“可能已经有恶意文件”的目录来设计:不可执行、不可列目录、与主站隔离。
- 同一类功能,全站只应有一种写法。这次站内一个上传接口是安全的,另一个是有漏洞的。把上传收敛成一个统一的、经过审计的公共函数,比逐个修补可靠得多。
- 备份文件也是攻击面。一个带时间戳的
.bak就能泄露整站配置,备份策略要和上传策略一样被当作安全项来评审。 - 应急处置的顺序很重要:先隔离、再堵漏、再清理暴露面、再轮换密钥、最后排查持久化。每一步都要验证——“我以为修好了”是最危险的状态。
这次事件没有造成可确认的资金损失,但它提醒我们:很多安全事件不是因为攻击者多高明,而是因为某个看似无害的“小功能”从一开始就没按安全的方式写。如果你运营的站点也有文件上传功能,不妨现在就去看一眼它的校验逻辑——用的到底是服务器检测到的真实类型,还是浏览器自报的类型。
评论区
0 条评论 · 评论需审核通过后显示
发表评论