作者:360 Alpha Lab
原文鏈接:https://vul.360.net/archives/144
在2020年7月,我們向谷歌上報了一條遠程ROOT利用鏈,該利用鏈首次實現了針對谷歌旗艦機型Pixel 4的一鍵遠程ROOT,從而在用戶未察覺的情況下實現對設備的遠程控制。截至漏洞公開前,360 Alpha Lab已協助廠商完成了相關漏洞的修復。該漏洞研究成果也被2021年BlackHat USA會議收錄,相關資料可以在這里找到。該項研究成果也因其廣泛的影響力在谷歌2020年官方漏洞獎勵計劃年報中得到了公開致謝,并斬獲“安全界奧斯卡”Pwnie Awards的“史詩級成就”和“最佳提權漏洞”兩大獎項的提名。這條利用鏈也因其廣泛的影響力被我們命名為“颶風山竹”。
這篇文章將對利用鏈中使用的Chrome V8引擎漏洞(CVE-2020-6537)進行分析,并介紹該漏洞在利用過程中遇到的困難與限制,在研究過程中,我們先后提出了兩種不同利用思路,其中的心路歷程也會在文中進行分享。
The Bug
Promise.allSettled 是一個JavaScript內建函數。從MDN的介紹可以了解到,該函數接收一個可迭代的對象作為參數,并返回一個promise對象。在所有位于可迭代對象中的promise-like元素得到處理后,這個promise對象將被resolve,從而得到一個結果數組。下面是Promise.allSettled 的用法示例:
Promise.allSettled([
Promise.resolve(1),
Promise.reject(2)
])
.then(results => console.log(results));
// output will be:
// [
// {status: "fulfilled", value: 1},
// {status: "rejected", reason: 2},
// ]
可以看到,結果數組中包含了2個對象,分別描述了參數中傳遞的2個promise的處理結果。
在詳細分析V8引擎對于Promise.allSettled的實現之前,需要強調一點:只有當參數中所有的promise都被處理后,allSettled返回的promise才會被resolve,這意味著V8內部應當有相應的實現機制,用于判斷是否所有的promise都已經被處理,并決定何時resolve作為返回值的promise。
以下源碼分析均基于V8 8.4.371版本。Promise.allSettled是使用Touque language來實現的:
// ES#sec-promise.allsettled
// Promise.allSettled ( iterable )
transitioning javascript builtin PromiseAllSettled(
js-implicit context: Context, receiver: JSAny)(iterable: JSAny): JSAny {
return GeneratePromiseAll(
receiver, iterable, PromiseAllSettledResolveElementFunctor{},
PromiseAllSettledRejectElementFunctor{});
}
PromiseAllSettled僅僅是調用了GeneratePromiseAll, 然后再調用至PerformPromiseAll。這個函數代碼比較多,因此只在這里列出相關的部分:
transitioning macro PerformPromiseAll<F1: type, F2: type>(
implicit context: Context)(
constructor: JSReceiver, capability: PromiseCapability,
iter: iterator::IteratorRecord, createResolveElementFunctor: F1,
createRejectElementFunctor: F2): JSAny labels
Reject(Object) {
// ...
while (true) {
let nextValue: JSAny;
const next: JSReceiver = iterator::IteratorStep(
iter, fastIteratorResultMap) otherwise goto Done;
nextValue = iterator::IteratorValue(next, fastIteratorResultMap);
// Set remainingElementsCount.[[Value]] to
// remainingElementsCount.[[Value]] + 1.
const remainingElementsCount = UnsafeCast<Smi>(
resolveElementContext[PromiseAllResolveElementContextSlots::
kPromiseAllResolveElementRemainingSlot]);
resolveElementContext[PromiseAllResolveElementContextSlots::
kPromiseAllResolveElementRemainingSlot] =
remainingElementsCount + 1;
const resolveElementFun = createResolveElementFunctor.Call(
resolveElementContext, nativeContext, index, capability);
const rejectElementFun = createRejectElementFunctor.Call(
resolveElementContext, nativeContext, index, capability);
// Let nextPromise be ? Call(constructor, _promiseResolve_, ?
// nextValue ?).
const nextPromise = CallResolve(
UnsafeCast<Constructor>(constructor), promiseResolveFunction,
nextValue);
const then = GetProperty(nextPromise, kThenString);
const thenResult = Call(
nativeContext, then, nextPromise, resolveElementFun,
rejectElementFun);
// ...
}
return promise;
}
大致上說,這個函數對傳入的參數進行迭代,并對其中的每一個元素都調用了promiseResolve。同時,函數中使用了remainingElementsCount這個變量來代表“尚未處理完成的promise數量”,并將這個值保存在了resolveElementContext中,便于全局訪問。我們可以用下面的偽代碼來概括性的描述這個函數所做的事情:
for(promise in iterable) {
remainingElementsCount += 1
promiseResolve(promise).then(resolveElementFun, rejectElementFun)
}
當promise被resolve時,就會調用resolveElementFun;相應的,promise被reject時,就會調用 rejectElementFun 。這兩個函數分別由createResolveElementFunctor 和 createRejectElementFunctor生成,并且它們最終都會調用至PromiseAllResolveElementClosure。在這里,V8會將promise處理的結果保存至一個數組中,同時減少“尚未處理完成的promise數量”的值。
transitioning macro PromiseAllResolveElementClosure<F: type>(
implicit context: Context)(
value: JSAny, function: JSFunction, wrapResultFunctor: F): JSAny {
//...
let remainingElementsCount =
UnsafeCast<Smi>(context[PromiseAllResolveElementContextSlots::
kPromiseAllResolveElementRemainingSlot]);
remainingElementsCount = remainingElementsCount - 1; // ---> [1]
context[PromiseAllResolveElementContextSlots::
kPromiseAllResolveElementRemainingSlot] = remainingElementsCount;
if (remainingElementsCount == 0) {
const capability = UnsafeCast<PromiseCapability>(
context[PromiseAllResolveElementContextSlots::
kPromiseAllResolveElementCapabilitySlot]);
const resolve = UnsafeCast<JSAny>(capability.resolve);
Call(context, resolve, Undefined, valuesArray); // ---> [2]
}
return Undefined;
}
可以看到,函數會從resolveElementContext中讀取出remainingElementsCount,減去1,然后再保存回去(代碼[1]處)。當remainingElementsCount減少至0時,代表所有promise都處理完畢,那么函數就會將結果數組返回給用戶(代碼[2]處)。
正常而言,resolveElementFun 和rejectElementFun 這兩個函數,最多只能有一個被調用,代表著這個promise是被resolve,還是被reject(promise不可能既resolve,同時又reject)。但是,通過一些回調手法,我們可以獲得resolveElementFun 和rejectElementFun 這兩個函數對象,從而有機會同時調用這兩個函數。這將導致在處理一個promise對象時,remainingElementsCount 會被減去2次,于是進一步導致我們可以在并非所有promise都被處理完的情況下,提前拿到結果數組。此時,V8內部和我們都會持有valuesArray ,這就為類型混淆創造了機會。
Type Confusion
讓我們重新來審計PromiseAllResolveElementClosure這個函數,只不過這一次我們關心的是V8如何將promise的處理結果保存至valuesArray 中。
transitioning macro PromiseAllResolveElementClosure<F: type>(
implicit context: Context)(
value: JSAny, function: JSFunction, wrapResultFunctor: F): JSAny {
// ...
// Update the value depending on whether Promise.all or
// Promise.allSettled is called.
const updatedValue = wrapResultFunctor.Call(nativeContext, value); // ---> [3]
const identityHash =
LoadJSReceiverIdentityHash(function) otherwise unreachable;
assert(identityHash > 0);
const index = identityHash - 1;
// Check if we need to grow the [[ValuesArray]] to store {value} at {index}.
const valuesArray = UnsafeCast<JSArray>(
context[PromiseAllResolveElementContextSlots::
kPromiseAllResolveElementValuesArraySlot]);
const elements = UnsafeCast<FixedArray>(valuesArray.elements); // ---> [4]
const valuesLength = Convert<intptr>(valuesArray.length);
if (index < valuesLength) {
// The {index} is in bounds of the {values_array},
// just store the {value} and continue.
elements.objects[index] = updatedValue; // ---> [5]
}
// ...
}
在代碼 [4] 處,valuesArray 的element被直接當作FixedArray來進行處理。但與此同時,我們已經獲得了valuesArray,并能夠對其進行操作了。通過在其上設置一個較大的索引值,我們可以把它轉換為一個dictionary array,此時,就會出現FixedArray和NumberDictionary之間的類型混淆。
Exploitation
在發現這個漏洞后,我們實現的第一套利用方法,在穩定性和兼容性上都存在一定問題,但仍不失為一個有趣的思路。最終的Android Root利用鏈中,我們采取的是另一種穩定性更高的方法。下面我們將分別進行介紹。
Limitations
乍一看上去,利用FixedArray和NumberDictionary之間的類型混淆似乎很容易就能造成可控的越界寫。當V8想要將結果保存至valuesArray時,會首先檢查index是否越界。如果index < array.length,那么V8就直接將結果寫入array.elements(如代碼 [5] 所示)。這是因為V8假定了valuesArray一定是屬于PACKED_ELEMENT類型,使用的是FixedArray來存儲元素,這種類型的數組,一定會有FixedArray長度大于等于array長度的約束。如果滿足了index < array.length,顯然也就滿足了index < FixedArray.length,因此向FixedArray寫入數據是不可能發生越界的。但利用類型混淆,我們可以將valuesArray轉換為dictionary mode,此時使用的是NumberDictionary存儲元素,array.length可以是一個很大的值,而NumberDictionary的size卻可以比較小。這樣一來,我們就可以繞過index < array.length的檢查,造成越界寫。但實際情況真的是這樣嗎?
經過進一步測試發現,在Torque編譯器生成形如 elements.objects[index] = value 的代碼時,是會額外加入越界檢查的:
// src/builtins/torque-internal.tq
struct Slice<T: type> {
macro TryAtIndex(index: intptr):&T labels OutOfBounds {
if (Convert<uintptr>(index) < Convert<uintptr>(this.length)) {
return unsafe::NewReference<T>(
this.object, this.offset + index * %SizeOf<T>());
} else {
goto OutOfBounds;
}
}
// ...
}
這會最終執行到’unreachable code’,然后導致進程崩潰。因此,我們必須考慮其他方式。
另外的一個限制在于,越界寫入的數據內容并不是我們可以控制的,它總是一個JSObject的地址,這個object生成于代碼 [2] 處,例如: {status: “fulfilled”, value: 1} 。
The NumberDictionary
我們雖然能造成FixedArray與NumberDictionary之間的類型混淆,但是只能往NumberDictionary的范圍內寫入受限的內容。那么NumberDictionary中有什么內容是值得被寫的呢?
首先來看一下FixedArray與NumberDictionary的內存結構對比:

