Math.abs JIT Optimization Bug in JSC
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的成因進行了分析,詳細介紹了漏洞涉及到的全部優化過程,文章最後簡單介紹了漏洞利用方法和漏洞修復方法。