Skip to main content
Ulrich

Far From Random: Three Mistakes From Dart/Flutter's Weak PRNG

A look into how an unexpectedly weak PRNG in Dart led to Zellic's discovery of multiple vulnerabilities
Article heading

What happens when developers accidentally pick a predictable source of randomness, and that source happens to be significantly weaker than reasonable developers would expect? These are the stories of how some popular projects all got burned by the same underlying weakness in the Dart/Flutter ecosystem and how the projects were affected. The mistake is prevalent in many open-source projects, but we will highlight just a few of them here.

These are the vulnerabilities we’ll be taking a look into.

  • The Dart SDK one-click exploit, an arbitrary file read and write affecting most Dart/Flutter developers
  • The Proton Wallet encryption vulnerability, including attacks against their wallet and backup mnemonic security
  • An issue with predictable passwords in SelfPrivacy

1 — The Dart SDK One-Click Exploit

Flutter Is Not So Random

When you want to create an interactive application that runs on mobile, web, and desktop alike, and all in the same codebase, Flutter is a popular choice. It allows users to write performant applications that give similar user experiences on every supported platform. Flutter is powered by Dart, which can compile the code to ARM machine code for both iOS and Android, JavaScript/WebAssembly for browsers, and x64/ARM for desktop devices. It also features some nifty functionality like “stateful hot reload”, which allows users to change the code and instantly see the results of the change without restarting their entire application.

In order to run similarly on many platforms, Dart code runs in a virtual machine called the Dart VM. The VM comes with multiple built-in libraries that handle OS-specific operations like interacting with files and resources, rendering graphics, doing math operations, and also generating randomness. The platform-specific implementation of these libraries may have differences in exactly how they accomplish their tasks. For instance, platforms that have built-in randomness sources might use those for their CSPRNG and ask the Dart isolate (the main process) for an initial random seed for the insecure PRNG. And then there’s Wasm, that straight up hardcoded the initial seed until September 2024.

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: Make this actually random
- 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);
+ }

In an earlier blog post, we talked about various generators for randomness and how subtle mistakes could easily lead to loss of funds or private-key material down the road. The accidental usage of weak PRNGs can be hard to detect for a human, unless it is so weak that it generates duplicate keys often enough to notice. And even in that case, you have bugs like CVE-2008-0166 (Debian OpenSSL Predictable PRNG) that went undetected for nearly two years, despite having only 15 bits of entropy for all generated SSH keys.

One such accidental weakness is to initialize Dart’s Random() class directly and not call the special constructor Random.secure(), which actually generates cryptographically secure numbers. But how bad is the standard PRNG actually? We dived into the code to figure that out. First off, Dart’s standard PRNG is a multiply-with-carry (MWC) pseudorandom number generator using the native integer type for its state, which is technically 64 bits. The random module is slightly complicated by having platform-specific patches and overrides here and there, but we will quickly go through the main gist of its internals.

Dart PRNG Internals

Let’s assume a user wants to generate 100 random bytes in Flutter. The insecure code for doing this is something like this:

import 'dart:math';

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

In the VM, the Random() constructor in the math module is patched in to be

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

@patch
factory Random([int? seed]) {
var state = _Random._setupSeed((seed == null) ? _Random._nextSeed() : seed);
// Crank a couple of times to distribute the seed bits a bit further.
return new _Random._withState(state)
.._nextState()
.._nextState()
.._nextState()
.._nextState();
}

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

It takes in an optional seed parameter, if the caller wants reproducible outputs; otherwise, it will call _setupSeed(_Random._nextSeed()). But where is this seed coming from, if it’s the first time the random is being initialized?

  // Use a singleton Random object to get a new seed if no seed was passed.
static final _prng = new _Random._withState(_initialSeed());

// ...

// Get a seed from the VM's random number provider.
@pragma("vm:external-name", "Random_initialSeed")
external static int _initialSeed();

static int _nextSeed() {
// Trigger the PRNG once to change the internal state.
_prng._nextState();
return _prng._state & 0xFFFFFFFF;
}

