记一次pbootcms系统修复
记一次pbootcms系统修复
结论
攻击能够生效,根本原因是 View::cache() 的“剥离 PHP 标签”正则会把 <<??= 重新拼成 <?=,再把这段内容写进一个会被 include 执行的 .html 缓存文件。攻击者只需要发两次同一个 URL。
data/tempp3.php 现在还在,说明 dropper 本身没有被请求执行过,但 payload 确实已经执行过了(备份里 07:23 那两个 cache 文件就是执行点)。
完整链路
攻击者请求
/contact/?s=<<??=file_put_contents('data/tempp3.php',base64_decode('...'))??>>模板里的
{pboot:pageurl}被替换成get_current_url(),把 URL 原样、不转义输出到og:url由于
s参数在全站从不被读取(只有 3 处unset),所以它不会报错;关键在query_string('p,s'):它先把p、s删掉再判断是否为空,如果只剩p、s,结果就是空串 → 因此带s也会写缓存写缓存前执行剥离:
core/view/View.php:1761
preg_replace('/<\?(php|=)?|\?>|<%|%>|<script...>/i','',$content)
<<??=→ 删掉第 1–2 位的<?→ 剩<+?==<?=??>>→ 删掉?>→ 剩?+>=?>- 也就是说,这个用于清洗的函数反而拼出了可执行的 PHP 短标签,然后它被写进
runtime/cache/<md5>.html
内核命中缓存,直接
include这个.html(这正是它要剥离标签的原因)→ 第二次请求时 payload 执行,写出data/tempp3.php文件 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:先堵这条链(改任意一条即断,建议全改)
关闭带查询串的页面缓存
View.php:169改为:1
if (Config::get('tpl_html_cache') && ! $_SERVER['QUERY_STRING']) {
带任何
?参数的 URL 一律不落缓存,直接消除“种缓存”的可能。剥离器不应“删除”,要“转义 + 校验”
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 标签,不落缓存
}根本做法是把
<?、?>替换成<?、?>,而不是删掉——删掉必然能被构造出新的标签。{pboot:pageurl}输出转义ParserController.php:186:1
$content = str_replace('{pboot:pageurl}', htmlspecialchars(get_current_url(), ENT_QUOTES, 'UTF-8'), $content);
这条单独就能挡住本次攻击(
<<??=会先变成<<??=)。
P1:消除同类 RCE 面
模板名白名单 + 禁止绝对路径 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白名单写法
- core/view/View.php:118 的
编译期条件白名单
core/view/Parser.php:283validateIfCondition()是黑名单,可用use function fputs as test;绕过 → 改成与运行时ParserController::validateIfCondition()一致的白名单。若业务不用,禁用
{php}(Parser.php:433)和{fun=}(Parser.php:250),或至少对函数名走白名单。?tag=参数做整数/白名单校验(历史注入点)。
P2:减损与现场排查
删恶意缓存(必须做,否则同一 URL 再被访问还会执行):
1
rm -f runtime/cache/9327087c625faf0124c02ef725c870c2.html runtime/cache/c0a4b572dc4ff789e9be825e8c8fc11b.html
建议整个
runtime/cache、runtime/complile清空;后台“清除缓存”功能走一遍。Web 层禁止执行:
runtime/、log/、data/一律 deny(data 已有 403),并在这三个目录禁 PHP 解析(nginxlocation ~* ^/(runtime|log|data)/.*\.(php|html)$ { deny all; })。排查根目录 10 位随机名 php(dropper 一旦被执行就会生成
[a-z0-9]{10}.php):1
2find . -maxdepth 1 -name '*.php' -newermt '2026-10-25' -ls
grep -rlE 'eval\(|base64_decode\(|getBasicsInfo' --include=*.php .后台改密 + 审计
ay_user表;WAF 加规则拦<?、??>、%3C%3F、{pboot:if、file_put_contents等(本次日志 UA 就是pbootcms-multiroute-poc,很容易拦)。
我没动任何文件。如果你决定要我改,请指定改哪几条,我再动手。
.jpg)
