关键词:WebShell、双扩展名、.user.ini、.htaccess、auto_prepend_file、PHP 函数黑名单、证据固定
难度:进阶
前置知识:Linux 文件权限、时间戳、PHP 基本概念、Web 服务器配置
一、概述
站点目录是攻击者落地后门的主战场,也是唯一能"看见"入侵结果的地方。
前面几篇从配置和日志追到"发生了什么",这一篇回答的是另一件事——东西落在哪、长什么样。
具体是三类问题:哪些文件可能不是业务文件、哪些文件内容可疑但扩展名正常、哪些机制能让站点目录里一个可疑文件都没有也能被劫持。
这一篇的第一原则不是技术,是纪律:绝对不要执行任何可疑文件。
不是"不建议",是"禁止"。
理由很实在。执行会在检材上产生新的状态变化——进程、临时文件、连接、下游写操作,而且不可逆。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 直接拼进函数名 |
函数名运行期才确定,静态特征匹配不到 |
必须注意的三点:
assert 和 preg_replace 有历史歧义:preg_replace 的 /e 修饰符在 PHP 5.5 已移除,PHP 7 起 assert() 不再支持字符串求值。老站(PHP 5.x)上这两个是真漏洞,新站上不是——判定时必须结合站点实际 PHP 版本。
call_user_func、array_map、usort 等"回调型"函数可以把字符串变成函数名,只看这几个名字会漏。
- 单个函数命中不等于后门。业务代码里
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 |
在脚本执行后追加执行 |
它的可怕之处在于:
- 站点目录里没有任何可疑 PHP 文件,扩展名正常、内容正常,
grep 危险函数也命中不到——因为后门在 /tmp/ 或站点外。
- 生效范围是整个目录树,对该目录下所有 PHP 脚本生效,包括正常业务文件。
- 文件名合法:
.user.ini 是 PHP 标准配置文件名,安全扫描常把它排除在扫描目标外。
- 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');