对象:www.nanlou.net(PbootCMS 建站程序)
结论一句话:网站不是被"猜密码"进来的,而是程序里三个小毛病串在一起,让攻击者把一个网址"写"进了网页缓存,而系统又把这份缓存当成程序执行了。

一、先用一个生活化的比喻

想象你的网站是一家奶茶店:

  • 模板是店里的装修和菜单板;
  • 缓存是为了让顾客不用每次等,店里提前做好的"成品海报",有人来了直接递出去;
  • PHP 程序是店员,海报上写什么,他就照着念、照着做。

这次出事的地方在于:

  1. 菜单板上有一栏"当前页面地址",是直接把顾客说的地址抄上去的,不检查内容(漏洞一);
  2. 店里有一条规矩:"海报上不许出现命令",但这条规矩只检查了一遍,攻击者把命令拆成两半写,检查完自动拼回去了(漏洞二);
  3. 最要命的是,店员拿到海报后不是"念",而是"照做"——海报上的字会被当成程序执行(漏洞三)。

于是攻击者做了一件事:把一句命令拆开、伪装成一个网址,让店里生成一张"看起来正常"的海报。从此以后,每个访问网站的普通访客,都在替攻击者运行那段命令。


二、事情的真实经过(三个漏洞如何串成一条链)

第一步:网址被原样搬进了网页

PbootCMS 的模板里有一个标签 {pboot:pageurl},作用是"显示当前页面的网址"。它的实现只有一行:

$content = str_replace('{pboot:pageurl}', get_current_url(), $content);

get_current_url() 拿的是访客请求里的地址。也就是说,访客在网址里塞什么,它就把什么原样印到网页上,不做任何转义。

这属于典型的"反射型注入":用户输入直接进了输出。在正常情况下,这最多让人在网页上看到一段奇怪文字;但配合下面两个问题,它就成了入场券。

第二步:唯一的一道"消毒"只擦了一遍

系统在把网页写进缓存前,会做一次清理,把可能被当成 PHP 代码的标记(<?php、<?=、?> 等)删掉:

preg_replace('/<\?(php|=)?|\?>|<%|%>|<script language=php>/i', '', $content);

问题在于它只做了一遍替换。攻击者只要把标记拆开写,例如让页面里先后出现 <<?? 和 =...??>> 这样的碎片——单独看每一片都不像代码,能通过检查;但删掉中间那部分字符后,剩下的碎片会自动拼成一个完整的 <?= ... ?>。

这就是"过滤一次"类漏洞的经典绕过手法:过滤器和攻击者赛跑,攻击者只需要赢一次拼接。

第三步:缓存文件被 include,等于"网页内容 = 可执行程序"

生成缓存时,系统把清理过的网页内容写进 runtime/cache/xxxx.html。下次有人访问,Kernel.php 里是这么用的:

if (file_exists($cacheFile) && ...) {
    include $cacheFile;   // 把 .html 当 PHP 代码执行
}

include 不是"读出来显示",而是"读出来当程序跑"。一个后缀叫 .html 的文件,在这里其实是一个 PHP 脚本。

三步合起来就是:

攻击者构造特殊网址
   → {pboot:pageurl} 把网址原样写进网页
   → 清理只做一遍,碎片拼成 <?= 恶意代码 ?>
   → 网页被写入缓存 .html 文件
   → 任何人访问该页面,include 执行缓存 → 命令执行

整个过程中,攻击者不需要账号、不需要登录后台、不需要上传文件,只要会构造网址。

一个"帮凶":short_open_tag = On

服务器 PHP 开启了短标签,于是 <?if ... ?> 这种写法也能当代码执行。这让攻击者的可用写法多了一种,也解释了为什么被污染的缓存里有大量 <?if 开头的片段。


三、为什么这么隐蔽,常规检查没发现?

这次排查中,几个"反常识"的点值得记住:

  1. 木马不在 PHP 源码里,而在缓存里。 大家查后门习惯搜 .php 文件,但这次的载荷是 runtime/cache/*.html——一个平时没人会打开看的目录。
  2. 缓存文件长得"很正常"。 打开看就是一张正常的网页,恶意代码只是其中一小段;而且缓存会自动生成、自动过期、被清理后又重建,删掉它并不能断根。
  3. 同一份缓存会被反复执行。 只要不清缓存、不修代码,攻击者可以随时通过重新访问那个恶意网址"续命"。
  4. 攻击者还留了独立的"常驻后门"。 在拿到命令执行能力之后,他在网站里放了 7 个哥斯拉(Godzilla)木马和 1 个投放器,伪装成普通文件名的形式散落在不同目录——这部分是持久化的后门,和缓存投毒是两条线,必须分别清理。

换句话说:漏洞负责"进门",木马负责"住下来"。只删木马不修漏洞,会有下一次;只修漏洞不删木马,门一直开着。


四、我们做了哪些处置

动作结果
清除后门7 个哥斯拉木马 + 1 个投放器已删除;按木马的加密特征全站复扫,0 命中
留存证据木马样本、日志、封禁清单备份在 /www/backup/incident_20261021/
封禁攻击源105 个 IP 写入 firewalld 永久规则并校验生效(已排除本机与管理员 IP)
修复漏洞① 页面地址输出改为 HTML 转义;② 清理逻辑改为"循环替换到不再变化";③ 缓存读取从 include 改为 readfile(只输出、不执行);④ 关闭 HTML 缓存
清理污染282 个缓存文件全部清空(其中 41 个被投毒:12 个 <?= 型、30 个 <?if 型)
验证用两组真实攻击载荷回归测试:页面正常返回 200,探针文件未生成;过滤器单测确认碎片无法再拼成标签

修复后的效果是"三层独立防线":转义挡住了输入,循环过滤挡住了拼接,readfile 保证了即使缓存里还有代码也只会被打印出来,不会被执行。


五、给同类站点的五条经验

  1. "用户输入原样输出"永远是第一号风险。 只要模板里有"显示当前网址 / 搜索词 / 来源页"这类标签,就必须转义,而不是原样打印。
  2. 黑名单过滤只做一遍等于没做。 要么循环到稳定,要么干脆用白名单/转义,别指望"删掉关键词"能防住拼接。
  3. 缓存目录必须当成"不可信数据"对待。 缓存文件不能被当代码执行;用 readfile/静态输出,而不是 include。缓存目录也不该允许 PHP 执行。
  4. short_open_tag、display_errors 这类"方便配置",会让漏洞的利用面变大,生产环境应关闭。
  5. 升级包会覆盖补丁。 本次修复是改在程序源码里的,update/ 目录下的官方升级包里还是老代码——下次一键升级会把洞重新打开,升级后必须重新打补丁(或把升级包移出网站目录)。

六、一句话总结

这次入侵的本质不是"密码被猜到",而是"网页里的一句用户输入,被系统当成了程序来执行"。
用户输入没转义 → 消毒只做一遍被拼接绕过 → 缓存被 include 执行,三块短板首尾相接,才凑成了一条完整的远程命令执行链。

只要其中任何一环补上,攻击就断在半路。这也是为什么修复时我们选择了"三处全改",而不是只堵一个点。

标签: PbootCMS

添加新评论