0x00 概述
对于Project Zero来说,这是平常的一周,我们收到了来自Chrome团队的一封电子邮件,他们一直在调查一个严重的崩溃问题,该现象偶尔会在Android版本的Chrome上发生,但调查工作并没有取得太大的进展。借助ClusterFuzz工具,有人短暂复现了这一崩溃情况,其中包含一个引用外部网站的测试用例,但无法再次复现。看起来,似乎只能等待这个问题在合适的时机再次出现。
我们迅速浏览了关于该问题的详细信息,发现这个问题看起来非常关键,于是决定花费一些时间帮助Chrome团队定位问题的所在。我们之所以关注这个问题,很大的一部分原因是担心这个外部网站很可能会触发易受攻击的代码路径。这个漏洞似乎也非常容易被利用,根据我们所掌握的ASAN跟踪,这里存在一个越界堆写入,可能导致从网络读取数据。
尽管Chrome中的网络功能代码已经被拆分成一个新的服务进程,但还没有针对该进程实施严格的沙箱化,因此这仍然是一个高权限的攻击面。正因如此,这个漏洞就足以实现初始代码执行,并能实现Chrome沙箱逃逸。
在这篇文章中,我们将说明,即使是经验丰富的研究人员,在尝试理解复杂代码段中的漏洞时,也可能会遇到困难。最后的结局是令人开心的,我们成功帮助Chrome团队找到问题所在并解决问题。在这里,持久性比攻击方法要更加重要。
0x01 测试用例
最开始,我们得到了相当简单的测试用例,如下所示:
< script > window.open("http://example.com"); window.location = " < /script >
细心的读者可能会注意到,对于模糊测试的人员来说,这里得到的是非常平常的输出——上述过程只会加载两个网页。也许这可以对用户行为进行有效的模拟,这样的测试用例,也许是能够找到网络栈漏洞的一种好方法?
根据线程,我们无法再次复现该漏洞,所以现在我们只能看到使用ClusterFuzz首次触发漏洞时的ASAN回溯:
================================================================= ==12590==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x8e389bf1 at pc 0xec0defe8 bp 0x90e93960 sp 0x90e93538 WRITE of size 3848 at 0x8e389bf1 thread T598 (NetworkService) #0 0xec0defe4 in __asan_memcpy #1 0xa0d1433a in net::SpdyReadQueue::Dequeue(char*, unsigned int) net/spdy/spdy_read_queue.cc:43:5 #2 0xa0d17c24 in net::SpdyHttpStream::DoBufferedReadCallback() net/spdy/spdy_http_stream.cc:637:30 #3 0x9f39be54 in base::internal::CallbackBase::polymorphic_invoke() const base/callback_internal.h:161:25 #4 0x9f39be54 in base::OnceCallback::Run() && base/callback.h:97 #5 0x9f39be54 in base::TaskAnnotator::RunTask(char const*, base::PendingTask*) base/task/common/task_annotator.cc:142 ... #17 0xea222ff6 in __start_thread bionic/libc/bionic/clone.cpp:52:16 0x8e389bf1 is located 0 bytes to the right of 1-byte region [0x8e389bf0,0x8e389bf1) allocated by thread T598 (NetworkService) here: #0 0xec0ed42c in operator new[](unsigned int) #1 0xa0d52b78 in net::IOBuffer::IOBuffer(int) net/base/io_buffer.cc:33:11 Thread T598 (NetworkService) created by T0 (oid.apps.chrome) here: #0 0xec0cb4e0 in pthread_create #1 0x9bfbbc9a in base::(anonymous namespace)::CreateThread(unsigned int, bool, base::PlatformThread::Delegate*, base::PlatformThreadHandle*, base::ThreadPriority) base/threading/platform_thread_posix.cc:120:13 #2 0x95a07c18 in __cxa_finalize SUMMARY: AddressSanitizer: heap-buffer-overflow (/system/lib/libclang_rt.asan-arm-android.so+0x93fe4) Shadow bytes around the buggy address: 0xdae49320: fa fa 04 fa fa fa fd fa fa fa fd fa fa fa fd fa 0xdae49330: fa fa 00 04 fa fa 00 fa fa fa 00 fa fa fa fd fd 0xdae49340: fa fa fd fd fa fa fd fa fa fa fd fd fa fa fd fa 0xdae49350: fa fa fd fd fa fa fd fd fa fa fd fd fa fa fd fd 0xdae49360: fa fa fd fd fa fa fd fa fa fa fd fd fa fa fd fd =>0xdae49370: fa fa fd fd fa fa fd fd fa fa fd fa fa fa[01]fa 0xdae49380: fa fa fd fa fa fa fd fa fa fa fd fd fa fa fd fd 0xdae49390: fa fa fd fd fa fa fd fd fa fa fd fd fa fa fd fd 0xdae493a0: fa fa fd fd fa fa fd fa fa fa 00 fa fa fa 04 fa 0xdae493b0: fa fa 00 fa fa fa 04 fa fa fa 00 00 fa fa 00 fa 0xdae493c0: fa fa 00 fa fa fa 00 fa fa fa 00 fa fa fa 00 fa Shadow byte legend (one shadow byte represents 8 application bytes): Addressable:00 Partially addressable: 01 02 03 04 05 06 07 Heap left redzone: fa Freed heap region: fd Stack left redzone: f1 Stack mid redzone: f2 Stack right redzone: f3 Stack after return: f5 Stack use after scope: f8 Global redzone: f9 Global init order: f6 Poisoned by user: f7 Container overflow: fc Array cookie: ac Intra object redzone: bb ASan internal: fe Left alloca redzone: ca Right alloca redzone: cb Shadow gap: cc ==12590==ABORTING
这看起来是一个非常严重的问题。这是堆缓冲区溢出写入的数据,可能直接来源于网络。但是,我们没有可以用于Android环境的Chrome开发环境,因此我们决定尝试寻找漏洞存在的根本原因。起初我们以为,要找到一个IOBuffer大小的位置,这个过程并不会太难。
0x02 HttpCache::Transaction
由于我们无法再使用ClusterFuzz复现漏洞,所以我们就假设Web服务器或网络配置已经发生更改,并开始研究代码。
我们追溯在SpdyHttpStream::DoBufferedReadCallback中写入IOBuffer的位置,我们可能需要寻找HttpNetworkTransaction::Read的调用站点(Call Site),因为参数buf传递的IOBuffer大小与作为buf_len传递的长度不匹配。调用站点并不多,但是其中并没有明显存在问题的地方,我们花费了几天的时间,来回顾一个看上去没有希望的推论。
也许,我们从最开始就出现了错误,然后通过研究Chrome的崩溃转储存储库来尝试收集有关该漏洞的更多信息。但事实证明,有很多崩溃会产生我们根本无法解释的类似栈跟踪信息,这对于寻找漏洞来说没有帮助。经过了大约一周左右的研究,我们收集到大量相关的崩溃信息,但仍然没有找到这一问题的根本原因。
在此前的多次阅读代码的过程中,我们都忽略了其中的一段关键内容。当我们已经接近要放弃时,我们在
HttpCache::Transaction::WriteResponseInfoToEntry中发现了以下代码: // When writing headers, we normally only write the non-transient headers. bool skip_transient_headers = true; scoped_refptr data(new PickledIOBuffer()); response.Persist(data->pickle(), skip_transient_headers, truncated); data->Done(); io_buf_len_ = data->pickle()->size(); // Summarize some info on cacheability in memory. Don’t do it if doomed // since then |entry_| isn’t definitive for |cache_key_|. if (!entry_->doomed) { cache_->GetCurrentBackend()->SetEntryInMemoryData( cache_key_, ComputeUnusablePerCachingHeaders() ? HINT_UNUSABLE_PER_CACHING_HEADERS : 0); }
这段代码看起来非常可疑!在同一文件中的其他地方,可以明显看到io_buf_len_与IOBuffer read_buf_的大小相匹配。实际上,这个假设被用于将导致Read调用的调用中:
int HttpCache::Transaction::DoNetworkReadCacheWrite() { TRACE_EVENT0("io", "HttpCacheTransaction::DoNetworkReadCacheWrite"); DCHECK(InWriters()); TransitionToState(STATE_NETWORK_READ_CACHE_WRITE_COMPLETE); return entry_->writers->Read(read_buf_, io_buf_len_, io_callback_, this); }
上面的内容,符合我们所掌握的漏洞的线索,并且在目前看来,这是我们的最大突破口。但是,要想以一种有效的方式来到达这段代码并不容易。在HTTP缓存中,实现了一个具有大约50种不同状态的状态机。该状态机通常在请求期间运行两次——分别是在请求启动时(HttpCache::Transaction::Start)和读取响应数据时(HttpCache::Transaction::Read)。为了到达这段代码,我们需要在状态转换中的一个循环,让我们可以从某个Read状态回到可以调用WriteResponseInfoToEntry的状态,然后再进行转换以读取数据,并且不更新read_buf_ pointer的指针。因此,我们重点关注这个状态机的第二次运行,也就是说,从Read调用可以到达的状态。
WriteResponseInfoToEntry共有4个调用站点,都位于状态处理程序(State Handler)中:
DoCacheWriteUpdatedPrefetchResponse
DoCacheUpdateStaleWhileRevalidateTimeout
DoCacheWriteUpdatedResponse
DoCacheWriteResponse
我们首先需要确定是否存在从HttpCache::Transaction::Read到这些状态的转换路径,否则我们将不会得到先前read_buf_和io_buf_len_的值。
由于很难通过查看代码来推断出状态机的转换情况,因此我们绘制了一个状态机的示意图,使大家可以简单、轻松地理解。
在前期,这种绘图的方法非常明智。如果我们只是手动在源代码中对状态机进行深度优先搜索,那么会非常容易出错,并且难以理解。
标记为黄色的四个状态是可以更改io_buf_len_值的状态,而TransitionToReadingState的三个子状态(即:CACHE_READ_DATA、NETWORK_READ和NETWORK_READ_CACHE_WRITE,在图中标记为绿色)可以使用修改后的io_buf_len_值。
我们首先可以排除掉TOGGLE_UNUSED_SINCE_PREFETCH,因为只有在预获取后发送的第一个请求才能到达TOGGLE_UNUSED_SINCE_PREFETCH,并且在缓存条目相匹配的情况下,该请求会在Start期间产生,而并非在Read期间产生。
我们还可以排除CACHE_WRITE_UPDATED_RESPONSE,因为只有当前不在Read状态的时候,才能实现这一转换:
int HttpCache::Transaction::DoUpdateCachedResponse() { TRACE_EVENT0("io", "HttpCacheTransaction::DoUpdateCachedResponse"); int rv = OK; // Update the cached response based on the headers and properties of // new_response_. response_.headers->Update(*new_response_->headers.get()); response_.stale_revalidate_timeout = base::Time(); response_.response_time = new_response_->response_time; response_.request_time = new_response_->request_time; response_.network_accessed = new_response_->network_accessed; response_.unused_since_prefetch = new_response_->unused_since_prefetch; response_.ssl_info = new_response_->ssl_info; if (new_response_->vary_data.is_valid()) { response_.vary_data = new_response_->vary_data; } else if (response_.vary_data.is_valid()) { // There is a vary header in the stored response but not in the current one. // Update the data with the new request headers. HttpVaryData new_vary_data; new_vary_data.Init(*request_, *response_.headers.get()); response_.vary_data = new_vary_data; } if (response_.headers->HasHeaderValue("cache-control", "no-store")) { if (!entry_->doomed) { int ret = cache_->DoomEntry(cache_key_, nullptr); DCHECK_EQ(OK, ret); } TransitionToState(STATE_UPDATE_CACHED_RESPONSE_COMPLETE); } else { // If we are already reading, we already updated the headers for this // request; doing it again will change Content-Length. if (!reading_) { TransitionToState(STATE_CACHE_WRITE_UPDATED_RESPONSE); rv = OK; } else { TransitionToState(STATE_UPDATE_CACHED_RESPONSE_COMPLETE); } } return rv; }
这样一来,我们就还剩下可能会触发该漏洞的两种状态——CACHE_UPDATE_STALE_WHILE_REVALIDATE_TIMEOUT和CACHE_WRITE_RESPONSE。那么,我们需要分析到TransitionToReadingState的转换,并尝试将其结合起来。TransitionToReadingState只有一种转换方式,来自于FINISH_HEADERS_COMPLETE:
// If already reading, that means it is a partial request coming back to the // headers phase, continue to the appropriate reading state. if (reading_) { int rv = TransitionToReadingState(); DCHECK_EQ(OK, rv); return OK; }
我们希望在这里设置为reading_,由于此时已经设置为Read,并且应该不会被清除,所以目前看起来是一个不错的选择。但是,我们回顾刚刚的绘图,在所有正常情况下,我们实际上不会访问到图中的大多数状态。为了到达GET_BACKEND或START_PARTIAL_CACHE_VALIDATION,我们需要经过DoPartialCacheReadCompleted或DoPartialNetworkReadCompleted,并实现以下转换中的一个:
int HttpCache::Transaction::DoPartialCacheReadCompleted(int result) { partial_->OnCacheReadCompleted(result); if (result == 0 && mode_ == READ_WRITE) { // We need to move on to the next range. TransitionToState(STATE_START_PARTIAL_CACHE_VALIDATION); } else if (result < 0) { return OnCacheReadError(result, false); } else { TransitionToState(STATE_NONE); } return result; } int HttpCache::Transaction::DoPartialNetworkReadCompleted(int result) { DCHECK(partial_); // Go to the next range if nothing returned or return the result. // TODO(shivanisha) Simplify this condition if possible. It was introduced // in https://codereview.chromium.org/545101 if (result != 0 || truncated_ || !(partial_->IsLastRange() || mode_ == WRITE)) { partial_->OnNetworkReadCompleted(result); if (result == 0) { // We need to move on to the next range. if (network_trans_) { ResetNetworkTransaction(); } else if (InWriters() && entry_->writers->network_transaction()) { SaveNetworkTransactionInfo(*(entry_->writers->network_transaction())); entry_->writers->ResetNetworkTransaction(); } TransitionToState(STATE_START_PARTIAL_CACHE_VALIDATION); } else { TransitionToState(STATE_NONE); } return result; } // Request completed. if (result == 0) { DoneWithEntry(true); } TransitionToState(STATE_NONE); return result; }
在最初的研究过程中,我们浪费了一些时间,但在越过最初的障碍后,又卡在了这里。我们尝试通过这段代码(负责根据部分输入的缓存条目进行验证)来找到可行的路径。我们需要找到一种特殊的条件,从而使得先前一部分缓存中的请求来响应完整的请求。然后,需要让先前的部分请求无法通过重新验证。但遗憾的是,我们尝试了很多可以让浏览器发送部分请求的方法,都没有能触发想要的代码路径。
在这里,我们明显看到代码存在漏洞,因此我们建议对Chrome进行修复,添加一个CHECK过程,以确保在发生此类状态转换循环时不会成为可以利用的漏洞。
0x03 找到触发方式
由于相关代码没有经过更改,并且测试用例引用了外部服务器,所以在最开始,我们所有人都认为是因为服务器端发生了变动,才导致漏洞无法复现。但是,在我们向Chrome提供研究进展的同时,一位针对此问题进行研究的Chrome开发人员发现,他们仍然可以在与原来的ClusterFuzz报告完全相同的Chromium Android版本上复现此问题。
基于上述事实,我们判断,之所以这个漏洞无法在Android上复现,可能是因为一些无关的更改已经影响了调度,从而防止这一漏洞的触发。对于我们来说,这是一个非常关键的信息,可以节省我们用于创建环境来发现问题的大量时间。于是,我们应用了CHECK,并进行了测试,确认该问题仍然存在。
到这里,我们对此前的思考过程进行了更深入的质疑,并且立即尝试在Linux中使用相同版本进行复现尝试。
我们发现,导致这一切的罪魁祸首似乎是所访问网站上的某个图像,该图像是使用loading=lazy属性进行的加载。我们在稳定的桌面版Chrome中启用了这个功能(该功能在Android中是启用的)。浏览器发出的请求如下:
GET /image.bmp HTTP/1.1 (1) Accept-Encoding: identity Range: bytes=0-2047 HTTP/1.1 206 Partial Content Content-Length: 2048 Content-Range: bytes 0-2047/9999 Last-Modified: 2019-01-01 Vary: Range GET /image.bmp HTTP/1.1 (2) Accept-Encoding: gzip, deflate, br If-Modified-Since: 2019-01-01 Range: bytes=0-2047 HTTP/1.1 304 Not Modified Content-Length: 2048 Content-Range: bytes 0-2047/9999 Last-Modified: 2019-01-01 Vary: Range GET /image.bmp HTTP/1.1 (3) Accept-Encoding: gzip, deflate, br HTTP/1.1 200 OK Content-Length: 9999
经过合理的混淆和修改,我们最终将触发条件缩减到上述请求-响应顺序上。我们能从中看出什么?
使用Chrome的跟踪功能,我们可以看到状态机的路径。在这里,实际上涉及两个HttpCache::Transaction对象。第一个请求来自于第一个事务,读取前2048个字节并将其存储到缓存中。然后,第二个和第三个请求来自于第二个事务,请求的是完整的数据。由于存在URL对应的缓存条目,因此会首先通过发送第二个请求,来验证缓存条目是否有效。
由于该条目有效,因此事务将开始读取(进入Read状态),就如同在缓存中读取完整的响应一样。但是,由于我们的缓存条目并不完整,因此还需要第三个请求来检索其余的响应数据。在处理第三个请求时,发生了危险的状态转换。当浏览器尝试确定如何验证服务器对数据的第二部分的响应时,就会发生这种情况,需要发送不同的Range标头,因此会打破服务器的Vary限制。由于服务器没有提供强大的验证机制(类似于Etag),因此浏览器无法确定它是否已经将两个完全不同的响应拼接到一起,因此它必须从头开始重新进行请求。但是,这时已经将响应的标头返回给调用代码,因此会尝试透明地执行此操作,而不会退出状态机。这样也就触发了漏洞。
请注意,延迟在这里是有帮助的。如果第一个请求的响应花费了过长时间才到达浏览器,那么第二个请求将会从头开始,而不再对缓存进行验证,也就不会触发该漏洞。如果我们使用远程服务器进行漏洞复现,需要对测试代码进行一些修改,以确保顺序的正确。
下图展示了从第二个事务执行读取时,遵循的状态转换过程:
为了利用这一漏洞,我们需要注意一些事情。为了在不退出状态机的情况下进入到可以在Read调用中循环返回的状态,我们需要触发对DoRestartPartialRequest的调用。这将使当前的缓存条目失效,并且会截断存储的响应数据。这也意味着,当我们到达CACHE_READ_RESPONSE时,将无法控制这里所使用的值:
int HttpCache::Transaction::DoCacheReadResponse() { TRACE_EVENT0("io", "HttpCacheTransaction::DoCacheReadResponse"); DCHECK(entry_); TransitionToState(STATE_CACHE_READ_RESPONSE_COMPLETE); io_buf_len_ = entry_->disk_entry->GetDataSize(kResponseInfoIndex); read_buf_ = base::MakeRefCounted(io_buf_len_); net_log_.BeginEvent(NetLogEventType::HTTP_CACHE_READ_INFO); return entry_->disk_entry->ReadData(kResponseInfoIndex, 0, read_buf_.get(), io_buf_len_, io_callback_); }
实际上,由于该条目已经被截断,因此io_buf_len_将始终为0。
然而,在对WriteResponseInfoToEntry的调用中,我们对CACHE_WRITE_RESPONSE期间设置的值具有完全的控制:
// When writing headers, we normally only write the non-transient headers. bool skip_transient_headers = true; scoped_refptr data(new PickledIOBuffer()); response_.Persist(data->pickle(), skip_transient_headers, truncated); data->Done(); io_buf_len_ = data->pickle()->size();
正如前面所说,在使用不正确的长度从网络读取响应数据时,将会发生越界写入的漏洞。尽管现在使用的长度是Non-transient标头的大小,但在响应正文中写入的字节数将与服务器返回的字节数相同,因此我们可以准确控制写入特定的字节数,从而控制要利用的内存损坏原语。
0x04 漏洞利用
至此,我们已经找到了一个强大的原语。漏洞使我们能在堆分配结束后写入指定大小的受控数据。然而,这里还有一个问题需要解决,根据漏洞的工作原理,我们要覆盖的分配大小始终为0。
与大多数其他分配器一样,tcmalloc以最小的类进行存储,也就是最多16个字节,这就导致存在两个问题。首先,“有用的”对象(即包含我们可能要覆盖的指针的对象)通常比该对象要大。其次,size类非常拥挤,因为几乎每个对网络进程的IPC调用都会触发其中的分配和释放。因此,我们并不能使用大量提取API进行堆喷射或堆修饰(Heap Spraying或Heap Grooming)。遗憾的是,网络进程中几乎没有适合16字节的对象类型,并且创建对象类型不会触发其他分配。
在这里也有一些好消息。如果网络进程崩溃,它将以静默的方式重新启动。因此,如果我们无法保证其可靠性,我们可以进行多次尝试,使用攻击程序尝试多次后可能会成功。
NetToMojoPendingBuffer
通过枚举与网络进程相关的小类,我们找到了一个能相对较快地构建“write-what-where”原语的对象。在每个URLLoader::ReadMore调用时,都会创建一个新的NetToMojoPendingBuffer对象,因此攻击者可以通过延迟在Web服务器端分配响应块来控制这些分配。
class COMPONENT_EXPORT(NETWORK_CPP) NetToMojoPendingBuffer : public base::RefCountedThreadSafe { mojo::ScopedDataPipeProducerHandle handle_; void* buffer_; };
我们不需要担心覆盖handle_,因为当Chrome遇到无效的句柄时,它会直接返回而不会发生崩溃。将要写入缓冲区后备存储的数据时下一个HTTP响应块,因此这部分也可以实现完整的控制。
不过,有一个问题。如果没有单独的信息泄露,仅凭借这个原语是无法满足要求的。我们要想让漏洞利用更加强大,一个比较有效的思路是对buffer_进行部分覆盖,然后破坏其他size类中的对象。但是,指针永远不会分配给常规堆地址。相反,NetToMojoPendingBuffer对象的后备存储分配在仅用于IPC且不包含对象的共享内存区域之中,因此这里不存在损坏的地方。
除了NetToMojoPendingBuffer之外,我们在16字节size类中没有找到任何有帮助的东西。
分析STL容器
幸运的是,我们不仅仅局限于C++类和结构。相反,我们可以针对任意大小的缓冲区,例如容器后备存储。例如,当一个元素被插入到一个空的std::vector时,我们为后备存储分配一个空间,该空间仅用于单个元素。在随后的插入中,如果没有剩余空间,则它会增加一倍。其他一些容器类也以类似的方式运行。因此,如果我们能精确地控制对指针向量的插入,就可以对其中一个指针进行部分覆盖,从而将漏洞转化为某种类型的混淆。
WatcherDispatcher
当我们尝试使用NetToMojoPendingBuffer时,出现了与WatcherDispatcher相关的崩溃。WatcherDispatcher类并非特定于网络进程,它是Mojo的基本结构之一,在IPC消息的发送和接收中都得到了广泛的使用。类的布局如下:
class WatcherDispatcher : public Dispatcher { using WatchSet = std::set; base::Lock lock_; bool armed_ = false; bool closed_ = false; base::flat_map watches_; base::flat_map watched_handles_; WatchSet ready_watches_; const Watch* last_watch_to_block_arming_ = nullptr; }; class Watch : public base::RefCountedThreadSafe { const scoped_refptr watcher_; const scoped_refptr dispatcher_; const uintptr_t context_; const MojoHandleSignals signals_; const MojoTriggerCondition condition_; MojoResult last_known_result_ = MOJO_RESULT_UNKNOWN; MojoHandleSignalsState last_known_signals_state_ = {0, 0}; base::Lock notification_lock_; bool is_cancelled_ = false; }; MojoResult WatcherDispatcher::Close() { // We swap out all the watched handle information onto the stack so we can // call into their dispatchers without our own lock held. base::flat_map watches; { base::AutoLock lock(lock_); if (closed_) return MOJO_RESULT_INVALID_ARGUMENT; closed_ = true; std::swap(watches, watches_); watched_handles_.clear(); } // Remove all refs from our watched dispatchers and fire cancellations. for (auto& entry : watches) { entry.second->dispatcher()->RemoveWatcherRef(this, entry.first); entry.second->Cancel(); } return MOJO_RESULT_OK; }
实际上,std::flat_map由std::vector支持,并且watched_handles_在大多数情况下仅包含一个元素,该元素恰好占用16个字节。这意味着,我们可以覆盖Watch指针!
Watch类的大小相对较大,为104个字节,由于tcmalloc的原因,我们只能将大小相似的对象作为目标对象的部分覆盖对象。此外,目标对象在某些偏移处应该包含有效的指针,以使Watch方法的调用不受影响。遗憾的是,网络进程似乎没有包含可以满足上述简单类型混淆要求的类。
但是,我们可以利用Watch是一个引用计数类的事实。我们的思路是,喷射许多Watch size的缓冲区,tcmalloc将其放置在实际Watch对象的旁边,并希望带有被覆盖的最低有效字节的scoped_refptr指向我们的缓冲区之一。缓冲区应该有第一个64位字,也就是伪引用计数器,设置为1,其余设置为0。在这种情况下,对WatcherDispatcher::Close的调用将释放scoped_refptr,这将会导致删除虚假的Watch,析构函数将正常完成,缓冲区将被释放。
如果我们计划将缓冲区发送到攻击者的服务器或返回渲染器进程,那么将会泄露tcmalloc的freelist指针,另一种思路是,如果我们想办法在此期间分配其他内容,则可能会泄露一些有用的指针。因此,我们现在需要尝试在网络进程中创建此类缓冲区,并延迟发送它们,直到发生损坏。
事实证明,Chrome中的网络进程还负责处理WebSocket连接。重要的是,WebSocket是一种低开销的协议,它允许传输二进制数据。如果我们使连接的接收端足够慢,并且发送足够的数据来填充OS套接字发送缓冲区,直到TCPClientSocket::Write变为“asynchronous”(异步)操作为止,随后对WebSocket::send的调用将导致原始帧数据存储为IOBuffer对象,而每个调用仅有两个额外的32字节分配。此外,我们可以通过调整接收方的延迟,来控制缓冲区的生命。
看上去,我们找到了一个近乎完美的堆喷射(Heap Spraying)原语。不过,它有一个缺点——无法释放单个缓冲区。在发送当前批处理或断开连接时,与连接相关的所有帧都会立即释放。我们显然不能为每个喷射对象建立一个WebSocket连接,并且上述每个操作都会在堆中产生很多我们不希望得到的“噪音”。但是,我们先不考虑这些。
下面是该方法的概述:
遗憾的是,我们很快就证明了,watched_handles_不是太理想的方案。其缺点在于:
1、实际上,有两个flat_map成员,但我们只能使用其中一个成员,因为watched_handles_的损坏会在RemoveWatcherRef虚拟方法调用期间立即引起崩溃。
2、每个WatcherDispatcher分配,都会在我们关注的size类中产生很多不希望得到的“噪音”。
3、对于Watch size类的指针,其LSSB可能有16个(= 256 / GCD(112, 256))可能的值,其中大多数甚至都不指向对象的开头。
尽管我们可以利用这种方法泄露一些数据,但成功率相对偏低。这种方法本身似乎是合理的,但我们必须找到一个更“方便”的容器来进行覆盖。
WebSocket框架
现在,我们可以仔细研究一下如何发送WebSocket框架。
class NET_EXPORT WebSocketChannel { [...] std::unique_ptr data_being_sent_; // Data that is queued up to write after the current write completes. // Only non-NULL when such data actually exists. std::unique_ptr data_to_send_next_; [...] }; class WebSocketChannel::SendBuffer { std::vector frames_; uint64_t total_bytes_; }; struct NET_EXPORT WebSocketFrameHeader { typedef int OpCode; bool final; bool reserved1; bool reserved2; bool reserved3; OpCode opcode; bool masked; uint64_t payload_length; }; struct NET_EXPORT_PRIVATE WebSocketFrame { WebSocketFrameHeader header; scoped_refptr data; }; ChannelState WebSocketChannel::SendFrameInternal( bool fin, WebSocketFrameHeader::OpCode op_code, scoped_refptr buffer, uint64_t size) { [...] if (data_being_sent_) { // Either the link to the WebSocket server is saturated, or several // messages are being sent in a batch. if (!data_to_send_next_) data_to_send_next_ = std::make_unique(); data_to_send_next_->AddFrame(std::move(frame)); return CHANNEL_ALIVE; } data_being_sent_ = std::make_unique(); data_being_sent_->AddFrame(std::move(frame)); return WriteFrames(); }
WebSocketChannel使用两个单独的SendBuffer对象来存储传出帧。在连接饱和后,新的帧将会进入data_to_send_next_。并且,由于缓冲区由std::vector<std::unique_ptr
如上所述,我们的堆喷射技术为每个所需的分配提供了两个额外的32字节分配。但遗憾的是,WebSocketFrame(我们打算覆盖的指针)大小正好是32个字节。这意味着,除非我们使用其他的堆操作技巧,否则在堆喷射期间生成的所有对象只有1/3属于正确的类型。另一方面,与Watch相比,这个size类中LSB的可选值只有一半,并且指向正确分配的开始部分的概率更大一些。更重要的是,与WatcherDispatcher不同,WebSocket::Send除了调整目标std::vector的大小之外,不会触发任何分配,因此size类的堆喷射会非常简洁。总而言之,我们现在认为data_to_send_next_是最好的目标。
分配方式
由于缺少更可靠的选项,我们只能使用WebSocket::Send作为默认的堆操作工具。它至少需要做到:
1、喷射32字节的缓冲区,我们要覆盖其中的WebSocketFrame指针。
2、插入目标向量,并创建绑定的WebSocketFrame。
3、分配IOBuffer对象,替代释放的缓冲区。
上面红色标记的对象是“不需要的”分配。每个不需要的分配,都会对漏洞利用的可靠性产生负面影响,但在目前,我们还没有办法避开它们,只能希望通过多次尝试来得到成功的结果。
信息泄露
一旦可以相对可靠地覆盖WebSocketFrame指针,我们就可以将仅允许损坏16字节Bucket损坏的原语,转换为让我们可以从32字节Bucket中释放分配的新原语。由于data_to_send_next_使用std::unique_ptr而不是scoped_refptr,因此我们也不需要关注虚假的引用计数器。要释放虚假的WebSocketFrame的唯一要求是数据指针应为null。
我们可以使用这个原语来构建非常有用的信息泄露,这样的泄露将使我们能够了解Chrome二进制文件在内存中的位置,以及我们可以在堆上控制的数据的位置,这就为我们完成漏洞利用提供了所需的所有信息。
如果在我们的堆操作中使用WebSockets,其优点之一在于浏览器将把存储在这些帧中的数据发送到服务器。因此,如果我们可以利用它来释放一个已经排队等待发送的IOBuffer的后备存储,我们就可以泄露该分配的新内容。另外,由于这个size类与IOBuffer对象的分配大小相匹配,因此我们可以将空闲的后备存储替换为新的IOBuffer对象。这样一来,就导致泄露IOBuffer vtable指针,这是我们需要的第一个信息。
但是,IOBuffer对象还包含一个指向其后备存储的指针,这是我们能控制大小的堆分配。如果我们保证它位于一个不会干扰其他堆操作的size类中,那么我们现在就可以泄露该指针,然后在漏洞利用中,我们可以释放这个分配,并将其重新使用,以得到更多有用的信息。
代码执行
假设我们可以重复使用泄露地址的更大分配,那么我们就离漏洞的成功利用越来越近了。我们知道可以在其中写入一些数据,我们也知道应该在此处写入什么数据,并且我们也具有相对强大的32字节原语可以实现信息泄露。
但遗憾的是,正如上文所说,对于单独分配IOBuffers或WebSocketFrames,我们没有真正的好方法。但好事成双,尽管对于信息泄露我们没有过多的灵活性(需要释放IOBuffer后备存储,并且需要使用IOBuffer对象进行替换),但在下一步的利用中,我们有几种选择可以尝试增加成功的概率。
由于我们不再对释放IOBuffer后备存储感兴趣,因此可以将这些分配移动到不同的size类中,这样一来,我们现在只有三中不同的对象类型来自32字节Bucket:WebSocketFrame、IOBuffer和SendBuffer。如果我们可以完美地喷射,那么应该就能够为每个“受害者WebSocketFrame”安排3对“目标IOBuffer”和“目标WebSocketFrame”。这意味着,当我们通过再次触发漏洞来损坏指向“受害者WebSocketFrame”的指针时,释放IOBuffer或释放WebSocketFrame的可能性是相同的。
通过精心设计替换对象,我们可以利用这两种可能性。在WebSocketFrame或IOBuffer的析构函数调用期间,我们会获得执行控制权。在WebSocketFrame中唯一真正重要的字段是数据指针,该数据指针需要指向IOBuffer对象。由于它对应IOBuffer对象末尾的填充字节,因此我们可以创建一个替换对象,该对象可以填充释放的IOBuffer或释放的WebSocketFrame的空间。
然后,释放替换对象时,如果我们替换了IOBuffer,则当递减ref_count_结果为0时,我们将通过伪造的vtable进行虚拟调用。如果我们替换了WebSocketFrame,则WebSocketFrame将释放其数据成员,我们已经指向了另一个伪造的IOBuffer,它将再次通过伪造的vtable进行虚拟调用。
在上述所有过程中,我们一直忽略了一个小细节,这主要归功于我们精心的事先准备。我们需要将第二个伪造的IOBuffer和伪造的vtable放入已知地址的内存中。但遗憾的是,由于泄露的IOBuffer对象将被释放,所以我们无法释放之前泄露地址的分配。
不过,这并不是主要的问题。我们可以为较大的分配选择一个“安静的”Bucket大小。如果我们使用来自两个不同websocket的后备缓冲区预先准备Bucket大小,那么就可以释放这些websocket中的一个,以确保泄露的地址与第二个websocket缓冲区相邻。泄漏地址后,我们可以释放第二个websocket,并用虚假的对象替换该相邻缓冲区的内容。
总而言之,我们在已知地址使用较大IOBuffer的后备数据将受控数据写入内存。它包含一个伪造的IOBuffer vtable,以及我们的代码重用Payload和另一个伪造的IOBuffer。然后,我们可以再次触发该漏洞,这一次会导致IOBuffer对象IOBuffer对象或WebSocketFrame对象的Use-After-Free,这两个对象都将使用较大的IOBuffer备份数据的指针,该数据现在位于已知地址。
释放损坏的对象后,我们的Payload就会运行,漏洞利用的工作就几乎完成了。
回顾组件
目前,我们已经进行了许多研究,对于想要深入探究漏洞利用源代码的读者来说,下面是快速细分:
serv.py:一个自定义的Web服务器,它将仅处理对图像文件的请求,并返回适当顺序的响应以触发漏洞。
pywebsocket.diff:pywebsocket的一些补丁,可以删除压缩,并为websocket服务器设置SO_RCVBUF。
get_chrome_offsets.py:一个脚本,附加到运行的浏览器中,并收集Payload的所有必需偏移量。需要预先安装Frida。
CrossThreadSleep.py:实现了一个基本的可睡眠等待原语,该原语用于使websocket服务器中的各个线程休眠,并将它们从其他线程唤醒。
exploit/echo_wsh.py:pywebsocket的websocket处理程序,负责处理几种消息类型,这些消息类型将导致定时延迟或可唤醒延迟,从而允许我们进行需要的Socket缓冲操作。
exploit/wake_up_wsh.py:pywebsocket的websocket处理程序,处理多个控制消息以唤醒休眠的“echo”Socket。
exploit/exploit.html:用于实现利用逻辑的JavaScript代码。
我们还提供了一些脚本,让读者更容易获得存在该问题的Chromium构建版本,并正确设置其余环境:
get_chromium.sh:一个Shell脚本,负责配置易受攻击的Chrome版本。
get_pywebsocket.sh:一个Shell脚本,将从漏洞利用服务器下载并修补pywebsocket。
run_pywebsocket.sh:启动漏洞利用服务器的Shell脚本,我们需要单独运行serv.py脚本。
漏洞利用服务器在两个端口上运行:exploit.html由websocket服务器提供服务,第二个服务器提供用于触发漏洞的映像。
0x05 更可靠的利用
在这里,我们已经获得了一种漏洞利用方式,有一定概率能够成功。它会在堆喷射期间创建大量“垃圾”分配,并且我们对命中正确的对象类型做出了许多假设。接下来,我们来评估一次漏洞利用成功的可能性:
考虑到一次运行大约需要1分钟,显然,如果需要我们尝试多次才能成功利用漏洞,这并非一个很好的结果。
基于Cookie的堆修饰(Heap Grooming)
为了使堆喷射更加可靠,我们需要一种方法来分离“好的”和“不好的”32字节分配。正如读者可能已经注意到的内容,在网络进程中精确地操纵堆并不是一件容易的事,特别是对于无法利用的渲染器的位置而言。
HTTP Cookie是我们还没有考虑过的网络功能之一,但令我们惊讶的是,网络进程不仅负责发送和接收Cookie,还负责将它们存储在内存中,并将其保存到磁盘。由于存在用于Cookie操作的JavaScript API,并且操作似乎不复杂,因此我们可以使用该API来构建起他堆操作原语。
经过实验,我们构造了三个新的堆操作原语:
实际上,它们比我们最初预期的要简单很多。举例来说,这是Frida脚本的输出,在执行free_slot()方法期间,该脚本跟踪32字节堆状态转换,顾名思义,该方法只是将新条目添加到32字节的空闲列表中。
如上所示,这可能不是简单、整洁的原语,但是却能满足我们的需要!
我们将新方法集成到漏洞利用程序中,于是我们就摆脱了堆喷射存储区域中所有不需要的分配。如下图所示:
在最坏的情况下,我们将使用相同的值覆盖WebSocketFrame指针的LSB,将不会产生任何影响。这样一来,我们就将2/8的概率提升到了7/8。
同样,这个方法也有一些限制,例如:
1、在Chrome中,网站上可以容纳的Cookie数量上限为180个。
2、第512个与Cookie相关的操作都会触发将内存中的Cookie存储刷新到磁盘上,这回破坏任何堆喷射。
3、每30秒还会进行一次自动的刷新。
4、在每次堆喷射之前,我们网站的Cookie应该处于特定的状态,否则操作方法可能会产生不可靠的结果。
幸运的是,我们的漏洞利用程序可以自动应对上述大多数限制。堆喷射必需分成小块,但可以单独处理。因此,由于我们已经达到已连接WebSocket数量的最大数量的堆喷射大小限制,因此实际利用最终会变得不是这么可靠。但是,结合重新启动网络进程的功能,似乎通常只需要尝试2-3次就可以成功,这要比之前的版本更稳定。
0x06 总结
回顾这个漏洞,我们可以归纳出两点需要注意的问题:
1、将一个变量赋予两种不同的含义是不好的编码习惯。即使最开始编写的代码是正确的,在后续迭代中也很可能会出现问题。
2、不推荐使用C语言的风格编写C++程序,独立存储缓冲区内容和其大小的IOBuffer设计模式是非常危险的。
状态机的代码非常复杂,似乎可以考虑对代码进行不同的设计从而降低复杂度。然而,当前的代码也是随着HTTP协议的发展而逐步演变而来,对其进行重写也有可能会产生新的复杂性或引入新的漏洞。
Chrome持续进行的维护工作,对漏洞的利用产生了一些有趣的影响。首先,将这个代码移动到单独的服务进程(网络服务)中对发现可靠的堆修饰原语的难度产生了巨大的影响。这意味着,即使没有适当的沙箱,由于Chrome中的网络服务实现,也将导致针对网络栈的漏洞利用比以前更难。与较大的组件相比,实现相对较少功能的服务进程是更难以进行漏洞利用的目标。开发人员减少了可用组件数量,这也减少了攻击者的可选目标。我们此前没有仔细考虑过这个问题,所以这也算是研究过程中的一个惊喜。
其次,重新启动服务进程有助于漏洞利用。对于漏洞利用开发人员而言,如果能根据需要进行多次尝试,可以减轻很大的压力,我们可以拥有更多创建和使用不可靠原语的自由。之所以我们选择Linux,是考虑尝试构建更为稳定的信息泄露。在其他平台上,漏洞利用可能会更容易。
考虑到额外的复杂性和可靠性问题,攻击者目前不太可能直接利用这个漏洞。渲染器漏洞和OS/内核特权提升的“传统”浏览器漏洞利用链易于编写也易于维护。但是,如果攻击者觉得这样的利用链不会受到太大关注,就有可能会转向这类漏洞的利用。并且我们可以证明,可以有效利用这类漏洞。这表明,即使是对于一些不太明显的攻击面,也需要进行沙箱检查,这对于浏览器的整体安全性来说也至关重要。
本文翻译自:https://googleprojectzero.blogspot.com/2020/02/several-months-in-life-of-part1.html https://googleprojectzero.blogspot.com/2020/02/several-months-in-life-of-part2.html如若转载,请注明原文地址: