网站是怎么被黑的?—— 一次"网页缓存被改造成后门"的入侵复盘
对象:www.nanlou.net(PbootCMS 建站程序)
结论一句话:网站不是被"猜密码"进来的,而是程序里三个小毛病串在一起,让攻击者把一个网址"写"进了网页缓存,而系统又把这份缓存当成程序执行了。
一、先用一个生活化的比喻
想象你的网站是一家奶茶店:
- 模板是店里的装修和菜单板;
- 缓存是为了让顾客不用每次等,店里提前做好的"成品海报",有人来了直接递出去;
- PHP 程序是店员,海报上写什么,他就照着念、照着做。
这次出事的地方在于:
- 菜单板上有一栏"当前页面地址",是直接把顾客说的地址抄上去的,不检查内容(漏洞一);
- 店里有一条规矩:"海报上不许出现命令",但这条规矩只检查了一遍,攻击者把命令拆成两半写,检查完自动拼回去了(漏洞二);
- 最要命的是,店员拿到海报后不是"念",而是"照做"——海报上的字会被当成程序执行(漏洞三)。
于是攻击者做了一件事:把一句命令拆开、伪装成一个网址,让店里生成一张"看起来正常"的海报。从此以后,每个访问网站的普通访客,都在替攻击者运行那段命令。
二、事情的真实经过(三个漏洞如何串成一条链)
第一步:网址被原样搬进了网页
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 开头的片段。
三、为什么这么隐蔽,常规检查没发现?
这次排查中,几个"反常识"的点值得记住:
- 木马不在 PHP 源码里,而在缓存里。 大家查后门习惯搜
.php文件,但这次的载荷是runtime/cache/*.html——一个平时没人会打开看的目录。 - 缓存文件长得"很正常"。 打开看就是一张正常的网页,恶意代码只是其中一小段;而且缓存会自动生成、自动过期、被清理后又重建,删掉它并不能断根。
- 同一份缓存会被反复执行。 只要不清缓存、不修代码,攻击者可以随时通过重新访问那个恶意网址"续命"。
- 攻击者还留了独立的"常驻后门"。 在拿到命令执行能力之后,他在网站里放了 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 保证了即使缓存里还有代码也只会被打印出来,不会被执行。
五、给同类站点的五条经验
- "用户输入原样输出"永远是第一号风险。 只要模板里有"显示当前网址 / 搜索词 / 来源页"这类标签,就必须转义,而不是原样打印。
- 黑名单过滤只做一遍等于没做。 要么循环到稳定,要么干脆用白名单/转义,别指望"删掉关键词"能防住拼接。
- 缓存目录必须当成"不可信数据"对待。 缓存文件不能被当代码执行;用
readfile/静态输出,而不是include。缓存目录也不该允许 PHP 执行。 short_open_tag、display_errors这类"方便配置",会让漏洞的利用面变大,生产环境应关闭。- 升级包会覆盖补丁。 本次修复是改在程序源码里的,
update/目录下的官方升级包里还是老代码——下次一键升级会把洞重新打开,升级后必须重新打补丁(或把升级包移出网站目录)。
六、一句话总结
这次入侵的本质不是"密码被猜到",而是"网页里的一句用户输入,被系统当成了程序来执行"。
用户输入没转义 → 消毒只做一遍被拼接绕过 → 缓存被include执行,三块短板首尾相接,才凑成了一条完整的远程命令执行链。
只要其中任何一环补上,攻击就断在半路。这也是为什么修复时我们选择了"三处全改",而不是只堵一个点。