Math.abs JIT Optimization Bug in JSC

語言: CN / TW / HK

2021年天府杯我們成功完成iPhone 13 pro RCE的目標,這篇文章將會詳細介紹其中使用到的Safari JavaScriptCore(JSC) 漏洞,漏洞編號為CVE-2021-30953。

ArithNegate

在JSC的JIT FTL優化過程中,對於 -n 的表示式會生成ArithNegate opcode,且ArithNegate會伴隨相應的ArithMode,ArithMode有如下幾種定義:

enum Mode {
    NotSet, // Arithmetic mode is either not relevant because we're using doubles anyway or we are at a phase in compilation where we don't know what we're doing, yet. Should never see this after FixupPhase except for nodes that take doubles as inputs already.
    Unchecked, // Don't check anything and just do the direct hardware operation.
    CheckOverflow, // Check for overflow but don't bother with negative zero.
    CheckOverflowAndNegativeZero, // Check for both overflow and negative zero.
    DoOverflow // Up-convert to the smallest type that soundly represents all possible results after input type speculation.
};

相信從註釋中大家也能明白他們的含義,這裡我們主要關注Unchecked和CheckOverflow,顧名思義Unchecked表示不需要對ArithNegate操作做任何檢查,CheckOverflow則需要檢查是否產生溢位。那麼 -n 操作為什麼需要檢查溢位呢?什麼資料能導致 -n 操作產生溢位呢?

我們都知道在INT32型別中,有一個INT_MIN,它的實際值是-2147483648,在JSC中,-(-2147483648)的結果會是什麼呢?我們來看一個例子:

n = -2147483648 (INT_MIN)

let y = -n;  // 2147483648 in 64bit value

let z = -n; // -2147483648 in 32 bit value, but overflow check normally

在JSC中,所有Number型別均採用64位浮點數表達,但是如果在JIT過程中n的型別是32位,則編譯器會認為ArithNegate操作產生的結果也是32位的,且會附加上CheckOverflow的檢查,所以當n=-2147483648時,-n的結果也會是-2147483648,如果此時ArithMode為CheckOverflow,則會發生bailout,如若ArithMode為Unchecked,則不會bailout。

我們來看看ArithNegate的JIT編譯函式:

