Searchlight Cyber 的 wp2shell 安全公告涉及一种无需身份验证的 WordPress REST 批量路由混淆 SQL 注入,本仓库是与之对应的独立概念验证。
本仓库并非 Searchlight Cyber 的官方检测工具。check 用于确认 SQLi 路径,read 演示数据库读取,shell 则打开一个由插件支撑的命令 Shell——既可使用提供的管理员凭据,也可先利用 SQLi-to-admin 桥接。
Searchlight Cyber 的公告列出了以下 wp2shell RCE 受影响版本范围:
| 版本范围 | 状态 |
|---|---|
| <= 6.8.5 | 不受影响 |
| 6.9.0 – 6.9.4 | 受影响 |
| 7.0.0 – 7.0.1 | 受影响 |
REST 批量端点(/batch/v1)无需身份验证,可在一次调用中运行多个子请求,其安全性依赖于每个子请求各自独立地完成验证与权限检查。
serve_batch_request_v1() 会构建两个并行数组——$matches(每个子请求匹配到的处理器)和 $validation(每个子请求的验证结果)——然后在分发时以相同的偏移量对两者进行索引。某个子请求的路径若未通过 wp_parse_url(),则只会被追加到 $validation 而不会追加到 $matches,导致两个数组失步,于是该子请求会在另一个子请求的处理器下被分发。这便是路由混淆。
该 PoC 将上述原语嵌套两次:
requests 请求体的 POST /wp/v2/posts 请求会直接在批量处理器本身之下被分发。由于它已作为 posts 请求通过验证,其 requests 列表永远不会与批量模式进行比对,因此其子请求可以使用 GET——方法白名单被绕过。GET /wp/v2/posts/999999 条目路由请求携带 author_exclude、orderby 和 per_page 等 posts 集合查询参数。999999 这个 ID 无需真实存在;它只是一个极不可能用到的文章 ID,用于匹配条目路由,而该路由的模式不会验证那些仅限集合的参数。随后,失步会让同一请求在 posts 的 get_items() 下被分发,其中 author_exclude 映射到 WP_Query 的 author__not_in 查询变量,而存在漏洞的版本会将该变量以字符串形式拼接到 SQL 中。最终结果是一种可在认证前触达的、基于布尔与基于时间的盲 SQL 注入。该 PoC 还包含 SQLi-to-admin 链所使用的 UNION 伪造文章原语。
此处实现的 RCE 路径为:
wp_posts 行,通过 posts 集合渲染攻击者可控的内容。渲染桥接使用 /wp/v2/posts/999999 条目路由作为来源——与 SQLi read 用来触达 get_items() 的是同一条路由。POST /wp/v2/users,从而创建一个自动生成的管理员。步骤 1–5 发生在认证前;命令执行步骤则是已认证管理员的插件上传。
Python 3.8+ 及标准库。无第三方依赖。
在仓库目录下运行:
./wp2shell.py <command> <url> [options]
或者运行 pip install .,即可在 PATH 中获得 wp2shell 命令。
首先打印被动的 WordPress 特征标记和公开的版本提示,然后发送一个无害的批量标记探测。存在漏洞的批量实现会返回 HTTP 207,并伴随路由混淆标记模式 parse_path_failed、block_cannot_read 和 rest_batch_not_allowed。
该标记探测基于 WordPress 核心的修复方案。畸形的 /// 请求会产生 parse_path_failed;/wp/v2/posts 请求充当一个允许批量处理的间隔请求;/wp/v2/block-renderer/... 路由不允许批量处理,但若匿名触达其处理器,则会返回 block_cannot_read;/batch/v1 则会给出 rest_batch_not_allowed。在存在漏洞的版本中,解析错误会使批量处理器数组失步,因此间隔请求会在 block-renderer 处理器下被分发。已修复的版本会保持数组对齐,因此精心构造的探测不应同时出现这三种标记模式。
默认情况下,check 到此停止,不会发送 SQLi 载荷。若你还需要主动的 SQLi 确认,请使用 --confirm-sqli。确认过程会首先尝试 UNION 读取原语;如果 UNION 回显不可用,则回退到成对的计时探测。
这些信号彼此独立:版本提示仅供参考,标记模式表明存在路由混淆,--confirm-sqli 则表明载荷已到达数据库。WAF 可能会拦截载荷,因此确认失败并不能证明漏洞不存在。
./wp2shell.py check http://target
./wp2shell.py check targets.txt # scan every URL in the file
./wp2shell.py read http://target # server fingerprint
./wp2shell.py read http://target --preset users # user logins and password hashes
./wp2shell.py read http://target --query "SELECT @@version"
默认情况下,提取使用 --technique auto,并按以下顺序尝试可用的方法:
UNION 伪造一个 WP_Post 行,并从 REST 响应中将其标题以 ||HEX(value)|| 形式读回。该载荷使用相同的 /wp/v2/posts/999999 来源路由,并携带 orderby=none 与 per_page=500,使伪造行得以作为渲染后的文章保留。每个值仅需一个请求。WP_DEBUG_DISPLAY)时,EXTRACTVALUE/UPDATEXML 每个请求可泄露约 15 字节。X-WP-Total 响应头作为真/假信号,且无需任何回显值。可使用 --technique union|error|blind 强制指定其中一种。这些读取路径不会写入任何数据库行。
使用 --user 和 --password 时,shell 会以所提供的管理员凭据登录,并利用 WordPress 的插件上传行为。
未提供凭据时,shell 会先运行认证前的 SQLi-to-admin 桥接,以自动生成的管理员身份登录,随后上传插件 Shell。
./wp2shell.py shell http://target --user admin --password '<recovered>' --cmd id
./wp2shell.py shell http://target --user admin --password '<recovered>' -i # interactive shell
./wp2shell.py shell http://target --cmd id # pre-auth bridge
./wp2shell.py shell http://target -i # pre-auth interactive
shell 会上传一个插件 WebShell(受随机路径和每次运行生成的令牌保护)并打印其路径。上传的 WebShell 会被自动移除。当认证前桥接创建了管理员时,该自动生成的账号会在 Shell 会话结束后被自动删除。
升级到 WordPress 7.0.2;若站点运行于 6.9 分支,请升级到 6.9.5。在此之前,请在网络边缘同时拦截 /wp-json/batch/v1 以及 rest_route=/batch/v1 查询参数,或通过 rest_pre_dispatch 过滤器要求批量端点进行身份验证。
仅限授权的安全测试。请仅将其用于您拥有或已获得明确书面许可进行测试的系统。不提供任何担保,亦不对滥用行为承担任何责任。
| 选项 | 适用范围 | 说明 |
|---|
--proxy URL | 全部 | 通过 HTTP 代理转发流量(例如 Burp)。 |
--timeout N | 全部 | 请求超时时间(秒)。 |
--sleep N | check | --confirm-sqli 计时回退所使用的延迟。 |
--samples N | check | --confirm-sqli 计时回退所使用的计时样本对数。 |
--confirm-sqli | check | 同时发送一个主动的 SQLi 确认载荷。 |
--preset | read | fingerprint 或 users。 |
--technique | read | auto(默认)、union(带内,伪造虚假文章)、error(带内,需可见的数据库错误)或 blind。 |
--query | read | 要读取的标量 SQL 表达式。 |
--prefix | read | 数据库表前缀(默认 wp_)。 |
--max-length N | read | 每个值最多读取的字符数(默认 128)。 |
--user / --password | shell | 可选的管理员凭据;两者均省略时使用认证前桥接。 |
--cmd | shell | 要运行的命令(使用 -i 时省略)。 |
-i / --interactive | shell | 部署后打开一个交互式 Shell。 |