JavaScript Engine Internal Concepts
When we run JavaScript, the JavaScript engine is the browser component that handles executing it. Over the years, these engines have grown from simple interpreters to complex, full blown compilation pipelines that include multiple layers of optimizations.
Diving too deep into the specifics of one engine without having a general "lay of the land" can be both daunting and detrimental. In this module, we will aim to define the concepts that JavaScript engines must implement while highlighting 'problem points' that force engines to make unique design decisions.
In the following modules, we will explore how JavaScriptCore (WebKit) and V8 (Chromium) choose to implement those concepts and do their best to manage the sharper edges.
JS Values
An immediate & fundamental architectural issue pops up almost as soon as we begin trying to implement a JS engine. Namely, these engines are written in C++, a language with strong typing requirements, while JavaScript has very generic, dynamic, and weakly typed objects.
Clearly, there must be a way to describe these JS objects using C++. We could choose to store
a type
and value
in a class to approximate this:
C++class JSValue {
uint64_t type;
Value* value;
}
// Pointer to object somewhere
JSValue* obj;
JavaScript can freely change the type
, C++ can just use the value
in whatever way is
contextually appropriate. Speaking very coarsely, this is the strategy used to represent JS
types and objects in both engines.
Unfortunately, the naive approach can quickly become wasteful. For example, consider the memory footprint if we try to store a number:
sizeof(JSValue*) + sizeof(JSValue) + sizeof(Value) >= 32
However, we know from a previous section that JavaScript integers are at most 32 bits. In effect, we end up "spending" 32 bytes of memory in C++ to represent a single 32-bit number:
- 8 byte (64-bit)
uint64_t
- 8 byte (64-bit)
pointer (Value *)
- 8 byte (64-bit minimum)
Value
- 8 byte (64-bit)
pointer (JSValue * obj)
Although trivial for many applications, in the context of modern web browsers, this can become a huge waste of both memory and performance.
JS Numbers
Given the problem of space-efficiency mentioned above, let's explore ways of more efficiently representing our special 32-bit numbers.
As a first attempt, we can try to inline the value
. We know that this will contain a number,
so there's no need for the pointer dereference:
C++class JSNumber {
uint64_t type;
uint64_t value;
}
JSNumber* obj;
This ends up costing us 24 bytes, because we save a pointer dereference. However, we can do even better than that. JavaScript integers are only 32 bits long, so why not use some of the extra space within a single integer field to store type information?
C++class JSNumber {
uint64_t type_and_value;
}
JSNumber* obj;
Now we're down to 16 bytes, and if we avoid using pointers, just 8. This leads to another problem, however. Consider the following code:
C++class JSNumber {
uint64_t type_and_value;
}
JSNumber obj;
JSObject* obj2;
The native representations of both JSNumber
and JSObject *
just look like 64-bit numbers. How do the engines tell the
difference between these? If they ever get it wrong, it would lead to a type-confusion
(i.e. treating a number as a pointer or vice versa) and would very likely be exploitable.
Pointer Tagging / NaN Boxing
The problem of deciding between JSNumber
and pointer (and similar situations) is
solved by using some sort of marker within the 64 bit values to differentiate the two.
Each engine chooses a different approach here, so we will leave the details for the appropriate module later in this training. However, at a high level, their strategies are:
V8: Pointer Tagging (use a spare bit to decide if pointer/number)
JSC: NaN Boxing - Encode that information in IEEE 754 double-precision floats
JS Objects
In the previous section, we explored how we can represent some primitive JavaScript types using C++. However, we know that full-blown JavaScript Objects are essential for the vast majority of "interesting" code to function.
Unlike our primitive Value or Number types, JS Objects require a lot more book-keeping from our end:
- Object Type
- Arbitrary Sized Key-Value store (properties)
- Prototype
- Length (for Arrays)
- Pointer to memory buffer (TypedArrays)
- etc
The simplest way forward is to try to extend our JSValue class to incorporate all the features we need:
C++class JSObject {
uint64_t type;
std::unordered_map<JSValue, JSValue> properties;
JSValue prototype;
... // Objects can add more fields
}
As you may imagine, this class also has some "waste" problems just like our initial JSNumber:
- Hash Map is bad for caching + wasteful for small number of keys
- Prototype and property keys duplicated for same object types
Right off the bat, we can make some improvements:
- Store properties as an Array
- Share type information
C++class Type {
uint64_t type;
JSValue prototype;
Name* property_names; // Indexes into property_array
// other shared metadata
}
class JSObject {
Type* type_information;
JSValue* property_array;
... // Objects can add more fields
}
In this way, every "kind" of object shares the same Type
information, so we don't have to
"carry around" copies of it with every JavaScript object.
Furthermore, since most objects have relatively few properties, we save both time and space by choosing to store properties as an Array.
Property Array
As mentioned, we can store properties in an array to offset some of the issues with using a hashmap:
C++class JSObject {
Type* type_information;
JSValue* property_array;
... // Objects can add more fields
}
However, this begs the question of what happens when we have a JS Object that does need a large number of properties. The answer: simply switch to using a hashmap when it makes sense performance wise!
Since we have a special case for some properties and many properties, why not also have one for a few properties:
C++class JSObject {
Type* type_information;
JSValue* property_array ;
JSValue[] inline_properties;
... // Objects can add more fields
}
In this manner, code can add properties to the inline_properties
array and save a pointer
dereference. This is especially useful for scenarios where you have a lot of small objects.
Elements
Elements are just properties indexed by numbers instead of by keys:
JavaScriptlet b = {};
b['a'] = 0x41424344; // This is a named property
b[0] = 0x41424344; // This is an element
let c = [1,2,3,4]; // Arrays store elements
The easiest way to implement this is to treat elements like properties. Of course, we run into the problem of slow access, as this will grow at O(n).
There is also the issue of "large gaps" between elements:
JavaScriptlet a = [];
a[0] = 1;
a[100000] = 2;
Actually allocating and taking up that much memory would be extremely wasteful. Instead, the engines dynamically choose whether or not to use a traditional array or a hashmap ("sparse" array) based on how the array is being used.
JavaScriptlet a = []; // Empty element array
a[0] = 1; // Element array length 1
a[100000] = 2; // Switch to hashmap
Shared Object Type
As mentioned in the JS Object section, sharing type information across objects is another way of saving space and increasing performance.
This is done by creating a structure that is used specifically for describing objects' types. Then, we simply use a pointer to that structure whenever we have a JS Object with that type.
Shared Object Type - Finding Shared Types
It may seem strange to talk about "type" in this context, as we've stressed that JavaScript is very weakly typed at best. This is true from the user's (i.e. programmer's) perspective, but "under the hood", the engine still keeps track of what "kind" each object is.
So, if we create a new object, how does the engine find the right Type
to assign to it?
JavaScript// We start with an empty object Type (lets call it type_0)
let obj = {};
// Create new Type for changed object (lets call it type_1)
obj.a = 1;
// Again we start with an empty object Type (type_0)
let obj2 = {};
// What do we do?
obj2.a = 2;
This is handled by the concept of Type Transitions: The main idea being to store changes between types as a tree:
- When making a new
Type
, store what was changed as an edge - When trying to see if a type exists, check edges
Conclusion
In this module, we introduced some of the fundamental concepts of how JavaScript engines commonly structure their objects and types. There are many, many things that we omitted, but these concepts are the essential building blocks from the standpoint of vulnerability research against JavaScript engine internals.
Some main points to keep in mind:
Engines use tagging/NaN-boxing to tell numbers & objects apart
Objects have type information, properties, elements, and inline properties
Objects share Type structures to cut down on metadata duplication
Type structures build trees of transitions between types
We will explore the relevant specifics of each engine in the following modules.