WordPress REST API 批量请求路由混淆漏洞(CVE-2026-60137)深度剖析

摘要:本文分析 WordPress REST API 批量请求(Batch)端点中的一处"路由混淆"漏洞(CVE-2026-60137)。攻击者通过在批量请求中插入畸形路径,破坏 \requests、\\matches、$validation 三个数组的下标对齐,使 GET /wp/v2/categories 的请求被错误地交给 WP_REST_Posts_Controller::get_items() 处理,绕过参数校验并将 author_exclude 直接拼入 SQL,最终实现 SQL 注入。本文从批量调度机制出发,逐步还原"下标错位 → 路由混淆 → SQL 注入"的完整利用链。

本文内容结合个人理解与AI辅助学习整理而成,因个人认知有限,文中难免存在疏漏与冗余,如有不妥之处,欢迎大家交流指正,还望海涵。

漏洞详情

CVE-2026-60137 详见 NVD:NVD - CVE-2026-60137

参考

正常逻辑

  • 子请求、路由匹配结果、校验状态三者数组下标一一对应
  • 每条请求用对应的路由处理器和校验结果

漏洞根源

调度器遍历子请求生成三个数组时存在不一致:

数组 行为
$requests 所有子请求(包括畸形)都写入,占下标
$validation 所有子请求都写入,与$requests等长
$matches 只有解析成功的请求才写入,畸形请求跳过,不填充占位

validation** **的作用与错位后果** :正常情况下\\validation$i 保存\requests\[i] 的校验结果,执行时按 i 取出。**畸形请求被跳过、不给\\validation 占位后,\$validation 同样整体左移一位。 于是 categories 请求(原下标 1)在校验阶段读到的是 posts 请求(下标 2)的校验状态,而真正执行时又拿 posts 的 handler,两处错位叠加,最终让用 categories 参数清单放行"的 payload 被 posts 控制器执行。

当插入一条畸形路径(如 path: "http://"):

  • 它占用了\requests和\\validation的下标0
  • 但$matches没有对应元素,后续正常请求的匹配结果整体前移一位

利用效果

构造请求清单:

  • 请求 0:畸形路径 → 制造下标错位
  • 请求 1:/wp/v2/categories?author_exclude=注入payload

执行时:

  1. 请求1的分类接口没有 author_exclude 参数,其validate_callback / rest_validate_request_arg() 不识别该参数,payload 作为未知参数被放行
  2. 下标错位 导致请求1错误取到了下一条请求的路由匹配结果------文章接口 /wp/v2/posts 的处理器 get_items()
  3. 下标错位使请求1错误取到/wp/v2/posts的get_items() 处理器。
  4. get_items()读取author_exclude并映射为author__not_in;由于传入的是字符串而非数组,WP_Query走非数组分支,跳过array_map('absint',...)数字净化,字符串被直接拼接进WHERE子句,触发SQL注入。

注入链路细化:

  • author_exclude → author__not_in(字符串);
  • WP_Query 判断非数组 → 不净化;
  • 字符串直接进入 SQL WHERE ... AND post_author NOT IN (...);
  • 通过报错/时间/布尔回显差异判断注入点(可附最小复现请求与差异说明)。

payload 与角色分工

复制代码
{
  "requests": [                                          ── 外层 batch ──
    {"method": "POST", "path": "http://:"},  ← 外层探针(制造缺口的畸形请求)
    {"method": "POST", "path": "/wp/v2/posts", "body": { ← 受害者(伪装成 posts 的内层批量体)
      "requests": [                                      ── 内层 batch ──
        {"method": "GET", "path": "http://:"},           ← 内层探针(制造缺口的畸形请求)
 {"method": "GET", "path": "/wp/v2/categories?author_exclude=注入"},← 受害者(被注入的请求)
        {"method": "GET", "path": "/wp/v2/posts"}  ← 供货商(被偷 handler 的正常请求)
      ]
    }},
    {"method": "POST", "path": "/batch/v1"}              ← 供货商(被偷 handler 的正常请求)
  ]
}

每个函数作用

