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