随机数问题:Dart/Flutter弱伪随机数生成器的三个错误

  • zellic
  • 发布于 2024-12-12 15:19
  • 阅读 17

本文讨论了开发者在Dart/Flutter生态系统中使用不安全的伪随机数生成器(PRNG)所导致的安全漏洞,导致多个项目受到威胁。具体案例包括Dart SDK的漏洞、Proton Wallet的加密缺陷以及SelfPrivacy的可预测密码问题。文章深入分析了这些漏洞的技术细节和影响,并强调了使用安全、加密的随机数生成器的重要性。

当开发者不小心选择了一个可预测的随机数来源,而这个来源恰好比合理开发者预期的要弱得多时,会发生什么?这些都是一些流行项目因 Dart/Flutter 生态系统中相同的基础弱点而遭受损失的故事,以及这些项目是如何受到影响的。这个错误在许多开源项目中普遍存在,但我们在这里将重点突出其中的一些。

我们将要研究的漏洞有:

  • Dart SDK 一键利用,影响大多数 Dart/Flutter 开发者的任意文件读取和写入
  • Proton Wallet 加密漏洞,包括针对其钱包和备份助记词安全性的攻击
  • SelfPrivacy 中可预测密码的问题

1 — Dart SDK 一键利用

Flutter 并不那么随机

当你想创建一个在移动、网页和桌面上运行的交互式应用,并且都使用相同的代码库时, Flutter↗ 是一个流行的选择。它允许用户编写性能出色的应用程序,能够在每个支持的平台上提供类似的用户体验。Flutter 由 Dart↗ 提供支持,可以将代码编译为 iOS 和 Android 的 ARM 机器码,浏览器的 JavaScript/WebAssembly 以及桌面设备的 x64/ARM。它还具有一些方便的功能,例如“有状态热重载”,允许用户更改代码并立即查看更改的结果,而无需重新启动整个应用。

为了在多个平台上运行得如此相似,Dart 代码在一个称为 Dart VM 的虚拟机中运行。这个 VM 配备有多个内置库,用于处理与操作系统特有的操作,如与文件和资源交互、渲染图形、进行数学操作,以及生成随机数。这些库的具体实现可能有不同。例如,具有内置随机数源的平台可能将其用于他们的 CSPRNG,并请求 Dart isolate(主进程)提供初始随机种子用于不安全的 PRNG。而 Wam,即明确 硬编码了初始种子↗,直到 2024 年 9 月。

