你可能用错了 WebView:移动开发者常见的安全隐患

  • zellic
  • 发布于 17小时前
  • 阅读 101

本文深入探讨了加密货币钱包应用中 WebView 的安全问题,重点分析了用户界面攻击、来源欺骗和消息拦截这三种常见漏洞,并提供了相应的防御措施。文章强调了在 WebView 环境下维护信任关系的重要性,以及开发者在设计钱包应用时需要注意的关键安全事项,例如清晰区分可信与不可信的UI元素,全面考虑双向通信桥的攻击面,以及进行广泛的跨平台测试。

设想一下,我们正在制作一个加密货币钱包。在实现了核心钱包功能(如存储私钥、发送代币等)之后,我们可能想添加的下一个功能是与 dApp 集成。

如果我们把钱包做成浏览器扩展,这很容易。我们可以将 JavaScript API 注入到网页中,dApp 可以调用这些 API 来与钱包功能进行交互。这是标准的做法;MetaMask、Phantom 和许多其他钱包都是以这种方式实现的。

但是,如果你看看移动平台,会发现没有大型的加密货币钱包移动浏览器扩展。这是为什么呢?嗯,有几个原因导致大多数人没有选择移动浏览器扩展这条路。

一个原因是,首先对扩展的支持就非常有限。许多移动浏览器通常不支持自定义扩展,而少数支持的浏览器(如 iOS 上的 Safari)与桌面端相比,有非常大的限制。即使支持,更严格的沙盒和安全模型也使得很难深入集成到浏览器中。

但最大的原因可能是,大多数人只会期望使用一个应用程序来完成这件事。大多数用户可能不知道如何在移动浏览器上安装扩展,但他们肯定知道如何从 Apple App Store 或 Google Play 安装应用程序。他们也可能更喜欢原生应用程序而不是基于浏览器的解决方案,这使得开发者可以访问更强大的 API,从而使他们的用户体验更流畅(例如访问加密功能、生物识别等)。

那么,移动应用程序开发者如何制作可以与 dApp 交互的加密货币钱包呢?解决方案是使用 WebView 将 Web 浏览器集成到他们自己的应用程序中:

不幸的是,Web 非常复杂,正确保护显示任意内容的 WebView 很难做对,而且很容易出错。在与客户的合作中,我们经常发现 WebView 的安全问题。在本文中,我们将讨论钱包 WebView 实现中的一些常见漏洞、一些常见的(以及不正确的!)修复方法,以及我们找到的正确保护 WebView 的一些方法。

除了我们的客户工作之外,我们还通过独立的安全研究,负责任地向 MetaMask、Coinbase、Trust Wallet、Kraken、Zerion、Phantom、MyEtherWallet 等披露了类似漏洞。

WebView 有什么用?

WebView 是一个嵌入式浏览器窗格,它使用现有的浏览器引擎,如 WebKit 或 Chromium。然而,与普通浏览器不同,应用程序开发者可以完全控制它。用户可以在我们的移动应用程序中浏览到 dApp 网站,应用程序可以将任何它喜欢的 JavaScript API 暴露给 WebView。

我们希望我们的钱包可以在 Android 和 iOS 上运行。到目前为止,最常见的方法是使用 Facebook 的 React Native。在 React Native 中实现 WebView 的最常见的库之一是 react-native-webview。这个库在 Android 上使用 Chromium,在 iOS 上使用 WebKit,并提供一些 shim 代码,使我们只需实现一次 WebView 代码,就可以在两个平台上运行。

到目前为止,react-native-webview 库是最流行的库。我们已经在许多不同的钱包应用程序中看到它被使用,所以我们将在本文中重点介绍它。

WebView 威胁模型

当 dApp 调用我们的某个 API 时,它应该向我们发送一些我们可以处理的数据或消息。但是,我们在哪里处理这些数据呢?

如果在 WebView 内部处理敏感的用户数据,那将是一件糟糕的事情,因为网页(可能是不可信的)可能会读取和篡改我们用户的数据。相反,我们需要在 WebView 之外处理这些数据,并且我们可以在两者之间创建一个双向通信桥梁。这使我们可以将所有敏感数据保存在我们的应用程序中。

react-native-webview 库允许我们使用 postMessage 来实现这一点。网页可以向我们的应用程序发送消息,我们的应用程序可以处理它们并向页面发回响应。