可以看到,NumberDictionary有著更多的metadata fields。Capacity代表了NumberDictionary所能保存的最大entry數量,看上去是一個比較有價值的目標。通過將其修改為一個JSObject的地址(通常是一個很大的值),我們就得到一個畸形的JSArray,并利用其中的NumberDictionary造成越界讀寫。然而,通過這種方式進行的越界訪問偏移值卻是不可預測的。
我們通過向普通NumberDictionary中寫入一次數據為例:
let arr = [];
arr[0x10000] = 0x42;
為了寫入key-value,V8需要確定兩件事情。
- 當前NumberDictionary中是否已經存在以key為索引的entry,如果存在,則只需更新該entry即可
- 如果不存在對應entry,則可以使用一個空entry,或者新增entry
這個過程是通過FindEntry來完成的。
// v8/src/objects/hash-table-inl.h
// Find entry for key otherwise return kNotFound.
template <typename Derived, typename Shape>
InternalIndex HashTable<Derived, Shape>::FindEntry(IsolateRoot isolate,
ReadOnlyRoots roots, Key key,
int32_t hash) {
uint32_t capacity = Capacity();
uint32_t count = 1;
Object undefined = roots.undefined_value();
Object the_hole = roots.the_hole_value();
USE(the_hole);
// EnsureCapacity will guarantee the hash table is never full.
for (InternalIndex entry = FirstProbe(hash, capacity);;
entry = NextProbe(entry, count++, capacity)) {
Object element = KeyAt(isolate, entry);
// Empty entry. Uses raw unchecked accessors because it is called by the
// string table during bootstrapping.
if (element == undefined) return InternalIndex::NotFound();
if (Shape::kMatchNeedsHoleCheck && element == the_hole) continue;
if (Shape::IsMatch(key, element)) return entry;
}
}
// v8/src/objects/hash-table.h
inline static InternalIndex FirstProbe(uint32_t hash, uint32_t size) {
return InternalIndex(hash & (size - 1));
}
inline static InternalIndex NextProbe(InternalIndex last, uint32_t number,
uint32_t size) {
return InternalIndex((last.as_uint32() + number) & (size - 1));
}
// v8/src/objects/dictionary-inl.h
uint32_t NumberDictionaryBaseShape::Hash(ReadOnlyRoots roots, uint32_t key) {
return ComputeSeededHash(key, HashSeed(roots));
}
函數調用了FirstProbe 來決定從何處開始搜索key。假設FirstProbe 返回了 i ,且偏移為 i 的entry不符合條件,那么V8將會調用NextProbe 來獲得下一個偏移。這個嘗試偏移的序列為:i, i + 1, i + 1 + 2, i + 1 + 2 + 3 …
FirstProbe接受2個參數,size代表NumberDictionary的容量,即我們覆寫的Capacity字段,hash 則由NumberDictionaryBaseShape::Hash計算而來。不幸的是,hash 并不是可預測的,因為V8使用了一個Int64長度的隨機值作為種子。
因此如果我們通過覆寫Capacity,以此來觸發越界,那么計算出來的偏移將可能是 [0, capacity – 1] 范圍內的任意值。
Strategy 1
既然我們不能控制越界的偏移,那么是否能利用堆噴來讓越界訪問變得“有意義”?
正常而言,在32位環境下的堆噴比64位更穩定,因為64位環境下的地址空間實在是太大了。但是在V8堆上,情況可能有所不同。目前64位下的V8開啟了名為指針壓縮的配置,確保所有的V8對象都分配在一個4GB大小的空間中。這個配置反而讓64位環境下的堆噴存在一定可能。
我們繼續來分析NumberDictionary的元素存取方式。前文說到了,將一個key-value對保存在dictionary array中時,V8需要在NumberDictionary中尋找到合適的entry。IsMatch 函數負責檢查當前的key是否等于entry中保存的key。
// v8/src/objects/dictionary-inl.h
bool NumberDictionaryBaseShape::IsMatch(uint32_t key, Object other) {
DCHECK(other.IsNumber());
return key == static_cast<uint32_t>(other.Number());
}
// v8/src/objects/objects-inl.h
double Object::Number() const {
DCHECK(IsNumber());
return IsSmi() ? static_cast<double>(Smi(this->ptr()).value())
: HeapNumber::unchecked_cast(*this).value();
}
如果entry中保存的key不是一個Smi,V8則會直接將其當作HeapNumber,然后把它的值轉為uint32_t類型。這也可以理解為一次類型混淆,因為在觸發越界訪問時,位于越界堆上的數據可能為任意object。一旦V8在越界堆上找到了所謂“正確的”entry,它將往這個entry的value field中寫入一個可控的值。因此,我們可以大量的在堆上偽造entry,然后觸發一次越界寫,讓V8往我們想要的地方寫入值,從而造成影響更大的內存破壞。下面是我們采取的方式:
- 使用JSObject
obj進行堆噴。 - 觸發一次越界寫,讓V8將
obj.properties當作dictionary_entry.key,將obj.elements當作dictionary_entry.value。因此V8將向obj.elements寫入我們控制的值。在這里我們選擇的是寫入一個double JSArrayarray。這會造成FixedArray(obj.elements)和JSArray(array)之間的類型混淆。 - 現在,類似 obj[idx] = value 的代碼將會直接修改
array的內存,例如修改其length。 - 利用這個length被修改的JSArray,達到任意地址讀寫的目標就很容易了。
下面的圖片展示了在堆噴之后的內存布局:

