Arrays and array-like objects in JavaScript are primary targets for some simple yet efficient optimizations. The core observation is that a lot of arrays will contain only elements of the same basic type such as 32-bit integers or doubles. Every major engine thus implements certain optimizations to allow fast access and dense representation for different types of elements.

In JavaScriptCore, the JavaScript engine employed in WebKit, the way in which elements are stored in an object is represented as an IndexingType value, an 8-bit integer representing a combination of flags. The definition can be found in IndexingType.h. Throughout the code, the engine will often inspect (or emit code which inspects) the indexing type of an object and decide which one of several specialized fast paths to use based on that. One important indexing type is ArrayWithUndecided, which indicates that all elements are undefined and no actual values have to be stored. In this case, the engine can leave the elements uninitialized for increased performance.

Let’s now look at an old version of the code implementing Array.prototype.concat which lives inside ArrayPrototype.cpp:

EncodedJSValue JSC_HOST_CALL arrayProtoPrivateFuncConcatMemcpy(ExecState* exec)
{
    ...

    unsigned resultSize = checkedResultSize.unsafeGet();
    IndexingType firstType = firstArray->indexingType();
    IndexingType secondType = secondArray->indexingType();
    IndexingType type = firstArray->mergeIndexingTypeForCopying(secondType); // [[ 1 ]]
    if (type == NonArray || !firstArray->canFastCopy(vm, secondArray) || resultSize >= MIN_SPARSE_ARRAY_INDEX) {
        ...
    }

    JSGlobalObject* lexicalGlobalObject = exec->lexicalGlobalObject();
    Structure* resultStructure = lexicalGlobalObject->arrayStructureForIndexingTypeDuringAllocation(type);
    if (UNLIKELY(hasAnyArrayStorage(resultStructure->indexingType())))
        return JSValue::encode(jsNull());

    ASSERT(!lexicalGlobalObject->isHavingABadTime());
    ObjectInitializationScope initializationScope(vm);
    JSArray* result = JSArray::tryCreateUninitializedRestricted(initializationScope, resultStructure, resultSize);
    if (UNLIKELY(!result)) {
        throwOutOfMemoryError(exec, scope);
        return encodedJSValue();
    }

    if (type == ArrayWithDouble) {
        [[ 2 ]]
        double* buffer = result->butterfly()->contiguousDouble().data();
        memcpy(buffer, firstButterfly->contiguousDouble().data(), sizeof(JSValue) * firstArraySize);
        memcpy(buffer + firstArraySize, secondButterfly->contiguousDouble().data(), sizeof(JSValue) * secondArraySize);
    } else if (type != ArrayWithUndecided) {

    ...

This function determines the indexing type of the resulting array at [[ 1 ]], and we can see that if the indexing type is ArrayWithDouble, it will take the fast path at [[ 2 ]]. Let’s now look at the implementation of mergeIndexingTypeForCopying which is responsible for determining the indexing type of the resulting array when Array.prototype.concat is called:

inline IndexingType JSArray::mergeIndexingTypeForCopying(IndexingType other)
{
    IndexingType type = indexingType();
    if (!(type & IsArray && other & IsArray))
        return NonArray;

    if (hasAnyArrayStorage(type) || hasAnyArrayStorage(other))
        return NonArray;

    if (type == ArrayWithUndecided)
        return other; [[ 3 ]]

    ...

We can see here that in the case where one input array has an indexing type of ArrayWithUndecided, the resulting indexing type would be the indexing type of the other array. Therefore, if we called Array.prototype.concat with one array having an indexing type of ArrayWithUndecided and another having an indexing type of ArrayWithDouble, we’ll end up taking the fast path at [[ 2 ]] which would concatenate the two array by creating raw copies of the elements backing the two arrays.

We can see that this code does not ensure that the two butterflies are properly initialized before calling memcpy. This means that if we could find a code path that lets us create an unitialized array to be passed to Array.prototype.concat, we would end up with an array containing uninitialized values from the heap, but with an indexing type that is not ArrayWithUndecided. In a sense, this primitive is the inverse of what an old bug reported by lokihardt in 2017 leads to.

One way to create such an array is by using the NewArrayWithSize DFG JIT opcode (although it can also be achieved using pure standard library calls as demonstrated by another exploit that does not rely on JIT compilation at all). Looking at the FTL implementation allocateJSArray of this opcode inside FTLLowerDFGToB3.cpp, we can see that the array will remain uninitialized. It does not have to initialize the array because the indexing type is ArrayWithUndecided.

ArrayValues allocateJSArray(LValue publicLength, LValue vectorLength, LValue structure, LValue indexingType, bool shouldInitializeElements = true, bool shouldLargeArraySizeCreateArrayStorage = true)
{
    [ ... ]
    initializeArrayElements(
	indexingType,
	shouldInitializeElements ? m_out.int32Zero : publicLength, vectorLength,
	butterfly);

...
void initializeArrayElements(LValue indexingType, LValue begin, LValue end, LValue butterfly)
{

    if (begin == end)
        return;

    if (indexingType->hasInt32()) {
        IndexingType rawIndexingType = static_cast<IndexingType>(indexingType->asInt32());
        if (hasUndecided(rawIndexingType))
            return;  // [[ 4 ]]

As such, the expression new Array(n), when compiled by the FTL JIT will hit [[ 4 ]] and return an array of indexing type ArrayWithUndecided with uninitialized elements.

Exploitation

Given the previous analysis, triggering this bug is not hard: we repeatedly call a function which creates an array using new Array() and then calls concat on that array and an array containing only doubles. We call the function enough times so that it gets compiled by the FTL compiler.

The exploit leverages this bug to leak the address of a target object. It works by spraying memory with our object and scalar objects. We then trigger the bug and inspect the array returned to find the address of our object.

Conclusion

This bug was patched and a patched version of Safari rolled out with the release of iOS 12 and macOS Mojave. It was assigned CVE-2018-4358 and was fixed in commit b68b373dcbfbc68682ceeca8292c5c0051472071.