對於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的
但是,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