Wednesday, August 24, 2022

Firefox Fullchain: Prototype Pollution Leading to Sandbox Escape and RCE

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

  1. 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.

  2. 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, has Array.prototype as its prototype. If an attacker modifies this prototype, they can influence how the array behaves, introducing potential risks.

  3. Interaction with Array.prototype
    The function invokes std_Array_push, which internally calls the Array.prototype.push method. Although using std_Array_push reduces side effects compared to directly using Array.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:

  1. Define a Setter on Array.prototype[0]: When Array.prototype.push is called, the setter is triggered.
  2. 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:

  1. Create a New Array Object.
  2. Assign Named Properties to force the allocation of a slots_ array, including a property called pendingAsyncDependencies.
  3. 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:

  1. Read a Pointer from the Windows PEB (Process Environment Block).
  2. 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

” message, the process is as follows:

receiveMessage(message) {
  switch (message.name) {
    case "Notification:Save":
      this.queueTask("save", message.data)
        .then(function() {
          returnMessage("Notification:Save:Return:OK", {requestID: message.data.requestID});
        })
        .catch(function(error) {
          returnMessage("Notification:Save:Return:KO", {requestID: message.data.requestID, errorMsg: error});
        });
      break;
  }
}

Once the message is queued, it is processed in the chrome process through the taskSave function:
taskSave(data) {
  var origin = data.origin;
  var notification = data.notification;
  if (!this.notifications[origin]) {
    this.notifications[origin] = {};
    this.byTag[origin] = {};
  }
  if (notification.tag) {
    var oldNotification = this.byTag[origin][notification.tag];
    if (oldNotification) {
      delete this.notifications[origin][oldNotification.id];
    }
    this.byTag[origin][notification.tag] = notification;
  }
  this.notifications[origin][notification.id] = notification;
  return this.save();
}

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:

  1. Marking the current JavaScript compartment as a system compartment.
  2. Patching the CanCreateWrapper function to bypass security checks.
  3. Calling the GetComponents method to gain access to the components 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:

  1. Session Restoration: After restarting the browser.
  2. Reopen Closed Tab: Using Ctrl+Shift+T.
  3. Tab Unloading: When Firefox runs out of memory, unused tabs are unloaded.
  4. 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);

Alternatively, they can use the available file and process APIs in the chrome process to execute commands directly:
const CMD = "C:\\Windows\\System32\\cmd.exe";
const ARGS = ["/c", "calc"];
let f = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFileWin);
f.initWithPath(CMD);
let p = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess);
p.init(f);
p.run(false, ARGS, ARGS.length);

This final step results in full system compromise, allowing the attacker to execute arbitrary commands on the victim’s machine.

Unveiling CVE-2024-38112 in the Shadows of Internet Explorer

Overview Recent security research uncovered a new vulnerability within Windows systems that exploits Internet Explorer to execute remote cod...