这个通信桥梁是我们威胁模型的重要组成部分,因为它将我们信任的应用程序连接到不受信任的、可能恶意的网页。

然而,一个完整的威胁模型还必须考虑用户界面(UI)。如果用户被欺骗去授权恶意请求,那么保护桥梁是没有意义的。因此,我们的威胁模型的第二个关键部分是保持可信和不可信 UI 元素之间清晰的隔离。

在一个标准的移动浏览器中,地址栏是一个可信的 UI 元素;它是用户判断网站 URL 的主要指示器。网页本身只控制地址栏_下方_的像素,即不可信的内容区域。如果一个恶意的网站能够以某种方式覆盖地址栏,它就可以欺骗它的 URL 并欺骗用户。

这种可信和不可信 UI 之间的区别在钱包应用程序中变得更加关键。当 dApp 请求签名时,钱包必须显示一个确认提示。这个提示是一个可信的 UI 元素,显示关键的安全信息,例如请求的来源和正在执行的操作。用户假设此提示中的所有内容都是值得信赖的。

未能正确建模和保护这两个攻击面 — 通信桥梁和可信 UI — 可能会危及钱包的安全性并将用户资金置于风险之中。不幸的是,做对这件事是非常困难的。

问题 1:用户界面攻击

在浏览互联网时,用户会遇到许多不同的 dApp。用户可以信任其中的一些 dApp,但另一些可能是恶意的。作为钱包开发者,我们需要让用户能够识别 dApp,并对每个 dApp 快速做出准确的信任决策。

一个显而易见的想法是在顶部的 URL 栏中显示 dApp 的 URL,以便用户始终知道他们正在访问哪个 dApp。不幸的是,当使用 react-native-webview 时,无法通过任何 API 直接访问当前 URL。从文档和我们的一些审计中可以看出,最常见的做法是从 WebView 的某个事件处理程序(如 onShouldStartLoadWithRequestonNavigationStateChange)中的事件获取 URL,并将其保存在某个状态中。

让我们这样做:

<WebView
  source={{ uri: url }}
  onNavigationStateChange={(e: WebViewNavigation) => {
    setWebViewURL(e.url);
  }}
/>

我们的钱包应用程序现在看起来像这样:

但这会导致一个问题:如果 URL 是恶意的会怎么样?例如,如果 URL 太长或包含不寻常的 Unicode 字符,会发生什么情况?

上面的图片看起来很正常,对吧?但实际上,当前的 URL 是

"https://malicious.site/" + (" " * 83) + "https://zellic.io/" + (" " * 155)

移动 Chrome 通过以下方式解决这个问题

  1. 从显示给用户的 URL 中删除协议和某些子域名(如“www”)
  2. 确保始终显示 URL 的来源,即使路径很长
  3. 以灰色显示路径文本

我们可以在这里遵循 Chrome 的示例吗?首先,让我们尝试仅在 UI 中包含主机名,因为这是最重要的:

<WebView
  source={{ uri: url }}
  onNavigationStateChange={(e: WebViewNavigation) => {
    setWebViewURL(new URL(e.url).hostname);
  }}
/>