舉例來看,我們的目標是讓V8將0x21e60bedc760處的內存當作一個dictionary entry,同時讓V8認為它找到了正確的entry。Fake entry中的 Key field實際上是某個JSObject的 Properties field。V8會將其視作一個HeapNumber,然后把它的value轉換為uint32_t類型。在這里例子中,轉換后的值是3。因此我們只需要執行confused_array[3] = xxx,即可讓V8覆寫Fake entry的 Value field。

到這里可能有人會產生一個疑惑,既然越界訪問的偏移是隨機的,那么如果V8沒有訪問到上面描述的那個Fake entry怎么辦?實際上,我們可以通過利用V8尋找entry的特性以及合理控制堆噴的內容來解決這個問題:
- 前文已經介紹過,當entry不符合要求時,V8會利用next probe計算下一次訪問的偏移。這就像堆噴中的nop sled一樣,保證了訪問entry這個操作會持續不斷進行。
- 由于指針壓縮特性,V8的堆只有4GB大小,完全可以在一個可接受的時間內完成堆噴。
完成這一步之后,實現任意地址讀寫就是一件很簡單的事情了。目前已有很多優秀的資料介紹了相關內容,在這里不再贅述。
Strategy 2
上述利用思路存在著一些缺點:
- 它不是一個100%成功率的方案
- 它無法在32位環境中使用
而當時Pixel 4上的Chrome是32位的,意味著并不能滿足要求。在之后的幾周時間,我們找到了新的利用思路。
重新回顧這張對比圖:

