作者: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 。這兩個函數分別由createResolveElementFunctorcreateRejectElementFunctor生成,并且它們最終都會調用至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]處)。

正常而言,resolveElementFunrejectElementFun 這兩個函數,最多只能有一個被調用,代表著這個promise是被resolve,還是被reject(promise不可能既resolve,同時又reject)。但是,通過一些回調手法,我們可以獲得resolveElementFunrejectElementFun 這兩個函數對象,從而有機會同時調用這兩個函數。這將導致在處理一個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的內存結構對比:

img

可以看到,NumberDictionary有著更多的metadata fields。Capacity代表了NumberDictionary所能保存的最大entry數量,看上去是一個比較有價值的目標。通過將其修改為一個JSObject的地址(通常是一個很大的值),我們就得到一個畸形的JSArray,并利用其中的NumberDictionary造成越界讀寫。然而,通過這種方式進行的越界訪問偏移值卻是不可預測的。

我們通過向普通NumberDictionary中寫入一次數據為例:

let arr = [];
arr[0x10000] = 0x42;

為了寫入key-value,V8需要確定兩件事情。

  1. 當前NumberDictionary中是否已經存在以key為索引的entry,如果存在,則只需更新該entry即可
  2. 如果不存在對應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往我們想要的地方寫入值,從而造成影響更大的內存破壞。下面是我們采取的方式:

  1. 使用JSObject obj 進行堆噴。
  2. 觸發一次越界寫,讓V8將 obj.properties 當作 dictionary_entry.key ,將 obj.elements 當作 dictionary_entry.value 。因此V8將向 obj.elements 寫入我們控制的值。在這里我們選擇的是寫入一個double JSArray array。這會造成FixedArray(obj.elements)和JSArray(array)之間的類型混淆。
  3. 現在,類似 obj[idx] = value 的代碼將會直接修改 array 的內存,例如修改其length。
  4. 利用這個length被修改的JSArray,達到任意地址讀寫的目標就很容易了。

下面的圖片展示了在堆噴之后的內存布局:

img

舉例來看,我們的目標是讓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。

img

到這里可能有人會產生一個疑惑,既然越界訪問的偏移是隨機的,那么如果V8沒有訪問到上面描述的那個Fake entry怎么辦?實際上,我們可以通過利用V8尋找entry的特性以及合理控制堆噴的內容來解決這個問題:

  1. 前文已經介紹過,當entry不符合要求時,V8會利用next probe計算下一次訪問的偏移。這就像堆噴中的nop sled一樣,保證了訪問entry這個操作會持續不斷進行。
  2. 由于指針壓縮特性,V8的堆只有4GB大小,完全可以在一個可接受的時間內完成堆噴。

完成這一步之后,實現任意地址讀寫就是一件很簡單的事情了。目前已有很多優秀的資料介紹了相關內容,在這里不再贅述。

Strategy 2

上述利用思路存在著一些缺點:

  • 它不是一個100%成功率的方案
  • 它無法在32位環境中使用

而當時Pixel 4上的Chrome是32位的,意味著并不能滿足要求。在之后的幾周時間,我們找到了新的利用思路。

重新回顧這張對比圖:

img

除了修改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利用鏈中的一環,在后面的系列文章中,我們將會對利用鏈中的提權漏洞進行詳細介紹。


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1691/