搜索全站

输入至少 2 个字符,查找相关内容。

↑↓ 选择 Enter 打开Esc 关闭

网站目录与 WebShell 检测

站点目录是攻击者落地后门的主战场,也是唯一能"看见"入侵结果的地方。

最后更新 2026-10-09版本 v2.0维护 DigiForensics查看历史 1

一、概述

站点目录是攻击者落地后门的主战场,也是唯一能"看见"入侵结果的地方。

前面几篇从配置和日志追到"发生了什么",这一篇回答的是另一件事——东西落在哪、长什么样。

具体是三类问题:哪些文件可能不是业务文件、哪些文件内容可疑但扩展名正常、哪些机制能让站点目录里一个可疑文件都没有也能被劫持。

这一篇的第一原则不是技术,是纪律:绝对不要执行任何可疑文件。

不是"不建议",是"禁止"。

理由很实在。执行会在检材上产生新的状态变化——进程、临时文件、连接、下游写操作,而且不可逆。WebShell 通常具备 system、exec 能力,在分析机上跑一遍,等于把攻击者的能力引进你的分析环境。

而取证要求"检材状态可复现"。执行过的东西,回不到未执行。

正确姿势只有一条:只读挂载 → 复制副本 → 哈希固定 → 用文本工具看内容。

在取证流程中的位置:本篇属于分析环的落地层,承接上一层的配置映射。

root / DocumentRoot 告诉你查哪个目录,本篇负责在这个目录里给文件定性。上游依赖 Nginx 与 Apache 配置取证 提供的站点映射。

下游支撑有三处:流量侧验证(第 3 步 找到的文件需要用 access.log 确认是否真被访问过)、主机侧交叉(账户、计划任务、SSH 痕迹),以及最终报告里的证据台账。

涉及的证据形态(不是"网站文件"这种大类):

形态 位置 / 特征 检测锚点
静态资源藏 PHP upload/a.php.jpg、static/x.gif —— file 判图片,内部含 <?php 两者矛盾 = 高危
双扩展名 x.php.jpg、x.jpg.phtml、x.pht、x.phar 点号数量 + 中间段是否可执行扩展名
正常扩展名的后门 cache/1738.php 放在 cache/、vendor/ 里 内容里的 PHP 危险函数
.user.ini 劫持 站点任意层级的 .user.ini auto_prepend_file / auto_append_file
.htaccess 劫持 逐级向上查找,含 php_value、RewriteRule Apache 侧,见 3.4
内存文件系统残留 /tmp、/dev/shm、/run、/var/tmp 关机即失,关机镜像里反而还在
依赖目录篡改 vendor/x.inc、node_modules/ 内被改的文件 无新增文件也能劫持
元数据 mtime / ctime / btime / 权限 / 属主 ctime 不可伪造,是篡改铁证

站点目录里哪些文件不符合"目录职责"预期;哪些 PHP 文件把用户输入直接当函数名;.user.ini 指向的载荷是否存在;哪些文件的时间戳被 touch 掩盖;/tmp 一类内存盘里有没有落地物;可疑文件的哈希与完整元数据。

还要回答"这是一条完整的时间线":这些文件是什么时候被放进来的、站点是什么时候上线的、异常文件相对部署时间是否更新。

单看一个 mtime 说明不了任何事。

后门是否真的能执行、能干什么——静态分析只看代码语义,不做实测(要验证只能在隔离沙箱里用样本副本)。

这个文件是不是唯一入口?得查账户、计划任务、SSH 侧。被删除的文件内容?除非在内存镜像或备份里。

还有一条最容易被跳过的:"未发现 WebShell"这个说法本身。阴性发现必须写清范围、方法与未覆盖项,见 4.5 时间判读与结论表述。

内容边界(本篇的红线):本文只讲如何发现、如何识别、如何排除误报,不提供任何载荷构造、免杀或持久化实现。

判断"这不是业务代码"靠内容特征 + 时间关系 + 目录上下文三者结合,不靠文件名——cache/1738.php 这种名字在正常站点里大量存在。

命中危险函数只是线索。框架代码里 preg_replace、base64_decode 极其常见,报告要写"命中 N 次,其中 M 处存在用户输入直接作为函数名的模式",而不是笼统的"发现 WebShell"。