除了修改Capacity之外,是否有其他更好的選擇?經過研究我們發現,MaxNumberKey這個字段有著非常特殊的含義。MaxNumberKey代表了這個數組中保存的所有元素的最大索引值,同時,其最低有效位表明了數組中是否存在特殊元素,例如accessors。以如下代碼為例,我們可以在數組上定義一個getter:
let arr = []
arr[0x10000] = 1
Object.defineProperty(arr, 0, {
get : () => {
console.log("getter called")
return 1
}
})
此時,MaxNumberKey最低有效位為0,代表存在特殊元素。但是通過漏洞,我們可以將其覆寫為一個JSObject的地址,而在V8中,任何HeapObject地址的最低位,恰好為1。即經過覆寫的數組,即使上面定義了特殊元素,V8也會認為它不再特殊。
接下來,我們需要尋找能夠充分利用這一影響的代碼,在這里我們選擇了Array.prototype.concat函數。該函數會調用至IterateElements,用于迭代被連接的數組。
bool IterateElements(Isolate* isolate, Handle<JSReceiver> receiver,
ArrayConcatVisitor* visitor) {
/* skip */
if (!visitor->has_simple_elements() ||
!HasOnlySimpleElements(isolate, *receiver)) {// ---> [6]
return IterateElementsSlow(isolate, receiver, length, visitor);
}
/* skip */
FOR_WITH_HANDLE_SCOPE(isolate, int, j = 0, j, j < fast_length, j++, {
Handle<Object> element_value(elements->get(j), isolate);// ---> [7]
if (!element_value->IsTheHole(isolate)) {
if (!visitor->visit(j, element_value)) return false;
} else {
Maybe<bool> maybe = JSReceiver::HasElement(array, j);
if (maybe.IsNothing()) return false;
if (maybe.FromJust()) {
ASSIGN_RETURN_ON_EXCEPTION_VALUE(isolate, element_value,
JSReceiver::GetElement(isolate, array, j), false);// ---> [8]
if (!visitor->visit(j, element_value)) return false;
}
}
});
/* skip */
}
在代碼[6]處,V8檢查了數組是否含有特殊元素,我們通過覆寫MaxNumberKey,可以繞過這一檢查,讓函數進入后續的快速遍歷路徑。在代碼[8]處,GetElement將觸發accessor,執行自定義的js代碼,從而有機會將數組的長度改為一個更小的值。隨著遍歷循環中的索引不斷增大,最終在代碼[7]處,會發生越界讀。
var arr; // 假設arr是一個類型混淆后的數組
var victim_arr = new Array(0x200);
Object.defineProperty(arr, 0, {
get : () => {
print("=== getter called ===");
victim_arr.length = 0x10; // 在回調函數中修改數組長度
gc();
return 1;
}
});
// 越界讀
arr.concat(victim_arr);
通過上述方案,我們將原本的類型混淆,轉換成了另一處越界讀問題。利用越界讀,我們就擁有了fake obj的能力,進而也可以輕松實現任意地址讀寫了。
Conclusion
本文對CVE-2020-6537的成因進行了分析,并介紹了兩種利用思路。這個漏洞是我們遠程ROOT Pixel 4利用鏈中的一環,在后面的系列文章中,我們將會對利用鏈中的提權漏洞進行詳細介紹。
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1691/
暫無評論