website-public/_posts/2024-05-19-research-update-...

361 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
layout: post
title: "Update #9 - Cake Wallet Technical Writeup"
author: ["Christian Reitter"]
date: 2024-05-19 22:00:00 +0000
---
In this post, we're disclosing new technical information on the Cake Wallet flaw from 2020/2021 that led to the generation and use of very insecure Bitcoin wallets.
<div id="toc-container" markdown="1">
<h2 class="no_toc">Table of Contents</h2>
* placeholder
{:toc}
</div>
<br/>
## Urgent Warning
If youre a Cake Wallet user or know someone who is, we **urgently** recommend checking if you
-> still use a vulnerable old wallet software version
-> still use an old and weak Bitcoin Electrum mnemonic seed generated with a vulnerable version
Affected wallets are at risk of **immediate** and **complete** loss of all Bitcoin funds, and this research update will show why.
## Introduction
Please see [research update #6]({% link _posts/2024-02-13-research-update-6.md %}) for some general context of the Cake Wallet PRNG vulnerability.
To summarize, the Cake Wallet software used a weak Pseudo Random Number Generator (PRNG) to generate Bitcoin wallets. The software vendor [publicly disclosed](https://www.reddit.com/r/Monero/comments/n9yypd/urgent_action_needed_for_bitcoin_wallets_cake/) this vulnerability in May 2021, released a patched software version and asked users to urgently transfer their affected funds to different wallets.
We first came became aware of the public Cake Wallet PRNG vulnerability in July 2023 while researching the Milk Sad vulnerability. Since it was one of the most relevant publicly acknowledged vulnerabilities in Open Source wallet software, we included [a short section]({% link disclosure.md %}#not-the-first-hack-weak-entropy-in-cake-wallet) on the issue in our main writeup for flaws in `libbitcoin-explorer`, but only looked briefly into the technical details at the time due to a different research focus on Mersenne Twister based wallet keys.
This research update is meant for technical readers with a background in security and programming.
## Technical Deepdive
### The Cake Wallet Footgun: randomBytes()
The Cake Wallet software is written in the Dart programming language. The vulnerable version used a custom PRNG wrapper function called `randomBytes()` to fill `Uint8List` objects (roughly similar to byte vectors) with random data, taken from either the Dart `Random.secure()` or `Random()` PRNG APIs.
Combining two cryptographically secure and insecure code paths in one function is a bad practice, as is defaulting to the insecure method. In combination with a robust-sounding function name, this created what's known to programmers as a "footgun" - a feature you're likely to shoot yourself in the foot with. 🦶 ⬅️ 🔫
[Code `bitcoin_mnemonic.dart`](https://github.com/cake-tech/cake_wallet/blob/b67bb0664f7268c31c24bd9fb9cbd438c691f5e3/lib/bitcoin/bitcoin_mnemonic.dart#L11-L22):
```dart
Uint8List randomBytes(int length, {bool secure = false}) {
assert(length > 0);
final random = secure ? Random.secure() : Random();
final ret = Uint8List(length);
for (var i = 0; i < length; i++) {
ret[i] = random.nextInt(256);
}
return ret;
}
```
This footgun went off in the important `generateMnemonic()` function that generates Bitcoin wallets. Here, `randomBytes(byteCount);` does not set the second function parameter to `secure == true` and consequently uses the known-insecure PRNG as the only entropy source for the master wallet private key. 🎯☠️
[Code `bitcoin_mnemonic.dart`](https://github.com/cake-tech/cake_wallet/blob/b67bb0664f7268c31c24bd9fb9cbd438c691f5e3/lib/bitcoin/bitcoin_mnemonic.dart#L105-L118):
```dart
String generateMnemonic({int strength = 132, String prefix = segwit}) {
final wordBitlen = logBase(wordlist.length, 2).ceil();
final wordCount = strength / wordBitlen;
final byteCount = ((wordCount * wordBitlen).ceil() / 8).ceil();
var result = '';
do {
final bytes = randomBytes(byteCount);
maskBytes(bytes, strength);
result = encode(bytes);
} while (!prefixMatches(result, [prefix]).first);
return result;
}
```
When the vulnerability was first disclosed on Reddit, this technical source of the bug was quickly [spotted and pointed out](https://www.reddit.com/r/Monero/comments/n9yypd/comment/gxqyscl/) by users in the space of a few hours.
### Analyzing Dart Random(), Part 1
Ok, so the vulnerable key generation function used the insecure variant of Dart's [Random() API](https://api.dart.dev/stable/3.4.0/dart-math/Random-class.html):
> The default implementation supplies a stream of pseudo-random bits that are not suitable for cryptographic purposes.
>
> Use the Random.secure constructor for cryptographic purposes.
Everyone agrees that this is bad. But how bad exactly?
`Random()` is not meant to be a Cryptographically Secure Pseudo Random Number Generator (CSPRNG), and so the code is generally allowed to return very weak and easily guessable results. One example for this can be found in its seeding function, which initializes itself via the current system time under some conditions:
[Code `random.cc`](https://github.com/dart-lang/sdk/blob/6bc417dd17f5edb228e60b9aebb70650bf7d5f50/runtime/vm/random.cc#L17-L33):
```c++
Random::Random() {
uint64_t seed = FLAG_random_seed;
[...] // OMITTED MAIN SEED CALCULATION CODE
if (seed == 0) {
// We did not get a seed so far. As a fallback we do use the current time.
seed = OS::GetCurrentTimeMicros();
}
Initialize(seed);
}
```
This fallback mechanism is a well-known weakness pattern, see [CWE-337: Predictable Seed in Pseudo-Random Number Generator (PRNG)](https://cwe.mitre.org/data/definitions/337.html) and [CWE-335: Incorrect Usage of Seeds in Pseudo-Random Number Generator (PRNG)](https://cwe.mitre.org/data/definitions/335.html). The exploitation difficulty depends mostly on the used resolution of the system clock and the potential time range an attacker wants to try out via brute-force (_minutes, days, years?_).
However, let's focus on the default case where this special fallback is _not_ used to pick the PRNG seed, for reasons that will become clear later.
While investigating the Cake Wallet behavior, we searched for prior work in the form of existing security analysis writeups for the Dart `Random()` PRNG in general, or the Cake Wallet vulnerability specifically, that would tell us about its practical properties.
Reviewing and reversing cryptographic mechanisms is usually very time consuming, so we hoped that other researchers had written about this before, but no luck - we seem to be the first to do so. (In case there was something out there that we missed, [let us know]({% link index.md %}#contact) so that we can add it as a reference).
Our primary research question: how weak, exactly, are the generated wallets to brute-force attacks?
In other words, is it realistic to generate all weak wallet candidates and scan them for usage on the blockchain, as we did with Mersenne Twister-based weak wallets?
For this, the amount of actual (not-otherwise-guessable) entropy supplied by `Random()` is an essential factor, so let's look more closely into that.
Right from the start, the initial signs were not at all encouraging for our research goal of practical scans.
[Code `random.cc`](https://github.com/dart-lang/sdk/blob/6bc417dd17f5edb228e60b9aebb70650bf7d5f50/runtime/vm/random.cc#L17-L33):
```c++
Random::Random() {
uint64_t seed = FLAG_random_seed;
if (seed == 0) {
Dart_EntropySource callback = Dart::entropy_source_callback();
if (callback != nullptr) {
if (!callback(reinterpret_cast<uint8_t*>(&seed), sizeof(seed))) {
// Callback failed. Reset the seed to 0.
seed = 0;
}
}
}
[...] // OMITTED FALLBACK SEED CALCULATION CODE
Initialize(seed);
}
```
The code defines the local variable `uint64_t seed`, which can hold up to `64` bits of information, and calls `DartUtils::EntropySource(uint8_t* buffer, intptr_t length)` to fill it completely with random values.
Relevant code positions for the entropy source:
[Code `main_impl.cc`](https://github.com/dart-lang/sdk/blob/6bc417dd17f5edb228e60b9aebb70650bf7d5f50/runtime/bin/main_impl.cc#L1362):
```C++
init_params.entropy_source = DartUtils::EntropySource;
```
[Code `dart.cc`](https://github.com/dart-lang/sdk/blob/6bc417dd17f5edb228e60b9aebb70650bf7d5f50/runtime/vm/dart.cc#L307):
```C++
set_entropy_source_callback(params->entropy_source);
```
The `EntropySource()` function seems to always use cryptographically secure entropy:
[Code `dartutils.cc`](https://github.com/dart-lang/sdk/blob/6bc417dd17f5edb228e60b9aebb70650bf7d5f50/runtime/bin/dartutils.cc#L312-L314):
```c++
bool DartUtils::EntropySource(uint8_t* buffer, intptr_t length) {
return Crypto::GetRandomBytes(length, buffer);
}
```
The `Crypto::GetRandomBytes(length, buffer)` implementation depends on the operating system, but generally fetches random bytes directly from strong sources like `/dev/urandom`.
The PRNG state variable is also large enough to hold this complexity between PRNG iterations:
[Code `random.h`](https://github.com/dart-lang/sdk/blob/6bc417dd17f5edb228e60b9aebb70650bf7d5f50/runtime/vm/random.h#L50-L54):
```c++
private:
uint64_t NextState();
void Initialize(uint64_t seed);
std::atomic<uint64_t> _state;
```
The `uint64_t _state` is sufficiently large to keep the intermediary PRNG states from being directly enumerable by us.
One PRNG function looked interesting:
[Code `random.h`](https://github.com/dart-lang/sdk/blob/6bc417dd17f5edb228e60b9aebb70650bf7d5f50/runtime/vm/random.h#L31-L41):
```c++
// Returns a random number that's a valid JS integer.
//
// All IDs that can be returned over the service protocol should be
// representable as JS integers and should be generated using this method.
//
// See https://github.com/dart-lang/sdk/issues/53081.
uint64_t NextJSInt() {
// Number.MAX_SAFE_INTEGER (2 ^ 53 - 1)
const uint64_t kMaxJsInt = 0x1FFFFFFFFFFFFF;
return NextUInt64() & kMaxJsInt;
}
```
For context, Dart code can run in a Javascript environment. Since Javascript has the odd property to store integers as floating point numbers, integers in that environment [are limited](https://stackoverflow.com/questions/2802957/number-of-bits-in-javascript-numbers) to 53 bits of complexity instead of the original 64 bits. At first, this seems like an interesting restriction that could bring down the overall complexity of this and other functions. The vulnerable Cake Wallet software doesn't run in a Dart Javascript context, though, so this is not _directly_ relevant for our analyzed target.
To recap, a first review pass over the main implementation of the insecure Dart `Random()` PRNG class suggests it self-seeds with `64` bits of system-provided entropy under normal conditions.
Cryptographic keys with this complexity level are still considered totally broken against resourceful adversaries. That said, the exponentially increased brute-force complexity between keys with `32` bits of entropy and keys with `64` bits of entropy means that they're much less feasible to break at scale in our self-funded research.
Weak but not easily exploitable - case closed! Right?
### Analyzing Dart Random(), Part 2
Not exploitable for us? Not so fast!
In security research, extra persistent digging can sometimes overcome seemingly impossible odds. There's always room for implementation issues in crucial functionality, or other "real world" problems which undermine the expected security of the theoretical model. A combination of hard work, patience and luck can lead you to previously hidden layers, and that's often where the new or interesting vulnerabilities are.
In the case of Dart `Random()`, the breakthrough came in finding [math_patch.dart](https://github.com/dart-lang/sdk/blob/59aa375d220df43df69f2208bfe9c00849222d58/sdk/lib/_internal/vm/lib/math_patch.dart) during further code review. This source code file contains not just math functions, but also special _implementations_ of the `Random` class in pure Dart instead of C++.
In this previously hidden layer, the security situation looks quite different and much less solid than before.
[Code `math_patch.dart`](https://github.com/dart-lang/sdk/blob/59aa375d220df43df69f2208bfe9c00849222d58/sdk/lib/_internal/vm/lib/math_patch.dart#L201-L206):
```dart
class _Random implements Random {
// Internal state of the random number generator.
@pragma("vm:entry-point")
final Uint32List _state;
static const _kSTATE_LO = 0;
static const _kSTATE_HI = 1; // Unused in Dart code.
```
Instead of a single `uint64_t _state` of the C++ implementation, here the PRNG state is expressed as two 32 bit elements - low (`LO`) and high (`HI`) - with an ominous code comment describing that the high element is **unused**. Does this mean the PRNG operates on a total of 32 bits of state alone in practice? 🤔
Yes, it does!
[Code `math_patch.dart`](https://github.com/dart-lang/sdk/blob/59aa375d220df43df69f2208bfe9c00849222d58/sdk/lib/_internal/vm/lib/math_patch.dart#L271-L275):
```dart
static int _nextSeed() {
// Trigger the PRNG once to change the internal state.
_prng._nextState();
return _prng._state[_kSTATE_LO];
}
```
As you can see, the seed handling throws away the higher 32 bits by only return the lower 32 bits. 🔥
More fundamentally, it becomes clear that Dart is actually using two nested PRNGs. The outer one (in pure C++, shown before) has 64 bits of state and is seeded with 64 bits of entropy, but the inner one (in Dart & some C++, shown here) has only 32 bits of effective state and is seeded with a truncated 32 bits of output from the outer PRNG.
This is somewhat bizarre, and clearly really lucky for us on the researcher side - but bad for any real world users who are caught in some insecure usage.
We can only speculate why this particular design was chosen, but we expect it had something to do with the Dart virtual machine boundaries, the desire to write as much fundamental code in Dart as possible, and the previously discussed Javascript runtime environment restrictions.
[Code `math_patch.dart`](https://github.com/dart-lang/sdk/blob/59aa375d220df43df69f2208bfe9c00849222d58/sdk/lib/_internal/vm/lib/math_patch.dart#L264-L269):
```dart
// This is a native to prevent 64-bit operations in Dart, which
// fail with --throw_on_javascript_int_overflow.
// TODO(regis): Implement here in Dart and remove native in math.cc.
static Uint32List _setupSeed(int seed) native "Random_setupSeed";
// Get a seed from the VM's random number provider.
static Uint32List _initialSeed() native "Random_initialSeed";
```
### Brute-Forcing Dart Random()
Regardless of the reasons for Dart's dual-stage PRNG design, we can now effectively break most of the remaining defensive characteristics by peeling off the outer PRNG layer that is more difficult to attack. Whatever the outer layer does, the inner PRNG only uses it for an initial 32 bit request for seeding data. Instead of trying to exactly simulate the outer PRNG to determine what value that was, we can treat the inner PRNG's smaller seeding state space as coming from a perfect but limited RNG source and directly brute-force through all combinations!
To spell this out, instead of trying `18446744073709551616` iterations of the outer PRNG, we only have to try `4294967296` of the inner PRNG, which is clearly _a lot less work_.
In our hands-on research, we re-implemented the relevant components of the inner Dart PRNG in Rust to avoid any runtime dependency on Dart, to experiment with the behavior and to ensure fast computation.
This involved some intermediary computation details that aren't important for understanding the general vulnerability, such as
* The extra four rounds of "cranking" during PRNG seeding
* The `mix64()` function to spread 32 bits into the 64 bit range
* The `nextInt()` "fast case" used by Cake Wallet via `random.nextInt(256)`
The main takeaway message is that the non-secure `Random()` variant in Dart on Android, as used by the vulnerable Cake Wallet app, can be brute-forced in practice on a single machine in short order since it has only `2^32` potential starting states.
### Reconstructing Cake Wallet Keys
Now that we know the algorithm to generate the `2^32` different raw byte chunks that make up all possible PRNG outputs Cake Wallet can ever get during the key generation, it's time to focus back on the Cake Wallet code.
[Code `bitcoin_mnemonic.dart`](https://github.com/cake-tech/cake_wallet/blob/b67bb0664f7268c31c24bd9fb9cbd438c691f5e3/lib/bitcoin/bitcoin_mnemonic.dart#L105-L118):
```dart
String generateMnemonic({int strength = 132, String prefix = segwit}) {
final wordBitlen = logBase(wordlist.length, 2).ceil();
final wordCount = strength / wordBitlen;
final byteCount = ((wordCount * wordBitlen).ceil() / 8).ceil();
var result = '';
do {
final bytes = randomBytes(byteCount);
maskBytes(bytes, strength);
result = encode(bytes);
} while (!prefixMatches(result, [prefix]).first);
return result;
}
```
Some explanations are in order. Most wallet software uses the [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) standard to standardize, encode and checksum the master private key of a cryptocurrency wallet into the form of a human readable passphrase, the so-called "mnemonic" or "seed phrase". Cake Wallet uses the [Electrum seed phrase](https://electrum.readthedocs.io/en/latest/seedphrase.html), a different standard that is derived from `BIP39` but intentionally incompatible with it.
We won't go into all the details and differences between the two, but one important design aspect of the Electrum seed phrase in this context is how it implements checksums over the seed phrase.
Where BIP39 reserves some bits in the raw encoded data purely for checksums and computes them based on the selected key (12 word mnemonics: 128 key bits + 4 checksum bits = 132 bits total), the Electrum seed format uses all available bits for the key (12 word mnemonics: 132 key bits total) and then applies certain restrictions on which keys are considered valid, depending on the intended Bitcoin usage.
The vulnerable Cake Wallet version used a hardcoded mnemonic length of 12 words = 132 bits for this, see `int strength = 132`. Since 132 bits aren't directly on a byte boundary, some extra masking via `maskBytes(bytes, strength)` is required after fetching 136 bits of output from the PRNG to cut off the extra bits.
Consider `prefixMatches()`, which is called with the single string prefix target `const segwit = '100';`
[Code](https://github.com/cake-tech/cake_wallet/blob/b67bb0664f7268c31c24bd9fb9cbd438c691f5e3/lib/bitcoin/bitcoin_mnemonic.dart#L97-L103):
```dart
List<bool> prefixMatches(String source, List<String> prefixes) {
final hmacSha512 = Hmac(sha512, utf8.encode('Seed version'));
final digest = hmacSha512.convert(utf8.encode(normalizeText(source)));
final hx = digest.toString();
return prefixes.map((prefix) => hx.startsWith(prefix.toLowerCase())).toList();
}
```
In effect, this is a complex way of asking "if I HMAC-hash the private key I just generated, does the resulting hash value start with these specific bits I'm interested in, which serve as a checksum?"
Assuming that the `HMAC-SHA512` hash result values are effectively random, and since we're asking for the exact hex prefix of `0x100` (`0001 0000 0000` in binary notation) with a length of 12 bits, only `1` in `2^12` results will - on average - satisfy this requirement.
In other words, the wallet generation code loop will - on average - instantiate a new `Random()` PRNG for 4096 times, rejecting the first 4095 of them because the generated key doesn't have the right hash properties.
Here is the loop code again:
[Code `bitcoin_mnemonic.dart`](https://github.com/cake-tech/cake_wallet/blob/b67bb0664f7268c31c24bd9fb9cbd438c691f5e3/lib/bitcoin/bitcoin_mnemonic.dart#L111-L115)
```dart
do {
final bytes = randomBytes(byteCount);
maskBytes(bytes, strength);
result = encode(bytes);
} while (!prefixMatches(result, [prefix]).first);
```
Extra observant readers may have already spotted the implications of this checksum-and-reject mechanism, combined with picking a newly-seeded PRNG after each rejection.
Since the checksum mechanism is necessarily deterministic and will always reject the same inputs, and since the Dart PRNG is deterministic with regards to a certain seed, this acts as a giant filter on top of the already very-limited `Random()` PRNG state space. While there are `2^32` potential private keys that `randomBytes(byteCount);` can return, the vast majority of them will not get accepted by `prefixMatches(result, [prefix]).first`, leading to another roll of the (loaded) PRNG dice - a behavior that is the same across all vulnerable Cake Wallet instances.
In effect, this reduces the number of possible weak Cake Wallet private keys to about `2^32 / 2^12 = 2^20` keys. We counted `1049308`, to be exact.
This is extremely bad. 💥💥
Let that number sink in for a moment - you're asking a security software to generate a private key with over 120 bits of non-guessable entropy, and you're getting .. 20 bits. 😱
## Real World Implications
This is already a very long post, so the observations on historical usage, a timeline and other details will follow in a future research update.
We're sad to say that there are still some users of this range of extremely weak Bitcoin wallets who are acutely at risk.
Finding weak wallets with remaining balances is a real ethical dilemma. As mentioned before, this vulnerability has now been widely public for three years after the vendor disclosed it (long before we were involved). We made a number of efforts to try and reduce the impact on remaining vulnerable wallet owners, including
* Confidential discussions with the vendor, including on how to inform affected users
* Helping the vendor to roll out [a special app update](https://github.com/cake-tech/cake_wallet/pull/1238/files#diff-f0e3a7e177b8361801485b78f89e31eb8667f5084044ba4e63ff53780e974059) to detect and warn most victims where still possible
* Publishing the generic [research update #6]({% link _posts/2024-02-13-research-update-6.md %}) to raise awareness of the issue without directly revealing technical details
* Delaying disclosure of this issue for many months to give victims additional time
Unfortunately, we've now exhausted our options and decided it's finally time to publish.
As with our previous work on Milk Sad, we did not move any funds in this range that we didn't legally own. We therefore cannot help with getting any funds back that were moved illegitimately by others.
## Summary & Outlook
In this research update, we've shown a broad range of technical details on the Cake Wallet PRNG vulnerability, including security information that has not been public before. We hope this helps to improve the public understanding of the known-insecure Dart `Random()` PRNG and provides another useful case study on the catastrophic impacts of badly chosen PRNG mechanisms during cryptographic key generation.
We will discuss other details of this work in a future research update iteration.
If you would like to see more articles with tech-heavy focus on our work or have something interesting to share, [let us know]({% link index.md %}#contact)!
<br/>