尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

三道防线同时失效——WordPress batch 端点“校验身份与执行身份分裂”漏洞深度解析

三道防线同时失效——WordPress batch 端点“校验身份与执行身份分裂”漏洞深度解析 三道防线同时失效——WordPress batch 端点“校验身份与执行身份分裂”漏洞深度解析漏洞类型REST API 路由混淆 → SQL 注入时间盲注影响组件WordPress REST API/batch/v1端点分析基础WordPress 6.9.4 源码本文仅作原理分析与安全学习请勿用于未授权测试。0x00 最有意思的地方没有一道防线写错这个漏洞最值得琢磨的不是绕过了哪个 WAF、哪个过滤函数而是——三道独立的防线一道都没写错却同时失效。因为它们守的都不是那扇门。数据校验的经典假设是“进来的每个参数都会被校验规则覆盖到。”这个漏洞用一套“偷天换日”打破了这个假设校验的时候用 A 路由的参数表点名执行的时候调的却是 B 路由的处理器。注入参数不在任何一张名单上——三道看门狗全部“查无此人”。我把它称为“校验身份与执行身份分裂”。下面从头拆。0x01 预备知识参数校验表是怎么“挂”到请求上的WordPress REST API 的每个路由在注册时register_rest_route都会挂一张参数说明书schemaregister_rest_route(wp/v2,/posts,array(array(methodsWP_REST_Server::READABLE,callbackarray($posts_controller,get_items),args$posts_controller-get_collection_params(),// ← schema 挂在这),));运行时路由匹配成功后这张表会被挂到请求对象上$request-get_attributes()[args]随后的has_valid_params()校验和sanitize_params()清洗都照它逐项执行。以 posts 路由为例author_exclude参数的 schema 是author_excludearray(typearray,itemsarray(typeinteger),// 每个元素必须是整数),记住一个伏笔这张表跟着“路由匹配结果”走——谁匹配的请求就挂谁的表。后面的整个漏洞链都建立在这句话上。0x02 正常情况下防线是有效的直接注入必死先验证防御正常工作的样子。直接请求GET /wp-json/wp/v2/posts?author_excludeSELECT IF(ASCII(SUBSTRING((SELECT user_pass FROM wp_users WHERE user_loginadmin),1,1))30,SLEEP(0.5),0)结果HTTP 400rest_invalid_param。注入串连控制器的面都见不到。失效过程schema 声明typearray校验函数先把字符串按空格/逗号preg_split拆成数组——注入串被拆成十几个碎片items integer触发逐元素校验第一个碎片SELECT就不是数字报错指纹很说明问题author_exclude[0] is not of type integer——[0]证明死在数组元素级递归校验上结论schema 校验本身没有缺陷。想注入就不能走正门。0x03 根因batch 派发器的两段循环对不上/batch/v1端点允许一次 POST 提交多个子请求批量执行处理函数是WP_REST_Server::serve_batch_request_v1()。它的结构是两段 foreach// ── 阶段一校验每个子请求按自己的路由匹配 校验 ──foreach($requestsas$single_request){if(is_wp_error($single_request)){$validation[]$single_request;continue;// ★ 失败项不进 $matches}$match$this-match_request_to_handler($single_request);// 挂自己的参数表$matches[]$match;// ... has_valid_params() sanitize_params() ...}// ── 阶段二执行按下标取 $matches[$i] 派发 ──foreach($requestsas$i$single_request){$match$matches[$i];// ★ 错位点list($route,$handler)$match;$result$this-respond_to_request($single_request,$route,$handler,$error);}问题在哪两个循环的数组长度可能不一致。阶段一中解析失败的子请求会continue跳过不进入$matches——于是$matches比$requests短了一格。阶段二却仍然按下标一一对应地取值全体左移阶段一校验循环谁出错谁出局不进 matches requests[0] 探针 http://: → wp_parse_url 解析失败 → continue requests[1] 受害者 → 按自己路由匹配 → matches[0] requests[2] 供货商 → 按自己路由匹配 → matches[1] ↑ matches 比 requests 短一格 阶段二执行循环按下标取 match requests[1] 受害者 → matches[1] 供货商的 match ★ 偷换发生在这里 requests[2] 供货商 → matches[2] 不存在 → 报错利用已完成无害尾巴于是出现了本文的核心命题——身份分裂校验阶段受害者请求挂着自己的路由参数表被校验执行阶段它却被派发到供货商路由的处理器上执行。校验用的身份和执行用的身份不是同一个。http://:这个看似无意义的 URL 就是“探针”它唯一的作用就是解析失败、制造那个缺口。一个探针 把整条派发流水线往后推一格。0x04 三条铁律逼出 payload 的形状利用不是拍脑袋写的是被三条硬约束逼出来的铁律来源后果① 注入必须走 GETquery string注入点在author_exclude参数请求必须是 GET 集合请求② batch 顶层子请求 method 只有 POST/PUT/PATCH/DELETErequests参数的 schema 枚举GET 不能直接当顶层子请求否则整个 batch 直接 400③ 一次错位只能偷换一次 handler错位机制本身攻击需要两次偷换 → 嵌套两层三条合起来payload 的形状就唯一了把 GET 们藏进一个 POST 的 body 里再套两层 batch。0x05 完整利用链双重偷换{requests:[{method:POST,path:http://:},{method:POST,path:/wp/v2/posts,body:{requests:[{method:GET,path:http://:},{method:GET,path:/wp/v2/categories?author_exclude注入串},{method:GET,path:/wp/v2/posts}]}},{method:POST,path:/batch/v1}]}每一层都是同一个三人组配方探针制造缺口→ 受害者偷到下一个的 handler→ 供货商被偷。外层偷换探针让/wp/v2/posts请求偷到/batch/v1的 handler即serve_batch_request_v1本身——这个“创建文章”的 POST 被劫持成了“批量派发器”藏在它 body 里的内层 requests 才被执行。内层偷换探针让categories请求偷到posts的 handlerget_items——带着author_exclude注入参数的请求被 posts 的代码处理。两个关键追问Q1body 里藏 batchschema 为什么不拦看 body 参数的 schema 定义bodyarray(typeobject,propertiesarray(),// 空的additionalPropertiestrue,// 不深入校验内容),顶层校验只确认“body 是个 object”就放行了根本不往下看。一句话限制卡在“schema 点名”这一关而内层 requests 从没被点过名。Q2内层全是 GET不违反 method 枚举吗枚举限制是顶层requests参数的 schema 校验行为发生在外层派发前。内层 requests 来自 body从未经过那道校验而serve_batch_request_v1构造内层请求时直接采用数组里的 method无二次枚举——内层 GET 畅通无阻。0x06 三道防线逐一失效点现在把三道防线放在一起看每一道自身逻辑都正确防线位置失效原因① 校验has_valid_params()WP_REST_Request迭代categories 的参数表逐项点名——表里只有分类相关的十几个参数author_exclude不在其中循环根本轮不到它② 清洗sanitize_params()同上关键一行if (!isset($attributes[args][$key])) continue;清洗是迭代表、不是迭代输入——表外参数直接continue既不验也不洗却随请求对象原样保留③ SQL 层absint兜底WP_Query 的author__not_in处理块if ( is_array(...) )为假注入串是字符串→ absint 整段跳过 → 原样拼进 SQL缺席参数化查询$where . ... NOT IN ($author__not_in)直接字符串内插本该存在的最后防线根本没有这就是“查无此人”的全部含义数据清洗不是“洗所有进来的数据”而是“照着名单点名点到谁洗谁”。正常请求时author_exclude在 posts 的名单上 → 被拆解 → 死。这次categories 请求身上挂的是 categories 的名单没有这个参数 → 三道看门狗全部放行。不是消毒写错了是注入根本不在任何一张名单上。0x07 落地验证时间盲注注入串最终拼进 SQL 的post_author NOT IN (...)...post_authorNOTIN(SELECTIF(ASCII(SUBSTRING((SELECTuser_passFROMwp_usersWHEREuser_loginadmin),1,1))30,SLEEP(1),0))...验证方式是时间差分管理员密码哈希首字符是$ASCII 36——猜测真值结果3036 30 为真SLEEP 执行响应慢 ≈ 1s4036 40 为假秒回≈ 0.05s一次请求一个比特。对每个字符做 8 次 0~255 二分34 字符的哈希约 279 次请求即可完整提取——证明注入能读取数据库任意数据。0x08 跳出个案这一类漏洞的通用启示这才是研究它的价值。剥掉 WordPress 的外壳这是一个漏洞类凡“校验与执行分离、且两者之间用位置/索引传递关联”的架构都可能出现校验对象与执行对象的错位。它是 TOCTOU 的架构版本也符合 Confused Deputy混淆代理人模型。审计时可以按这个 checklist 找同类问题找“先校验、后执行”分成两段循环的代码检查两个循环靠索引还是唯一键对齐找“失败项 continue 跳过、但不补偿索引”的模式缺口来源找 schema 里properties为空 additionalProperties: true的“透明对象”走私通道找“迭代白名单表”式清洗——表外参数默认放行检查最终拼接点是否参数化纵深防御的最后一环对应的防御原则校验结果与执行对象用唯一键绑定如请求 ID禁止位置索引传递Fail-closed批量请求中出现非法子请求整体拒绝而不是跳过继续body 等容器字段深度定义 schemaadditionalProperties: false不留透明通道清洗改为迭代输入的未知参数默认拒绝而非仅迭代已知表SQL 层参数化永不缺席——本案中前三道全破若NOT IN用了占位符注入依然无法落地0x09 结语回头看整条链骗过枚举body 透明→ 骗过派发双重错位→ 骗过消毒查无此人。没有任何一行防御代码被“打败”它们只是被调换了服务的对象。对防御者而言这个案例提醒我们安全属性必须绑定在不可篡改的身份上而不是位置和顺序上。参考资料WordPress 6.9.4 源码class-wp-rest-server.php/class-wp-rest-request.php/class-wp-rest-posts-controller.php/class-wp-query.php。本文仅供学习交流渗透测试须在授权环境下进行。
返回列表