And here we have a big surprise waiting for us. Dart will always generate an internal singleton Random object, which is seeded with 64 bits of secure randomness directly from the VM’s entropy source. Upon constructing a new Random() class, it will fetch a 64-bit state from this singleton and then truncate it by masking with 0xFFFFFFFF. This makes all possible PRNGs only have 32 bits of entropy when initialized like in the insecure example above. To summarize, anything generated from a freshly initialized Random() class is one out of 4,294,967,296 possible streams of outputs. This is absolutely trivial to brute force with modern desktop computers.

Reasonable developers might assume that since the PRNG state is 64 bits and seeded with 64 bits, then the security would also be 64 bits, but due to the 32 bit truncation this isn’t true. It’s at most 32 bits. In short, that means that using Random() over Random.secure() should only be done for the most trivial of applications, but this was accidentally overlooked by the Dart SDK team.

The Attack Scenario

Many new adopters and testers of Flutter might start their journey by following the tutorial on the Flutter website. It will ask a user to install the required Dart SDK and create a new project in an IDE like Android Studio or Visual Studio Code. Once they have created their first project and are staring at their blank template, they might be tempted to look up the documentation online not knowing they are one click away from malicious users stealing files from their computer, or potentially executing code.

Flutter IDEs like Visual Studio Code and Android Studio rely on a persistent, long-running background process. That’s the Dart Tooling Daemon, or DTD for short. Once a Flutter workspace is opened, DTD will automatically start running in the background. This happens automatically when the IDE starts, and it is not triggered by building or running the project. From the package documentation itself,

The Dart Tooling Daemon is a long running process meant to facilitate communication between Dart tools and minimal file system access for a Dart development workspace.

When writing or running a Dart or Flutter application, in an IDE, the Dart Tooling Daemon is started by the IDE. It persists over the life of the IDE’s workspace.

Essentially, DTD is a websocket that listens on a random port. By connecting to it, users gain access to reading and writing files in the workspace directory, listing file directory contents, registering and listening to services, and listening to and posting events to streams. This websocket can be accessed from a browser, but it is somewhat secured by binding to a random port and a generated, random secret that has to be provided in the websocket path when connecting. From their own examples, the URI might look like this,

ws://127.0.0.1:62925/cKB5QFiAUNMzSzlb

where the random port is 62925 and the URI auth code is cKB5QFiAUNMzSzlb. In addition to this secret, there’s a second secret that is required for the special the API call setIDEWorkspaceRoots(secret, roots), which unlocks the capability for the clients to access any file on the computer — not just the ones in the workspace.

But how are these secrets generated? This all happens in dart_tooling_daemon.dart, where we extract only the relevant snippets:

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();

Turns out, these secrets are merely 32 bits. Let’s confirm by brute forcing the seed of the example URI.

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

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

This isn’t great. A silver lining is that the two secrets are generated independently, so an attacker needs twice the brute force (33 bits) in order to recover both. But a huge downside is that the websocket can be accessed by JavaScript on any malicious website, completely without user interaction. The website can automatically brute force the port, followed by testing all four billion possible secrets. At this point, the website can list directory contents, extract and exfiltrate secret files from the workspace, or overwrite build scripts and GIT hooks to indirectly run arbitrary code. After recovering the second secret and changing the workspace roots, the same can be applied to all files that the current user has access to, for example in a typical stealer malware fashion. The same attack scenario applies to local processes that run under less privileged users, allowing privilege escalation.

In our report, we included a JavaScript implementation of the attack that runs when a developer visits a website. It brute forces the port, then starts a throttled scan to guess the authentication code. Such an attack takes some time to run, because browsers have a limit to how many concurrent websockets they allow. So in a real-life scenario, the attacker would need to put the malicious code on a website where the victim is likely to linger (e.g., a Flutter tutorial website, websites that stream video, have messaging services or similar). It is possible to make the attack persist through page clicks by using cookies or localStorage to store progress.

Timeline and Conclusion

  • August 23, 2024 — The bug was reported to the Google Open Source Software Vulnerability Reward Program.
  • September 5, 2024 — The Google Bug Hunters team made a response that an internal bug report has been filed with the product team.
  • September 25, 2024 — The bug was fixed about one month after the initial report.
  • November 1, 2024 — The Google Bug Hunters team decided to not reward nor announce this security fix, because it only affects developers.
  • December 11, 2024 — As of the time of writing, Dart SDK 3.6.0 has not been marked “stable” and has thus not made its way into the stable Flutter SDK.

