对野外僵尸0day的尸检分析
作者:Maddie Stone,Google Project Zero
每当有新的在野0day漏洞被披露时,我总是非常想了解该漏洞的根本原因。这让我们能够判断漏洞是否被完全修复、寻找变体,并构思新的缓解措施。这篇博客讲述的是一个"Safari僵尸0day"的故事,以及它如何"死而复生",在2022年被披露为在野利用漏洞。CVE-2022-22620最初于2013年修复,2016年被重新引入,然后在2022年被披露为在野利用漏洞。如果您对CVE-2022-22620的完整根本原因分析感兴趣,我们已将其发布在此处。
在2020年在野利用0day年度回顾中,我写道2020年检测并披露的在野利用0day中有25%是先前已披露漏洞的变体。现在2022年已过去近半,我们似乎看到了类似的趋势。攻击者不需要全新的漏洞就能用0day有效利用用户,他们可以使用与先前披露漏洞密切相关的漏洞。这篇博客只聚焦于今年的一个例子,因为它与我们之前讨论过的其他变体略有不同。我们之前讨论的大多数变体之所以存在,是因为补丁不完整。但在本例中,当该漏洞在2013年最初被报告时,变体已被完全修复。然而,这个变体在3年后的大规模重构工作中被重新引入。然后该漏洞又继续存在了5年,直到2022年1月作为在野0day被修复。
开始调查
对于CVE-2022-22620,我有两条信息来帮助我弄清楚这个漏洞:补丁(感谢Apple与我分享!)和安全公告中的描述,其中说明该漏洞是一个释放后使用(use-after-free)漏洞。补丁中的主要更改是将函数FrameLoader::loadInSameDocument的第二个参数(stateObject)的类型从原始指针SerializedScriptValue*更改为引用计数指针RefPtr<SerializedScriptValue>。
trunk/Source/WebCore/loader/FrameLoader.cpp
1094
1094
// 这与 didOpenURL 所做的工作类型相同,只是它依赖于一个事实
1095
1095
// 即更高级别已经检查了URL是否匹配,并且滚动是正确的操作。
1096
void FrameLoader::loadInSameDocument(const URL& url, SerializedScriptValue* stateObject, bool isNewNavigation)
1096
void FrameLoader::loadInSameDocument(URL url, RefPtr
每当我分析浏览器在野0day的根本原因时,除了研究代码,我通常还会搜索提交历史和bug跟踪器,看看是否能找到任何相关信息。我这样做是为了试图了解bug是何时引入的,同时也为了节省时间。(有很多0day需要研究!😀)
前世今生
对于CVE-2022-22620,我浏览了FrameLoader.cpp的gitblame视图。具体来说,我正在查看loadInSameDocument的定义[https://github.com/WebKit/WebKit/blame/7b23cae2a1b1ffd026288f15261f8ba272c3b24b/Source/WebCore/loader/FrameLoader.cpp#L1096)。查看我们补丁之前这一行的git blame时,有一个非常有趣的提交。该提交实际上将stateObject参数从引用计数指针PassRefPtr<SerializedScriptValue>更改为原始指针SerializedScriptValue*。这个2016年12月的更改引入了CVE-2022-22620。变更日志甚至写道:
(WebCore::FrameLoader::loadInSameDocument):为序列化脚本值状态对象使用原始指针。没有人传递所有权。
但将其作为Ref传递给statePopped,因为我们需要传递null值的所有权,至少目前是这样。
现在我很感兴趣,想追踪之前将stateObject参数更改为PassRefPtr<SerializedScriptValue>的提交。我很幸运,只需要在历史记录中再往回追溯两步。有一个2013年的提交将stateObject参数从原始指针SerializedScriptValue*更改为引用计数指针PassRefPtr<SerializedScriptValue>。这个2013年2月的提交所做的与我们2022年的提交相同。该提交的标题是"SerializedScriptValue::deserialize中的释放后使用",并包含了对该释放后使用如何工作的良好描述。
该提交还包括一个测试:
添加了一个测试,演示了由于SerializedScriptValue的释放后使用而导致的崩溃。
测试:fast/history/replacestate-nocrash.html
该测试的触发条件是:
Object.prototype.defineSetter("foo",function(){history.replaceState("", "")});
history.replaceState({foo:1,zzz:"a".repeat(1<<22)}, "");
history.state.length;
我希望这个测试会使易受攻击的WebKit版本崩溃,这样我就可以完成根本原因分析,继续研究下一个bug。不幸的是,它没有崩溃。
提交描述中包含了一条注释,建议查看一个Chromium bug。(在此期间,Chromium仍在使用WebKit渲染引擎。Chromium在2013年4月分叉了Blink渲染引擎。)我看到我现在的Project Zero队友Sergei Glazunov早在2013年就报告了这个Chromium bug,所以我向他询问了细节。
2013年的释放后使用漏洞(未分配CVE编号)是History API实现中的一个bug。该API允许访问(和修改)当前框架中访问过的页面堆栈,这些页面状态存储为SerializedScriptValue。History API为state公开了一个getter,以及一个replaceState方法,允许覆盖"最近"的历史条目。该bug在于,在state的getter实现中,对当前"最近"历史条目值调用了SerializedScriptValue::deserialize,但没有增加其引用计数。由于SerializedScriptValue::deserialize可能触发用户JavaScript的回调,该回调可以调用replaceState,通过用新值替换历史条目值来丢弃对其的唯一引用。当回调返回时,SerializedScriptValue::deserialize的其余部分使用了一个已释放的this指针运行。
为了修复这个bug,开发人员似乎决定通过将参数类型从原始指针更改为PassRefPtr<SerializedScriptValue>,来更改每个调用SerializedScriptValue::deserialize的地方,以增加stateObject上的引用计数。虽然最初报告的触发是通过V8History::stateAccessorGetter函数在stateObject上调用deserialize,但开发人员的修复也捕获并修补了通过loadInSameDocument调用deserialize的路径。
影响stateObject的更改时间线如下:
2009年12月 - 添加了状态对象History API。
HistoryItem.m_stateObject的类型是RefPtr<SerializedScriptValue>HistoryItem::stateObject()返回SerializedScriptValue*FrameLoader::loadInSameDocument将stateObject参数作为SerializedScriptValue*接收
-
HistoryItem::stateObject返回一个PassRefPtr<SerializedScriptValue>FrameLoader::loadInSameDocument将stateObject参数作为PassRefPtr<SerializedScriptValue>接收
2015年9月 - 弃用history目录中PassRefPtr的使用
HistoryItem::stateObject返回RefPtr而不是PassRefPtr
-
HistoryItem::stateObject()更改为返回原始指针而不是RefPtr
-
FrameLoader::loadInSameDocument更改为将stateObject作为原始指针而不是PassRefPtr<SerializedScriptValue>接收
-
FrameLoader::loadInSameDocument更改为将stateObject作为RefPtr<SerializedScriptValue>接收
深入剖析
当我们查看FrameLoader::loadInSameDocument的更改时间线时,似乎该bug是在2016年12月由于重构而被重新引入的。问题是,为什么补丁作者认为loadInSameDocument不需要持有引用。根据2016年12月提交的变更日志:为序列化脚本值状态对象使用原始指针。没有人传递所有权。
我的评估是,这是由于2016年10月HistoryItem:stateObject的更改。当作者在2016年12月评估dom目录中所需的重构更改时,看起来唯一调用loadInSameDocument的地方要么传递了一个null值,要么传递了stateObject()的结果,而自2016年10月起,stateObject()现在传递的是原始SerializedScriptValue*指针。当查看参数类型的这两种选项时,开发人员可能因此认为loadInSameDocument不需要共享stateObject的所有权。
那么,为什么HistoryItem::stateObject的返回值在2016年10月从RefPtr更改为原始指针呢?我很难找到解释。
根据描述,2016年10月的补丁旨在"用WebCore::Exception替换所有ExceptionCodeWithMessage的使用"。但当我们查看变更日志时,似乎作者还决定对HistoryItem进行一些(看似无关的)重构。这些是该提交中少数与异常无关的描述的更改之一。作为外部人员查看这些提交,似乎开发人员在处理所需的异常重构时,碰巧想顺便做一点"清理"。如果这只是代码工作中的额外临时步骤,而不是提交的目标,那么开发人员和审阅者可能没有进一步追踪HistoryItem::stateObject的完整生命周期,这似乎是合理的。
虽然2016年10月对HistoryItem的更改不足以引入该bug,但似乎该更改很可能导致2016年12月的开发人员认为loadInSameDocument不需要增加stateObject的引用计数。
2016年10月和2016年12月的提交都非常庞大。10月的提交更改了40个文件,增加了900行,删除了1225行。12月的提交更改了95个文件,增加了1336行,删除了1325行。对于任何开发人员或审阅者来说,要详细理解这些提交中每个更改的安全影响似乎是不现实的,尤其是当它们涉及生命周期语义时。
僵尸复活
我们现在已经追踪了修复2013年漏洞的更改演变过程……然后又撤销了这些修复……所以我回过头来确定2022年的bug。这是同一个bug,但通过不同的路径触发。这就是为什么2013年的测试用例没有使本应易受CVE-2022-22620攻击的WebKit版本崩溃:
- 2013年的测试用例通过
V8History::stateAccessorAndGetter路径而不是FrameLoader::loadInSameDocument触发该bug,并且 - 作为Sergei 2013年bug报告的一部分,采取了额外的加固措施,防止在反序列化期间处理用户代码回调。
因此,我们需要弄清楚如何调用loadInSameDocument,并且不是使用反序列化来触发JavaScript回调,而是需要在loadInSameDocument函数中找到另一个会触发用户JavaScript回调的事件。
为了快速弄清楚如何调用loadInSameDocument,我修改了WebKit源代码,以便在调用loadInSameDocument时触发测试失败,然后运行fast/history目录中的所有测试。在80个测试中,有5个调用了loadInSameDocument:
- fast/history/multiple-back-forward-navigations.html
- fast/history/history-traversal-is-asynchronous.html
- fast/history/history-back-forward-within-subframe-hash.html
- fast/history/link-inside-any.html
- fast/history/timed-refresh-in-cached-frame.html
测试history-back-forward-within-subframe-hash.html和fast/history/history-traversal-is-asynchronous.html最有帮助。我们可以通过设置一个历史堆栈来触发对loadInSameDocument的调用,该堆栈中的对象位置是同一页面,但包含一个哈希(hash)。然后我们调用history.back()返回到包含带哈希URL的状态。loadInSamePage负责滚动到该位置。
history.pushState("state1", "", location + "#foo");
history.pushState("state2", ""); // 当前状态
history.back(); // 返回到 state1,触发 loadInSameDocument
既然我知道了如何调用loadInSameDocument,我就与Sergei合作,确定我们如何在loadInSameDocument函数执行期间的某个时刻(但在调用statePopped之前)获得用户代码执行(FrameLoader.cpp#1158):
m_frame.document()->statePopped(stateObject ? Ref
用户代码的回调必须发生在调用statePopped之前,因为stateObject在那里被转换为引用,因此现在将被引用计数。我们假设这将是"已释放"对象被"使用"的地方。
如果你深入研究loadInSameDocument中进行的调用,我们会发现有一条路径会派发blur事件。我们也可以使用像CodeQL这样的工具来查看是否存在从loadInSameDocument到dispatchEvent的路径,但在本例中我们只是手动审计。到blur事件的调用树是:
FrameLoader::loadInSameDocument
FrameLoader::scrollToFragmentWithParentBoundary
FrameView::scrollToFragment
FrameView::scrollToFragmentInternal
FocusController::setFocusedElement
FocusController::setFocusedFrame
dispatchWindowEvent(Event::create(eventNames().blurEvent, Event::CanBubble::No, Event::IsCancelable::No));
每当焦点从一个元素移动到另一个元素时,blur事件就会在该元素上触发。在我们的例子中,当我们需要滚动到当前页面内的新位置时,会触发loadInSameDocument。如果我们正在滚动并因此将焦点更改为新元素,则blur事件会在先前具有焦点的元素上触发。
我们触发器的最后一部分是在onblur事件处理程序中释放stateObject。为此,我们调用replaceState,它用新对象覆盖当前历史状态。这导致对stateObject的最后一个引用被丢弃,因此它被释放。loadInSameDocument在其调用statePopped时仍然使用已释放的stateObject。
input = document.body.appendChild(document.createElement("input"));
a = document.body.appendChild(document.createElement("a"));
a.id = "foo";
history.pushState("state1", "", location + "#foo");
history.pushState("state2", "");
setTimeout(() => {
input.focus();
input.onblur = () => history.replaceState("state3", "");
setTimeout(() => history.back(), 1000);
}, 1000);
在2013年和2022年的案例中,根本漏洞都是stateObject没有被正确引用计数。2013年,开发人员在修补触发漏洞的所有不同路径方面做得很好,不仅仅是提交的概念验证中的那一条。这意味着他们也消灭了loadInSameDocument中的漏洞。2016年12月的重构随后复活了该漏洞,使其能够在野外被利用,并在2022年重新修补。
结论
通常当我们谈论变体时,它们之所以存在是因为补丁不完整:供应商没有正确且完全地修复报告的漏洞。然而,对于CVE-2022-22620,该漏洞在2013年已被正确且完全地修复。它的修复只是在2016年的重构过程中出现了倒退。我们不知道攻击者在野外利用这个漏洞有多长时间,但我们知道该漏洞(再次)存在了5年:从2016年12月到2022年1月。
对于本应采取哪些不同措施,并没有简单的答案。2013年响应初始bug报告的开发人员遵循了许多最佳实践:
- 修补了触发漏洞的所有路径,不仅仅是概念验证中的那一条。这意味着他们修补了后来成为CVE-2022-22620的变体。
- 随补丁提交了测试用例。
- 详细的提交消息解释了漏洞以及他们如何修复它。
- 反序列化期间的额外加固措施。
作为一个专注于攻击性安全的研究团队,我们可以对现代软件开发团队面临的核心挑战做出假设:遗留代码、对审阅者快速周转的期望、重构和安全工作通常不受重视且回报不足,以及缺乏内存安全缓解措施。开发人员和安全团队需要时间来审查补丁,尤其是安全问题的补丁,并且奖励这些努力将会带来改变。从长远来看,这也将为供应商节省资源。在本例中,在一个漏洞最初被分类、修补、测试和发布9年后,整个过程不得不重复一遍,但这次是在野外利用的压力下进行的。
虽然这个案例研究是关于Safari/WebKit的0day,但这并不是Safari独有的问题。早在2022年,我们已经看到了针对Chromium、Windows、Pixel和iOS的在野0day,它们也是先前披露漏洞的变体。这提醒我们,作为防御者,我们都需要在审查和审计代码及补丁方面保持警惕。