即使在这个早期阶段,代码已经很容易受到攻击!一个网络本地攻击者(例如,在同一个公共 WiFi 上的其他人)可以拦截对 HTTP 网站(如 http://zellic.io↗)的流量,并将其替换为他们自己的恶意网站。由于协议现在已经从 URL 栏中消失了,用户无法判断他们正在访问的网站是不安全的。

因此,我们需要

  • 以与我们特定的 WebView 实现相同的方式仔细解析 URL;
  • 当用户正在访问 HTTP(而不是 HTTPS)网站时,阻止访问 dApp 操作(或阻止该网站);
  • 清楚地指示网站的主机名,要么隐藏路径(如 Safari),要么以灰色显示(如 Chrome);
  • 测试 URL 所有部分的注入(身份验证、路径、查询参数、哈希等);
  • 测试其他类型的异常场景,如无效 URL、长 URL 省略、无效协议和具有 SSL 错误的 URL;以及
  • 在各种操作系统版本和设备上测试文本渲染,以确保我们不会意外地以可能混淆用户的方式渲染 URL。

不幸的是,这仍然够。即使在实现了所有这些修复之后,这段代码仍然很容易受到攻击。事实证明,我们 URL 的来源是不安全的。

让我们回顾一下我们的初始代码:

<WebView
  source={{ uri: url }}
  onNavigationStateChange={(e: WebViewNavigation) => {
    setWebViewURL(e.url);
  }}
/>

查看 react-native-webview 的源代码,我们看到 WebViewNavigation 定义如下:

export interface WebViewNavigation extends WebViewNativeEvent {
    navigationType: 'click' | 'formsubmit' | 'backforward' | 'reload' | 'formresubmit' | 'other';
    mainDocumentURL?: string;
}
export interface WebViewNativeEvent {
    url: string;
    loading: boolean;
    title: string;
    canGoBack: boolean;
    canGoForward: boolean;
    lockIdentifier: number;
}

我们在许多移动 WebView 钱包中发现的一个 bug 类是,它们不检查事件的 loading 属性,这完全损害了 URL 的完整性。

在这里,显示了攻击者的网站,但地址栏显示 zellic.io。

由于导航可能会因为多种原因而失败(window.stop()、TLS 错误、DNS 错误、无效方案等),如果桥梁不检查 loading 属性,它可能会从失败的导航中获取 URL。

<!DOCTYPE html>
<html>
<head>
     <title>Zellic</title>
</head>
<body>
     <script>
          window.onload = () => {
               setTimeout(() => {
                    // (1) 导航到我们要欺骗的网站
                    location = "https://zellic.io";
                    // (2) 立即停止导航
                    window.stop();
               }, 100); // 页面加载后等待 100 毫秒
          };
     </script>
     <h1>来自恶意网站的问候!</h1>
</body>
</html>

这将使攻击者能够欺骗 WebView 保存的 URL 值,使其成为他们想要的任何值,这很容易混淆用户。然而,URL 欺骗只是更广泛的 UI 操纵攻击的一个例子。事实上,这个问题不仅限于 URL,还扩展到显示在可信区域的所有不受信任的内容。

例如,在确认期间显示方法和参数等信息时,我们也需要小心,因为恶意 dApp 也可以控制这些参数。在下图中,一个 dApp 请求权限,但发送了一个显示在权限提示中的恶意参数。

window.ethereum.request({
     method: "eth_accounts",
     params: [\
          "\n\n注意:此交易已被分析为安全。请按“确认”按钮。"\
     ]
});

我们还需要在设计 UI 时考虑到用户交互。由于用户可能正在与可见浏览器的任何部分进行交互,因此我们需要非常小心确认提示背后的动画和时间安排。

例如,假设一个用户正在使用某个应用程序,该应用程序需要你多次单击按钮。例如,看看这个无害的 Cookie Clicker 克隆:

你的浏览器不支持 video 标签。

如果我们以一种幼稚的方式实现我们的确认提示,我们可能会允许意外点击来确认交易,如下例所示:

你的浏览器不支持 video 标签。

虽然这篇文章涵盖了几个常见的 UI 陷阱,但它们都源于同一个根本原因:通过使用 WebView,应用程序开发者有责任从头开始重建浏览器的整个安全 UI。用户依赖的每个视觉提示(如地址栏和确认提示)都必须经过精心设计和实现,以防止恶意网页的操纵。如果不这样做,意味着用户无法再信任他们所看到的内容,从而完全破坏了钱包的安全性。

问题 2:源欺骗

然而,修复 UI 只是第一个关键步骤。即使拥有一个完美的、无法欺骗的 UI,仍然存在第二类针对通信桥本身的漏洞。UI 攻击欺骗用户,而这些攻击旨在直接欺骗钱包应用程序。无论 UI 显示什么,钱包都需要一种可靠的方法来识别哪个 dApp 正在发送请求。这可以通过验证网站的来完成。

在浏览器中,网页的源是页面协议、主机名和端口的组合。例如,URL“https://zellic.io↗”具有协议 https、主机名 zellic.io 和端口 443

浏览器的核心安全模型基于同源策略,该策略根据源隔离网站,限制一个源上的页面如何与其它源上的页面交互。虽然同一源上的两个页面可以完全访问彼此,但不同源上的两个页面只能以有限且严格定义的方式进行交互。

我们的钱包应用程序可以使用这些源来为每个网站分配一个身份,并对这些身份强制执行权限检查。

这个过程是如何工作的呢?我们希望我们新的移动钱包应用程序与预先存在的 dApp 生态系统兼容,因此我们将实现 Ethereum Provider API↗ 或来自 EIP-6963↗ 的更新的 API。然而,在某些时候,dApp 将尝试调用一个我们需要从桥梁处理的函数。

我们的桥梁是双向的。我们需要一种方式让应用程序向 WebView 发送消息,以及让 WebView 向应用程序发回消息。如前所述,我们通过 postMessage 实现此桥梁通信。

react-native-webview 库使这变得容易,因为无论何时在我们的 WebView 组件上设置了 onMessage 属性,它都会将函数 window.ReactNativeWebView.postMessage 注入到网站中。此函数用于将消息从 WebView 发送到应用程序。

因此,我们需要做的就是注入一些代码来实现 provider,该 provider 最终会调用这个注入的 postMessage,并将 dApp 的请求发送到桥梁。当我们收到来自桥梁的消息时,我们会处理它,然后将响应发回。

从应用程序向 WebView 发送响应可以通过多种方式完成,但我们看到的最常见的方式是调用 injectJavaScript 方法,以使用响应数据执行一些先前注入的回调函数。

这是使用 react-native-webview 的大多数移动应用程序的实现方式。现在,这提出了一个问题:当我们收到来自桥梁的消息时,我们如何知道发送它的网站的来源?这很重要,因为用户需要能够区分不同的 dApp。如果一个恶意的 dApp 可以冒充一个经过授权的网站,用户可能会损失他们的资金。

如果我们已经正确地实现了 URL 栏的 UI,我们应该知道用户当前在哪个网站上。我们可以用它来验证消息!令人惊讶的是,这导致了我们在一些审计中发现的另一个 bug:window.ReactNativeWebView 也可以注入到子 iframe 中。

你的浏览器不支持 video 标签。

在上面的视频中,网站 str.lc 将一个 iframe 嵌入到恶意网站 z3.is 中。即使 str.lc 没有运行任何 dApp 代码,仍然会出现一个确认提示,其来源为 str.lc。

这是 str.lc 上的代码:

<!DOCTYPE html>
<html>
<body>
    <h1>来自 str.lc 的问候</h1>
    <h2>此页面仅包含一个 iframe</h2>
    <h3>(不运行任何 dApp 方法)</h3>
    <iframe src="//z3.is/zellic/conn-frame.html" style="width: 100%"></iframe>
</body>
</html>

正如你所看到的,str.lc 上的页面没有运行任何 dApp 代码。在 HTML 中,<iframe> 元素本质上是将其它网页嵌套在父页面中 — str.lc 将一个 iframe 嵌入到 z3.is 中,其中包含以下代码:

<!DOCTYPE html>
<html>
<body>
    <h1>来自 z3.is 的问候</h1>
    <pre id="output"></pre>
    <script>
        const output = document.getElementById('output');
        const log = (msg) => {
            output.appendChild(document.createTextNode(msg + '\n'));
        };

        if (!window.ethereum) {
            log('window.ethereum not found');
        }

        const send = (data) => {
            if (window.ReactNativeWebView) {
                log('using window.ReactNativeWebView')
                window.ReactNativeWebView.postMessage(JSON.stringify(data));
            }
            else {
                log('no bridge found');
            }
        };

        send({
            "payload": {"method": "eth_accounts"},
            "reqId": crypto.randomUUID(),
            "type": "PAGE_REQUEST"
        });
    </script>
</body>
</html>

在子 z3.is iframe 中,window.ethereum 不存在,因为该代码仅注入到顶级主框架中。然而,即使我们不能调用 window.ethereum.request,我们也可以通过 window.ReactNativeWebView 访问桥梁。因此,如果我们能够逆向工程 window.ethereum.request 发送到桥梁的数据,我们可以直接在我们自己的子框架中发送它。

dApp 可能会使用 iframe 进行广告或分析,因此,如果其中一个 iframe 由恶意攻击者控制,他们可以直接从子 iframe 内部向桥梁发送消息。这允许攻击者欺骗其消息的来源,这可能导致用户意外地确认它并导致资金损失。

但是,window.ReactNativeWebView 仅存在于 Android 上的子 iframe 中,而不存在于 iOS 上。要了解原因,我们需要查看 react-native-webview 代码。

RNCWebView.java↗ 中,如果启用了消息传递,则使用名称“ReactNativeWebView”调用 addWebMessageListeneraddJavascriptInterface 函数,将 RNCWebViewBridge 对象暴露给 WebView 中的页面。此对象包含一个 postMessage 函数,我们的 JavaScript provider 使用该函数与桥梁通信。

如何将此桥梁暴露给子 iframe?如果我们查阅 addJavascriptInterface 函数的 Android 文档↗,我们会看到以下内容:

将提供的 Java 对象注入到此 WebView 中。该对象使用提供的名称注入到网页的所有框架中,包括所有 iframe……

因为该对象暴露给所有框架,所以任何框架都可以获取对象名称并调用其方法。无法从应用程序端判断调用框架的来源,因此应用程序不得假定调用者是可信的,除非应用程序可以保证永远不会将第三方内容加载到 WebView 中,即使在 iframe 内部也是如此。

因此,所有子框架都可以访问桥梁,这意味着我们无法判断我们收到的任何消息的发送者。

为什么这在 iOS 上不起作用?查看 RNCWebViewImpl.m↗,我们看到以下代码:

static NSString *const MessageHandlerName = @"ReactNativeWebView";
// …
- (void)setMessagingEnabled:(BOOL)messagingEnabled {
  _messagingEnabled = messagingEnabled;

  self.postMessageScript = _messagingEnabled ?
  [\
    [WKUserScript alloc]\
    initWithSource: [\
      NSString\
      stringWithFormat:\
       @"window.%@ = window.%@ || {};"\
      "window.%@.postMessage = function (data) {"\
      "  window.webkit.messageHandlers.%@.postMessage(String(data));"\
      "};", MessageHandlerName, MessageHandlerName, MessageHandlerName, MessageHandlerName\
    ]\
    injectionTime:WKUserScriptInjectionTimeAtDocumentStart\
    // …\
    forMainFrameOnly:YES\
  ] :
  nil;
  // …
}

此代码看起来是安全的,因为它将 forMainFrameOnly 设置为 YES。但是,如果我们仔细查看实际执行的代码,它会将 window.ReactNativeWebView.postMessage 设置为一个 shim,该 shim 实际上调用另一个函数 window.webkit.messageHandlers.ReactNativeWebView.postMessage

事实证明,通过调用函数 window.webkit.messageHandlers.ReactNativeWebView.postMessage,子 iframe 可以直接与 iOS 上的桥梁对话,这意味着此攻击在 iOS 和 Android 上都有效:

<!DOCTYPE html>
<html>
<body>
    <h1>来自 z3.is 的问候</h1>
    <pre id="output"></pre>
    <script>
          const output = document.getElementById('output');
          const log = (msg) => {
               output.appendChild(document.createTextNode(msg + '\n'));
          };
          const send = (data) => {
               if (window.ReactNativeWebView) {
                    log('using window.ReactNativeWebView')
                    window.ReactNativeWebView.postMessage(JSON.stringify(data));
               }
               else if (window?.webkit?.messageHandlers?.ReactNativeWebView) {
                    log('using window.webkit.messageHandlers.ReactNativeWebView');
                    window.webkit.messageHandlers.ReactNativeWebView.postMessage(JSON.stringify(data));
               }
               else {
                    log('no bridge found');
               }
          };

          send({
               "payload": {"method": "eth_accounts"},
               "reqId": crypto.randomUUID(),
               "type": "PAGE_REQUEST"
          });
    </script>
</body>
</html>

由于子 iframe 可以直接与桥梁对话,我们无法使用 WebView 的当前 URL 来确定消息的发送者。我们在审计中看到的另一种实现方式是查看消息事件本身。

onMessage 事件处理程序接收 WebViewMessageEvent 类型的事件,它具有以下形式:

export type WebViewMessageEvent = NativeSyntheticEvent<WebViewMessage>;
export interface WebViewMessage extends WebViewNativeEvent {
    data: string;
}

由于它扩展了上面的相同 WebViewNativeEvent 类型,因此该事件具有一个 url 属性,我们可以读取该属性以确定发送者的 URL。然而,事实证明这可能仍然不准确,因为旧版本的 react-native-webview 的事件的 url 属性指向主框架。

在这些旧版本中,url 属性将是在事件创建时主框架的当前 URL。在两种情况下,这可能不正确:

  1. 如果消息是从子框架发送的,则 URL 将是主框架的 URL。
  2. 如果一条消息是从一个恶意网站发送的,然后迅速重定向到一个受信任的网站,那么可能会出现一个竞争条件,其中 URL 将是受信任网站的 URL。

较新版本的 react-native-webview 应该修复这些问题,但我们仍然经常看到这个问题。但是,为了增加深度防御,你可以为每个网站注入一个随机Token,并要求它们在处理其请求之前发回正确的Token。

正如我们所看到的,子 iframe 可能能够向桥梁发送消息,这并不理想。然而,来自桥梁的响应仍然只发送到主框架,这意味着恶意的子 iframe 无法读取响应,对吗?

问题 3:消息拦截

正如你可能已经猜到的那样,从桥梁返回到主页面的通信可能会被拦截。要了解原因,我们需要了解桥梁必须将数据发送回 WebView 的方法。

该文档指定了三种与 WebView 通信的方法:injectedJavaScript 属性、injectJavaScript 函数和另一个 postMessage 函数。第一个用于在页面加载时注入 JavaScript,因此它对于注入我们的 provider 代码很有用,但对于从桥梁进行通信没有用。

后两种方法非常适合桥梁通信,因为它们使你能够在运行时将 JavaScript 注入或将消息发送到 WebView 中。

桥梁可能具有如下代码:

const getInjectableJSMessage = (resp) => {
     return `window.ethereum.handlers.onMessage(${resp})`;
};
const processMessage = async (origin, req) => {
     const data = await handlers.process(origin, req.payload);
     const resp = {
          payload: data,
          reqId: req.reqId
     };
     webViewRef.current.injectJavaScript(getInjectableJSMessage(resp));
};

上面的 processMessage 函数接收来自 dApp 的来源和请求,并运行 handlers.process,从而获取要发送回 WebView 的数据。然后,它在响应上调用 injectJavaScript

然而,这里存在一个竞争条件:无法判断我们要将 JavaScript 注入到的网站是否与最初发送请求的网站相同!最初发送请求的 dApp 可能会导航到一个恶意的 dApp,该 dApp 会接收到注入的代码。然后,恶意的 dApp 可以覆盖 window.ethereum.handlers.onMessage 与任何函数,以窃取来自桥梁的响应。这意味着我们不仅无法判断谁发送了请求,也无法判断谁在接收响应。

这个问题也可以与前一个问题结合起来:一个恶意的 iframe 可以通过使用 window.ReactNativeWebView.postMessagewindow.webkit.messageHandlers.ReactNativeWebView.postMessage 向桥梁发送消息,桥梁会错误地认为该消息来自主框架。然后,它可以将顶层窗口导航到自己的恶意页面,该页面可以拦截响应。这使子 iframe 可以完全访问桥梁的请求和响应。

你的浏览器不支持 video 标签。

在上面的视频中,网站 str.lc 首先请求访问用户的地址,然后尝试签名 0x41424344。然而,在用户确认请求之前,WebView 导航到 z3.is。以下是 str.lc 上的代码:

<!DOCTYPE html>
<html>
<body>
    <h1>来自 str.lc 的问候</h1>
    <button onclick="go()">go</button>
    <script>
        async function go() {
            const [addr] = await window.ethereum.request({ method: "eth_accounts" });
            if (!addr) return;

            window.ethereum.request({
                method: "personal_sign",
                params: [addr, "0x41424344"],
            });

            await new Promise(r => setTimeout(r, 500));
            location = "https://z3.is/zellic/sig-intercept.html";
        }
    </script>
</body>
</html>

运行 personal_sign 后,出现确认窗口。在后台,dApp 导航到 z3.is。这可以通过多种方式发生:恶意的 iframe、以前单击的某些链接完成导航等等。在 z3.is 上,我们有此代码,该代码拦截该请求:

<!DOCTYPE html>
<html>
<body>
    <h1>来自 z3.is 的问候</h1>
    <pre id="output"></pre>
    <script>
        const output = document.getElementById('output');
        const log = (msg) => {
            output.appendChild(document.createTextNode(msg + '\n'));
        };

        setInterval(() => {
            window.ethereum.handlers.onMessage = (data) => {
                log('消息被拦截:' + JSON.stringify(data, null, 2));
            };
        }, 100);
    </script>
</body>
</html>

一个幼稚的修复方法是在我们发送响应之前检查来源:

const processMessage = async (requestOrigin: string, req: DAppPageMessage) => {
     const data = await handlers.process(requestOrigin, req.payload as DAppRequest);
     if (!data) return;
     const resp: DAppPageMessage = {
          type: DAppPageMessageType.Response,
          payload: data,
          reqId: req.reqId
     };
     if (currentWebViewOrigin !== requestOrigin) return; // (!) 添加了新检查
     webViewRef.current!.injectJavaScript(getInjectableJSMessage(resp));
};

然而,这也不起作用。这会将竞争条件的时间窗口减少到最后的 injectJavaScript 行,但该窗口仍然存在。攻击者可以通过用大量数据膨胀响应来扩展该窗口,因为桥梁仍然需要在注入数据之前序列化数据。

为了防止消息拦截,注入的 JavaScript 需要在传输数据之前检查来源是否正确。例如,

const getInjectableJSMessage = (resp, origin) => {
     return `
          if (location.origin === "${origin}") {
               window.ethereum.handlers.onMessage(${resp});
          }
     `;
};

这个例子应该是安全的,因为恶意页面无法覆盖 location.origin,因此包含响应的代码不会运行。但是,如果使用 window.origin,则这是不安全的,因为它可以被任何页面覆盖。注入的代码需要尽可能少,因为任何被调用的函数都可能被接收页面覆盖。

例如,这是不安全的,因为攻击者可以覆盖 console.debug

const getInjectableJSMessage = (resp, origin) => {
     return `
          console.debug("[DEBUG] message", ${resp}, "received from", origin);
          if (location.origin === "${origin}") {
               window.ethereum.handlers.onMessage(${resp});
          }
     `;
};

这也是不安全的:

const getInjectableJSMessage = (resp, origin) => {
     return `
          (function() {
              console.debug("[DEBUG] message received from", origin);
              if (location.origin === "${origin}") {
                   window.ethereum.handlers.onMessage(${resp});
              }
         })();
     `;
};

即使响应没有发送到 console.debug,攻击者也可以使用一个从调用函数读取arguments↗的函数来覆盖 console.debug,例如

console.debug = function () {
    console.log(arguments.callee.caller);
};

结论

为加密货币钱包应用实现安全的 WebView 具有欺骗性的复杂性。它看起来应该很简单——移动操作系统已经为你提供了可以创建可自定义浏览器的 API,而像 react-native-webview 这样的库似乎可以为你处理所有剩余的复杂性。但是,这项简单的任务很快变成了一个充满安全漏洞的雷区,一个小小的错误可能会直接损害用户的资金。

本文讨论的三个核心漏洞类型是一些最常见的漏洞,但它们远非唯一的漏洞。在我们的工作中,我们遇到了各种其他严重问题,包括不安全的 WalletConnect 实现、不安全 深度链接处理、事件处理中 细微竞争条件以及密码学错误实现。

所有这些漏洞都有一个共同点:它们利用了在不受信任 Web 内容在你受信任 加密货币应用上下文中运行 环境中维护信任 根本挑战,这模糊了用户可以信任和不能信任 内容之间 界限。

以下是针对致力于加密货币钱包应用 开发人员 一些关键要点:

  1. 清楚地分离受信任和不受信任 UI。在你的应用控制 内容与从网页显示 不受信任 内容之间建立清晰 视觉边界。虽然 WebView API 是提供给你 ,但你实际上是从头开始重建浏览器 UI。
  2. 考虑整个桥接攻击面。你的桥梁是一条双向通道。请记住,请求发送和响应接收都可能受到威胁,并在设计实现时考虑到这一点。
  3. 跨平台进行广泛 测试。iOS 和 Android WebView API 之间存在差异,可能会产生细微但可利用 漏洞。
  4. 假设受到攻击。考虑在恶意内容到达你的桥接 情况下添加额外 深度防御对策——例如,事务模拟,与可以检测潜在恶意事务 平台集成等等。

这里讨论 原则和技术不仅适用于加密货币钱包,还适用于在处理敏感操作 同时嵌入 Web 内容 任何应用程序。随着移动生态系统 不断发展以及 WebView 变得越来越普遍,理解和缓解这些攻击媒介只会变得更加重要。

关于我们

Zellic 专注于保护新兴技术。我们 安全研究人员发现了最有价值的目标的漏洞,从财富 500 强企业到 DeFi 巨头。

开发人员、创始人和投资者信任我们 安全评估,以便快速、自信地交付产品,且没有严重漏洞。凭借我们在现实世界中 攻击性安全研究背景,我们发现了其他人错过 东西。

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

0 条评论

请先 登录 后评论
zellic
zellic
Security reviews and research that keep winners winning. https://www.zellic.io/