2 — Proton Wallet Encryption Vulnerability

The preview version of Proton Wallet launched the summer of 2024, with parts of its application code open sourced. The mobile app is written in Flutter and featured the same kind of mistake that the Dart SDK team made. The vulnerability was only present for a single day, but to understand the impact of this vulnerability, let’s take a look at the security of Proton.

Proton’s Protection Mechanisms

Proton’s threat model includes some protections against subpoenas, database hacks, and internal threats. To accomplish this, they try to store the bare minimum of what is necessary to authenticate a user, with a layered approach for the various applications in their ecosystem. Most data is encrypted using AES-256-GCM, where the encrypted key is stored together with the data. To decrypt the data, knowledge about the user password is required at some point. This does come with a few significant downsides, however.

Firstly, they do not know a user’s password, nor the direct hash of their password. The password is not actually sent at all during authentication. But this means that if a user loses their password and have not set up the proper recovery mechanisms, their data is irrecoverably lost — at least until they are able to remember it. The account itself can be recovered, but old data will not be decrypted.

Secondly, while Proton cannot scan a user’s email contents, it also means the user cannot search the contents of their emails without decrypting and caching all the emails locally. Proton’s web client supports doing this in the background, but for large mailboxes, this could take a long time. The sender, recipients, and subject of every email are not encrypted, so make sure to not leave sensitive information in those.

It is worth noting that Proton does not protect against compromised devices or shoulder surfing and that it assumes that the normal certificate-pinning mechanisms will protect against serving malicious websites on the official domains. Users can still fall prey to phishing scams, but there are protections like 2FA that can mitigate some of these. Naturally, Proton itself could serve a user a fake website, but they do release regular versions of their clients that the most vigilant users can vet and then run locally.

Secure Remote Password Protocol

To accomplish the first feat of not knowing a user’s password, Proton implements the Secure Remote Password protocol, revision 6a (SRP-6a). SRP is what we call an augmented password-authenticated key exchange (PAKE), where “augmented” means that the server does not store password-equivalent data. Instead, the client will prove to the server that it has the password, without revealing any information about the password to a passive eavesdropper or an active man in the middle. The server is also authenticated in the same transaction, as it has to store a verifier of the password in order to start the protocol.

The protocol itself is reminiscent of the Diffie–Hellman key exchange, where two parties can agree on a shared, secret key over a public channel. The general flow is like this:

Steps in the SRP protocol

First, the client will send a username to the server, and the server will need to retrieve its verifier and a corresponding, user-specific salt. In Proton’s case, they pick a random modulus from a large pool of moduli whenever the verifier is generated. This reduces the value of solving the general discrete log for any specific modulus, which is something that is likely only within nation-state capabilities as it’s 2,048 bits. The modulus is also signed by Proton, so an attacker cannot use malicious moduli where discrete log is easier. But as an extra precaution, the modulus actually goes into the password itself, so if the modulus somehow is malicious, the attacker only learns about the incorrect hash. The usage of multiple moduli, signing the moduli, or embedding the modulus into the password are not required by the TLS-SRP RFC 5054. The additions are used to harden the protocol against various attacks that Proton wants to protect its users against.

Next, the client will create a hash of its own password using the salt provided by the remote. This hash function is simply SHA-1 in RFC 5054, but this makes dictionary attacks against the verifier faster, so Proton decided to use bcrypt — a memory hard-hashing function — instead here. This does introduce a 72-character password limit, but the hardness of bcrypt makes up for that. Specifically, the salt from the server is used in the bcrypt salt, combined with a work factor of 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()

The mysterious hash_class here is a custom hash function called PMHash, which extends SHA-512 to 2,048 bits by hashing the data four times and combining:

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()

The resulting hash is the de facto secret that is used to decrypt everything in a user’s account, so it is absolutely vital to never reveal anything about this xx value. What we call the verifier is actually v=gxmodmodulusv = g^x \bmod modulus, and the server has knowledge about this stored away somewhere.

To prove that it does possess the password hash, the client creates a secret, random aa and calculates A=gamodmodulusA = g^a \bmod modulus then sends that to the server. The server creates a secret, random bb and sends B=kv+gbB = kv + g^b and some random uu to the client. In Proton’s case, the server simply sends these values together with the modulus and salt at the very start, in order to remove a round trip. These two values constitute the server challenge. The kk value is special to the 6a revision of SRP and is deterministically derived from the modulus and the generator gg.

