背景
最近帮一套老旧业务系统做安全排查,起因是主机侧监控发现 Web 目录下出现了异常 PHP 文件,并且访问时间集中在凌晨。业务本身不大,典型的传统 PHP + Nginx + MySQL 架构,后台有文件上传、富文本编辑、插件管理等功能。
这类事件在中小型系统里很常见:入侵点未必复杂,往往是上传校验不严格、历史组件未更新、后台弱口令或配置暴露。本文不讨论攻击利用过程,而是整理一次偏防守视角的排查方法:如何从日志中定位入口,如何确认影响范围,如何回到代码层修复问题,并建立后续检查清单。
问题场景
现场环境大致如下:
- Web 服务:Nginx 1.20
- 运行环境:PHP 7.4-FPM
- 应用类型:自研 CMS,部分历史模块多年未维护
- 上传目录:
/data/www/app/public/uploads/ - 日志路径:
/var/log/nginx/access.log、/var/log/nginx/error.log
异常现象包括:
- 上传目录出现随机命名的 PHP 文件
- Nginx access log 中存在访问该文件的记录
- 业务用户反馈偶发 500 错误
- Web 目录内部分文件 mtime 与发布记录不一致
处理这类事件时,第一原则不是立刻删除文件,而是先保留证据、复制样本、冻结现场时间线。否则后续很难判断入侵入口和影响范围。
技术分析
排查思路可以分成四层:文件层、日志层、进程层、代码层。不要只盯着 WebShell 文件本身,它通常只是结果,入口可能在上传接口、后台功能、第三方组件或凭据泄露。
一、文件层:确认异常文件和时间线
先在 Web 根目录和可写目录中查找最近变更的脚本文件。这里重点关注上传目录、缓存目录、临时目录、模板编译目录。
find /data/www/app -type f \( -name "*.php" -o -name "*.phtml" -o -name "*.phar" \) -mtime -14 -printf "%TY-%Tm-%Td %TH:%TM:%TS %p\n" | sort如果系统存在发布记录,可以将文件变更时间与发布窗口对比。非发布期间新增的可执行脚本,应当重点检查。
常见可疑特征包括:
- 文件名随机,如
a9f3.php、cache_update.php - 代码中存在高风险函数调用,如
eval、assert、system、shell_exec、passthru - 存在明显混淆,如长 Base64 字符串、字符串拼接、压缩编码
- mtime 与周边文件明显不一致
可以用只读方式做初筛:
grep -RIn --include="*.php" -E "eval\s*\(|assert\s*\(|shell_exec\s*\(|passthru\s*\(|base64_decode\s*\(" /data/www/app 2>/dev/null需要注意,存在这些函数不等于一定被入侵,很多老项目会在模板、插件、任务脚本中使用动态执行。这里的价值是缩小人工审计范围。
二、日志层:还原访问链路
确认异常文件后,先查它的访问记录。假设发现文件为 /uploads/2024/11/tmp_a.php,可以搜索对应 URI:
grep "tmp_a.php" /var/log/nginx/access.log*重点关注字段:
- 首次访问时间
- 来源 IP 和 User-Agent
- 请求方法,是否有 POST
- 响应状态码和响应大小
- Referer 是否指向上传接口或后台页面
如果 access log 格式中记录了请求耗时、上游状态、请求体大小,会更容易判断是否存在异常交互。建议生产环境至少保留以下字段:
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time uct=$upstream_connect_time '
'uht=$upstream_header_time urt=$upstream_response_time';接着反查同一 IP 在异常文件出现前后的行为:
grep "1.2.3.4" /var/log/nginx/access.log | awk '{print $4,$5,$6,$7,$9,$10}'这里不是为了做攻击溯源,而是为了定位入口。比如在异常文件首次访问前,如果同一 IP 请求了 /admin/upload、/ueditor/php/controller.php 或某个插件接口,就要回到对应代码审计。
三、进程层:确认是否有横向影响
WebShell 类事件有时会伴随系统命令执行、计划任务写入或异常进程。排查时建议看以下位置:
ps auxww --sort=start_time | tail -30
crontab -l
ls -la /etc/cron.d /var/spool/cron 2>/dev/null
last -a | head
ss -tunlp如果业务容器化部署,也要看容器内外两层:
docker ps --no-trunc
docker inspect <container_id>
docker exec -it <container_id> sh -c 'ps aux; crontab -l; find /tmp -type f -mtime -7 -ls'这里的判断标准是“是否存在与业务无关、无法解释的持久化和网络连接”。不要看到陌生进程就直接 kill,先记录命令行、父进程、启动时间、可执行文件路径。
四、代码层:定位入口并修复
本次事件最终定位到一个历史上传接口。问题点不复杂:后端只根据前端传来的 MIME 类型和文件扩展名判断,没有重新解析文件内容,也没有禁止上传目录执行脚本。
典型问题代码类似下面这样:
if ($_FILES['file']['type'] === 'image/jpeg') {
$ext = pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION);
$target = __DIR__ . '/uploads/' . date('Ymd') . '/' . uniqid() . '.' . $ext;
move_uploaded_file($_FILES['file']['tmp_name'], $target);
}这里至少有三个问题:
$_FILES['file']['type']来自客户端,不可信- 扩展名只取原始文件名,未做白名单归一化
- 上传目录允许 PHP 解析执行
修复建议不是简单加黑名单,而是采用白名单、内容校验、随机文件名、权限隔离和 Web 服务器层禁止执行。示例:
$allowExt = ['jpg', 'jpeg', 'png', 'gif'];
$originName = $_FILES['file']['name'] ?? '';
$ext = strtolower(pathinfo($originName, PATHINFO_EXTENSION));
if (!in_array($ext, $allowExt, true)) {
throw new RuntimeException('invalid extension');
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['file']['tmp_name']);
$allowMime = ['image/jpeg', 'image/png', 'image/gif'];
if (!in_array($mime, $allowMime, true)) {
throw new RuntimeException('invalid mime');
}
$dir = __DIR__ . '/uploads/' . date('Ymd');
if (!is_dir($dir)) {
mkdir($dir, 0750, true);
}
$name = bin2hex(random_bytes(16)) . '.' . $ext;
$target = $dir . '/' . $name;
if (!move_uploaded_file($_FILES['file']['tmp_name'], $target)) {
throw new RuntimeException('upload failed');
}Nginx 层也要限制上传目录执行脚本。即使应用层漏掉一次,Web 服务器层也应提供兜底:
location ^~ /uploads/ {
types {
image/jpeg jpg jpeg;
image/png png;
image/gif gif;
}
default_type application/octet-stream;
location ~* \.(php|phtml|phar|shtml|cgi|pl)$ {
return 403;
}
}如果是 Apache,可以通过目录配置禁用脚本执行:
<Directory "/data/www/app/public/uploads">
php_admin_flag engine off
Options -ExecCGI
RemoveHandler .php .phtml .phar
</Directory>关键步骤整理
| 阶段 | 目标 | 建议动作 |
|---|---|---|
| 发现 | 确认异常文件 | 查找近期新增脚本、记录路径和时间 |
| 保全 | 避免证据丢失 | 复制样本、保存日志、记录 hash |
| 定位 | 找到入口 | 围绕首次访问时间反查上传、后台、组件接口 |
| 处置 | 清除影响 | 移除异常文件、重置凭据、检查计划任务和进程 |
| 修复 | 关闭漏洞 | 代码白名单校验、目录禁执行、权限最小化 |
| 复盘 | 降低复发 | 补日志字段、加文件完整性监控、建立发布基线 |
注意点
- 不要只删除 WebShell 文件。删除结果不修入口,后续大概率还会出现。
- 不要完全依赖文件扩展名和客户端 MIME。上传校验必须在服务端完成。
- 上传目录、缓存目录、日志目录原则上不应具备脚本执行能力。
- 业务账号、数据库账号、后台账号都应在事件后轮换,尤其是配置文件可能被读取的情况。
- 日志保留时间要覆盖排查窗口。很多现场最大的问题不是漏洞复杂,而是日志只有一天。
- 对老项目做代码审计时,优先看文件上传、模板渲染、插件安装、压缩包解压、远程图片抓取等高风险功能。
后续加固建议
从长期维护角度看,建议增加几项低成本措施:
- 对 Web 根目录做文件完整性基线,发布时更新基线,非发布变更告警。
- 将上传文件放到独立存储域名或对象存储,避免与应用代码同目录。
- PHP-FPM 运行用户与部署用户分离,Web 进程只拥有必要写权限。
- 后台入口增加多因素认证或至少限制来源 IP。
- 对历史第三方组件建立版本清单,定期核对公开漏洞。
一个简单的完整性检查可以先从 hash 基线开始,不一定一开始就上复杂平台:
cd /data/www/app
find . -type f \( -name "*.php" -o -name "*.js" -o -name "*.css" \) -print0 | sort -z | xargs -0 sha256sum > /opt/baseline/app.sha256后续比对:
cd /data/www/app
sha256sum -c /opt/baseline/app.sha256 | grep -v "OK$"总结
WebShell 事件的处理重点不在“识别一个恶意文件”,而在还原链路和修复入口。比较稳妥的顺序是:先保留现场,再建立时间线,然后通过访问日志定位入口,最后回到代码和配置层做闭环。
对大多数传统 Web 系统来说,上传目录禁执行、服务端白名单校验、权限最小化、日志字段补全、文件完整性监控这几项投入不高,但收益很明显。真正能降低复发率的,通常不是某个单点工具,而是把排查经验沉淀成可重复执行的检查清单。
推荐意见