ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

WordPress 7.0.1 嵌套 Batch 路由错位实现 author_exclude SQL 注入(CVE-2026-60137)全程调试

WordPress 7.0.1 嵌套 Batch 路由错位实现 author_exclude SQL 注入(CVE-2026-60137)全程调试 本文内容结合个人理解与 AI 辅助学习整理而成知识产权归个人所有。因个人认知有限文中难免存在疏漏与冗余如有不妥之处欢迎大家交流指正还望海涵。未经许可严禁复制、搬运、二次转载。1. 漏洞背景与 CVE 对应关系CVE内容影响版本修复版本CVE-2026-60137GHSA-fpp7-x2x2-2mjfWP_Query的author__not_in参数未正确清洗导致的 SQL 注入本文最终注入点6.8.0–6.8.5、6.9.0–6.9.4、7.0.0–7.0.16.8.6 / 6.9.5 / 7.0.2CVE-2026-63030GHSA-ff9f-jf42-662qREST APIbatch 路由混淆错位配合上面 SQLi → 可 RCE6.9.0–6.9.4、7.0.0–7.0.16.9.5本文调试用的版本是7.0.1整条利用链一句话概括直接注入 /wp/v2/posts 的 author_exclude → 被 schema 校验拦截上一篇已验证 │ ▼ 用「双层嵌套 batch」制造路由错位 校验阶段 → 按 categories 路由清洗categories 没有 author_exclude放行 执行阶段 → 交给 posts 的 get_items把 author_exclude 映射成 author__not_in 传给 WP_Query │ ▼ WP_Query 对「字符串类型」的 author__not_in 不做 absint 清洗 → 直接拼进 SQL → 时间盲注2.样本batch批次BatchWordPress/batch/v1批量接口接口地址/wp-json/batch/v1WordPress5.6新增核心定位把多个独立REST子请求打包进1个HTTP请求一次性发给服务端服务端依次执行所有子请求一次性返回全部结果path子请求的请求路径。 作用用于路由匹配寻找对应的处理 handler。{ requests: [ {method: POST, path: http://:}, {method: POST, path: /wp/v2/posts, body: { requests: [ {method: GET, path: http://:}, {method: GET, path: /wp/v2/categories?author_excludeSELECTIF%28ASCII%28SUBSTRING%28%28SELECTuser_passFROMwp_usersWHEREuser_login%3D%27admin%27%29%2C1%2C1%29%29%3E30%2CSLEEP%281%29%2C0%29}, {method: GET, path: /wp/v2/posts} ] }}, {method: POST, path: /batch/v1} ] }URL 解码后真正的注入 payload 是author_excludeSELECT IF(ASCII(SUBSTRING((SELECT user_pass FROM wp_users WHERE user_loginadmin),1,1))30,SLEEP(1),0)2.1逐字段理解这个样本外层batch3个子请求[0] POST http://: —— 故意传一个非法路径让 wp_parse_url() 解析失败、生成一个 WP_Error用来占位制造下标错位后面详细讲。[1] POST /wp/v2/postsbody 里塞了内层 batch—— 这是夹带私货的请求内层 batch 被塞进它的 body。[2] POST /batch/v1 —— 一个普通占位请求。内层batch3个子请求[0] GET http://: —— 同样是非法路径制造 WP_Error 占位。[1] GET /wp/v2/categories?author_exclude... ——真正的注入载体把 author_exclude 放在 categories 的 URL Query 里。[2] GET /wp/v2/posts —— 占位用来让错位把 get_items 这个 handler 送上门。注入 payload 是一个时间盲注ASCII(SUBSTRING((SELECT user_pass FROM wp_users WHERE user_loginadmin),1,1)) 30 若为真就执行 SLEEP(1)响应慢 1 秒否则立即返回。通过不断调整阈值 30可以二分出 admin 密码哈希的每一个字符。2.2 serve_batch_request_v1的三阶段处理函数serve_batch_request_v1()分三阶段阶段一解析源码1712–1738行——把每个子请求的path用wp_parse_url()解析解析失败就压入一个WP_Error成功则new WP_REST_Request(method,path)并把query、body、headers塞进请求对象。注意WP_Error和正常请求混在同一个$requests数组里且都占下标。阶段二匹配校验源码1744–1798行——遍历\$requests逐个match_request_to_handler()得到\$matches并做has_valid_params()/sanitize_params()得到\$validation。漏洞根因就在这一段。阶段三执行源码1820–1864行——再遍历一次\$requests用\$matches[$i]取handler去respond_to_request()。错位在这里生效。3. 几个问题问题一为什么要套两层batch既然内层也会错位可不可以只写一层单层batch仅能制造一次数组错位存在2个硬障碍无法完成注入顶层/batch/v1路由自带 JSON Schema 校验一级子请求不允许GET方法无法把author_exclude放在 URL Query 传递即使改用POST方式传参错位后要么参数直接丢失要么当前路由handler不存在author_exclude的映射逻辑无法进入WP_Query注入点单层时能用 GET和能到达 get_items两个条件同时被顶层 schema 卡死所以必须套一层把GET 请求藏进内层 batch 里。properties属性method 的 enum 是 POST/PUT/PATCH/DELETE没有 GET。body 的 properties 为空、additionalProperties truebody 里放什么都不会被校验。问题二既然batch只能接收post为什么第二个batch传递的可以是get因为method的enum限制只作用于最外层那次真正发到/batch/v1的请求而内层batch是被走私进body的外层请求的body属性在schema里是propertiesarray()空additionalPropertiestrue意味着body内的内容完全不被校验可以放任意请求包括method为GET的请求。当错位让serve_batch_request_v1被递归地再次调用去处理body里的内层batch时代码是直接读$args[method]去new WP_REST_Request()的不会重新套用顶层enum限制所以内层的method:GET畅通无阻。问题三为什么内层要是get注入点只挂在GET上。只有get_itemslist操作里才有author_excludeauthor__not_in的参数映射并最终调用WP_Query。create_item/update_item/delete_item都不处理author_exclude。match_request_to_handler()是按method匹配handler的if(empty(\$handler[methods][\$checked_method]))continue;。只有GET才能匹配到get_items这个handlerPOST只会匹配到create_item。错位需要送handler的那一步是GET。内层[2]GET /wp/v2/posts的作用就是让\$matches[1]落到posts的get_items。如果[2]用POST\$matches[1]就会是create_item注入就断了。同时[1]的author_exclude必须放在URLQuery里GET才有query这样才会随categories请求对象一起被get_items读到。简单来讲就是顶层不让 GET → 塞进body走私body里能用GET是因为不受enum限制必须用GET是因为注入点get_items只有 GET 能命中。4. 调试4.1 上传完整的样本把上面那段嵌套batch的JSON作为请求体POST到/wp-json/batch/v1可带?rest_route/batch/v1。这是整个利用链的起点。外层请求从dispatch()入口一路走到/batch/v1的callback即serve_batch_request_v1这段流程与上一篇一致rest_api_loaded→new WP_REST_Request→dispatch→match_request_to_handler→校验→清洗此处略过。4.2 第一次进入dispath这是最外层请求进入dispatch()随后会匹配到/batch/v1路由执行它的callbackserve_batch_request_v1。4.3 第一次参数清洗外层请求本身的参数requests数组、validation在这一步做校验与清洗。因为外层请求体里三个子请求的method都是POST、path都是字符串符合顶层schema所以外层全部通过。下两图在清洗/校验回调rest_validate_request_arg()内部\$value待校验的请求参数值\$args当前参数对应的schema校验规则数组type、enum等配置\$param参数名称校验出错时用于提示报错信息。此时进入rest_validate_value_from_schema()判断参数类型。因为外层参数requests的type是array所以命中switch的casearray分支进入数组类型参数的专门校验。外层所有子请求都合法所以这里全部通过。第一次参数清洗由于外层全部没问题所以全部通过现在把请求正式交给对应的处理函数执行并把执行结果保存到$response。4.4 第一次进入serve_batch_request_v1外层清洗通过后dispatch()调用serve_batch_request_v1()。这里开始进入本文的核心错位的制造与利用。4.4.1 第一次错位处理的是最外层的 batch_requestserve_batch_request_v1阶段一把最外层requests里的3个子请求逐个解析。第0个http://:解析失败→变成WP_Error后两个正常→生成WP_REST_Request对象。于是\$requests里有3个元素1个错误对象2个正常对象且错误对象仍占着下标0。这两张图是解析完成后\$requests里3个元素的route/method情况\$requests[0]WP_Errorpath http://:解析失败\$requests[1]POST /wp/v2/postsbody里带着内层batch\$requests[2]POST /batch/v1。4.4.2外层batch第一次获取对应​requests[1])进入阶段二对上一轮生成的三个请求对象含错误对象进行路由匹配和验证。注意这里开始出现下标错位的苗头——$requests[0]是WP_Error会被单独处理。4.4.3 数据清洗$request[1]对\$requests[1]POST/wp/v2/posts做清洗。它的body是内层batch的{requests:[...]}而posts路由的schema里没有requests这个参数所以rest_validate_request_arg()对找不到schema的参数直接continue、返回true放行。所以此处\$validation[1]true第一个请求WP_Error直接标记为错误进入\$validation数组第二个请求/wp/v2/posts匹配到postshandlerallow_batchv1true参数校验/清洗通过\$validation[1]true第三个请求/batch/v1匹配到batchhandler同样通过\$validation[2]true。此时的$requests[0]WP_Error、[1]posts请求、[2]batch请求共3个此时的\$match[]此时的\$matches只有2个元素\$matches[0]posts的handler对应\$requests[1]\$matches[1]batch的handler对应\$requests[2]。原因\$requests[0]是WP_Error在阶段二的循环里走了\$validation[]...;continue;分支没有往\$matches里push任何东西。于是\$matches比\$requests少一个、整体左移一位下标错位由此产生。4.5 实际执行请求的阶段4.5.1 遍历子请求第二轮 i1进入阶段三执行foreach(\$requests as \$i \$single_request)。i0是WP_Error直接返回错误响应、continue来到i1$requests[1]POST/wp/v2/posts。执行阶段用\$match\$matches[\$i]取handler。此时\$i1而\$matches[1]batch的handler不是posts的handler。于是$requests[1]本该交给posts被错位交给了batch的callback。4.5.2 respond_to_request() 真正执行业务逻辑respond_to_request()的作用调用路由处理器中定义的 callback 回调函数如 serve_batch_request_v1将 \$single_request 对象作为参数传入执行回调函数并返回结果数组下标错位使得回调函数变成了serve_batch_request_v1——也就是说posts请求被当成batch请求再次执行。此处单步进入\ $this-respond_to_request再次回调server_batch_request_v1去处理posts请求body里夹带的内层batch。4.5.3 递归进入内层batch同样的错位进入内层batch后流程和外层一模一样\$requests[0]是WP_Errorhttp://:第一次还是错位\$requests[1]、$requests[2]正常处理。此处内层最终的$requests\$requests[0]WP_Errorpathhttp://:解析失败\$requests[1]GET/wp/v2/categories?author_exclude...携带注入payload\$requests[2]GET/wp/v2/posts。4.6 再次进行路由匹配和验证4.6.1 参数清洗 $requests[1]为什么通过因为此时是按categories路由的schema来清洗的而categories路由的schema里没有author_exclude这个参数。rest_validate_request_arg()对找不到schema的参数直接放行返回truesanitize_params()同理不做任何处理。于是注入payload原样保留、未被清洗。4.6.2 根据 request 找 matches最终结果\$matches里只有两个值\$matches[0]categories的handler对应\$requests[1]\$matches[1]posts的get_items对应\$requests[2]。这就是错位查询的关键点\$requests[0]是WP_Error没往\$matches里push导致\$matches左移一位\$matches[1]实际是posts的get_items。4.7 接着 respond_to_request() 再次执行业务逻辑这三张图是执行阶段错位生效的过程\$i1时取\$matches[1]posts的get_items于是categories请求带着author_exclude被交给posts的get_items执行。4.8 最终进入get_items错位效果生效实际进入的是WP_REST_Posts_Controller::get_items()。author_exclude映射为author__not_in现在用的是posts控制器来控制categories的参数清洗参数时用的是categories——因为categories没有author_exclude所以没有清洗绕过处理参数时用的是posts——posts的get_items有author_exclude→author__not_in映射。一句话清洗用categories放行处理用posts拼接。4.9 创建查询对象进入查询get_items把$args含author__not_in注入字符串组装成WP_Query参数创建查询对象。进入get_posts()查询因为传进来的author__not_in是字符串而不是数组is_array()为false跳过了absint清洗直接把字符串拼进了SQL。这就是CVE-2026-60137的根因。对比author__in分支源码2411行无论如何都会array_map(absint,...)所以author__in是安全的author__not_in有洞。5. 最终盲注拼接成功执行查询最终拼接出的SQL这三张图是SQL实际执行、根据响应时间差异逐字符盲注提取admin密码哈希的过程。至此从嵌套batch→错位→绕过校验→SQL拼接→盲注整条链打通。6. 漏洞本质与官方修复6.1 两处修复对照 7.0.2修复 1路由错位CVE-2026-63030在serve_batch_request_v1阶段二里WP_Error分支补了一行\\$matches[]\$single_request;让\$matches与\$requests下标重新对齐// 修复前7.0.1 if ( is_wp_error( $single_request ) ) { $has_error true; $validation[] $single_request; continue; // ← 没往 $matches 里 push导致错位 } ​ // 修复后7.0.2 if ( is_wp_error( $single_request ) ) { $has_error true; $matches[] $single_request; // ← 新增这一行保持 $matches 与 $requests 对齐 $validation[] $single_request; continue; }修复 2SQL 注入CVE-2026-60137WP_Query里对author__not_in的处理改为统一用wp_parse_id_list()解析成整数列表// 修复前7.0.1 if ( ! empty( $query_vars[author__not_in] ) ) { if ( is_array( $query_vars[author__not_in] ) ) { $query_vars[author__not_in] array_unique( array_map( absint, $query_vars[author__not_in] ) ); sort( $query_vars[author__not_in] ); } $author__not_in implode( ,, (array) $query_vars[author__not_in] ); $where . AND {$wpdb-posts}.post_author NOT IN ($author__not_in) ; } ​ // 修复后7.0.2 if ( ! empty( $query_vars[author__not_in] ) ) { $author__not_in_id_list wp_parse_id_list( $query_vars[author__not_in] ); // ← 无论数组/字符串都解析成整数 if ( count( $author__not_in_id_list ) 0 ) { sort( $author__not_in_id_list ); $where . sprintf( AND {$wpdb-posts}.post_author NOT IN (%s) , implode( ,, $author__not_in_id_list ) ); $query_vars[author__not_in] $author__not_in_id_list; } }7. 总结一句话结论直接向/wp/v2/posts提交author_exclude会被schema校验拦下本漏洞通过双层嵌套batch制造路由下标错位让请求校验阶段按categories放行、执行阶段交给posts的get_items拼接最终把未清洗的author__not_in字符串拼进WP_Query的SQL完成时间盲注。完整链路回顾外层/batch/v1请求的3个子请求里[0]http://:解析失败产生WP_Error占位serve_batch_request_v1阶段二里WP_Error分支不push$matches导致\$matches比\$requests左移一位错位执行阶段\$matches[\$i]取错handler外层\$requests[1]POST/wp/v2/posts被当成batch递归执行posts请求body里夹带的内层batch被解析内层同样用[0]http://:制造一次错位内层\$requests[1]categoriesauthor_exclude清洗时走categoriesschema无author_exclude放行执行时\$matches[1]落到posts的get_itemsget_items把author_exclude映射成author__not_in传给WP_QueryWP_Query对字符串类型的author__not_in跳过absint直接implode进NOTIN(...)→SQL注入SELECTIF(ASCII(SUBSTRING(...))N,SLEEP(1),0)时间盲注逐字符拖库。防御要点author__not_in必须像author__in一样无条件做整数化wp_parse_id_list/absintbatch处理时\$matches、\$validation、\$requests三个数组必须下标对齐任何continue都要同步维护占位。
返回列表