Now the client will use its knowledge about xx to calculate

S=(Bkgx)(a+ux)S=(kv+gbkgx)(a+ux)S=(kgxkgx+gb)(a+ux)S=(gb)(a+ux)\begin{aligned} S = (B - k \cdot g^x)^{(a + ux)}\\ S = (kv + g^b - k \cdot g^x)^{(a + ux)}\\ S = (k \cdot g^x - k \cdot g^x + g^b)^{(a + ux)}\\ S = (g^b)^{(a + ux)} \end{aligned}

The server, knowing the verifier v=gxv=g^x, can calculate

S=(Avu)bS=(gagux)bS=(g(a+ux))bS=(gb)(a+ux)\begin{aligned} S = (A \cdot v^u)^b\\ S = (g^a \cdot g^{ux})^b\\ S = (g^{(a + ux)})^b\\ S = (g^b)^{(a + ux)} \end{aligned}

which should be the same value SS as the client, and it becomes a shared secret. The client goes first and reveals a hash based on the public parameters and the shared secret Pclient=PMhash(A,B,S)P_{client} = PMhash(A, B, S). The server is able to calculate the expected value of PclientP_{client} for verification. If a mismatch is detected, it means that someone tampered with the public values, or that the client has the wrong password/secret, and the server must then immediately terminate the protocol. Sending any data encrypted with the shared secret is a common pitfall when implementing SRP, and enables the client to brute-force the password offline. If everything looks good, the server will also send a commitment Pserver=PMhash(A,Pclient,S)P_{server} = PMhash(A, P_{client}, S) that shows agreement with the client parameters, the client commitment, and the shared secret SS.

At this point, the client is authenticated and Proton will serve it encrypted data that the server itself cannot decrypt. The client has proved knowledge about the secret key that is required to decrypt the data, after all.

Insecure Recovery Phrase

From the description of the SRP-6a protocol, it is possible to realize a potential venue of attack that comes to play if someone gets access to the database containing the verifiers. Since the verifier is deterministically generated, it is possible to run an offline dictionary attack using the user salt and modulus. Each step involves running bcrypt once, SHA-512 four times, and then calculating gxmodmodulusg^x \bmod modulus to check if it matches the server verifier. The combination of these steps makes testing a single password quite slow. As each password is also combined with the modulus and a random salt, there are no benefits to scaling up the brute-force operations and attacking multiple hashes at once. Combined with the requirement of database access, this makes it very hard to get to the user password itself with Proton’s hardening.

One of multiple recovery mechanisms for Proton accounts is to use a recovery phrase, which is essentially a BIP39 mnemonic phrase. This phrase can decrypt a backup key that the server stores for a user and can recover all user data in case a password is forgotten. A user will often be reminded by Proton to set up recovery options, and the recovery phrase is one of the easier ones to set up. It just requires the user to generate then safekeep a string of words that are ultimately even more valuable than the password itself. That’s because a recovery phrase will also disable 2FA when used.

In the Proton Wallet application, users are again reminded to set this up to not lose access to their funds. This code snippet, a part of the on<EnableRecovery> event handler, is responsible for generating this backup phrase when no phrase already exists:

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

/// check if the server proofs are valid
final check = clientProofs.expectedServerProof == serverProofs;
logger.i("EnableRecovery password server proofs: $check");
if (!check) {
return Future.error('Invalid server proofs');
}

/// generate new entropy and mnemonic
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,
);
// ...

And this is how the start of WalletKeyHelper used to look like,

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

which uses the insecure Random() construct we discussed in the Dart SDK vulnerability. This means that there were only 2322^{32} unique recovery phrases available, and if a user manages to guess the correct one, they would get immediate access to the account while also bypassing 2FA. Proton employs captchas when a user guesses the incorrect phrase too many times, and it was not (to our knowledge) possible for remote users to detect if an account has an insecurely generated phrase or not. But the opportunity existed, and it opened up for internal attacks from users with database access or cracking data stemming from hacks or subpoenas. Brute forcing four billion recovery phrases — despite captcha requirements — is well within the budget and capabilities of many nation states and threat actors. Their issue would be to detect if an account was vulnerable or not. Proton does store metadata about which client created the phrase and when, but this is not externally visible unless logged in.