void compileArithNegate()
    {
        switch (m_node->child1().useKind()) {
        case Int32Use: {
            LValue value = lowInt32(m_node->child1());

            LValue result;
            if (!shouldCheckOverflow(m_node->arithMode()))
                result = m_out.neg(value);
            else if (!shouldCheckNegativeZero(m_node->arithMode())) {
                CheckValue* check = m_out.speculateSub(m_out.int32Zero, value);
                blessSpeculation(check, Overflow, noValue(), nullptr, m_origin);
                result = check;
            } else {
                speculate(Overflow, noValue(), nullptr, m_out.testIsZero32(value, m_out.constInt32(0x7fffffff)));
                result = m_out.neg(value);
            }

            setInt32(result);
            break;
        }

從程式碼中也能看出,CheckOverflow會產生溢位檢查的彙編程式碼,Unchecked則直接產生 neg 彙編指令。

CheckInBounds

JSC中針對陣列的訪問,FTL SSALowering優化階段會引入一個index範圍檢查的opcode: CheckInBounds,相應的程式碼如下:

case GetByVal: {
        lowerBoundsCheck(m_graph.varArgChild(m_node, 0), m_graph.varArgChild(m_node, 1), m_graph.varArgChild(m_node, 2));
        break;
    }

case PutByVal:
case PutByValDirect: {
    Edge base = m_graph.varArgChild(m_node, 0);
    Edge index = m_graph.varArgChild(m_node, 1);
    Edge storage = m_graph.varArgChild(m_node, 3);
    if (lowerBoundsCheck(base, index, storage))
        break;

...

Node* length = m_insertionSet.insertNode(
    m_nodeIndex, SpecInt32Only, op, m_node->origin,
    OpInfo(m_node->arrayMode().asWord()), Edge(base.node(), KnownCellUse), storage);
checkInBounds = m_insertionSet.insertNode(
    m_nodeIndex, SpecInt32Only, CheckInBounds, m_node->origin,
    index, Edge(length, KnownInt32Use));

編譯 CheckInBounds 的函式如下:

void compileCheckInBounds()
    {
        speculate(
            OutOfBounds, noValue(), nullptr,
            m_out.aboveOrEqual(lowInt32(m_node->child1()), lowInt32(m_node->child2())));

從程式碼中也可以看出,CheckInBounds實際就是檢查 index>= 0 && index < array.length。

DFGIntegerRangeOptimization

JSC FTL優化的 DFGIntegerRangeOptimization階段,會刪除一些它認為冗餘的溢位和範圍檢查,例如下面的程式碼:

for (var i = 0; i < array.length; ++i) array[i];

執行到該階段之前,迴圈體內相應的主要opcode如下:

CheckInBounds
GetByVal

很顯然從JS程式碼中可以看出,i 的範圍是[0, array.length),所以DFGIntegerRangeOptimization認為CheckInBounds是可以刪除掉的,經該階段優化之後,迴圈體內的opcode只剩GetByVal。

GetByVal

DFGIntegerRangeOptimization通過for (var i = 0; i < array.length; ++i)建立兩個關係:Relationship(i >=0)和Relationship(i < array.length),而這兩個關係剛好滿足優化CheckInBounds的條件,相關程式碼如下:

case CheckInBounds: {
    auto iter = m_relationships.find(node->child1().node());
    if (iter == m_relationships.end())
        break;

    bool nonNegative = false;
    bool lessThanLength = false;
    for (Relationship relationship : iter->value) {
        if (relationship.minValueOfLeft() >= 0)
            nonNegative = true;                                       // (1)

        if (relationship.right() == node->child2().node()) {
            if (relationship.kind() == Relationship::Equal
                && relationship.offset() < 0)
                lessThanLength = true;

            if (relationship.kind() == Relationship::LessThan
                && relationship.offset() <= 0)
                lessThanLength = true;                               // (2)
        }
    }

    if (DFGIntegerRangeOptimizationPhaseInternal::verbose)
        dataLogLn("CheckInBounds ", node, " has: ", nonNegative, " ", lessThanLength);

    if (nonNegative && lessThanLength) {
        executeNode(block->at(nodeIndex));
        if (UNLIKELY(Options::validateBoundsCheckElimination()) && node->op() == CheckInBounds)
            m_insertionSet.insertNode(nodeIndex, SpecNone, AssertInBounds, node->origin, node->child1(), node->child2());
        // We just need to make sure we are a value-producing node.
        node->convertToIdentityOn(node->child1().node());            // (3)
        changed = true;
    }
    break;
}

根據 (1) && (2) 優化CheckInBounds(3)。從上述程式碼中可以總結出這樣一個結論:要想優化CheckInBounds,必須建立兩個Relationships:index >=0 和 index < array.length。

The Bug

DFGIntegerRangeOptimization會通過如下程式碼給 i = ArithAbs(n) 建立 i >= 0的關係:

case ArithAbs: {
    if (node->child1().useKind() != Int32Use)
        break;
    setRelationship(Relationship(node, m_zero, Relationship::GreaterThan, -1));
    break;

當 n < 0 且 Math.abs(n) 不會產生溢位的時候,DFGIntegerRangeOptimization會將 Math.abs(n)轉化成 ArithNegate(n),且 ArithMode 為 Unchecked,相關程式碼如下:

case ArithAbs: {
    if (node->child1().useKind() != Int32Use)
        break;
    ...

    executeNode(block->at(nodeIndex));

    if (minValue >= 0) {
        node->convertToIdentityOn(node->child1().node());
        changed = true;
        break;
    }
    bool absIsUnchecked = !shouldCheckOverflow(node->arithMode());         // (1)
    if (maxValue < 0 || (absIsUnchecked && maxValue <= 0)) {
        node->convertToArithNegate();                                      // (2)
        if (absIsUnchecked || minValue > std::numeric_limits<int>::min())
            node->setArithMode(Arith::Unchecked);                          // (3)
        changed = true;
        break;
    }

結合上述兩段程式碼,如下例項程式碼會產生關係 i >= 0,且 Math.abs(n) 轉換成 -n,但此時 ArithMode 為 CheckOverflow。

if(n < -1){
    let i = Math.abs(n); // => (-n), CheckOverflow, i>=0;
}

那麼關鍵問題就在於:要想 -int_min 操作不會被檢查CheckOverflow,即 ArithNegate 的 ArithMode被設定成Arith::Unchecked(3),則 ArithAbs 的 ArithMode也必須為 Arith::Unchecked。

此時問題轉化成如何將 ArithAbs 的 ArithMode 設定成 Arith::Unchecked。

在Fixup階段會設定 ArithAbs 的 ArithMode:

case ArithAbs: {
    if (node->child1()->shouldSpeculateInt32OrBoolean()
        && node->canSpeculateInt32(FixupPass)) {
        fixIntOrBooleanEdge(node->child1());
        if (bytecodeCanTruncateInteger(node->arithNodeFlags()))    // (1)
            node->setArithMode(Arith::Unchecked);
        else
            node->setArithMode(Arith::CheckOverflow);
        node->clearFlags(NodeMustGenerate);
        node->setResult(NodeResultInt32);
        break;
    }

如果滿足條件(1),則會將 ArithMode 設定成 Unchecked。bytecodeCanTruncateInteger函式程式碼如下:

static inline bool bytecodeUsesAsNumber(NodeFlags flags)
{
    return !!(flags & NodeBytecodeUsesAsNumber);
}

static inline bool bytecodeCanTruncateInteger(NodeFlags flags)
{
    return !bytecodeUsesAsNumber(flags);
}

此時問題轉化成如何將 ArithAbs 的 NodeFlags設定成 ~NodeBytecodeUsesAsNumber。

而 NodeFlags 的設定操作發生在 BackwardsPropagation階段:

case ArithBitOr:    //(1)
case ArithBitXor:
case ValueBitAnd:
case ValueBitOr:
case ValueBitXor:
case ValueBitLShift:
case ArithBitLShift:
case ArithBitRShift:
case ValueBitRShift:
case BitURShift:
case ArithIMul: {
    flags |= NodeBytecodeUsesAsInt;
    flags &= ~(NodeBytecodeUsesAsNumber | NodeBytecodeNeedsNegZero | NodeBytecodeUsesAsOther);
    flags &= ~NodeBytecodeUsesAsArrayIndex;
    node->child1()->mergeFlags(flags); // (2)
    node->child2()->mergeFlags(flags);
    break;
}

ArithBitOr 的操作會將 ArithBitOr->child1->flags 設定成 ~NodeBytecodeUsesAsNumber。

結合BackwardsPropagation階段的程式碼來看看如下例項:

if(n < -1){
    let i = Math.abs(n) | 0; 
}

此時 ArithBitOr->child1() 即是 ArithAbs(n),那麼ArithAbs(n)->flags 會 merge( ~NodeBytecodeUsesAsNumber),將 ArithAbs 的 NodeFlags設定成 ~NodeBytecodeUsesAsNumber。然而 DFGIntegerRangeOptimization 階段並沒有 ArithBitOr 的優化處理,則 Math.abs(n)>= 0 的關係並不會傳遞到 i 。

此時問題轉化成如何將 Math.abs(n) | 0 轉換成 Math.abs(n)。

StrengthReduction 階段解決了該問題:

case ArithBitOr:
    handleCommutativity();

    if (m_node->child1().useKind() != UntypedUse && m_node->child2()->isInt32Constant() && !m_node->child2()->asInt32()) {
        convertToIdentityOverChild1(); // (1)
        break;
    }
    break;

當 ArithBitOr->child2() 等於0時,ArithBitOr 被轉換成 child1(),從而 Math.abs(n) | 0 轉換成 Math.abs(n)。

把上述涉及到的幾個優化階段串聯起來:

結合上述的優化流程,如下例項程式碼則成功優化 CheckInBounds:

function jit(arr, n) {
    // Force n to be a 32bit integer
    n |= 0;
    if (n < -1) {
        let i = Math.abs(n)|0;                  // (1) i >= 0, Unchecked
        if (i < arr.length) {                   // (2) i < array.length
                arr[i] = 1.04380972981885e-310; // (3) remove CheckInBounds
        }
    }
}

程式碼(1)建立關係 i >= 0;程式碼(2)建立關係 i < arr.length,則程式碼(3)處的 CheckInBounds會被優化。

再結合文章開始分析的,當 n = -2147483648 時,i = -n = -2147483648,整數溢位不會被檢查,而此時 arr[i] 也沒有CheckInBounds檢查,則發生越界寫。

Exploit

漏洞利用採用比較常規的方法,通過越界寫完成addrOf 和 fakeObj 兩個原語,再結合 Samuel Groß介紹的方法完成任意地址讀寫。JSC公開的利用方法有很多,在這裡就不詳細介紹了。

Patch

DFGIntegerRangeOptimization 在建立 ArithAbs >= 0關係時,增加了對 ArithMode 和最小值的檢查。

Conclusion

本文對CVE-2021-30953的成因進行了分析,詳細介紹了漏洞涉及到的全部優化過程,文章最後簡單介紹了漏洞利用方法和漏洞修復方法。