如果你正在运行 WordPress 并且想检查你的实例是否容易受到攻击,你可以使用我们托管在这里的工具:https://wp2shell.com/:https://wp2shell.com/。
我们推迟发布这个问题,以给防御方一个在周末升级其 WordPress 实例的机会,但在此期间,Calif:https://x.com/calif_io/status/2079016161891521022 和 Hacktron:https://www.linkedin.com/feed/update/urn:li:activity:7484494074658512896/ 能够在其他 PoC 出现在 GitHub 上之前独立重现整个链条。
像大多数安全研究人员一样,我们非常密切地关注 Searchlight Cyber 的新模型发布。当 GPT5.6 Sol Ultra 发布时,我们非常兴奋地测试它。
我曾读到 Sol 最近解决了一个著名的数学猜想——循环双覆盖猜想:https://en.wikipedia.org/wiki/Cycle_double_cover。作为安全研究人员,对我来说有趣的是 OpenAI 发布了他们使用的提示词,这里有官方发布链接:https://cdn.openai.com/pdf/04d1d1e4-bc75-476a-97cf-49055cd98d31/cdc_prompt.pdf。我在安全社区中没有看到有人谈论过它或者真正提及它,但我认为如果这个提示词足够好去解决困难的数学问题,它大概也足够用于安全研究。我采用了这个提示词并进行了调整,然后将其指向 WordPress,并要求它使用 4 个代理至少运行 6 个小时。以下是我最终使用的精确提示词:
我使用的文件夹结构如下:
在开始之前,我将最新的稳定版本 WordPress 克隆到 main/ 目录中,并删除了 .git 目录。我这样做是因为我经常发现,在进行安全研究时,LLM 会查看更改历史或互联网寻找线索,而对于新型漏洞发现,我个人认为这是浪费 token。这也是我添加以下这行代码的原因:
我的经验还表明,模型有时会为了达成你的要求而“作弊”,要么选择极不可能的配置选项,要么编造攻击者无法实现的前置条件。这就是为什么我非常明确地指出,它应该是在典型生产部署的 MySQL 环境下进行的预认证 RCE。
最终,我发现模型并不会真正‘深挖’底层库,即使有这个需要。它们的第一反应是,如果不知道,就去搜索有关某个 API 或 PHP 函数的信息。但模型在阅读源代码方面非常擅长,所以我只是让它们去阅读源代码:
提示的其余部分几乎完全取自 OpenAI 的 CDC 提示。
当我回来时,我在其运行输出中看到,它声称发现了一个预先认证的 SQL 注入漏洞。起初我并不完全相信,毕竟 WordPress 是有史以来防护最严格的目标之一,而且本十年也没有出现过任何有意义的预认证漏洞。但当我了解它所做的事情时,我意识到它确实发现了完全预认证的 SQL 注入漏洞。仍然不完全相信,我在一台远程服务器上安装了一个默认的 WordPress 实例,并让它窃取管理员的邮箱。几分钟之内,它打印出了我用来设置该实例的邮箱。
从那时起,我问 Sol 是否可以将此升级为远程代码执行 (RCE)。大约 4 小时后,Sol 给出了肯定的答复:预认证的只读 SQL 注入可以可靠地用于提升权限到管理员级别,而无需破解任何密码或进行任何离线计算。
总使用量:每周使用量的 50%。按 200 美元订阅的比例总成本:约 25 美元美金。
此时,我意识到我在全球最受欢迎的软件之一的默认配置中发现了一个漏洞。估算不一,但大多数人同意全球运行的 WordPress 实例超过 5 亿个。
接下来的这一天,我花时间理清 Sol 做了什么,并准备将报告发送给 WordPress。虽然 SQL 注入相对容易理解,但 Sol 为将其升级到远程代码执行所做的后利用工作完全令人难以置信。Sol 可能只花了 4 小时来编写,但我理解它却花了远远更长的时间。下面是我(人类)对该漏洞的描述:初始漏洞、SQL 注入以及用于升级到远程代码执行的后利用链。
WordPress 批量 API 于 2020 年的 WordPress 5.6 中引入,允许用户在一次请求中发起多个虚拟 API 请求。无论是否已通过身份验证,你都可以访问此端点,但每个子请求都会传递该身份验证信息。使用它的一个简单例子是一次性更新多个博客文章的标题或标签;这是一个简单的例子:
如果你直接访问一个端点,例如 POST /wp-json/wp/v2/posts,WordPress 有一个大致如下的验证流程:
这意味着流入端点回调本身的任何参数已经被验证为正确的形状和数据类型。API 端点本身在多个地方依赖此验证,以确保例如,文章 ID 是整数,文章标题是字符串等。因此,能够绕过参数净化过程是非常重要的。
批量 API 的处理方式略有不同。它并不是像你预期的那样依次运行上述四步流程,而是将验证和执行批量处理成两个循环,如下所示:
这是在 class-wp-rest-server.php 中通过两个数组实现的,一个用于匹配($matches),一个用于验证($validation)。意图是 matches 数组中的每个索引 $i 对应 validation 数组中的同一索引。因此 $validation[0] 包含 $matches[0] 的验证,$validation[1] 包含 $matches[1] 的验证,依此类推。验证过程如下:
你发现这里的漏洞了吗?如果我们取 is_wp_error( $single_request ) 分支,$validation 数组会被更新,但由于 continue;,matches 数组不会更新!假设第一个请求是格式错误的。原始请求及其验证结果保持对齐,但 $matches 中的每个条目都向后错位一个位置。第二个执行循环会跳过索引 0 的错误。在索引 1,它使用索引 1 的原始请求及该请求的验证结果,但 $matches[1] 现在包含的是索引 2 的原始请求所匹配的处理器。$matches[0] 从未被使用。这使我们能够验证一个请求,然后使用下一个请求的端点处理器来执行它。
使用这个方法,我们可以通过将每个端点与不同端点的验证匹配来绕过所有批处理启用端点的所有数据清理,这些不同的端点不会对相同的参数进行清理。但我们可以在哪里使用它呢?
路由 GET /wp/v2/posts 允许用户列出符合特定条件的帖子。该 API 提供了从结果中排除作者 ID 的功能:
这里有一个严重的漏洞。如果 author__not_in 输入的是数组,它会使用 absint 过滤每个项,将其清理为整数。但如果输入是标量,它将保持不变。因此,提供像 "foobar" 这样的标量字符串会直接插入原始 SQL 查询中而没有任何转义。
直接调用此路由时,这通常不会是问题,因为公共 author_exclude 参数必须是整数数组。帖子控制器仅在验证后将其映射到 author__not_in。然而,通过批处理 API,我们有验证/执行不匹配的问题,使我们可以巧妙地绕过参数验证。让我们试一试:
这里我们应用到目前为止的方法。首先,在请求中使用了一个无效路径,以不同步路径和主体验证。author_exclude 参数在 DELETE /wp/v2/posts/1 路由上进行验证,而该路由没有对其进行验证(它不识别该参数),但是,由于我们的不同步,author_exclude 被应用到 GET /wp/v2/posts 上。只有一个问题:
批处理 API 不支持 GET 请求。我们发现的 SQL 注入只能通过 GET 访问,所以似乎我们遇到了死胡同。Sol 的解决方案非常巧妙且具有启发性。该模型意识到请求方法的验证本身是作为参数验证实现的。我们有绕过参数验证的方法吗?有!我们可以利用不同步漏洞!因此,Sol 构造了一个载荷,在其中递归调用批处理端点。在内层调用中,由于不同步,未对请求方法进行验证。然后我们可以发起 GET 请求。预认证 SQL 注入的最终载荷就是:
在这里,我们利用批处理 API 验证漏洞进行了两次递归攻击。在外层请求中,我们使其不同步,以至于 method 字段未被验证。然后在内层请求中,我们再次使其不同步,使 author_exclude 字段未被验证。有效负载 0) OR 1=1 -- 将返回所有帖子行,确认注入成功。从这里开始,基于 UNION 的注入可以通过返回一个完整的 wp_posts 形状的行来泄露任意数据库值。
此时,我们在 WordPress 中已经拥有一个预认证 SQL 注入漏洞,这已经是一个大问题。然而,受到 LLM 强大功能的启发,我问它是否能够将此升级为全面的 RCE(远程代码执行)。
我的第一本能是泄露像密码、重置令牌或 API 密钥之类的东西,以便提升权限。然而,WordPress 有一个相当完善的安全模型,所有这些都在数据库中被哈希处理。除非管理员的密码特别弱并且可以被破解,否则仅仅泄露数据库是不足以接管管理员账户的。然而,Sol 很快发现了另一个有希望的角度:
WordPress 的性能非常高。作为其中的一部分,WordPress 在请求生命周期中会维护 WP_Post 对象的内存缓存。这些缓存不会持久化;请求结束后,这些缓存对象会被丢弃。然而,如果在请求生命周期中多次引用同一篇文章,WordPress 会在首次从数据库取回后使用缓存的文章。这避免了如果同一篇文章在同一次请求中被多次使用时的多次数据库往返。例如,用户可能访问 ID 为 10 的文章,但同时,侧边栏小部件列出了包括文章 ID 10 在内的最近 5 篇文章,等等。
由于我们在 posts 端点中有一个 SQL 注入漏洞,我们可以使用基于 UNION 的注入来“伪造”返回的帖子。由于这些帖子会被缓存,我们对缓存的帖子数据有极大的控制权限。此外,WordPress 在渲染文章文本之前会进行后处理,即使通过 API 渲染,我们也能完全控制返回的文章文本。
即使我们可以污染请求缓存,也不太明显我们可以用这个原语做什么。毕竟,我们返回的假帖子并不是真正由数据库支持的帖子,这限制了跨请求的影响。看起来我们无法将这些假帖子变成真实的数据库行——除了……
WordPress 有一个叫做 embeds 的功能:https://wordpress.org/documentation/article/embeds/。在文章中包含类似 [embed]https://example.com[/embed] 的结构,如果远程页面的内容是 WordPress 支持的格式,该内容将被嵌入到你的文章中。
为了避免每次加载文章时都发起 HTTP 请求,WordPress 不仅在内存中缓存这些嵌入内容,而且也在数据库级别进行缓存。这以 oembed_cache 类型的帖子形式存储在数据库的 wp_posts 表中。
WordPress 帖子是另一种受支持的嵌入类型。如果你嵌入一个帖子并提供相对路径而不是绝对 URL,WordPress 会识别该 URL 指向的是本地帖子,而实际上不会发起 HTTP 请求。然而,WordPress 实际上不会检查嵌入中所引用的帖子 ID 是否存在。因此,放置类似 [embed width="500" height="750"]/?p=10[/embed] 的文本,将会生成一个类型为 oembed_cache 的数据库行,其中包含帖子 10 的嵌入数据。假设新行的 ID 是 11。
现在我们在数据库中有了帖子 ID 为 11 的行,事情开始变得有趣。如果我们再次滥用同样的 SQLi,我们可以在内存中再次伪造任何我们喜欢的帖子内容,并且这些内容将存储在每请求的内存缓存中。然而,这一次,内存中的帖子版本和数据库缓存的版本不一致。WordPress 会识别这一点,并尝试调和两个版本:
在这里,ID 和 post_content 在写入数据库之前就已设置。然而,帖子还有许多其他字段,例如 post_status 和 post_type。在数据库中,post_type 是 oembed_cache,但使用我们的 SQLi,我们可以伪造任何我们想要的帖子类型,例如 post(这是一篇普通的 WordPress 帖子)。如果数据库行和内存缓存不一致,WordPress 会优先使用内存中的字段,而这些字段我们完全可以控制。因此,我们可以强制 oembed_cache 行变成普通帖子,从而突然‘出现’。我们唯一无法控制的是 post_content——因为它在调用 wp_update_post 时被明确指定,我们无法覆盖它。
用帖子篡改网站很有趣,特别是考虑到我们能够在仅 SELECT 的 SQLi 中凭空伪造它们。但这不是 RCE。下一步是什么?Sol 锁定了一种特殊类型的帖子,称为 customize_changeset。
当你在 WordPress 中草拟对网站主题的编辑时,它需要以某种方式将这些草拟的更改保存到网站设置中。它所采用的方式是在 wp_posts 中使用一行特殊记录,post_type 设置为 customize_changeset。它不是将整个网站设置保存为一个 blob,而是将更改字段的差异存储在 post_content 中。一个示例可能如下所示:
如果你继续编辑网站主题,WordPress 将临时应用该 changeset,使你能够从上次停止的地方继续编辑。如果你发布更改,这些更改将永久应用到网站。
帖子中可以更改的每一项都有一个键(例如 blogname)和一个三元素字典:你正在更改的内容类型、更改的值以及用于执行更改的用户 ID。在我们的例子中,更改的用户 ID 是 1,因此它们将用管理员权限应用。当应用 changeset 时,WordPress 会暂时根据 changeset 中指定的 user_id 设置当前用户:
因此,如果在我们以匿名用户身份时应用了一个更改集,我们可以暂时假设管理员的身份。有一个主要问题:如前所述,更改集使用 post_content 字段来存储更改集的 JSON。这是目前我们作为攻击者使用缓存中毒技巧无法控制的唯一字段,因为在前面提到的 wp_update_post 调用中,post_content 被显式地覆盖为嵌入内容。然而,Sol 发现了一个小工具,它将迫使 WordPress 协调这些冲突的缓存表示。
WordPress 允许文章有父级。这意味着你可以把 WordPress 文章集看作一棵树,每篇文章有零个或一个父级。在这里,我们用箭头从子节点指向父节点:
然而,WordPress 不允许存在环。对于许多操作,如果 WordPress 对文章应用更改,它会调用过滤器 wp_insert_post_parent,该过滤器会遍历到文章的父级、父级的父级,依此类推,直到到达树的顶端。这意味着如果文章层级被某种方式破坏并且文章图存在循环,WordPress 可能会陷入无限循环:
这与页面层级相关。如果你让一篇文章成为它自己的父级,可能会引发许多问题。
WordPress 已预料到这种情况,并添加了循环检测逻辑。如果 WordPress 在更新文章层级时检测到循环,它会将文章的父级 ID 更新为零:
这是与前面 wp_update_post 不同的调用。重要的是,这次调用不会覆盖 post_content,因此我们可以使用 SQLi 控制通过内存中伪造的文章的 post_content。因此,我们可以伪造一个合法的 customize_changeset,其中包含与管理员用户关联的 JSON。这允许我们以管理员身份修改其他文章。然而,一旦对其他文章的更改完成,我们的权限就会恢复为访客。我们如何从能够更改文章内容,转变为能够以管理员身份做任何事情?
为了支持其丰富的插件生态系统,WordPress 有一个名为 hooks 的功能:https://developer.wordpress.org/plugins/hooks/。这些钩子,名称像 wp_enqueue_scripts 或 publish_post,允许插件‘挂接’(顾名思义)WordPress 生命周期的几乎每一个部分。钩子分为动作(可以调用但不返回值的东西)和过滤器(返回值的东西)。例如,插件作者可能希望添加功能来记录所有用户登录,并使用钩子来实现:
动作钩子也可以通过 do_action() 手动调用。在用户登录时,WordPress 并不仅仅调用一些内部的 ->wpLogin() 函数,而是使用 do_action('wp_login', $username, $user_obj)。这种动态派发在整个代码库中无处不在,这也是 WordPress 如此可定制的原因。
当发布文章时,WordPress 允许用户挂接该发布事件。他们通过调用实现:
在合法情况下,文章的状态可能已从 draft 变为 publish,因此动态动作将被调用为 publish_post,插件可以挂接它。Sol 意识到在我们假设临时管理员角色时,该界面是可访问的,并且还意识到因为我们在内存中伪造了整个文章,new_status 和 $post->post_type 可以对应任何我们想要的内容,并不一定非得对应合法的 post_type 或状态。这允许我们作为攻击者,只要动作名称至少包含一个下划线,就可以以管理员身份调用任何动作。
主要问题是 $post->ID 在我们的控制之下,但它仅仅是一个 ID。此外,$post 是一个 WP_Post 对象。这意味着我们几乎无法控制传递给动作的参数。Sol 又想出了一个策略来解决这个问题:它针对 parse_request 钩子。这个钩子在请求生命周期的最开始被调用,几乎在任何操作之前。因此调用 parse_request 将从头开始重放整个 Batch API 请求,只是我们仍然拥有临时假设的管理员角色。
现在我们拥有了所有步骤,让我们来制作利用程序。
最终的漏洞利用会发出两个请求。为了清晰起见,我们将漏洞利用中使用的每个帖子ID称为一个字母,例如C或O。实际上,这些字母变量可以被任何ID替代,只要该ID足够大,不会与目标系统中已存在的合法ID冲突。
在第一个请求中,我们给数据库“播种”3行oembed_cache,分别对应帖子O、C和D。我们通过利用SQL注入返回一个ID为零的伪造帖子,并包含三条指向S的嵌入链接来实现:
[embed width="500" height="750"]/?p=S&wpsec_seed=foobar-outer[/embed] [embed width="500" height="750"]/?p=S&wpsec_seed=foobar-changeset[/embed] [embed width="500" height="750"]/?p=S&wpsec_seed=foobar-dispatch[/embed]
这三个URL都指向同一个S,但额外的查询字符串标记不同。因此,它们生成三个不同的oEmbed缓存哈希。这三个哈希标识未来的O、C和D行。
对于第二个请求,我们需要六个伪造帖子,构造如下:
在SQL注入执行的此时点,WordPress的状态如下:
伪造帖子T触发了执行。它包含一个指向S的嵌入,由于请求是本地嵌入且哈希对应O,因此会调用get_post(O)。该请求由数据库中的一行支撑,而O是由帖子S支持的嵌入(正如我们在第一个请求中播种的那样)。S在数据库中不存在,不是一个真实的帖子,但这没关系,因为它在内存中的帖子缓存中。由于我们在O上放置了一个伪造的post_modified_gmt,WordPress会认为S的数据缓存已过期,因此会调用get_post(S),从伪造的内存版本S中返回数据。S的数据将被嵌入例程转换成适合嵌入的内容。
If you’re running WordPress and want to check if your instance is vulnerable, you can use our tool we’ve hosted here: https://wp2shell.com/ :https://wp2shell.com/ .
We held off on publishing this issue to give defenders a chance to upgrade their WordPress instances over the weekend, but during that time, Calif:https://x.com/calif_io/status/2079016161891521022 and Hacktron:https://www.linkedin.com/feed/update/urn:li:activity:7484494074658512896/ were able to independently reproduce the full chain before other PoCs surfaced on GitHub.
Like most security researchers, we follow the new model releases at Searchlight Cyber very closely. When GPT5.6 Sol Ultra was released, we were very excited to test it out.
I had read that Sol had recently solved a famous mathematical conjecture called the Cycle Double Cover conjecture:https://en.wikipedia.org/wiki/Cycle_double_cover. As a security researcher, the interesting thing for me was that OpenAI released the prompt they used, which is published here:https://cdn.openai.com/pdf/04d1d1e4-bc75-476a-97cf-49055cd98d31/cdc_prompt.pdf. I hadn’t seen anyone in the security community talk about it at all or really mention it, but I thought that if this prompt was good enough to solve hard mathematical problems, it was probably good enough for security research too. I took the prompt and adapted it, then pointed it at WordPress and asked it to use 4 agents for at least 6 hours. Here is the exact prompt I landed on:
The folder structure I used was as follows:
Before starting, I cloned the latest stable WordPress release into main/ and removed the .git directory. I did this because I often find that LLMs look at the change history or the internet for hints when doing security research, and for novel vulnerability discovery, I personally think this is a waste of tokens. This is also why I added this line:
My experience has also been that models will sometimes ‘cheat’ to achieve what you ask, either by choosing extremely unlikely configuration options or by fabricating preconditions that aren’t achievable by an attacker. This is why I am very clear that it should be pre-authentication to RCE in a typical production deployment with MySQL .
Finally, I have found that models don’t really ‘get into the weeds’ with the underlying libraries if they need to. Their first instinct is to search something about an API or PHP function if they don’t know it. But models are really good at reading source code, so I just ask them to read the source:
The rest of the prompt is taken almost wholesale from OpenAI’s CDC prompt.
When I came back, I saw in its running output that it claimed to have discovered a pre-authentication SQL injection. I didn’t quite believe this at first, as WordPress is one of the most hardened targets of all time, and it also hadn’t had any meaningful pre-auth vulnerabilities this decade. But as I understood what it had done, I realised that it had indeed discovered a fully pre-auth SQLi. Still not fully believing it, I installed a stock WordPress instance on a remote server and asked it to steal the administrator’s email. Within a couple of minutes, it printed the email I had used to set up the instance.
From there, I asked Sol if this could be escalated to an RCE. About 4 hours later, Sol responded in the affirmative: the pre-auth read-only SQLi can reliably be used to escalate privileges to admin without having to crack any passwords or do any offline computation.
Total usage: 50% of weekly usage. Pro-rata total cost on the $200 subscription: ~ $25 USD.
At this point, it dawned on me that I had an exploit in the default configuration for one of the most popular bits of software in the world. Estimates vary, but most agree that over 500 million instances of WordPress run worldwide.
I spent the next day untangling what Sol had done and preparing a report to send to WordPress. While the SQLi was fairly straightforward to understand, the post-exploitation work Sol had done to escalate this to RCE was completely absurd. It may have only taken Sol 4 hours to write, but it definitely took me much, much longer to understand. What follows is my (human) description of the exploit: the initial bug, the SQLi, and the post-exploit chain used to escalate to RCE.
The WordPress batch API was introduced in WordPress 5.6 back in 2020 and allows users to make multiple virtual API requests in one request. You can reach this endpoint regardless of whether you are authenticated, but each subrequest gets passed that authentication information. A simple example of why you would want to use this is to update the title or tags of multiple blog posts at once; here is a simple example:
If you directly hit an endpoint, such as POST /wp-json/wp/v2/posts , WordPress has a validation pipeline that roughly looks as follows:
This means that any parameters that flow into the endpoint callback itself have been validated to be of the right shape and data type. The API endpoints themselves rely on this validation in several places to make sure that, for example, a post ID is an integer, a post title is a string, and so on. Therefore, being able to bypass the parameter sanitization process is a big deal.
The batch API does things slightly differently. Rather than run the four-step process above serially, as you would expect, it batches the validation and the execution into two loops, as follows:
This is implemented in class-wp-rest-server.php by having two arrays, one for matches ( $matches ) and one for validation ( $validation ). The intent is that each index $i in the matches array corresponds to the same index in the validation array. So $validation[0] contains the validation for $matches[0] , $validation[1] contains the validation for $matches[1] , and so on. The validation routine works as follows:
Do you spot the vulnerability here? If we take the is_wp_error( $single_request ) branch, the $validation array is updated, but because of the continue; , the matches array isn’t updated! Suppose the first request is malformed. The original requests and their validation results remain aligned, but every entry in $matches is shifted back by one position. The second execution loop skips the error at index 0. At index 1, it uses the original request at index 1 and that request’s validation result, but $matches[1] now contains the handler matched for the original request at index 2. $matches[0] is never used. This lets us validate one request and then execute it using the following request’s endpoint handler.
Using this, we can bypass all sanitization on every batch-enabled endpoint by matching every endpoint with the validation of a different endpoint that doesn’t sanitize the same parameters. But where can we use this?
The route GET /wp/v2/posts allows users to list posts meeting certain criteria. The API offers functionality to exclude author IDs from the result:
There is a nasty bug here. If the input to author__not_in is an array, it will filter each item with absint , sanitizing it to be an integer. But if the input is a scalar, it will leave it untouched. Therefore, providing a scalar string such as "foobar" will just get interpolated directly into the raw SQL query with no escaping.
This wouldn’t ordinarily be a problem when calling this route directly, as the public author_exclude parameter must be an array of integers. The posts controller only maps it to author__not_in after validation. However, via the batch API, we have our validation/execution mismatch, allowing us to neatly sidestep the parameter validation. Let’s try it out:
Here we apply what we have so far. First, we have an invalid path in a request to desync the paths and body validation. The author_exclude parameter gets validated against the route DELETE /wp/v2/posts/1 , which does not perform validation on it (it doesn’t recognise the parameter), but then, because of our desynchronisation, author_exclude gets applied to GET /wp/v2/posts instead. There’s only one problem:
The batch API does not support GET requests. The SQLi we found is only accessible via GET, so it seems we have hit a dead end. Sol’s solution to this is clever and quite instructive. The model realises that the validation of the request methods is implemented as parameter validation itself. Do we have a way to bypass parameter validation? Yes we do! We can use the desync bug! Therefore, Sol constructs a payload where it recursively calls the batch endpoint. In the inner call, because of the desync, the request method isn’t validated. Then we can make our GET request. The final payload for a pre-authentication SQLi is just:
Here we exploit the batch API validation bug twice, recursively. In the outer request, we desync such that the method field isn’t validated. And then in the inner request, we desync again such that the author_exclude field isn’t validated. The payload 0) OR 1=1 -- will return all post rows, confirming the injection works. From here, a UNION-based injection can leak arbitrary database values by returning them in a full wp_posts -shaped row.
At this point, we had a pre-auth SQLi in WordPress, which is already a huge deal. Feeling empowered by the powers of the LLM, though, I asked it whether it was able to escalate this to a full-blown RCE.
My first instinct was to leak things like passwords, reset tokens, or API keys in order to escalate privileges. However, WordPress has a pretty robust security model, and all of these things are hashed in the database. Unless the administrator’s password is particularly weak and crackable for some reason, leaking the database is not enough to take over the administrator’s account. However, Sol quickly identified another promising angle:
WordPress is highly performant. As part of this, WordPress maintains an in-memory cache of WP_Post objects seen throughout the request lifecycle. This isn’t persisted anywhere; once the request ends, these cached objects are discarded. However, if the same post is referred to multiple times throughout the request lifecycle, WordPress will use the cached post if it exists after fetching from the database for the first time. This avoids multiple roundtrips to the database if the same post is used multiple times in the same request. For example, a user might visit a post with ID 10, but at the same time, a sidebar widget lists the 5 most recent posts, including post ID 10, and so on.
Since we have a SQLi in the posts endpoint, we can use a UNION-based injection to ‘fake’ the posts that come back. Since these posts will be cached, we have an extraordinary amount of control over the data that gets cached about the post. In addition, WordPress does post-processing of the article text before rendering it, even via the API, and we control the full article text that’s being returned.
Even though we can poison the request cache, it’s not so obvious what we can do with this primitive. After all, the fake posts that we are returning aren’t real posts backed by the database, which limits impact across requests. It doesn’t look like we can turn these fake posts into real database rows – except…
WordPress has a feature called embeds:https://wordpress.org/documentation/article/embeds/. By including a construction like [embed]https://example.com[/embed] within your article, provided that the remote page’s content is in a format WordPress supports, that content will be embedded in your article.
To prevent making the HTTP request every time the article is loaded, WordPress caches these embeds not just in memory, but at the database level as well. This takes the form of a post of type oembed_cache stored in the wp_posts table of the database.
WordPress posts are another supported embed type. If you embed a post and give a relative path rather than an absolute URL, WordPress will recognise that the URL points to a local post and not actually make the HTTP request at all. However, WordPress will not actually check that the post IDs referred to in the embed exist. Thus, placing text like [embed width="500" height="750"]/?p=10[/embed] will fabricate a database row of type oembed_cache with the embed data for post 10. Let’s say that new row ID is 11.
Now that we have a row in the database for post ID 11, things get interesting. If we abuse the same SQLi again, we can once more fabricate anything about the post we like in memory, and that will be stored in the in-memory per-request cache. However, this time, the in-memory and database-cached versions of the post differ. WordPress will recognise this and try to reconcile the two versions:
Here, the ID and post_content are set before writing to the database. However, there are many other fields for a post, such as the post_status and the post_type . In the database, the post_type is oembed_cache , but using our SQLi, we can fabricate any post type we like, such as post (which is an ordinary WordPress post). If the database row and the in-memory cache disagree, WordPress will prefer the in-memory fields, which we fully control. Therefore, we can force the oembed_cache rows to become normal posts, suddenly ‘popping’ them into existence. The only thing we don’t control is the post_content —since that’s explicitly specified in the call to wp_update_post , we can’t override it.
Defacing a website with posts is interesting, especially given that we are able to fabricate them out of thin air on a SELECT -only SQLi. But it’s not RCE. What’s the next step? Sol hones in on a special type of post called a customize_changeset .
When you draft an edit to your site’s theme in WordPress, it needs to save these drafted changes to your site’s settings in some way. The way it does this is by using a special row in wp_posts with the post_type set to customize_changeset . Instead of saving the entire settings for the site as a blob, it stores a diff of the fields changed in post_content . A sample might look like this:
If you resume editing the site theme, WordPress will temporarily apply the changeset, allowing you to continue editing where you left off. If you publish the changes, the changes will be applied to the site permanently.
Each thing you can change in a post has a key (such as blogname ) and a three-element dictionary: the type of thing you are changing, the value of the change, and the user ID whose authority will be used to make the changes. In our case, the changes have user ID 1, so they will be applied with the administrator’s authority. When a changeset is applied, WordPress temporarily sets the current user using the user_id specified in the changeset:
Thus, if a changeset is applied while we are an anonymous user, we can temporarily assume the administrator’s identity. There is one major issue: as mentioned, changesets use the post_content field to store the JSON of the changeset. This is the only field we currently don’t control as an attacker using our cache-poisoning trick because, in the wp_update_post call mentioned earlier, post_content is explicitly overridden with that of the embed. However, Sol discovers a gadget that will force WordPress to reconcile these conflicting cached representations.
WordPress allows posts to have a parent. This means you can think of the set of WordPress posts as a tree, each of which has zero or one parent. Here, we use an arrow that points from its child to its parent:
WordPress, however, does not allow cycles. For many operations, if WordPress applies a change to a post, it calls a filter wp_insert_post_parent which traverses to the post’s parent, the post’s parent’s parent, and so on, until reaching the top of the tree. This would mean that if the post hierarchy was somehow corrupted and the post graph had a cycle, WordPress could end up in an infinite loop:
This is relevant to page hierarchies. If you made a post a parent of itself, it could cause many issues.
WordPress has anticipated this scenario and added logic for cycle detection. If WordPress detects that there is a loop while updating a post hierarchy, it will update the post’s parent ID to zero:
This is a different call to the earlier wp_update_post . Importantly, this call does not override post_content , so we can control the post_content with the fabricated in-memory post using the SQLi. Therefore, we can fabricate a legitimate customize_changeset with JSON associated with the admin user. This allows us to make changes to other posts as the administrator. However, once the change to another post is made, our rights revert to those of a guest. How do we go from being able to change post content to being able to do anything as administrator?
To support its rich plugin ecosystem, WordPress has a feature called hooks:https://developer.wordpress.org/plugins/hooks/. These hooks, with names like wp_enqueue_scripts or publish_post , allow plugins to ‘hook’ (as the name suggests) almost every part of the WordPress lifecycle. Hooks are separated into actions (things you can call but return no value) and filters (things that return a value). For example, a plugin author might want to add functionality to log all user logins and use hooks to do so:
Action hooks can also be called manually with do_action() . When logging a user in, rather than just calling some internal ->wpLogin() function, WordPress uses do_action('wp_login', $username, $user_obj) . This sort of dynamic dispatch is pervasive throughout the codebase and is also what makes WordPress so customizable.
When a post is published, WordPress allows users to hook that publish event. They do so by calling:
In a legitimate case, it might be that the status of a post has gone from draft to publish , so the dynamic action will be called publish_post , and plugins can hook that. Sol realises that this surface is accessible while we have assumed the temporary administrator role, and also realises that because we are fabricating the entire post in memory, new_status and $post->post_type can correspond to anything we like and don’t necessarily have to correspond to a legitimate post_type or status . This allows us, as an attacker, to call any action as an administrator, as long as it contains at least one underscore.
The major problem is that $post->ID is under our control, but it’s only an ID. Additionally, $post is a WP_Post object. That means that we have almost no control over the arguments that we provide to the actions. Sol again comes up with a strategy to solve this: it targets the parse_request hook. This hook is called at the very start of the request lifecycle, before almost anything is done. The result is that calling parse_request will replay the entire Batch API request from the beginning , except that now, we still have our temporarily assumed administrator role.
Now that we have all the pieces, let’s craft the exploit.
The final exploit makes two requests. For clarity, we will refer to each post ID we use in our exploit as a single letter, such as C or O . In reality, these letter variables can be substituted by any ID, as long as the ID is high enough to not clash with a legitimate ID already used by the target.
In the first request, we ‘seed’ the database with 3 oembed_cache rows for posts O , C and D . We do this by using the SQLi to return a fake post with ID zero with three embed links pointing to S :
[embed width="500" height="750"]/?p=S&wpsec_seed=foobar-outer[/embed] [embed width="500" height="750"]/?p=S&wpsec_seed=foobar-changeset[/embed] [embed width="500" height="750"]/?p=S&wpsec_seed=foobar-dispatch[/embed]
All three URLs point to the same S , but the extra query-string token differs. Therefore, they produce three different oEmbed cache hashes. The three hashes identify the future O , C , and D rows.
For the second request, we need six fake posts, constructed as follows:
The state of WordPress at the point of the SQLi looks as follows:
The forged post T kicks off execution. It contains an embed to S , which, since the request is to a local embed with hash for O , calls get_post(O) . This is backed by a database row, and O is an embed backed by the post S (as we seeded in the first request). S doesn’t exist in the database and isn’t a real post, but that’s OK, since it’s in the in-memory post cache. Due to a fake post_modified_gmt we place on O , WordPress will believe the cache of S ‘s data has expired, so it will call get_post(S) , which returns data from the fake in-memory version of S . The data from S will be transformed by the embed routine into something suitable for embedding.