Unlukey is a collaborative research project on weak crypto wallet generation vulnerabilities.
From our research into Ill Bloom, Coldcard, and other weak seed generation vulnerabilities, we learned that there is room to do more to protect users from this class of attacks. We decided to try to tackle this problem not only because it is very likely that new vulnerabilities of this class will be exploited in the future, but also because there are still victims at risk from already discovered ones.
Warning the users of wallets generated from weak recovery phrases is difficult. If there is a single product affected then the vendor's official channels can be used to reach users. However, in cases like Ill Bloom, many affected wallets had been generated by discontinued applications but their users imported the vulnerable mnemonics into other applications. Over time, users may no longer know which software originally generated their recovery phrase.
This makes disclosure by the original vendor insufficient. A recovery phrase can remain in use long after the wallet that generated it is gone. Since the same phrase can end up in another wallet, detection and alerting needs to work wherever the phrase is currently used, not only where it was originally created.
A shared detection mechanism allows multiple systems to warn affected users as soon as a vulnerability is known.
There is also a timing advantage for defenders. We observed cases where attackers drained funds derived from a vulnerable seed on one blockchain while funds associated with the same seed remained untouched on another. Exploiting vulnerable seeds requires attackers to generate candidate seeds, derive accounts, and check multiple blockchains for funds. This process is computationally expensive and takes time, creating an opportunity for defenders to identify affected users and warn them before attackers discover all associated funds.
Once a recovery phrase is created with insufficient entropy, the risk can persist and affect future deposits. Affected users who are not victims of an initial coordinated drain attack may continue using vulnerable wallets for years until their balances exceed an attacker's minimum threshold or new attackers exploit the same vulnerability using a different strategy. Certain mistakes, such as depending on a platform's insecure random number generator to derive entropy, can happen again in new applications or be introduced through a shared dependency. A detection system integrated into wallets that covers every known variant of insecure entropy generation can detect new instances early on and prevent further damage.
- Open — Research, generators, and datasets should be publicly available.
- Privacy — Wallets should perform vulnerability checks without transmitting entropy, mnemonics, seeds, or derived addresses.
- Independent distribution — Datasets should be able to be hosted and mirrored independently, without relying on a centralized lookup service.
- Simple integration — Wallets should be able to check entropy against all Unlukey-supported vulnerabilities with a single integration.
- Extensible — New weak-seed vulnerabilities should be supported without requiring wallets to change their existing integration.
For each known vulnerability, Unlukey generates the possible entropy values, hashes them, and stores the resulting keys in a dataset.
The wallet derives the entropy used as input for BIP-39, hashes it, and checks the resulting key against the dataset.
The Unlukey client:
- Computes
key = SHA-256(entropy). - Sends only a prefix of the key to static hosting.
- Downloads the corresponding bucket.
- Compares the remaining part of the key locally.
- Returns
vulnerableornot-found.
The prefix groups many possible keys into the same bucket, providing k-anonymity. The server cannot determine the full key being checked or whether it matches.
Datasets are separated by entropy length: one for 128-bit entropy and one for 256-bit entropy.
All vulnerabilities producing the same entropy length share a dataset.
The client uses the datasets provided by Unlukey by default. They can also be regenerated using the included generators and tools.
Each vulnerability generator enumerates its weak-seed search space and computes SHA-256(entropy).
The hashes are processed in two stages:
bucket_partitionsplits the hash stream into shard files.bucket_finalizebuilds the final prefix buckets from those shards.
The prefix determines which bucket contains a key and controls the size of the k-anonymity set.
A longer prefix creates more buckets with fewer keys per bucket. This reduces the amount of data downloaded per lookup, but also reduces the anonymity set.
A shorter prefix creates larger buckets with more keys, providing more anonymity at the cost of downloading more data.
The prefix is chosen per dataset based on its size, targeting a consistent anonymity set across vulnerabilities with different numbers of keys.
After the prefix, the dataset stores part of the remaining hash. Fewer bytes make the dataset smaller, but increase the chance of false positives.
This is a trade-off between dataset size and lookup accuracy.
By default, the complete remaining hash is stored, giving zero false positives.
Both prefix-bits and keep-bytes are configured in datasets/build/bucket_finalize.c and must match DATASET_CONFIG in client/index.js.
Unlukey uses BIP-39 entropy as the lookup input and hashes it with SHA-256.
- Language-agnostic. The same entropy can be encoded using different BIP-39 wordlists. Hashing the entropy gives one key regardless of mnemonic language.
- Path and network-agnostic. The weakness belongs to the seed itself, so one key covers accounts, derivation paths, address types, and supported coins derived from it.
- No on-chain activity required. Unlukey does not rely on previously observed on-chain activity to identify vulnerable seeds.
Check an entropy against the published datasets:
node client/cli.js --remote 72039ecb02a3c880d4249e4b206933945a4f3a76ebaab26fa14e47a24e0f907e
# VULNERABLEimport { check } from './client/index.js';
const entropy = mnemonicToEntropy(
secretRecoveryPhrase,
wordlist
);
const { vulnerable } = await check(entropy);entropy must contain either:
- 16 bytes for 12-word mnemonics, or
- 32 bytes for 24-word mnemonics.
The client automatically selects the appropriate dataset based on entropy length.
cc -O3 -o illbloom_dump \
vulnerabilities/ill-bloom/generate/illbloom_dump.c
cc -O3 -o coldcard_dump \
vulnerabilities/coldcard/generate/coldcard_dump.c
cc -O3 -o bucket_partition \
datasets/build/bucket_partition.c
cc -O3 -o bucket_finalize \
datasets/build/bucket_finalize.cRunning a generator with no arguments enumerates its entire search space (terabytes, for these vulnerabilities). Pass a small range instead for testing:
./illbloom_dump 0 2 illbloom_sample.bin
./coldcard_dump 0 2 3 0 coldcard_sample.bin normalSee each vulnerability's README.md for the full generator options.
./bucket_partition 8 shards128 illbloom_sample.bin
./bucket_finalize shards128 datasets/128 20
./bucket_partition 8 shards256 coldcard_sample.bin
./bucket_finalize shards256 datasets/256 19The third argument to bucket_finalize is the prefix length in bits. The value is chosen per dataset (see Prefix bits)
client/cli.js can test against a locally generated dataset:
node client/cli.js 7123000046089b516efdc72c6af30279
# VULNERABLE
node client/cli.js 00112233445566778899aabbccddeeff
# NOT FOUND