最近处理了一次非常顽固的 PHP 后门感染。起初以为只是 ThinkPHP 的 App.php 被插入 WebShell。
但实际排查后发现,这个后门并不是只感染 ThinkPHP。它的真正目标是 PHP 项目里“每次启动都会被加载”的核心入口文件。因此 ThinkPHP、Laravel、Workerman、Yii、TopThink ORM 都会被感染。
这类后门最恶心的地方不是代码有多长,而是它会选择框架核心文件作为落点。只要项目被访问、队列启动、Workerman 常驻进程运行,甚至 IDE 或 Composer 触发 PHP 代码扫描,都可能让它重新执行。
一、感染特征
最明显的特征是文件中出现如下标记:
;$b25st=0;
$l0ader=function($check){...};
;$b25ed=0;
b25st 是后门开始标记,b25ed 是结束标记,中间是一大段高度混淆的 PHP loader。
我们实际样本中解出的强特征包括:
$b25st
$b25ed
$l0ader=function($check)
0x6578706c
restore_error_handler()
error_reporting(0)
__run_code_x20
/sess_zziudbrorkdadhip90v9jmj
udp://pudp.crmeb.pw:9988
http://p30.crmeb.pw/v20/init?
它不是普通一句 eval($_POST [x]) 那种低级 WebShell,而是把大量函数名和远程地址藏进十六进制数组里,运行时再动态还原。
被还原出来的能力包括:
base64_decode
json_decode
file_get_contents
file_put_contents
stream_socket_client
pcntl_fork
cli_set_process_title
gzuncompress
这些函数组合起来,基本就能判断它不是单纯的信息收集代码,而是具备远程通信、下载指令、写文件、后台驻留、执行动态代码的完整后门。
二、实际感染范围
开始只是看到ThinkPHP 被感染,
但我们本机实际排查发现,感染范围远不止 ThinkPHP。
常见被感染文件包括:
ThinkPHP:
thinkphp/library/think/App.php
vendor/topthink/framework/src/think/App.php
vendor/topthink/framework/library/think/App.php
TopThink ORM:
vendor/topthink/think-orm/src/facade/Db.php
Laravel:
vendor/laravel/framework/src/Illuminate/Foundation/Application.php
Workerman:
vendor/workerman/workerman/Worker.php
Yii:
vendor/yiisoft/yii2/base/Application.php
也就是说,它真正盯上的不是某一个框架,而是所有 PHP 项目中高频加载、稳定存在、很少被业务开发者检查的核心文件。
Laravel 项目启动会加载 Application.php,ThinkPHP 会加载 App.php,Workerman 常驻进程会加载 Worker.php,Yii 会加载自己的 Application.php。后门注入这些文件后,就能借正常业务启动流程自动运行。
三、它具体干了什么
从样本行为看,这个后门主要做了几件事。
第一,隐藏自身。
它用 $b25st 和 $b25ed 包住恶意代码,但真正的函数名、远程地址、临时文件名都被编码成十六进制数组。普通搜索 eval、assert 不一定能直接发现。
第二,收集环境信息。
它会读取当前系统、PHP 环境、运行用户、路径、主机标识等信息,生成一个客户端 ID,用来让远程控制端识别这台机器。
第三,建立 C2 通信。
我们样本中出现了:
udp://pudp.crmeb.pw:9988
http://p30.crmeb.pw/v20/init?
这说明它会尝试向远程服务器上报信息,并获取后续指令。
第四,写入临时标记文件。
样本里出现了类似:
/sess_zziudbrorkdadhip90v9jmj
这类文件通常用于记录运行状态、控制执行频率、避免重复启动,或者作为后续代码落地点。
第五,尝试后台驻留。
它使用了 pcntl_fork、cli_set_process_title 等函数。CLI 模式下可能 fork 子进程,让后门脱离当前请求继续运行。Web 模式下也可能通过请求触发后继续在后台回连。
第六,具备重新写文件能力。
它包含 file_put_contents,并且我们的实际现象也证明:清理后某些文件会再次被改回感染状态。这说明不是单个文件静态中毒,而是某个运行中的 PHP 进程或持久化入口还在继续落盘。
四、为什么这个病毒很顽固
这次最明显的现象就是:删了又出现。
最初我们发现:
~/Sites/workman-project/vendor/topthink/think-orm/src/facade/Db.php
被反复修改。后来又陆续发现:
~/Sites/project1/vendor/topthink/framework/src/think/App.php
~/Sites/project2/thinkphp/library/think/App.php
~Sites/project3/vendor/laravel/framework/src/Illuminate/Foundation/Application.php
也都被插入同类代码。
它顽固主要有三个原因。
第一,它感染的是 vendor/framework 核心文件。
这些文件不是业务代码,平时很少有人逐行看。项目一多,很容易漏掉。
第二,它感染多个框架。
如果只按 ThinkPHP 查,就会漏掉 Laravel、Workerman、Yii。我们一开始也遇到过这个问题:某些 Sites 里的项目没有被第一版规则命中,后来才把规则扩展到 Laravel Application.php、Workerman Worker.php、Yii Application.php。
第三,可能存在内存中的循环进程。
哪怕磁盘文件已经清理,如果某个 PHP 常驻进程已经加载过后门,它仍可能继续执行,把干净文件重新写脏。
这就是为什么单纯“删除恶意代码”不够,必须配合重启。
五、我们做过哪些排查和清理
这次不是直接写个脚本扫一下就结束,中间做了几轮确认。
第一步,监控被篡改文件。
最开始针对:
vendor/topthink/think-orm/src/facade/Db.php
做了文件 hash 监控。文件一变,就保存 baseline 和 mutated 副本。后来确实抓到了变化:
old_hash: 7861ba53...
new_hash: fdaccdbc...
mutated copy: runtime/security-watch/Db.php.mutated...
这证明文件确实被再次改写,不是误判。
第二步,分析注入代码特征。
根据感染样本,提取出:
$b25st / $b25ed
$l0ader=function($check)
十六进制函数表
C2 地址
临时 sess 标记
__run_code_x20
然后把清理脚本从“只匹配固定三行”改成“综合强特征匹配”,避免变种去掉边界标记后漏扫。
第三步,扩大扫描范围。
最初只看 ThinkPHP,后来确认 Laravel、Workerman、Yii 都会被感染,于是把扫描目标扩到整个:
~/Sites
并且不是只扫 .php,而是允许按文件大小限制扫描所有可疑文本文件。
第四步,批量清理。
一次关键清理结果是:
扫描目录: ~/Sites
命中: 66
成功清理: 66
这些是历史污染样本,用来留证,不是项目运行文件,而且当时是 root 权限保存的,普通用户脚本无法写回。所以真正项目中的 55 个感染文件已经全部清理。
被清理的真实项目文件包括 Laravel、ThinkPHP、Workerman、Yii、TopThink ORM 多类核心文件,例如:
vendor/laravel/framework/src/Illuminate/Foundation/Application.php
vendor/workerman/workerman/Worker.php
thinkphp/library/think/App.php
vendor/topthink/framework/library/think/App.php
vendor/topthink/think-orm/src/facade/Db.php
vendor/yiisoft/yii2/base/Application.php
第五步,排查可疑进程。
期间看到过:
composer update --dry-run --no-interaction
一开始怀疑是不是病毒触发。后来通过父进程确认,它是 PhpStorm 启动的 Composer 自动检查,不是这次后门的直接证据。
但这个发现也说明:IDE、Composer、项目扫描工具都可能启动 PHP 进程。在清理阶段,这些东西会干扰判断,所以最好临时关闭 PhpStorm 的 Composer 自动同步或直接退出 IDE。
第六步,重启电脑。
这是关键步骤。
因为这类后门可能已经被 Workerman、queue、php-fpm、CLI 进程加载进内存。只清理磁盘文件,不杀掉内存进程,就可能继续复发。
所以最终采用的流程是:
先全盘清理
立即重启
重启后再次扫描
等待一段时间
再扫描确认
第七步,重启后复扫。
重启后重新扫描:
~
/Applications
/Library
/usr
/usr/local
/opt
/private/tmp
/private/var
/etc
/bin
/sbin
/home
结果全部是:0
MATCHED=0
另外还专门检查了 ~/Sites 下这些高风险文件在重启后是否又被写入:
App.php
Application.php
Db.php
Worker.php
结果为空,没有新的修改记录。
当前也没有真实的:
php
composer
php-fpm
workerman
artisan
think
swoole
queue
进程在运行。
这说明:清理后立即重启,再复扫,目前没有发现后门重新落盘。
六、最终有效的清理方案
结合这次实战,比较可靠的清理流程应该是:
1. 停止所有 PHP 相关进程
包括:
php-fpm
Workerman
Laravel queue
artisan
think
swoole
composer
IDE 自动 Composer 检查
如果不确定有没有残留,直接准备重启。
2. 全盘扫描感染特征
不要只扫 ThinkPHP,也不要只扫当前项目。
至少覆盖:
所有 PHP 项目目录
/usr/local
/opt
/private/tmp
用户目录下的 PHP 环境目录
Web 服务目录
扫描特征至少包含:
$b25st
$b25ed
$l0ader=function($check)
0x6578706c
restore_error_handler()
error_reporting(0)
udp://pudp.crmeb.pw:9988
http://p30.crmeb.pw/v20/init?
/sess_zziudbrorkdadhip90v9jmj
__run_code_x20
3. 清理所有命中文件
清理时必须注意:
只删除恶意注入块
不要破坏原框架文件结构
清理前备份
保留文件权限
保留 mtime
输出清理列表
如果项目使用 Composer,最干净的方式是:
删除 vendor
重新 composer install
但如果项目很多,或者 vendor 中有历史改动,就需要先用特征脚本批量清理,再逐个验证。
4. 立即重启
这是这次清理能稳定下来的关键。
如果不重启,内存里残留的 PHP 后门进程可能继续写文件。尤其是 Workerman、队列、php-fpm worker 这类常驻进程,风险最大。
5. 重启后立即复扫
重启后再次全盘扫描,如果命中为 0,说明磁盘上的感染文件已经清掉,内存里的旧进程也没有立刻重新落盘。
6. 等 1 小时后再扫一次
这是观察是否存在持久化的关键。
如果 1 小时后仍然:0
MATCHED=0
并且高风险核心文件没有新的 mtime 变化,基本可以判断清理完成。
七、后续加固建议
清理完成不代表风险结束,因为后门运行期间可能已经泄露配置。
建议继续做:
修改服务器登录密码
修改数据库密码
修改后台管理员密码
更换第三方 API key
检查 crontab
检查 LaunchAgent / LaunchDaemon
检查 php-fpm 配置
检查 Workerman 启动脚本
检查 Composer scripts
检查 /tmp、/private/tmp 下异常 sess 文件
检查 Web 访问日志中的异常请求
如果是 Linux 服务器,建议用 auditd 对核心文件加监控;如果是 macOS,本次使用的是 fs_usage 思路,重点抓谁在写:
App.php
Application.php
Db.php
Worker.php
只有抓到写入进程,才能真正确认是否还有隐藏持久化。
八、结论
这次感染不能简单理解成“ThinkPHP app.php 中毒”。更准确地说,它是一种 PHP 框架核心文件感染型后门。
它会批量寻找 PHP 项目中稳定存在、每次启动都会加载的核心文件,然后插入混淆 loader。ThinkPHP、Laravel、Workerman、Yii、TopThink ORM 都可能中招。
它之所以顽固,是因为:
感染点分散在多个项目
落点都是框架核心文件
普通搜索不容易发现
可能存在内存中的循环进程
清理后不重启可能再次写回
感染点分散在多个项目
落点都是框架核心文件
普通搜索不容易发现
可能存在内存中的循环进程
清理后不重启可能再次写回
这次最终有效的办法是:
全盘扫描
清理全部感染文件
立即重启
重启后再次全盘扫描
等待观察
再次复扫确认
从当前结果看,重启后全盘复扫已经没有再发现感染特征,高风险框架文件也没有新的写入。后续只要 1 小时后复扫仍然为 0,并且监控没有抓到新的写入进程,就可以认为这次后门已经清理干净。