Proton managed to detect all these vulnerable phrases and invalidated them on their server. Users logging in will be met with this message, prompting them to update the recovery phrase.

Outdated recovery phrase

The vulnerable Flutter application version was also blocked from logging in entirely, making it impossible to accidentally generate a new, vulnerable recovery phrase. Do note that this only bug only affected people that used the preview version of the Proton Wallet application, had early access to the Wallet application, and created their recovery phrase through that application. The issue was present for only 1 day, and Proton has invalidated all vulnerable recovery phrases.

The Brute-Force Attack

The various applications in the Proton ecosystem (e.g., the Proton Wallet), will sometimes generate random passwords that protect certain pieces of data. These passwords can be encrypted using the private or public key of the current user account. The piece of data can then be stored together with this encrypted password, and knowledge about the account password is required to decrypt the password and then decrypt the data itself.

Here is how the Proton Wallet Flutter application used to do this when encrypting the wallet mnemonic and wallet name:

  Future<ApiWalletData> createWallet(
String walletName,
String mnemonicStr,
Network network,
int walletType,
String walletPassphrase,
) async {
/// Generate a wallet secret key
final SecretKey secretKey = WalletKeyHelper.generateSecretKey();
final Uint8List entropy = Uint8List.fromList(await secretKey.extractBytes());

/// get first user key (primary user key)
final primaryUserKey = await userManager.getPrimaryKey();
final String userPrivateKey = primaryUserKey.privateKey;
final String userKeyID = primaryUserKey.keyID;
final String passphrase = primaryUserKey.passphrase;

/// encrypt mnemonic with wallet key
final String encryptedMnemonic = await WalletKeyHelper.encrypt(
secretKey,
mnemonicStr,
);

/// encrypt wallet name with wallet key
final String clearWalletName = walletName.isNotEmpty ? walletName : "My Wallet";
final String encryptedWalletName = await WalletKeyHelper.encrypt(
secretKey,
clearWalletName,
);
// ...
}

The class WalletKeyHelper is the same as before, this time calling generateSecretKey() instead of 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);
}
// ...
}

However, the former is just a wrapper for the latter, and the same bug is introduced here. To our knowledge, the wallet mnemonic itself is securely generated. But when the mnemonic and wallet name were encrypted for storage, the key used to store them on Proton’s servers was only 32 bits. This allowed anyone with server access to attempt brute forcing the key and get access both to the funds and any historic transactions of the user with it.

Decrypting one’s own mnemonic is also an event that requires reauthentication in order to unlock the master password used to unwrap wallet keys, but when the wallet is weak, we can just fetch the encrypted data and brute force it. We implemented a naive, threaded brute forcer to test the security of this. Thanks to Proton using AES-GCM, which includes a MAC/tag, we can quickly verify if the decryption was successful without any additional heuristics.

To test this, we used the old, vulnerable application to create a wallet. After authenticating once at some point, the user is allowed to call GET https://wallet.proton.me/api/wallet/v1/wallets, which fetches the encrypted wallet data in a concatenated format like IV | ciphertext | MAC. At this step, the user is supposed to authenticate again to get the secret value that unwraps the key to decrypt the wallet data, but we will sidestep this for demonstration purposes.

By inspecting the browser traffic, we get that the encrypted mnemonic is

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

and the wallet name is this:

i3sVzkkK0YN9X4J9LqCX09ecvDmJjlqqx93rwyTJvZY64oPdittZ+zkjTg==

Parsing this and running a brute force that does not stop when the key is found, we can figure out the time it takes to exhaust all the possible passwords. Then, we can decrypt the data using a script like this:

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)

Using eight hyper-threaded cores on a normal desktop computer, we time the brute force:

$ time ./brute_aes
All threads created, waiting for all to finish
PTHREAD 0 ended
PTHREAD 1 ended
PTHREAD 2 ended
PTHREAD 3 ended
PTHREAD 4 ended
PTHREAD 5 ended
PTHREAD 6 ended
Found the key:
c8c3be3b2b4a54253f208749ff9bb6a152391b117dc3fe28f4df33afeca40281
PTHREAD 7 ended
PTHREAD 8 ended
PTHREAD 9 ended
PTHREAD 10 ended
PTHREAD 11 ended
PTHREAD 12 ended
PTHREAD 13 ended
PTHREAD 14 ended
PTHREAD 15 ended

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