读者前提:需要 Linux 文件权限与时间戳语义(尤其 mtime 与 ctime 的区别)、PHP 基本概念与函数执行模型,以及 Web 服务器配置基础(第 1 篇)。

执行前请先确认 3.1 第 0 步 的挂载参数真的生效了。ro、noload、noatime 缺一个,后面所有分析都建立在不稳固的基线上。

二、核心原理

2.1 网站目录的典型结构

先建立"正常长什么样"的基线,才能判断"不正常"。典型结构:

/www/wwwroot/digiforensics-site/
├── index.php            入口
├── upload/  uploads/    ★ 用户上传目录——最高危落点
├── static/  img/  css/  js/             静态资源
├── storage/  runtime/  cache/  log/     可写但不该有代码
├── vendor/  thinkphp/  laravel/        框架目录(异常文件多=被入侵)
├── .user.ini            ★ PHP 目录级配置(见 2.4)
└── .htaccess            Apache 重写/鉴权规则

核心判据:目录职责与文件行为是否匹配。

这张表不用背,看一眼站点的真实布局,把对应行填进去就行。

目录 正常内容 异常信号
static/ img/ css/ 图片、样式、字体 出现 .php .phtml .phar,或 jpg/png 里含 <?php
upload/ 用户上传的图片/文档 出现任何可执行脚本(除非业务明确允许)
static/ 下的 .js JS 代码 内容里出现 eval(atob(、document.write(unescape(
cache/ runtime/ 缓存、临时文件 出现长期不变的 .php(缓存不该是可执行代码)
vendor/ 框架库 mtime 与其他文件不一致、大小异常

2.2 WebShell 的落地形态

形态 特征 检测锚点
正常扩展名 shell.php 就是个 PHP 文件 内容里的 PHP 危险函数(见 2.3)
双扩展名 x.php.jpg、x.jpg.php、x.phtml、x.php5、x.phar、x.pht 点号数量、中间段是不是可执行扩展名
图片藏 PHP a.jpg 开头是 GIF89a/PNG magic,后面跟 <?php file 说图片,grep 却命中 <?php → 两者矛盾=高危
无扩展名 upload/1738 无后缀 上传目录里的无后缀可读文件 + 权限可写
.user.ini 劫持 目录级配置,没有可疑 PHP 文件也能劫持 找 .user.ini,看 auto_prepend_file
.htaccess 劫持 Apache 改写把请求映射到别处 找 .htaccess,看 RewriteRule/Redirect/php_value
藏在正常业务目录 后门放在 vendor/、lib/、class/ 里 靠内容检测 + 时间戳,文件名完全无异常

双扩展名的判定逻辑(最容易漏的一类):点号后面一段如果本身就是可执行扩展名,就很可疑。

x.php.jpg 可疑,因为 php 是可执行扩展名。x.min.js 正常,min 不是。report.2024.pdf 正常,2024 也不是。

可执行扩展名清单(以实际环境为准,需结合 mime.types 与 PHP 配置确认):

.php .php2 .php3 .php4 .php5 .php7 .phps .pht .phtm .phtml .phar .inc .shtml

2.3 PHP 危险函数:内容检测的核心

WebShell 的本质是"调用系统能力的 PHP 函数"。

查这些函数命中,比查文件名可靠得多。

类别 关键函数 说明
执行命令 system、exec、passthru、shell_exec、popen、proc_open 直接执行系统命令
代码执行 eval、assert、create_function、call_user_func(+ 数组形式)、preg_replace(带 /e) 动态执行代码
文件操作 file_put_contents、file_get_contents、fopen/fwrite、unlink、move_uploaded_file 落地后门、删痕迹
编码混淆 base64_decode、gzinflate、str_rot13、gzuncompress、hex2bin 隐藏真实载荷
信息收集 phpinfo、getenv、$_SERVER、php_uname、posix_getpwuid 侦察环境
网络 fsockopen、curl_exec、stream_socket_client 反连 C2、外传数据
变量函数 $_POST/$_GET/$_REQUEST/$_COOKIE 直接拼进函数名 函数名运行期才确定,静态特征匹配不到

必须注意的三点:

  1. assert 和 preg_replace 有历史歧义:preg_replace 的 /e 修饰符在 PHP 5.5 已移除,PHP 7 起 assert() 不再支持字符串求值。老站(PHP 5.x)上这两个是真漏洞,新站上不是——判定时必须结合站点实际 PHP 版本。
  2. call_user_func、array_map、usort 等"回调型"函数可以把字符串变成函数名,只看这几个名字会漏。
  3. 单个函数命中不等于后门。业务代码里 exec 用于健康检查、base64_decode 用于处理附件都是正常的。必须看上下文:$_POST 直接当函数名的写法在任何正常业务里都极少见。

第 3 条是日常最容易用错的地方。初筛出来的命中往往几十上百条,能不能定性全看实参从哪来。

取证立场:报告里应写"命中危险函数 X 次,其中 N 处存在『用户输入直接作为函数名』的模式",而不是笼统的"发现 webshell"。

2.4 .user.ini 机制(本篇最重要的隐蔽点)

PHP 会自动读取当前目录及其所有上级目录下的 .user.ini,其中两个指令构成隐蔽后门:

; /www/wwwroot/digiforensics-site/.user.ini
auto_prepend_file=/tmp/.x.php
auto_append_file=/var/www/other.php
指令 行为
auto_prepend_file 在每个 PHP 脚本执行前,先执行这个文件
auto_append_file 在脚本执行后追加执行

它的可怕之处在于:

  1. 站点目录里没有任何可疑 PHP 文件,扩展名正常、内容正常,grep 危险函数也命中不到——因为后门在 /tmp/ 或站点外。
  2. 生效范围是整个目录树,对该目录下所有 PHP 脚本生效,包括正常业务文件。
  3. 文件名合法:.user.ini 是 PHP 标准配置文件名,安全扫描常把它排除在扫描目标外。
  4. PHP-FPM 的 user_ini.cache_ttl 默认 300 秒——写入后最长需 5 分钟生效(以实际环境为准),这也是攻击者"写后等一会儿再验证"的原因。

也就是说,"站内 PHP 全扫一遍没命中"这句话,在有 .user.ini 的情况下什么都不能证明。

⚠️ auto_prepend_file 指向自身会形成无限递归,正常配置不会这么写。若发现指向自身,是配置错误或人为破坏,需分别对待。

排查时必须双向确认:

方向一:找 .user.ini,看它指向哪

find /mnt/df/www/wwwroot -name '.user.ini' -exec sh -c 'echo "== $1"; cat "$1"' _ {} ;

方向二:把指向的路径拿去文件系统里核对是否真的存在

ls -la /tmp/.x.php 2>/dev/null

方向三:看 .user.ini 的 mtime,与入侵时间线是否吻合

stat -c '%n mtime=%y' /mnt/df/www/wwwroot/digiforensics-site/.user.ini

/tmp 是重点排查区:tmpfs 目录关机即失,但内存镜像里还在——这也是"文件已消失但仍能取证"的原因,见第 01 篇。

真要找载荷落点,别忘了往站点目录外面看一眼。

2.5 时间戳是最便宜也最有力的线索

攻击者改文件内容,mtime 一定会更新(cp 保持 mtime,但 cat >/vi/tee/WebShell 写入都会更新)。所以:

时间戳 含义 取证价值
mtime 最后修改时间 ★★★★★ 内容何时被改
ctime 元数据变更时间(权限、所有者、硬链接数) ★★★★ ★比 mtime 更难伪造——touch -m 改不了 ctime
atime 最后访问 ★★★ 弱,易被 relatime 影响
btime 创建时间(需文件系统支持) ★★★★ 新增文件必是新的

ctime 是判断"是否被动过手脚"的关键:如果 mtime 显示 2020 年但 ctime 显示 2024 年,说明有人用 touch 改过 mtime 掩盖痕迹——这是很强的篡改证据。

找一个可信的时间基准(日志时间、系统日志、数据库时间),然后按时间排序看哪些文件"扎堆"出现在入侵窗口内(命令见 3.5)。

时间戳只说明文件"什么时候被动过"。WebShell 上线之后做了什么,还得看应用层与主机层:Web.config 篡改、App_Offline.htm 投放、ViewState 反序列化与清理痕迹的识别见第 04 篇。


三、操作步骤

3.1 第 0 步:只读挂载与基线固定(不可跳过)

1) 只读挂载:ro 不写入,noload 避免加载日志产生副作用,noatime 避免更新访问时间

sudo mount -o loop,ro,offset=1181696,noload,noatime
/work/DigiForensics.dd /mnt/df findmnt -no OPTIONS --target /mnt/df/www/wwwroot | tr ',' '
' | grep -x ro # 确认 ro 生效

2) 站点目录整树哈希(后续所有分析基于这个基线)

cd /mnt/df/www/wwwroot/digiforensics-site &&
find . -type f -exec sha256sum {} + | sort -k2 > /evidence/site-hashes.txt wc -l /evidence/site-hashes.txt

3.2 第 1 步:文件类型与内容矛盾检测

最高性价比的一步。

file 说是什么,grep 说看见什么。两者矛盾就一定要看。

S=/mnt/df/www/wwwroot/digiforensics-site

1) 声明是图片/文档、实际含 PHP 标签的(★最高危)

注意:这里必须用 grep -a 而不是 -I。

-I 表示"忽略二进制文件",会让本项【完全静默地失效】——

而图片藏 PHP 恰恰是二进制文件,这是最不能漏的一类。

find "$S" -type f ( -iname '.jpg' -o -iname '.png' -o -iname '.gif'
-o -iname '
.jpeg' -o -iname '.ico' -o -iname '.pdf' -o -iname '*.txt' )
-exec grep -laIE '<?php' {} + 2>/dev/null

2) 逐个核对:file 说是 PHP 的,是否真的是纯 PHP(有没有二进制的图片头)

find "$S" -type f -iname '*.php' -exec file {} + 2>/dev/null | grep -vE 'PHP script|ASCII text|Unicode text|UTF-8'

输出里出现 "JPEG image data" "PNG image data" → 伪装

3) 双扩展名(两个方向都要覆盖,缺一不可)

