作者:360漏洞研究院 戴建軍
原文鏈接:https://vul.360.net/archives/397
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的條件,相關代碼如下:

根據 (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的成因進行了分析,詳細介紹了漏洞涉及到的全部優化過程,文章最后簡單介紹了漏洞利用方法和漏洞修復方法。
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1917/
暫無評論