函数 作用
serve_batch_request_v1() 负责处理 Batch 中的一组子请求
wp_parse_url() 解析子请求的 URL/path
new WP_REST_Request() 把子请求转换成 WordPress 内部 Request 对象
match_request_to_handler() 根据请求找到应该执行的 REST handler
$matches[$i] 根据请求下标取得对应的 handler
get_items() Posts Controller 获取文章列表的业务处理函数
rest_validate_value_from_schema() 按照 REST 参数 schema 校验参数
WP_Query 根据查询参数构造并执行 WordPress 内容查询

为什么攻击者构造的请求,最终能够让一个"本来应该由 A 函数处理的请求",错误地交给 B 函数处理?

payload 中有两层 Batch:

复制代码
外层 Batch
    ├── [0] POST "http://"
    ├── [1] POST "/wp/v2/posts"
    │       └── body = 内层 Batch
    └── [2] POST "/batch/v1"

内层 Batch
    ├── [0] GET "http://"
    ├── [1] GET "/wp/v2/categories?author_exclude=注入"
    └── [2] GET "/wp/v2/posts"

真正要观察的是:

复制代码
请求
 ↓
WordPress 如何解析它?
 ↓
WordPress 如何找到应该处理它的函数?
 ↓
错误请求出现后,这个"请求 ↔ 处理函数"的对应关系有没有被破坏?
 ↓
如果破坏了,最终谁处理了这个请求?

第一站:serve_batch_request_v1()

普通 REST 请求流程:

  1. 收到 1 个 HTTP 请求
  2. 匹配到 1 条 REST 路由
  3. 调用对应的接口处理函数

Batch(批量)请求的特点:一次 HTTP 请求里,包含了多条子请求 (请求 0、请求 1、请求 2......)。 所以 WordPress 需要一个函数,完成这件事: Batch 内部包含的每一条子请求提取出来,逐个处理serve_batch_request_v1() 就是负责这项工作的函数。

此时:$batch_request代表整个 Batch 请求。


读取 Batch 里面的 requests

$batch_request'requests'

这里的作用非常简单:把 Batch 中的多个子请求取出来。

对于例子,它对应:

复制代码
requests[0]
requests[1]
requests[2]

也就是:

复制代码
[0] POST "http://"
[1] POST "/wp/v2/posts"
[2] POST "/batch/v1"

开始处理第一个子请求

接下来程序会遍历 Batch:

复制代码
foreach ( $batch_request['requests'] as $args )

第一次循环:

复制代码
$args
=
{
    "method": "POST",
    "path": "http://"
}

调用 wp_parse_url()

代码中会处理:

复制代码
$parsed_url = wp_parse_url( $args['path'] );

这个函数的作用是什么?

它不是漏洞函数。它只是:把字符串形式的 URL / 路径解析成 WordPress 可以进一步处理的 URL 信息。

例如正常:/wp/v2/posts解析后可以得到:path = /wp/v2/posts


第一个 "http://" 为什么特殊?

第一个请求是:

复制代码
{
    "method": "POST",
    "path": "http://"
}

程序于是执行:

复制代码
wp_parse_url( 'http://' )

结果无法得到正常的 URL 结构。

所以:$parsed_url会进入失败状态。这一步非常重要。


解析失败以后发生什么?

代码会判断:

复制代码
if ( false === $parsed_url )

作用:

发现这个 Batch 子请求的 path 根本无法解析,就不要继续把它当成正常 REST 请求处理。

然后程序会把一个:WP_Error放进请求结果数组。也就是说,

requests0最终变成了类似:requests0=WP_Error

然后continue;当前第 0 个请求处理到这里结束,直接进入下一个请求。

第二次循环:

复制代码
$args
=
{
    "method": "POST",
    "path": "/wp/v2/posts",
    "body": {...}
}

程序继续 foreach。这时候:$args'path'变成:/wp/v2/posts。这一次wp_parse_url()可以正常解析。于是程序继续向下执行。


WP_REST_Request:内部请求对象

WP_REST_Request 是干什么的?

这个类可以理解成:WordPress 对 HTTP 请求的"内部对象表示"。

