ARTICLE · 1089359
WordPress 页面模板存在文件包含漏洞(POC已公开)CVE-2026-87902

一、漏洞描述
WordPress 的页面模板从一组候选名里挑,主题目录下命中第一个存在的就直接用。
CVE-2026-87902 出在这组候选名的拼接上。匿名请求送进来的 pagename 值先过清洗,之后才被解码,解码出来的 ../ 段把候选名带到了主题目录之外。
触发要凑几样东西。主题目录根下得有一个名字以 page- 开头的顶层目录,存在即可,不要求可写。页面由数字 page_id 选中,且没有指定自定义模板。目标 php 文件存在、Web 账号读得到。
官方点名了自带这类目录的老主题,Twenty Twelve 与 Twenty Fourteen,还有 Neve、Hestia、Sydney 几个第三方主题。
走到代码执行还要再凑三样,服务器上有可读的 pearcmd.php,PHP 的 register_argc_argv 这个开关是打开的,另外还得有一个能写的目录,三样缺一不可。
公告提到官方 PHP 镜像与 PHP 8.5 之前的 cPanel 默认配置落在这几条里。
公开验证脚本在 7.0.2 加 PHP 8.3.33 的容器里跑通了整条链,两次匿名 POST,写出的文件 1219 字节,标记在响应里出现 12 次。
二、漏洞原理
请求从 parse_request 进来。pagename 和 page_id 两个都是公开查询变量,表单体里带的值会被读进查询对象,匿名就能送。
清洗先走一遍。class-wp-query.php 把 pagename 交给 wp_basename 处理,本意是砍掉路径里的目录部分。
能穿过清洗是因为编码套了两层。请求体里的 %252f 被 PHP 解一层成 %2f,wp_basename 又 urlencode 了一遍,替换斜杠用的 %2F 就对不上了。
// wp-includes/formatting.php 7.0.2
function wp_basename( $path, $suffix = '' ) {
return urldecode( basename( str_replace( array( '%2F', '%5C' ), '/', urlencode( $path ) ), $suffix ) );
}
// wp-includes/formatting.php 7.0.2 sanitize_title_with_dashes 开头三步
$title = preg_replace( '|%([a-fA-F0-9][a-fA-F0-9])|', '---$1---', $title );
$title = str_replace( '%', '', $title );
$title = preg_replace( '|---([a-fA-F0-9][a-fA-F0-9])---|', '%$1', $title );
sanitize_title_for_query 落到 sanitize_title_with_dashes,开头三步把百分号加两位十六进制的写法保护起来,只清掉落单的百分号,其余字符过一遍白名单。
%2f 与 %2e 就整体留了下来,句点替换碰不到。
本机按 7.0.2 的源码把这两个函数抄出来跑了一遍,下面是从请求体一路走到候选名的结果。
请求体里的 pagename templates%252f%252e%252e%252f...%252fpearcmd
PHP 解一层之后 templates%2f%2e%2e%2f...%2fpearcmd
清洗之后 templates%2f%2e%2e%2f...%2fpearcmd
模板处 urldecode templates/../../../../../../../usr/local/lib/php/pearcmd
拼上前缀与后缀 page-templates/../../../../../../../usr/local/lib/php/pearcmd.php
页面本身也得查得出来。page_id 那一段给 where 用的是赋值,前面按 pagename 查不到留下的条件被整段覆盖,查询照旧落在真实的已发布页面上,pagename 的值原样留在查询对象里。
// wp-includes/class-wp-query.php 7.0.2 2266 行起
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'];
}
}
到拼模板名那一步,get_page_template 对 pagename 做了一次 urldecode,解码前后不一样就把解码后的值也加进候选。
// wp-includes/template.php 7.0.2 get_page_template()
if ( $pagename ) {
$pagename_decoded = urldecode( $pagename );
if ( $pagename_decoded !== $pagename ) {
$templates[] = "page-{$pagename_decoded}.php";
}
$templates[] = "page-{$pagename}.php";
}
// wp-includes/template.php 7.0.2 locate_template()
if ( file_exists( $wp_stylesheet_path . '/' . $template_name ) ) {
$located = $wp_stylesheet_path . '/' . $template_name;
break;
}
// wp-includes/template-loader.php 7.0.2 116 行起
$template = $is_stringy ? realpath( (string) $template ) : null;
if (
is_string( $template ) &&
( str_ends_with( $template, '.php' ) || str_ends_with( $template, '.html' ) ) &&
is_file( $template ) &&
is_readable( $template )
) {
include $template;
}
候选名拼到主题根目录后面,locate_template 只问文件在不在,找到就停下。
template-loader 拿到后做一次 realpath,接下去查后缀、是不是文件、读不读得到。路径是否还在主题目录内这一步被跳过了。
本机复算这一段,拼出来的路径在 file_exists 里返回真,realpath 落在主题目录之外,带 .. 的路径被原样接受,include 就此发生。
用 POST 也是必需的。redirect_canonical 开头就对非 GET 与 HEAD 的请求直接返回,规范化跳转不做,查询串得以原样留给 PEAR 拆参数。
代码执行那一跳借的是 PHP 自带的老包管理组件 PEAR。
pearcmd.php 被包含进来后,config-create 把查询串里带的参数写进一份配置文件,写入内容里可以带 PHP 开始标记,第二次请求再把这份文件包含进来。
载荷里引号全部省掉。Web 层的转义会把引号变成带反斜杠的形式,脚本改用 chr 拼字符串绕开这一步。
PEAR 要求 config-create 的根路径是绝对路径,脚本索性把要执行的 PHP 整体塞进这个路径,PEAR 序列化配置时把它原样写进文件。
补在 7.0.6 与 7.1.2 里的改动分两处。
// wp-includes/template.php 7.0.6 get_page_template()
if ( $pagename_decoded !== $pagename && 0 === validate_file( $pagename_decoded ) ) {
$templates[] = "page-{$pagename_decoded}.php";
}
// 7.0.6 新增 _wp_is_template_path_allowed(),先查 .. 再比真实路径
if ( 0 === preg_match( '#(?:^|/)\.\.[. ]*(?:/|$)#', wp_normalize_path( $path ) ) ) {
return true;
}
// 命中 .. 时改用 realpath 与主题目录比对,两侧都带尾斜杠
if ( str_starts_with( $real_path, trailingslashit( wp_normalize_path( $real_directory ) ) ) ) {
return true;
}
validate_file 在这里起作用,路径里出现两次以上的 ../ 返回 1,解码后的候选名直接被丢掉。
第二处落在 locate_template。原来的写法命中第一个存在的文件就 break,改完之后先收集候选,再用新函数逐个判一次,不通过的继续往下找。
三、修复建议
按所在分支升到对应版本,7.1 线升 7.1.2,7.0 线升 7.0.6,6.9 线升 6.9.9,6.8 线升 6.8.10。更早的分支查公告里对应的那一行,最低是 4.7.37。