记一次pbootcms系统修复

结论

攻击能够生效,根本原因是 View::cache() 的“剥离 PHP 标签”正则会把 <<??= 重新拼成 <?=,再把这段内容写进一个会被 include 执行的 .html 缓存文件。攻击者只需要发两次同一个 URL。

data/tempp3.php 现在还在,说明 dropper 本身没有被请求执行过,但 payload 确实已经执行过了(备份里 07:23 那两个 cache 文件就是执行点)。

完整链路

  1. 攻击者请求 /contact/?s=<<??=file_put_contents('data/tempp3.php',base64_decode('...'))??>>

  2. 模板里的 {pboot:pageurl} 被替换成 get_current_url(),把 URL 原样、不转义输出到 og:url

  3. 由于 s 参数在全站从不被读取(只有 3 处 unset),所以它不会报错;关键在 query_string('p,s'):它先把 p、s 删掉再判断是否为空,如果只剩 p、s,结果就是空串 → 因此带 s 也会写缓存

  4. 写缓存前执行剥离:
    core/view/View.php:176

    1
    preg_replace('/<\?(php|=)?|\?>|<%|%>|<script...>/i','',$content)
    • <<??= → 删掉第 1–2 位的 <? → 剩 < + ?= = <?=
    • ??>> → 删掉 ?> → 剩 ? + > = ?>
    • 也就是说,这个用于清洗的函数反而拼出了可执行的 PHP 短标签,然后它被写进 runtime/cache/<md5>.html
  5. 内核命中缓存,直接 include 这个 .html(这正是它要剥离标签的原因)→ 第二次请求时 payload 执行,写出 data/tempp3.php

  6. 文件 mtime 07:23,与日志中的请求时间吻合;因此日志里每类 URL 都出现两条 200 响应(来自不同的 Cloudflare 出口 IP),其中第一条用于把 payload 种进缓存,第二条触发执行

证据(来自 data/runtime 备份)

  • data/tempp3.php(1105B,2026-10-29 07:23)= 日志 URL 里那段 base64 解码后的原文(_h() 随机名 + 写 XOR shell + echo 'wuti'.$h + @unlink(__FILE__))
  • 07:23 只有 3 个文件变动,其余 42 个都早于这个时间:
    • runtime/cache/9327087c625faf0124c02ef725c870c2.html、c0a4b572dc4ff789e9be825e8c8fc11b.html
    • 里面 og:url 已是剥离后的 <?=file_put_contents('data/tempp3.php',base64_decode('PD9waHA...,同页 Spider 脚本里的 URL 还保留 %3C%3C%3F%3F%3D(即 <<??=)→ 直接证实了“剥离 → 还原”这一步
  • DB mtime 10-29 05:46 早于攻击时间、ay_syslog 当天只有 2 条留言(05:24、05:46)→ 不是后台账号操作
  • runtime/archive/session_ticket.php、runtime/config/*.php、spider 日志、visits 分片均干净 → 排除其他执行落点

怎么改(按优先级)

P0:先堵这条链(改任意一条即断,建议全改)

  1. 关闭带查询串的页面缓存
    View.php:169 改为:

    1
    if (Config::get('tpl_html_cache') && ! $_SERVER['QUERY_STRING']) {

    带任何 ? 参数的 URL 一律不落缓存,直接消除“种缓存”的可能。

  2. 剥离器不应“删除”,要“转义 + 校验”
    View.php:176 之后加一道兜底,宁可放弃缓存也不写可疑内容:

    1
    2
    3
    4
    $content = preg_replace('/<\?(php|=)?|\?>|<%|%>|<script\s+language\s*=\s*["\']?php["\']?\s*>/i', '', $content);
    if (preg_match('/<\?|<\?=|<%/i', $content)) {
    return false; // 剥离后仍含 PHP 标签,不落缓存
    }

    根本做法是把 <?、?> 替换成 &lt;?、?&gt;,而不是删掉——删掉必然能被构造出新的标签。

  3. {pboot:pageurl} 输出转义
    ParserController.php:186:

    1
    $content = str_replace('{pboot:pageurl}', htmlspecialchars(get_current_url(), ENT_QUOTES, 'UTF-8'), $content);

    这条单独就能挡住本次攻击(<<??= 会先变成 &lt;&lt;??=)。

P1:消除同类 RCE 面

  1. 模板名白名单 + 禁止绝对路径 include

    • core/view/View.php:118 的 if (strpos($file,'/')===0) $tpl_file = ROOT_PATH.$file; 去掉;theme/file 的 .. 过滤只去一层,改成循环过滤到稳定
    • core/view/Parser.php:100 的 {include} 绝对路径同理
    • listtpl / contenttpl(来自 DB)加 /^[\w\-]+\.html$/ 校验,参考 SearchController 已有的 searchtpl 白名单写法
  2. 编译期条件白名单
    core/view/Parser.php:283 validateIfCondition() 是黑名单,可用 use function fputs as test; 绕过 → 改成与运行时 ParserController::validateIfCondition() 一致的白名单。

  3. 若业务不用,禁用 {php}(Parser.php:433)和 {fun=}(Parser.php:250),或至少对函数名走白名单。

  4. ?tag= 参数做整数/白名单校验(历史注入点)。

P2:减损与现场排查

  1. 删恶意缓存(必须做,否则同一 URL 再被访问还会执行):

    1
    rm -f runtime/cache/9327087c625faf0124c02ef725c870c2.html runtime/cache/c0a4b572dc4ff789e9be825e8c8fc11b.html

    建议整个 runtime/cache、runtime/complile 清空;后台“清除缓存”功能走一遍。

  2. Web 层禁止执行:runtime/、log/、data/ 一律 deny(data 已有 403),并在这三个目录禁 PHP 解析(nginx location ~* ^/(runtime|log|data)/.*\.(php|html)$ { deny all; })。

  3. 排查根目录 10 位随机名 php(dropper 一旦被执行就会生成 [a-z0-9]{10}.php):

    1
    2
    find . -maxdepth 1 -name '*.php' -newermt '2026-10-25' -ls
    grep -rlE 'eval\(|base64_decode\(|getBasicsInfo' --include=*.php .
  4. 后台改密 + 审计 ay_user 表;WAF 加规则拦 <?、??>、%3C%3F、{pboot:if、file_put_contents 等(本次日志 UA 就是 pbootcms-multiroute-poc,很容易拦)。

我没动任何文件。如果你决定要我改,请指定改哪几条,我再动手。