WP_REST_Request是WordPress对HTTP请求的内部对象表示,保存method、path、query、body等。**Batch调度把每个子请求的字符串形式转换成WP_REST_Request,并把其中的body(含内层requests数组)一并塞入对象。**于是POST/wp/v2/posts最终携带了完整的内层Batch数据,为后续第二次错位埋下伏笔。

浏览器发送:POST /wp/v2/posts,WordPress 内部不会一直拿一个字符串处理。它会把请求转换成:WP_REST_Request 对象,里面保存:

复制代码
HTTP 方法
URL
GET 参数
POST 参数
Body
Headers
...

所以这一步实际上是在做:字符串形式的请求转为WordPress 内部 Request 对象


第二个请求此时变成什么?

POST /wp/v2/posts会变成一个:WP_REST_Request它的:method=POST,route=/wp/v2/posts

为什么还要处理 body

第二个请求非常特殊:

复制代码
{
    "method": "POST",
    "path": "/wp/v2/posts",
    "body": {
        "requests": [
            ...
        ]
    }
}

所以代码还需要把body放进刚才创建的WP_REST_Request

这一步的意义是:

/wp/v2/posts 这个 Request 对象携带构造的内层 Batch 数据。

所以此时WP_REST_Request实际上相当于:

复制代码
POST /wp/v2/posts

Body:
{
    "requests": [
        ...
    ]
}

第二轮循环写入 $requests\[\] 时,新元素下标就是 1


路由匹配:match_request_to_handler()

现在 WordPress 已经知道:"我这里有三个 Request。"但是还有一个问题:每一个 Request 到底应该交给谁处理?

例如:/wp/v2/posts应该交给WP_REST_Posts_Controller,而/batch/v1应该交给:Batch handler所以 WordPress 必须做一次路由匹配。


下标错位产生

调度器遍历\requests 数组时,逐项赋值给\\single_request:

第一项(畸形请求):is_wp_error() 返回 true,执行 continue跳过后续的 match_request_to_handler()

第二项(正常请求):is_wp_error() 返回 false,执行 \\match=\\this->match_request_to_handler(\ single_request),结果存入 matches\[\]

结果:畸形请求占用了\requests\[0\],但\\matches 没有对应占位,导致:

$requests 长度 = 3

$matches 长度 = 2

正常请求在\requests\[1\],匹配结果却在 matches0下标整体左移一位

说明:外层 Batch 共有 3 个子请求(requests0=畸形、requests1=posts、requests2=batch),posts handler 存入 matches0、batch handler 存入 matches1

这个函数的作用就是:根据 Request 的 URL、HTTP 方法等信息,寻找与它匹配的 REST 路由和 handler。


两个非常重要的数组

$requests

保存:实际收到的请求。

复制代码
requests[0] = WP_Error

requests[1] = /wp/v2/posts

requests[2] = /batch/v1

$matches

保存:这些请求分别匹配到了什么处理信息。

正常情况下应该是:

复制代码
requests[0] ↔ matches[0]

requests[1] ↔ matches[1]

requests[2] ↔ matches[2]

按下标分发:\matches\[i]

后面真正执行请求的时候,会按照请求的下标:

这里的i就是:0,1,2,然后代码会根据这个 `i` 取\matches\[ i ]

比如$i=1程序就认为:requests1应该对应:matches1这就是正常设计。


漏洞就是在这里体现出来

| 循环次数 | requests** **下标** | **是否写入** **matches |
| 第 1 次 | 0 | 跳过 |
| 第 2 次 | 1 | $matches0 = posts handler |

第 3 次 2 $matches1 = batch handler

注意:URL 没变。 仍然是:/wp/v2/posts变化的是:WordPress 给它选择的 handler。

所以这叫:Route Confusion(路由混淆)

/batch/v1 路由对应的处理器就是 Batch handler 方法。这个方法就是整个批处理请求的核心调度器。


为什么第一次混淆非常重要?

接口 /wp/v2/posts 在正常情况下,收到 POST 请求后,会交由 Posts Controller 进行处理**。但在发生第一次错位混淆之后,同样的 POST /wp/v2/posts 请求,会被交给 Batch handler 处理。**

此时该请求携带的请求体内容,格式为包含 requests 数组的 JSON 结构,这份内容会被识别为第二个 Batch 请求。

