背景
这篇文章整理的是一次比较典型的 WebShell 排查过程。场景并不复杂:一套老旧 PHP 业务被发现存在异常外联和可疑文件落地,前端访问基本正常,业务侧最初只看到偶发 500 和目录下出现陌生 PHP 文件。类似事件在实际环境里很常见,真正有长期价值的不是某个“神奇命令”,而是把日志、文件、进程、代码审计串起来,形成可复用的排查闭环。
文中会尽量保留关键技术细节,但不会展开攻击利用或控制类操作,重点放在防守视角的取证、定位、修复和加固。
问题现象
- Web 目录下出现非发布流程产生的 PHP 文件,文件名接近缓存文件或图片缩略图命名。
- Nginx 访问日志中存在少量异常 POST 请求,User-Agent 比较固定。
- 业务上传目录中出现可执行脚本,且部分文件修改时间集中在凌晨。
- 应用错误日志中出现过一次上传组件报错,随后开始出现异常访问。
从这些现象看,优先怀疑入口在文件上传、后台富文本、插件组件或历史遗留接口。因为文件已落地到 Web 可访问目录,排查重点要覆盖“入口点”和“执行点”两部分。
第一步:先固定现场,避免线索被覆盖
很多现场排查容易一上来就删除文件或重启服务,这会破坏时间线。建议先做最小化固定:
# 记录系统时间,避免后续时间线误判
date -R
# 备份访问日志、错误日志和应用日志
mkdir -p /root/incident_backup/logs
cp -a /var/log/nginx /root/incident_backup/logs/nginx_$(date +%F)
cp -a /data/app/runtime/logs /root/incident_backup/logs/app_$(date +%F) 2>/dev/null
# 记录 Web 目录近期变更文件
find /data/www -type f -mtime -7 -printf '%TY-%Tm-%Td %TH:%TM:%TS %s %p\n' \
| sort > /root/incident_backup/recent_files.txt
# 对可疑目录做只读备份
cp -a /data/www/uploads /root/incident_backup/uploads_snapshot这里有两个注意点:第一,不要只看 mtime,攻击者可能修改文件时间;第二,先复制再分析,避免编辑器、杀软或脚本扫描改变文件元数据。
第二步:从访问日志建立时间线
WebShell 事件里,日志通常能回答三个问题:谁上传的、何时上传的、上传后访问了什么。先按可疑时间段过滤 POST 请求和上传接口:
# 统计可疑时间段 POST 请求
awk '$4 >= "[12/Mar/2025:00:00:00" && $4 <= "[12/Mar/2025:06:00:00" && $6 ~ /POST/' \
/var/log/nginx/access.log > /tmp/post.log
# 按 URL 聚合
awk '{print $7}' /tmp/post.log | sort | uniq -c | sort -nr | head -30
# 关注上传、编辑器、插件相关路径
grep -Ei 'upload|file|image|editor|ueditor|kindeditor|plugin|api' /tmp/post.log如果日志格式包含请求体长度、Referer、User-Agent、上游状态码,可以进一步聚合。真实事件中经常能看到这样的链路:
- 先访问后台登录页或某个公开上传接口。
- 随后 POST 到上传接口,返回 200 或 302。
- 几秒内访问上传目录下的 PHP 文件。
- 之后对同一文件持续 POST,访问频率低但有规律。
需要注意,日志里出现某个 IP 并不意味着它就是最终来源,可能是代理、扫描器、CDN 回源或安全设备地址。更有价值的是“同一会话行为链”和“入口 URL”。
第三步:定位可疑文件和执行痕迹
对 PHP 站点,可以从高风险函数、异常编码、非常规扩展名几个方向筛选。下面这些命令适合初筛,不建议直接作为定性依据:
# 查找近期新增或变更的 PHP 文件
find /data/www -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.php5' \) \
-mtime -14 -printf '%TY-%Tm-%Td %TH:%TM:%TS %s %p\n' | sort
# 查找上传目录中的脚本文件
find /data/www/uploads -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.phar' \) -ls
# 初筛高风险函数和混淆特征
grep -RInE 'eval\s*\(|assert\s*\(|system\s*\(|shell_exec\s*\(|passthru\s*\(|proc_open\s*\(|base64_decode\s*\(|gzinflate\s*\(' /data/www --include='*.php'单纯出现 base64_decode、call_user_func 不一定是 WebShell,很多框架和组件也会用到动态调用。更可靠的判断方式是结合上下文:
- 文件是否属于发布包或版本库。
- 文件路径是否位于 uploads、cache、tmp、public/static 等不应执行脚本的目录。
- 代码是否接收外部输入后进入动态执行、文件写入、命令执行等敏感点。
- 文件创建时间是否与异常请求时间吻合。
遇到混淆文件时,不建议在生产机器直接执行解码后的代码。可以将样本放到隔离环境,只做静态还原。还原时保留每一步输出,避免误把恶意逻辑执行起来。
第四步:回到代码,找入口而不是只删马
只删除 WebShell 通常会复发。需要确认它是如何写入的。本次排查中,入口最终定位到一个历史上传接口:后端仅检查了前端传入的 MIME 类型和文件名后缀,对真实文件内容、保存路径和扩展名白名单都没有做可靠限制。
问题代码大致类似下面这种模式:
// 反例:只相信客户端传入的类型和文件名
$type = $_FILES['file']['type'];
$name = $_FILES['file']['name'];
$tmp = $_FILES['file']['tmp_name'];
if ($type === 'image/jpeg' || $type === 'image/png') {
move_uploaded_file($tmp, __DIR__ . '/uploads/' . $name);
}这里的问题不在于某个函数本身,而是信任边界错误:客户端传入的文件名、Content-Type 都不可信。如果上传目录又能解析 PHP,就会把一个普通上传问题扩大成远程代码执行风险。
较稳妥的修复方式包括:
// 示例:白名单扩展名 + 随机文件名 + 内容识别 + 非 Web 根目录存储
$allowExt = ['jpg', 'jpeg', 'png', 'gif'];
$originName = $_FILES['file']['name'] ?? '';
$tmp = $_FILES['file']['tmp_name'] ?? '';
$ext = strtolower(pathinfo($originName, PATHINFO_EXTENSION));
if (!in_array($ext, $allowExt, true)) {
http_response_code(400);
exit('invalid extension');
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($tmp);
$allowMime = ['image/jpeg', 'image/png', 'image/gif'];
if (!in_array($mime, $allowMime, true)) {
http_response_code(400);
exit('invalid mime');
}
$newName = bin2hex(random_bytes(16)) . '.' . $ext;
$saveDir = '/data/upload_store/' . date('Ymd');
if (!is_dir($saveDir)) {
mkdir($saveDir, 0750, true);
}
move_uploaded_file($tmp, $saveDir . '/' . $newName);如果业务必须让文件可通过 URL 访问,建议通过下载接口或对象存储访问,不要直接把上传目录放进可执行的 Web 根目录。
第五步:限制上传目录执行能力
即使代码层修复了,也建议在 Web 服务器层面加一道保险。Nginx 场景下,可以禁止上传目录解析脚本:
location ^~ /uploads/ {
alias /data/www/uploads/;
autoindex off;
location ~* \.(php|phtml|php5|phar)$ {
return 403;
}
}如果 PHP-FPM 配置中存在宽泛匹配,也要一起检查,避免某些路径绕过 location 限制。常见需要关注的配置包括:
- 是否存在
location ~ \.php过于宽泛的匹配。 - 是否开启不必要的 PATH_INFO 解析。
- 上传目录是否被 alias 到 Web 可执行路径。
- PHP-FPM 用户是否拥有 Web 根目录写权限。
Apache 环境可以用目录级配置禁用脚本执行,但更推荐从虚拟主机配置层统一限制,避免依赖可被覆盖的 .htaccess。
第六步:检查持久化和横向痕迹
WebShell 落地后,不排除攻击者尝试写计划任务、替换文件、创建隐藏账号或读取配置。排查时建议覆盖以下位置:
# 计划任务
crontab -l
ls -la /var/spool/cron /etc/cron.* /etc/crontab 2>/dev/null
# 最近变更的系统脚本和临时目录
find /tmp /var/tmp /dev/shm -type f -mtime -14 -ls 2>/dev/null
# Web 目录异常权限
find /data/www -type f -perm -111 -ls
find /data/www -type d -perm -002 -ls
# 进程和网络连接基线
ps auxww
ss -antup还要重点检查应用配置文件是否泄露过数据库账号、对象存储密钥、第三方接口密钥。如果日志显示可疑文件访问过配置目录,建议轮换相关凭据,而不是只改后台密码。
代码审计关注点
围绕这类事件,后续代码审计可以按数据流梳理,不要只搜危险函数。建议关注以下几类入口:
| 审计点 | 典型风险 | 检查建议 |
| 文件上传 | 脚本落地、任意文件写入 | 扩展名白名单、内容识别、随机文件名、非 Web 根存储 |
| 文件管理 | 目录穿越、删除/覆盖敏感文件 | 路径规范化、限制根目录、禁止用户控制完整路径 |
| 模板渲染 | 模板注入、写入可执行模板 | 区分模板目录和用户内容目录,限制后台编辑能力 |
| 插件机制 | 未授权安装、历史组件漏洞 | 关闭不必要插件,校验插件来源和版本 |
| 后台接口 | 越权调用、弱鉴权 | 统一鉴权中间件,避免只依赖前端隐藏入口 |
对老项目来说,优先处理“可写目录可执行”“上传接口无白名单”“后台弱口令或无 MFA”“历史组件未升级”这几类问题,收益通常比大范围重构更明显。
日志与监控建议
事件后建议补齐最基本的检测能力,不需要一开始就上很复杂的平台。几个低成本但有效的点:
- 记录上传接口的用户 ID、源 IP、文件原名、保存名、大小、MIME、处理结果。
- 对 Web 目录新增 PHP 文件做定时检测,特别是 uploads、cache、static、tmp。
- 对异常 POST 到图片、静态目录、上传目录的请求做告警。
- 对配置文件、入口文件、核心框架目录做完整性校验。
- 保留足够长的访问日志和应用日志,至少覆盖一个业务周期。
# 简单完整性基线示例
find /data/www/app /data/www/public -type f \
-not -path '*/runtime/*' -not -path '*/uploads/*' \
-exec sha256sum {} \; | sort > /root/www_baseline.sha256
# 后续比对
find /data/www/app /data/www/public -type f \
-not -path '*/runtime/*' -not -path '*/uploads/*' \
-exec sha256sum {} \; | sort > /tmp/www_current.sha256
diff -u /root/www_baseline.sha256 /tmp/www_current.sha256几个容易忽略的注意点
- 不要只清理一个样本文件,要根据访问日志确认是否存在多个落地点。
- 不要只改后台密码,如果数据库、对象存储、短信网关密钥泄露过,应同步轮换。
- 不要在生产环境直接执行可疑样本,静态分析优先,必要时放隔离环境。
- 不要过度依赖文件后缀判断,Web 服务器解析规则和历史兼容配置可能带来额外风险。
- 修复后要复测入口是否真正关闭,并观察一段时间是否还有旧路径访问。
总结
WebShell 事件的处理重点是闭环:先固定现场,再用日志建立时间线,定位落地文件和访问行为,回到代码找写入入口,最后做配置加固、凭据轮换和监控补齐。只删文件通常解决不了根因,甚至会掩盖线索。
从经验看,上传目录可执行、老组件未维护、后台接口鉴权松散,是这类事件里最常见的组合风险。把这些基础问题处理扎实,比临时堆很多规则更可靠。真正有价值的复盘,不是证明“被打过”,而是让同类问题下次更早被发现、更难被利用、修复成本更低。
推荐意见