And it takes about 16 minutes to fully exhaust the entire 2322^{32} key space. Considering the relative cheap cost of online CPU cores, this is indeed trivial to recover if the information is obtained. Feeding the key into the initial Python script reveals both the mnemonic and the wallet name.

$ 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'

Having weakly encrypted keys does not impose any immediate threats for the users, but for Proton it breaks some of the fundamentals in their threat model. It would be feasible for an insider, or an attacker with database access, to easily decrypt wallets. That includes data from subpoenas or other legal avenues that Proton usually has some protection against. Fetching the encrypted wallet data still comes with the requirement that the user has logged in at some point, but it lets an attacker skip a step. For instance, a stolen computer where a user is already logged into Proton Mail should not give access to the mnemonic without authenticating again.

Proton fixed this bug by encrypting all affected wallets with the public key of each user and simultaneously updating the Flutter application to support this new encryption scheme. By introducing a migrationRequired flag to the wallet schema, the Flutter application will now automatically decrypt and then migrate any wallets that are flagged server-side. The migration transparently decrypts then reencrypts the wallet with a secure key, and the key is then encrypted using the user key — as before. The fix can be found here, which is a mirror of their internal repository.

Timeline and Conclusion

  • July 25, 2024 — The weak randomness bug was reported. Proton acknowledged it, stating that they are looking into it. The bug was fixed internally the same day, but the code mirror was not updated. In the initial report, we believed the PRNG to be seeded with a 64-bit random because the desktop version used that.
  • July 26, 2024 — The code mirror was updated, and we noticed that it contained a fix. We sent them an update noting that the fix was proper but that some migration of existing wallets will be required.
  • July 27, 2024 — Proton acknowledged the fix and noted that bounty adjudication would be completed the following week.
  • August 1, 2024 — Proton responded, clearing up some technical confusion from the initial report and stating that the weak key is only ephemeral and then encrypted with a user key after.
  • August 15, 2024 — After getting a bounty and also the green light to discuss the bug with others, we discovered that the PRNG was only 32 bit on mobile platforms. That made some brute-force attacks feasible.
  • August 16, 2024 — The team responded, stating that they are looking into the new information. The upcoming days, we sent additional emails explaining various threats that were enabled by the weak PRNG.
  • August 21, 2024 — A thank you and acknowledgement were given from Proton — and a request for a 120-day embargo to make sure they could forcefully migrate old wallets.

3 — Predictable Passwords in SelfPrivacy

Finally, we’ll take a look at SelfPrivacy, which offers bootstrapping of various self-hosted services like Gitea, Nextcloud, Bitwarden, and more. Their automatic setup will create passwords, API tokens, and the like using the password_generator.dart utility library. It has this general structure:

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, 'chart empty');

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

String res = '';
int loose = length;
// (...) for brewity, code that guarantees that it contains
// e.g. upper case has been removed
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,
);

Their use of Random _rnd = Random() puts them also at risk, especially if used to generate API tokens that lack proper rate limits when brute forced. Sending 2322^{32} requests is not a fast ordeal but very feasible for a persistent attacker. As an additional weakness, the PRNG is not reinitialized when multiple passwords are generated. While it sometimes makes it more difficult to guess the password, it makes it extremely easy to go from one password to the next, as an attacker can recover the entire state of the PRNG and then generate all future passwords stemming from the same session.

Timeline and Conclusion

The bug was reported August 23, 2024, and it was acknowledged after only 21 minutes, asking to verify their proposed fix. After acknowledging, a new release was pushed a few minutes later.

Long Story Short

These three issues were all caused by the same root cause; the usage of a non-cryptographically secure PRNG. All of the bugs were exacerbated by the unexpected low entropy in the Flutter PRNG, where the internal seeds are just 32 bits. We showed practical attacks that will recover secrets within a reasonable time and how they led to attacks on Flutter developers, users of the Proton Wallet mobile application, and users of SelfPrivacy.

About Us

Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.

Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.

Contact us for an audit that’s better than the rest. Real audits, not rubber stamps.