Compromising the Renderer Process
Modern JavaScript introduces module syntax, allowing developers to organize code into separate files. A more recent enhancement is the support for asynchronous modules, notably the top-level await
. In Firefox’s JavaScript engine SpiderMonkey, much of the implementation of this feature is done using built-in JavaScript code. Below, we examine a function from SpiderMonkey’s codebase located in /js/src/builtin/Module.js
:
function GatherAsyncParentCompletions(module, execList = []) {
assert(module.status == MODULE_STATUS_EVALUATED, "bad status for async module");
let i = 0;
while (module.asyncParentModules[i]) {
const m = module.asyncParentModules[i];
if (GetCycleRoot(m).status != MODULE_STATUS_EVALUATED_ERROR &&
!callFunction(std_Array_includes, execList, m)) {
assert(!m.evaluationError, "should not have evaluation error");
assert(m.pendingAsyncDependencies > 0, "should have at least one dependency");
UnsafeSetReservedSlot(m, MODULE_OBJECT_PENDING_ASYNC_DEPENDENCIES_SLOT, m.pendingAsyncDependencies - 1);
if (m.pendingAsyncDependencies === 0) {
callFunction(std_Array_push, execList, m);
if (!m.async) {
execList = GatherAsyncParentCompletions(m, execList);
}
}
}
i++;
}
callFunction(ArraySort, execList, (a, b) => a.asyncEvaluatingPostOrder - b.asyncEvaluatingPostOrder);
return execList;
}
Key Facts About the Code
Shared JavaScript Context
This function executes in the same JavaScript context as the user’s code, typical for most JavaScript functions in Firefox. This means that global objects, including their prototypes, are shared between built-in code and untrusted website code. This opens potential attack vectors where attackers can modify global prototypes.Default Argument (
execList = []
)
The function uses a default argument, which initializes a new empty array when no argument is passed. This new array, like any array, hasArray.prototype
as its prototype. If an attacker modifies this prototype, they can influence how the array behaves, introducing potential risks.Interaction with
Array.prototype
The function invokesstd_Array_push
, which internally calls theArray.prototype.push
method. Although usingstd_Array_push
reduces side effects compared to directly usingArray.prototype.push
, it still interacts with the prototype. This is risky because custom getters or setters on the prototype can be triggered, potentially leaking internal information or causing other unexpected behavior.
In particular, the assignment within Array.prototype.push
can trigger setters if they are defined on the prototype. This violates the ECMAScript specification, which defines the function in terms of abstract lists, not actual JavaScript arrays. This allows an attacker to access internal module objects that should not be available to untrusted scripts.
Array.prototype.push = function(x) {
let index = ToUint32(this.length);
this[index] = x;
}
When a setter is triggered, the value assigned to m
(an internal module object) can be leaked. This module object is not the same as the import()
returned module namespace but is an internal object of the JavaScript engine with unsafe methods, such as GatherAsyncParentCompletions
. If an attacker can call these methods with crafted input, they can corrupt memory by exploiting UnsafeSetReservedSlot
.
Triggering the Vulnerability
It’s straightforward to trigger this vulnerability and obtain a module object:
export let mod = {};
function setter(elem) {
delete Array.prototype[0]; // Remove the setter
this.push(elem); // Push the element normally
mod = elem; // Capture the module object
}
Array.prototype.__defineSetter__(0, setter);
export const _foo = await Promise.resolve(5);
Here’s how it works:
- Define a Setter on
Array.prototype[0]
: WhenArray.prototype.push
is called, the setter is triggered. - Capture the Module: The setter deletes itself after capturing the module object into
mod
.
The final line of the code marks the module as asynchronous, necessary to trigger the bug.
Achieving Memory Corruption
Once the module object is captured, we can use mod.gatherAsyncParentCompletions
with an object of the form {asyncParentModules:[obj]}
, leading to memory corruption. This function attempts to write the value obj.pendingAsyncDependencies - 1
to the internal object slot MODULE_OBJECT_PENDING_ASYNC_DEPENDENCIES_SLOT = 20
. The process of writing to internal slots is unsafe because it lacks bounds checking.
In SpiderMonkey, objects can have up to 16 "fixed slots," but slots with higher indices are stored in a dynamically allocated array (slots_
). By crafting the input, we can write beyond the bounds of this array, corrupting nearby memory. The general approach is as follows:
- Create a New Array Object.
- Assign Named Properties to force the allocation of a
slots_
array, including a property calledpendingAsyncDependencies
. - Write to Numbered Elements to ensure the allocation of an
elements_
array (the storage for array elements).
By correctly aligning memory, we can overwrite the elements_
array’s capacity, gaining arbitrary memory read/write capabilities by manipulating a typed array’s data pointer. However, the objects allocated in the nursery heap are short-lived, so this corruption needs to be used immediately to corrupt more permanent objects in the tenured heap, such as ArrayBuffer
objects.
Bypassing W^X and Executing Shellcode
Firefox implements W^X (Write XOR Execute), which ensures that writable memory cannot be executable, preventing the direct execution of shellcode. However, attackers can force the JIT compiler to emit arbitrary ROP (Return-Oriented Programming) gadgets by embedding specially crafted floating-point constants into JIT-compiled JavaScript functions. A technique developed by Manfred Paul improves upon this by using WebAssembly to insert longer stretches of shellcode, bypassing the need for ROP gadgets entirely.
There are some constraints, such as:
- No Repeated 8-byte Blocks: If the same block appears twice, it will only be emitted once.
- NaN Representation: Certain byte sequences, such as NaN, might not encode correctly.
Manfred Paul’s method involves minimal first-stage shellcode with two core functions:
- Read a Pointer from the Windows PEB (Process Environment Block).
- Invoke a Function with a specified address.
The attacker triggers the first function to leak a value from the PEB, which is then used in combination with an arbitrary read primitive to locate kernel32.dll
. After finding the VirtualProtect
address, the second function is invoked to mark an ArrayBuffer
as executable, allowing for further shellcode execution and full compromise of the renderer process.
Root Cause: Vulnerability Recap
The initial compromise of the renderer process stems from prototype pollution in built-in JavaScript code. Specifically, modifying Array.prototype
triggers unintended behavior in core functions like GatherAsyncParentCompletions
, which eventually leads to memory corruption.
After compromising the renderer, the next goal is to escape Firefox’s sandbox, which isolates the renderer from higher-privileged processes. The sandbox escape relies on a second prototype pollution vulnerability in built-in JavaScript code running in the fully-privileged parent process (also known as the chrome process).
Escaping the Sandbox: Renderer to Chrome Process
The renderer process can communicate with the chrome process through various interfaces, even while sandboxed. Some of these interfaces are accessible from JavaScript running in a “privileged” context. Achieving privileged JavaScript execution in the renderer process is the first step.
One of the key endpoints for communication with the chrome process is NotificationDB
, which is almost entirely implemented in JavaScript. When receiving a “Notification
taskSave
function:Exploiting the Prototype Pollution Primitive
At point [1]
in the code, the origin
and notification.id
are taken directly from the message data, without validation. An attacker can set these values to arbitrary serializable JavaScript values using the structured clone algorithm, which serializes data across process boundaries. If origin
is set to "__proto__"
, the function accesses Object.prototype
instead of a normal object property, leading to prototype pollution. This allows the attacker to write any value to any property of Object.prototype
.
By doing this, the attacker can corrupt the entire global JavaScript state in the chrome process, exposing all JavaScript modules in the chrome process to unexpected properties on Object.prototype
. This can then be leveraged for XSS and eventual native code execution outside of the sandbox.
Achieving Privileged JavaScript Execution
Before triggering the prototype pollution, the attacker must gain access to a privileged JavaScript context. This is done by:
- Marking the current JavaScript compartment as a system compartment.
- Patching the
CanCreateWrapper
function to bypass security checks. - Calling the
GetComponents
method to gain access to thecomponents
object.
Sandbox Escape: Manipulating Tab Restoration
One way to exploit this is during tab restoration, where the set
function in browser/components/sessionstore/TabAttributes.jsm
is called:
set(tab, data = {}) {
for (let name of this._attrs) {
tab.removeAttribute(name);
}
for (let name in data) {
if (!ATTRIBUTES_TO_SKIP.has(name)) {
tab.setAttribute(name, data[name]);
}
}
}
By manipulating the prototype of Object.prototype
, an attacker can insert arbitrary attributes into the tab, leading to XSS. The function tab.setAttribute
will be called with parameters controlled by the attacker.
Triggering Tab Restoration
There are several ways to trigger the tab restoration functionality:
- Session Restoration: After restarting the browser.
- Reopen Closed Tab: Using Ctrl+Shift+T.
- Tab Unloading: When Firefox runs out of memory, unused tabs are unloaded.
- Crashed Tab Restoration: Automatically restoring a crashed tab.
The most practical approach is crashing the renderer process, which forces the chrome process to restore the tab. After the polluted prototype is restored, the attacker’s chosen attributes are set on the tab, allowing them to execute arbitrary code.
Completing the Sandbox Escape
Once JavaScript execution is achieved in the chrome process, the attacker can either disable the sandbox entirely by setting the preference:
Services.prefs.setIntPref("security.sandbox.content.level", 0);