Exploiting a Safari information leak
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.