find "$S" -type f -regextype posix-extended
-regex '..(php|phtml|phar|pht|php[0-9]|inc|shtml).(jpg|jpeg|png|gif|ico|txt|html|zip|doc|xls|pdf)$' 2>/dev/null find "$S" -type f -regextype posix-extended
-regex '.
.(jpg|jpeg|png|gif|ico|txt|html|zip|doc|xls|pdf).(php|phtml|phar|pht|php[0-9]|inc|shtml)$' 2>/dev/null

原理:静态资源里出现 <?php 只有两种可能——要么是模板里嵌的 PHP 代码(少见但合法,如邮件模板),要么是 WebShell。必须逐个人工确认,不能批量定性。

🔴 一个极易踩的坑:grep -I(忽略二进制)会让本项完全静默地失效。图片藏 PHP 的文件是二进制的,-I 会直接跳过它们——而这恰恰是最高危的一类。必须用 grep -a(把二进制当文本处理)。 这是本篇最需要记住的一个技术细节。

3.3 第 2 步:PHP 危险函数内容扫描(三级递进)

S=/mnt/df/www/wwwroot/digiforensics-site mkdir -p /evidence/webshell-scan

1) 一级:所有命中危险函数的文件

grep -rlE '\b(system|exec|passthru|shell_exec|popen|proc_open|eval|assert|create_function|preg_replace|base64_decode|gzinflate|gzuncompress|str_rot13|file_put_contents|fsockopen|curl_exec|stream_socket_client|phpinfo|move_uploaded_file)\s*('
"$S" 2>/dev/null | tee /evidence/webshell-scan/l1-hits.txt

2) 二级:只看"用户输入直接当函数名"的高危模式(正常业务几乎不会有)

grep -rnE '$(POST|GET|REQUEST|COOKIE)\s*(' "$S" 2>/dev/null
| tee /evidence/webshell-scan/l2-dynamic.txt grep -rnE '${?\w+}\s*(\s*$
(POST|GET|REQUEST)' "$S" 2>/dev/null
| tee -a /evidence/webshell-scan/l2-dynamic.txt

典型: $_POST'x' / $f=$_GET['cmd']; $f('whoami');

变量函数: $m='sys'.'tem'; $m('id');

3) 三级:回调型函数(绕过静态检测的手法)

grep -rnE '(call_user_func|array_map|array_filter|usort|preg_replace_callback|register_shutdown_fun