最终形成嵌套的执行关系:外层 Batch 发起请求,请求目标是 POST ,该请求实际进入 Batch handler,进而处理内层 Batch。/wp/v2/posts

为什么还要第二层 Batch?

因为第一次混淆解决的是:如何让一个看起来是 Posts 请求的请求,实际上进入 Batch handler。

**进入 Batch handler 后,WordPress 又会重新处理body.requests,,**也就是内层 Batch里面有:

复制代码
[0] GET "http://"

[1] GET "/wp/v2/categories?author_exclude=注入"

[2] GET "/wp/v2/posts"

于是相同的处理机制再次发生。


第二次 serve_batch_request_v1() 是干什么?

在上图,i=1:读取 $matches1,handler_batch这里就是递归入口:原始数组中下标 1 对应的是 posts 子请求,但由于数组错位,分发时错误读取到了 batch 路由的 handler

第二次调用 serve_batch_request_v1 (内层 batch 启动)

复制代码
$single_request/batch/v1respond_to_requestcall_user_func($handler['callback'], $request)
参数 值说明
$single_request 原始对应的子请求对象:这个请求本身的 path 仍然是,body 里携带内层 batch 数组$requests1 {"method":"POST","path":"/wp/v2/posts","body":{"requests":...}} /wp/v2/posts
$route /batch/v1 路由信息(和 handler 配套的路由)
$handler /batch/v1 的路由 handler, 'callback' =\> \[$this, 'serve_batch_request_v1']
$error null(无解析错误,因为 $handler 有效)

回调

注意:这不是"凭空又调用了一次",而是第一次路由混淆以后:POST /wp/v2/posts被错误地交给serve_batch_request_v1()这个函数看到body.requests,于是它自然又开始处理第二层 Batch。


进入第二层后再次解析 "http://"

复制代码
"body": { ← 受害者(伪装成 posts 的内层批量体)
      "requests": [                                   
        {"method": "GET", "path": "http://:"},           ← 内层探针(制造缺口)
        {"method": "GET", "path": "/wp/v2/categories?author_exclude=注入"},  ← 受害者(注入)
        {"method": "GET", "path": "/wp/v2/posts"}        ← 供货商(被偷 handler)
      ]
    }

这一次:

复制代码
$args['path']=http://

再次经过wp_parse_url(),解析失败。于是又产生WP_Error,然后又影响:requests,matches,validation之间的对应关系。

校验

处理1个\single_request→生成\\match→校验+清洗→存入数组→再循环下一个

校验时用 categories 的参数清单(不认 author_exclude,放行原始 payload),调度错位后交给 posts 控制器执行(识别 author_exclude 并送入 WP_Query),WP_Query 的数组判断分支跳过数字消毒,实现 SQL 注入。

结果:

循环次数 $requests 下标 是否写入 $matches
第 1 次 0 跳过(path 非法,匹配失败,生成 WP_Error,不 push 进 $matches)
第 2 次 1 $matches0 = categories handler
第 3 次 2 $matches1 = posts handler

第二次错位的真正目标

第二个内层请求,请求内容为 GET /wp/v2/categories?author_exclude = 注入。这个请求在正常情况下,会匹配分类相关路由,交由 Categories Controller 处理。但是因为第二次索引错位,最终会出现执行逻辑错乱的情况。 请求本身依旧是 GET /wp/v2/categories?author_exclude = 注入,实际执行的处理器却变成了 WP_REST_Posts_Controller::get_items ()。

在路由混淆漏洞的触发路径下,恶意请求不会使用 posts 接口的 Schema 校验规则,校验阶段使用 categories 接口的参数清单,作为未知参数未被净化保留,后续才交给 posts 控制器执行author_exclude

这也是整条漏洞利用链里真正关键的一步。


整个过程现在可以这样理解

复制代码
外层 Batch → serve_batch_request_v1()→ 畸形请求(0) 产生 WP_Error → 下标错位
→ POST /wp/v2/posts 误得 batch handler → 递归进入内层 Batch
→ 内层畸形请求(0) 再错位 → GET /wp/v2/categories 误得 posts handler
→ get_items() → author_exclude → author__not_in(字符串) 
→ WP_Query 未净化 → SQL 注入