diff --git a/sdk/lib/_internal/wasm/lib/math_patch.dart b/sdk/lib/_internal/wasm/lib/math_patch.dart
index 07c6f21dca2b..8df63bd71e47 100644
--- a/sdk/lib/_internal/wasm/lib/math_patch.dart
+++ b/sdk/lib/_internal/wasm/lib/math_patch.dart
@@ -226,8 +226,11 @@ class _Random implements Random {

   static int _setupSeed(int seed) => mix64(seed);

-  // TODO: 让这个函数真正随机
-  static int _initialSeed() => 0xCAFEBABEDEADBEEF;
+  static int _initialSeed() {
+    final low = (_jsMath.random() * 4294967295.0).toInt();
+    final high = (_jsMath.random() * 4294967295.0).toInt();
+    return ((high << 32) | low);
+  }

在之前的一篇 博客文章↗ 中,我们讨论了各种随机数生成器,以及微妙的错误如何轻易导致后续资金或私钥材料的损失。意外使用弱 PRNG 对人类来说可能很难检测,除非它如此弱以至于经常生成重复的密钥以致于注意到。即使在那种情况下,像 CVE-2008-0166(Debian OpenSSL 可预测 PRNG)这样的错误也未被检测到近两年,尽管它为所有生成的 SSH 密钥提供的概率只有 15 位 的熵。

这样一种意外的弱点是直接初始化 Dart 的 Random() 类,而不调用特殊构造函数 Random.secure(),后者实际生成加密安全的数字。但标准 PRNG 实际上有多糟糕?我们深入代码以找出答案。首先,Dart 的标准 PRNG 是使用原生整数类型作为状态的 乘法带进位(MWC)伪随机数生成器↗,而该状态实际为 64 位。由于平台特定的补丁和覆盖,随机模块略显复杂,但我们将快速通过其内部逻辑的主要内容。

Dart PRNG 内部原理

假设一个用户想在 Flutter 中生成 100 个随机字节。执行此操作的 不安全 代码如如下所示:

import 'dart:math';

int len = 100;
Random random = Random();
final List<int> bytes = List<int>.generate(len, (_) => random.nextInt(256));

在虚拟机中,数学模块中的 Random() 构造函数被补丁为

@patch
class Random {
  static final Random _secureRandom = _SecureRandom();

  @patch
  factory Random([int? seed]) {
    var state = _Random._setupSeed((seed == null) ? _Random._nextSeed() : seed);
    // 为了分散种子位,对其进行几次操作。
    return new _Random._withState(state)
      .._nextState()
      .._nextState()
      .._nextState()
      .._nextState();
  }

  @patch
  factory Random.secure() => _secureRandom;
}

它接收一个可选的种子参数,如果调用者希望得到可重复的输出;否则,它将调用 _setupSeed(_Random._nextSeed())。但如果这是随机数第一次被初始化,这个种子是从哪里来的呢?

  // 使用单例 Random 对象生成新种子,如果未提供种子。
  static final _prng = new _Random._withState(_initialSeed());

  // ...

  // 从虚拟机的随机数提供者获取种子。
  @pragma("vm:external-name", "Random_initialSeed")
  external static int _initialSeed();

  static int _nextSeed() {
    // 触发 PRNG 一次,改变内部状态。
    _prng._nextState();
    return _prng._state & 0xFFFFFFFF;
  }

在这里我们有一个大惊喜等着我们。Dart 将始终生成一个内部单例 Random 对象,使用来自虚拟机熵源的 64 位安全随机数作为种子。在构造新的 Random() 类时,它将从这个单例获取 64 位状态,然后通过与 0xFFFFFFFF 进行掩码操作来 截断。这使得从新初始化的 Random() 类生成的所有可能 PRNG 只有 32 位的熵。总结来说,从不安全的示例中初始化 Random() 类生成的内容是一种来自 4,294,967,296 个可能输出流之一。这对于现代桌面计算机而言绝对微不足道。

合理的开发者可能假设,因为 PRNG 状态是 64 位且已用 64 位进行播种,因此安全性也将是 64 位,但由于 32 位的截断,这并不成立。它最多只有 32 位。简而言之,使用 Random() 代替 Random.secure() 仅应在最简单的应用程序中使用,但这被 Dart SDK 团队意外忽视了。

攻击场景

许多新的 Flutter 用户和测试人员可能会通过跟随 Flutter 网站上的教程来开始他们的旅程。它会要求用户安装所需的 Dart SDK,并在 Android Studio 或 Visual Studio Code 等 IDE 中创建一个新项目。创建完第一个项目并凝视着空白模板后,他们可能会试图查看在线文档,而不知道他们离恶意用户窃取他们计算机文件,或者可能执行代码,只需点击一下。

Flutter IDE 如 Visual Studio Code 和 Android Studio 依赖于一个持久的、长时间运行的后台进程。这就是 Dart 工具守护进程↗,以下简称 DTD。一旦打开 Flutter 工作区,DTD 将自动在后台运行。这在 IDE 启动时自动发生,并不是通过构建或运行项目触发的。根据包文档本身的描述,

Dart 工具守护进程是一个长时间运行的进程,旨在促进 Dart 工具与 Dart 开发工作区之间的通信以及有限的文件系统访问。

在 IDE 中编写或运行 Dart 或 Flutter 应用程序时,Dart 工具守护进程由 IDE 启动。它在 IDE 的工作空间的整个生命周期内保持不变。

本质上,DTD 是一个监听随机端口的 websocket。通过连接到它,用户可以获得在工作区目录中读取和写入文件的访问权限、列出文件目录内容、注册和侦听服务、以及发布和侦听事件流。这个 websocket 可以从浏览器访问,但它通过绑定到一个随机端口和生成的、随机的密钥进行了一定程度的安全保护,该密钥必须在连接时提供在 websocket 路径中。根据它们自己的示例,URI 可能看起来是这样的,

ws://127.0.0.1:62925/cKB5QFiAUNMzSzlb

其中随机端口是 62925,URI 认证代码是 cKB5QFiAUNMzSzlb。除了这个密钥外,还有一个第二个密钥是上述 API 调用 setIDEWorkspaceRoots(secret, roots) 所必需的,这解锁客户端访问计算机上任何文件的能力——不仅仅是工作区中的文件。

但这些密钥是如何生成的呢?这一切发生在 dart_tooling_daemon.dart 中,我们只提取相关片段:

static String _generateSecret() {
  String upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
  String lower = 'abcdefghijklmnopqrstuvwxyz';
  String numbers = '1234567890';
  int secretLength = 16;
  String seed = upper + lower + numbers;
  String password = '';
  List<String> list = seed.split('').toList();
  Random rand = Random();
  for (int i = 0; i < secretLength; i++) {
    int index = rand.nextInt(list.length);
    password += list[index];
  }
  return password;
}

final String? _uriAuthCode = disableServiceAuthCodes ? null : _generateSecret();
final secret = _generateSecret();

结果,这些密钥仅有 32 位。让我们通过暴力破解示例 URI 的种子来确认这一点。

$ time ./findseed.py cKB5QFiAUNMzSzlb
Recovered seed: 0xAA70CB0D

real    0m10.428s
user    0m10.242s
sys     0m0.006s

这并不好。一个值得庆幸的是,两个密钥是独立生成的,因此攻击者需要进行双倍的暴力破解(33 位)才能恢复两个。但一个巨大的缺点是,任何恶意网站上的 JavaScript 都可以访问这个 websocket,完全不需要用户交互。该网站可以自动暴力破解端口,然后测试所有四十亿个可能的密钥。在此阶段,网站可以列出目录内容、提取并渔获工作区中的秘密文件,或重写构建脚本和 GIT Hook,以间接运行任意代码。在恢复第二个秘密并更改工作区根之后,当前用户可访问的所有文件都适用相同的原则,例如在典型的窃取恶意软件行径中一样。相同的攻击场景也适用于以较低权限用户身份运行的本地进程,允许权限提升。

在我们的报告中,我们包括了一种在开发者访问网站时运行的攻击的 JavaScript 实现。它暴力破解端口,然后开始限制性扫描以猜测认证码。这样的攻击需要一些时间来运行,因为浏览器对允许的并发 websocket 数量有限。所以在现实情况下,攻击者需要将恶意代码放在受害者可能停留的网页上(例如,Flutter 教程网站、流媒体视频的网站、具有消息服务的网站等)。通过使用 cookies 或 localStorage 存储进度,可以使攻击在页面点击后依然持续进行。

时间线和结论

  • 2024 年 8 月 23 日 — 该错误被报告给谷歌开源软件漏洞奖励计划。
  • 2024 年 9 月 5 日 — 谷歌 Bug Hunter 团队作出回应,表示已向产品团队提交了内部错误报告。
  • 2024 年 9 月 25 日 — 该错误在初始报告后约一个月后被 修复↗
  • 2024 年 11 月 1 日 — 谷歌 Bug Hunter 团队决定不奖励也不宣布此安全修复,因为它仅影响开发者。
  • 2024 年 12 月 11 日 — 截至撰写时,Dart SDK 3.6.0 尚未标记为“稳定”,因此尚未进入稳定的 Flutter SDK。

2 — Proton Wallet 加密漏洞

Proton Wallet 的预览版在 2024 年夏季推出,其应用程序代码的某些部分 开源↗。该移动应用使用 Flutter 编写,存在与 Dart SDK 团队相同的错误。漏洞仅存在了一天,但要理解此漏洞的影响,我们来看看 Proton 的安全机制。

Proton 的保护机制

Proton 的威胁模型包括对传票、数据库攻击和内部威胁的某些保护。为此,他们试图仅存储用户身份验证所需的最少内容,并采取分层的方法应用于其生态系统中的各种应用程序。大多数数据使用 AES-256-GCM 加密,其中加密密钥与数据一起存储。为了解密数据,需要某个时刻了解用户密码。然而,这确实带来了一些显著的缺点。

首先,他们并不知道用户的密码,也不知道其密码的直接哈希。在身份验证期间,密码根本不会发送。但是,这意味着如果用户丢失了密码,并且未设定适当的恢复机制,他们的数据将无法恢复——至少直到他们能够记得它。帐户本身可以恢复,但旧数据将不会被解密。

其次,虽然 Proton 无法扫描用户的电子邮件内容,但这也意味着用户无法在不解密并在本地缓存所有电子邮件的情况下搜索其电子邮件的内容。Proton 的 web 客户端支持在后台执行此操作,但对于大型邮箱,这可能需要很长时间。每封电子邮件的发件人、收件人和主题都没有被加密,因此请确保不要在这些信息中留下敏感信息。

值得注意的是,Proton 并未防范被入侵的设备或偷窥,并且假定正常的证书固定机制将保护其服务于官方域名的恶意网站。用户仍可以受到网络钓鱼骗局的影响,但像二次身份验证(2FA)等保护措施可以减轻其中的一些。自然,Proton 自身也有可能对用户提供假网站,但他们会定期发布客户端的版本,以供最警惕的用户进行审查,然后在本地运行。

安全远程密码协议

为了实现不知晓用户密码的这一目标,Proton 实现了安全远程密码协议,第 6a 版(SRP-6a)。SRP 是我们称为增强型密码认证密钥交换(PAKE),其中“增强型”意味着服务器不存储等效于密码的数据。相反,客户端将向服务器证明其拥有密码,而不会向被动窃听者或处于中间的主动攻击者泄露任何关于密码的信息。在同一事务中,服务器也会进行身份验证,因为它必须存储密码的验证者以启动协议。

该协议本身类似于 Diffie–Hellman 密钥交换↗,其中双方可以通过公共渠道达成共享的秘密密钥。整体流程如下:

SRP 协议步骤

首先,客户端将发送用户名到服务器,服务器需要检索其验证器和相应的、特定于用户的盐。在 Proton 的情况下,每当生成验证者时,他们从大量模数中随机选择一个模数。这减少了解决任何特定模数的一般离散对数的价值,这只有国家级能力的攻击者才能实现,因为它是 2048 位。通过 Proton 签名的模数意味着攻击者无法使用恶意模数,其中的离散对数更易计算。但作为额外的预防措施,该模数实际上会放入密码本身中,因此如果模数某种程度上是恶意的,攻击者也只能获得错误的哈希。使用多个模数、签名模数或将模数组合到密码中并不需要遵循 TLS-SRP RFC 5054。添加这些功能的目的是加强协议以对抗 Proton 想要保护其用户免受的各种攻击。

接下来,客户端将使用远程提供的盐创建自行密码的哈希。该哈希函数在 RFC 5054 中只是 SHA-1,但这使得对验证者的字典攻击更快,因此 Proton 在这里决定使用 bcrypt —— 一种内存安全的哈希函数。这确实引入了 72 字符的密码限制,但 bcrypt 的硬度弥补了这一点。具体而言,来自服务器的 salt 被使用到 bcrypt 盐中,结合工作因子 10。

def hash_password_3(hash_class, password, salt, modulus):
    salt = (salt + b"proton")[:16]
    salt = bcrypt_b64_encode(salt)[:22]
    hashed = bcrypt.hashpw(password, b"$2y$10$" + salt)
    return hash_class(hashed + modulus).digest()

这里的神秘 hash_class 是一个被称为 PMHash 的自定义哈希函数,它将 SHA-512 扩展到 2048 位,通过四次哈希和组合数据。

def digest(b):
    return hashlib.sha512(b + b'\0').digest() +
           hashlib.sha512(b + b'\1').digest() +
           hashlib.sha512(b + b'\2').digest() +
           hashlib.sha512(b + b'\3').digest()

生成的哈希是用于解密用户账户中一切内容的实际秘密,因此绝对重要的是绝不要透露有关此 xxx 值的任何信息。我们称之为验证者的其实是 $ v = g^x \bmod \text{modulus} $,服务器在某个地方存储着这一信息。

为了证明它确实拥有密码哈希,客户端创建一个秘密随机 $ a $ 并计算 $ A = g^a \bmod \text{modulus} $,之后将其发送给服务器。服务器创建一个秘密随机 $ b $ 并发送 $ B = kv + g^b $ 和一些随机 $ u $ 给客户端。在 Proton 的情况下,服务器只是将这些值与模数和盐一起发送,以消除一个往返时间。这两个值构成了服务器挑战。 $ k $ 值在 SRP 的 6a 版中特殊,并且是确定性地派生自模数和生成器 $ g $。

现在客户端将利用对 $ x $ 的知识来计算

image.png 服务器知道验证者 $ v = g^x $,可以计算

image.png 这应与客户端相同,成为共享秘密。客户端首次披露一个基于公共参数和共享密钥的哈希: $ P{client} = PMhash(A,B,S) $。服务器能够计算期望的 $ P{client} $ 值进行验证。如果检测到不匹配,意味着某人篡改了公共值,或客户端错误密码/秘密,服务器必须立即终止协议。在实施 SRP 时,发送任何使用共享密钥加密的数据是一种常见的陷阱,能够使客户端脱机暴力破解密码。如果一切正常,服务器还将发送一个承诺 $ P{server} = PMhash(A,P{client},S) $,显示与客户端参数、客户端承诺以及共享秘密 $ S $ 一致。

此时,客户端获得身份验证,Proton 将为其提供服务器自身无法解密的加密数据。客户确实证明了它对解密数据所需的秘密密钥的知识。

不安全的恢复短语

从 SRP-6a 协议的描述中,我们可以发现一个潜在的攻击途径,如果有人访问包含验证者的数据库。由于验证者是确定性生成的,因此可以使用用户盐和模数运行离线字典攻击。每个步骤涉及运行一次 bcrypt、四次 SHA-512,然后计算 $ g^x \bmod \text{modulus} $ 以检查是否与服务器验证者匹配。这些步骤的组合使测试单个密码变得相当缓慢。由于每个密码也与模数和一个随机盐结合,因此没有扩展暴力破解操作、同时攻击多个哈希的好处。结合对数据库访问的需求,这使得通过 Proton 的加固获取用户密码变得非常困难。

Proton 帐户的多个恢复机制之一是使用恢复短语,这基本上是一个 BIP39 助记短语。这个短语可以解密服务器为用户存储的备份密钥,并在用户忘记密码的情况下恢复所有用户数据。Proton 经常提醒用户设置恢复选项,而恢复短语是其中较容易设置的一种。它仅要求用户生成然后妥善保存一个单词字符串,最终甚至 比密码本身还要有价值。因为恢复短语也会 在使用时禁用 2FA

在 Proton Wallet 应用中,用户再次被提醒设置此选项,以免失去对其资金的访问。这段代码片段为 on<EnableRecovery> 事件处理程序的一部分,负责在不存在短语的情况下生成此备份短语:

// ...
final serverProofs = await protonUsersApi.unlockPasswordChange(
  proofs: proofs,
);

/// 检查服务器证明是否有效
final check = clientProofs.expectedServerProof == serverProofs;
logger.i("EnableRecovery 密码服务器证明: $check");
if (!check) {
  return Future.error('无效的服务器证明');
}

/// 生成新的熵和助记词
final salt = WalletKeyHelper.getRandomValues(16);
final randomEntropy = WalletKeyHelper.getRandomValues(16);

final FrbMnemonic mnemonic = FrbMnemonic.newWith(entropy: randomEntropy);
final mnemonicWords = mnemonic.asWords();
final recoveryPassword = randomEntropy.base64encode();

final hashedPassword = await SrpClient.computeKeyPassword(
  password: recoveryPassword,
  salt: salt,
);
// ...

这就是 WalletKeyHelper 的开头看起来的样子,

class WalletKeyHelper {
  static SecretKey generateSecretKey() {
    final SecretKey secretKey = SecretKey(getRandomValues(32));
    return secretKey;
  }

  static Uint8List getRandomValues(int length) {
    final Random random = Random();
    final List<int> bytes = List<int>.generate(length, (_) => random.nextInt(256));
    return Uint8List.fromList(bytes);
  }
  // ...
}

该函数仍然使用不安全的 Random() 构造。正如我们在 Dart SDK 漏洞 中讨论过的那样。这意味着仅存在 2322^{32}232 个唯一的恢复短语可用,并且如果用户能够猜到正确的短语,他们将立即获得账户的直接访问,同时绕过 2FA。Proton 在用户猜测短语错误太多次时会采用验证码,但据我们所知,外部用户无法判断帐户是否具有不安全生成的短语。但这一机会是存在的,并为具备数据库访问权限的用户或通过攻击或传票获取数据的用户带来内外部攻击的风险。暴力破解四十亿个恢复短语——尽管要求验证码——对于许多国家恐怖主义和威胁行为者来说都是在预算和能力之内。他们面临的问题是检测帐户是否容易受到攻击。Proton 确实会存储生成短语时所用客户端的元数据以及生成时间表,但这并不会在未登录状态下外部可见。

Proton 成功检测到所有脆弱短语,并在其服务器上使其失效。登录的用户会看到此消息,提示他们更新恢复短语。

过期的恢复短语

脆弱的 Flutter 应用程序版本也被完全阻止登录,使得意外生成新的、脆弱的恢复短语成为不可能。请注意,此错误仅影响使用 Proton Wallet 应用程序预览版的用户,该应用程序曾早期访问该应用程序并通过该应用程序创建了其恢复短语。该问题仅存在 1 天,Proton 已使所有脆弱的恢复短语失效。

暴力攻击

Proton 生态系统中的各种应用程序(例如 Proton Wallet)有时会生成保护特定数据片段的随机密码。这些密码可以使用当前用户账户的私钥或公钥进行加密。然后,该数据片段可以与此加密密码一起存储,且解密密码并进一步解密数据本身需要知道账户密码。

以下是 Proton Wallet Flutter 应用在加密钱包助记词和钱包名称时以前的做法:

  Future<ApiWalletData> createWallet(
    String walletName,
    String mnemonicStr,
    Network network,
    int walletType,
    String walletPassphrase,
  ) async {
    /// 生成一个钱包秘密密钥
    final SecretKey secretKey = WalletKeyHelper.generateSecretKey();
    final Uint8List entropy = Uint8List.fromList(await secretKey.extractBytes());

    /// 获取第一个用户密钥(主用户密钥)
    final primaryUserKey = await userManager.getPrimaryKey();
    final String userPrivateKey = primaryUserKey.privateKey;
    final String userKeyID = primaryUserKey.keyID;
    final String passphrase = primaryUserKey.passphrase;

    /// 使用钱包密钥加密助记词
    final String encryptedMnemonic = await WalletKeyHelper.encrypt(
      secretKey,
      mnemonicStr,
    );

    /// 使用钱包密钥加密钱包名称
    final String clearWalletName = walletName.isNotEmpty ? walletName : "我的钱包";
    final String encryptedWalletName = await WalletKeyHelper.encrypt(
      secretKey,
      clearWalletName,
    );
    // ...
  }

WalletKeyHelper 与之前相同,这次调用的是 generateSecretKey() 而非 getRandomValues()

class WalletKeyHelper {
  static SecretKey generateSecretKey() {
    final SecretKey secretKey = SecretKey(getRandomValues(32));
    return secretKey;
  }

  static Uint8List getRandomValues(int length) {
    final Random random = Random();
    final List<int> bytes = List<int>.generate(length, (_) => random.nextInt(256));
    return Uint8List.fromList(bytes);
  }
  // ...
}

然而,前者仅是后者的封装,因此在这里引入了相同的错误。到我们所知,钱包助记词本身是安全生成的。但是,当助记词和钱包名称被加密存储时,用于存储它们在 Proton 服务器上的密钥仅为 32 位。这使得任何拥有服务器访问权限的人都能够尝试暴力破解密钥并获取资金及任何与之相关的历史交易。

解密自己的助记词也是需要重新认证的事件,因为解锁钱包密钥的主密码,而当钱包被弱化时,我们可以直接获取加密数据并进行暴力破解。我们实现了一个简单的、多线程的暴力破解程序来测试其安全性。考虑到 Proton 使用 AES-GCM,包括 MAC/标签,我们可以迅速验证解密是否成功,而无需任何额外的启发式。

为了测试,我们使用了旧的脆弱应用程序创建钱包。在某些时刻进行一次身份验证后,用户可以调用 GET https://wallet.proton.me/api/wallet/v1/wallets,这获取加密的钱包数据,格式为 IV | 密文 | MAC。在这个步骤中,用户应该再次进行身份验证以获得解锁钱包数据的秘密值,但为了演示,我们将跳过此步骤。

通过检查浏览器流量,我们得到加密的助记词是

pGxRIh/QNeAKidoUaTjg9xEuz55O5EeOnNrZnN2Zs66+e1R3qRqeM0H+HTOssHOPseQ+YRK3jNyCcNp7wsG6gypBv/xVDwwZH7jC+puX05/eJwWwBMOaEAWmmRgkuA6bR1kbFricwU7pAA5W3Q==

的钱包名称是:

i3sVzkkK0YN9X4J9LqCX09ecvDmJjlqqx93rwyTJvZY64oPdittZ+zkjTg==

解析这些内容并运行一个暴力破解时不会在找到密钥后停止,我们可以确定耗尽所有可能密码的时间。然后,我们可以使用像这样一个脚本解密数据:

from Crypto.Cipher import AES
from base64 import b64decode
import sys

data = sys.argv[1]
key = bytes.fromhex(sys.argv[2])

data = b64decode(data)
iv = data[:12]
tag = data[-16:]
ct = data[12:-16]

dec = AES.new(key, AES.MODE_GCM, nonce=iv).decrypt_and_verify(ct, tag)
print(dec)

在普通桌面计算机上使用八个超线程核心进行暴力破解时,我们计时暴力破解:

$ time ./brute_aes
所有线程已创建,等待所有完成
PTHREAD 0 结束
PTHREAD 1 结束
PTHREAD 2 结束
PTHREAD 3 结束
PTHREAD 4 结束
PTHREAD 5 结束
PTHREAD 6 结束
Found the key:
c8c3be3b2b4a54253f208749ff9bb6a152391b117dc3fe28f4df33afeca40281
PTHREAD 7 结束
PTHREAD 8 结束
PTHREAD 9 结束
PTHREAD 10 结束
PTHREAD 11 结束
PTHREAD 12 结束
PTHREAD 13 结束
PTHREAD 14 结束
PTHREAD 15 结束

real    16m31.705s
user    187m43.243s
sys     0m1.210s

用大约 16 分钟来完全耗尽 2322^{32}232 密钥空间。考虑到在线 CPU 核心的相对低廉成本,这的确是非常简单可以恢复的信息。如果获取的信息被获取,输入密钥到初步的 Python 脚本中就会暴露出助记词和钱包名称。

$ python3 decrypt.py pGxRIh/QNeAKi... c8c3be3b2b4...
b'miracle toy pudding isolate glide hour canvas circle violin olympic camera museum'

$ python3 decrypt.py i3sVzkkK0YN9... c8c3be3b2b4...
b'Primary Account'

使用弱加密密钥并不会立即给用户带来威胁,但对 Proton 来说却打破了其威胁模型的一些基本原则。内部人员或具有数据库访问权限的攻击者将能够轻易解密钱包,记录这些来自传票或其他法律途径的数据,而这些数据通常被 Proton 保护。获取加密钱包数据仍然需要用户在某个时间点登录,但允许攻击者跳过一个步骤。例如,盗窃了一台计算机,用户已登录 Proton Mail,就不应允许访问助记词而不再次进行了身份验证。

Proton 通过使用每用户的公钥加密所有受影响的钱包来修复此错误,并同时更新 Flutter 应用以支持这个新的加密方案。通过引入 migrationRequired 标志到钱包模式,Flutter 应用现在能自动解密并在服务器端标记的任何钱包上传送。迁移透明地解密然后再用安全密钥加密钱包,然后这个密钥将再次使用用户密钥进行加密——如之前所述。修复可以在 这里↗ 找到,这是他们内部存储库的镜像。

时间线和结论

  • 2024 年 7 月 25 日 — 弱随机性错误被报告。Proton 予以确认,称他们正在关注此问题。该错误在同一天得到内部修复,但代码镜像未更新。在初始报告中,我们认为 PRNG 被设定为 64 位随机,因为桌面版本使用了这一点。
  • 2024 年 7 月 26 日 — 代码镜像被更新,我们注意到其中包含修复。我们向他们发送了更新,指出修复是正确的,但一些现有钱包的迁移将是必要的。
  • 2024 年 7 月 27 日 — Proton 确认修复,并表示奖金裁定将在下周完成。
  • 2024 年 8 月 1 日 — Proton 对初始报告混淆的一些技术问题作出回应,并指出弱密钥仅为短期秘钥,随后会使用用户密钥进行加密。
  • 2024 年 8 月 15 日 — 在获得奖金并且获得与其他人讨论漏洞的绿灯之后,我们发现 PRNG 在移动平台上仅为 32 位。这使得某些暴力攻击变得合理可行。
  • 2024 年 8 月 16 日 — 团队作出回应,表示他们正关注新信息。在接下来的几天里,我们发送了更多电子邮件,解释了该弱 PRNG 可能启用的各种威胁。
  • 2024 年 8 月 21 日 — Proton 给予感谢和确认,并请求 120 天的禁令,以确保他们能够强制迁移旧钱包。

3 — SelfPrivacy 中可预测的密码

最后,我们来看看 SelfPrivacy↗,它提供各种自托管服务的启动,包括 Gitea、Nextcloud、Bitwarden 等。它们的自动设置将使用 password_generator.dart 实用库生成密码、API Token等。其大致结构如下:

import 'dart:math';

Random _rnd = Random();

typedef StringGeneratorFunction = String Function();

class StringGenerators {
  static const String letters = 'abcdefghijklmnopqrstuvwxyz';
  static const String numbers = '1234567890';
  static const String symbols = '_';

  static String getRandomString(
    final int length, {
    final hasLowercaseLetters = false,
    final hasUppercaseLetters = false,
    final hasNumbers = false,
    final hasSymbols = false,
    final isStrict = false,
  }) {
    String chars = '';
if (hasLowercaseLetters) {chars += letters;}
if (hasUppercaseLetters) {chars += letters.toUpperCase();}
if (hasNumbers) {chars += numbers;}
if (hasSymbols) {chars += symbols;}

assert(chars.isNotEmpty, '字符为空');

if (!isStrict) {return genString(length, chars);}

String res = '';
int loose = length;
// (...) 为简洁起见,保证它包含的代码
// 例如,大写字母已被移除
res += genString(loose, chars);

final List<String> shuffledlist = res.split('')..shuffle();
return shuffledlist.join();
}

static String genString(final int length, final String chars) =>
    String.fromCharCodes(
      Iterable.generate(
        length,
        (final _) => chars.codeUnitAt(
          _rnd.nextInt(chars.length),
        ),
      ),
    );

static StringGeneratorFunction userPassword = () => getRandomString(
      8,
      hasLowercaseLetters: true,
      hasUppercaseLetters: true,
      hasNumbers: true,
      isStrict: true,
    );

他们使用 Random _rnd = Random() 使他们也面临风险,尤其是在用于生成缺乏适当速率限制的 API Token时,当遭受暴力破解时。发送 2322^{32}232 个请求并不是一项快速的事情,但对于一个持续存在的攻击者来说是非常可行的。作为附加弱点,当生成多个密码时,PRNG 并没有重新初始化。虽然这有时会使猜测密码变得更加困难,但它使攻击者在从一个密码到下一个密码的过程中变得非常容易,因为攻击者可以恢复 PRNG 的整个状态,然后生成来自同一会话的所有未来密码。

时间线与结论

该漏洞在 2024 年 8 月 23 日被报告,并在仅仅 21 分钟后得到了确认,要求验证他们提议的修复。在确认后,几分钟后发布了新版本↗

长话短说

这三个问题都是由同一个根本原因引起的;使用了不合适的加密安全 PRNG。所有的漏洞都因 Flutter PRNG 中意外低的熵而加剧,其中内部种子仅为 32 位。我们展示了在合理时间内恢复秘密的实际攻击,以及这些攻击如何导致对 Flutter 开发者、Proton Wallet 移动应用用户和 SelfPrivacy 用户的攻击。

关于我们

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

开发者、创始人和投资者信任我们的安全评估,以快速、自信和无重大漏洞地交付。凭借我们在现实世界攻击性安全研究中的背景,我们发现了他人所忽视的问题。

联系我们↗ 进行更优于其他的审计。真正的审计,而非走过场。

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

0 条评论

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