🚨 If you ever used "Bitcoin Wallet" on Android (Schildbach's, de.schildbach.wallet) and exported a backup to Drive, email or any cloud: treat your keys as exposed.
The password protecting that file protects almost nothing. 🧵
2/ The backup format claims "1024 iterations" of encryption. It's dead code the real key-derivation cost is 3 MD5 hashes. Proven by decrypting an actual backup from the project itself.
A normal human password falls in seconds on a GPU. Offline. No rate limit.
3/ Patching the app won't save you. A file you already exported stays crackable forever including a backup rotting in an old inbox since 2014.
Your bitcoin keys are fine (256-bit, unbreakable). The weakness is the envelope around them.
4/ What to do, right now, in order:
1. Create a new wallet and move all your funds to it. The old keys become worthless.
2. Then delete the old backup files from your clouds and inboxes.
Moving funds takes 5 minutes. Take them.
5/ General lesson, every wallet: a key backup without real scrypt/argon2 isn't encrypted it's slowed down by 3 seconds. Check what your wallet actually does, not what its screen says.
🟠 AUDIT · LDK (rust-lightning)
LDK's esplora sync accepts a forged blockchain with no proof-of-work check. It verifies the Merkle proof against the header the server itself provides, never the PoW, even though LDK validates PoW correctly on another path (lightning-block-sync). So a hostile or MITM'd chain source can feed your node a fake chain.
Why this is worse than the usual "no SPV" bug: the node does not just show a fake balance, it ACTS on-chain. Proven by real execution: the victim pays 100k real sats on a real channel while being credited on a phantom one. Plus forced closes and silent justice failures.
Honest scope, because it matters:
- Not a wallet drain. Real funds are stolen, but bounded to a channel balance, and the victim is tricked into paying, not emptied.
- The attacker must be BOTH your channel peer AND control or MITM your chain source. The one party naturally both is your LSP.
- Run your own node and you are immune. The exposed set is lightweight LDK wallets trusting an LSP or public esplora over http.
Fix is small: validate PoW and chain continuity in esplora sync. The code already exists elsewhere in LDK.
#BitcoinSecurity #WalletAudit
🟠 AUDIT · Blockstream Green (gdk)
Green's 2-of-3 multisig gives you a recovery key so you are not fully trusting Blockstream. The catch: the client never independently verifies that recovery key. It rebuilds the whole wallet from server data at every login and takes the recovery key as given.
So a compromised or MITM'd Blockstream server could swap the recovery key for its own. It would then hold 2 of the 3 keys, derive poisoned addresses, and sweep future deposits alone. Undetectable client-side, because the client keeps rebuilding from that same server data.
Honest bounds, because they matter: this requires Blockstream's own server to be compromised, a high bar. The standard 2-of-2 mode is provably safe. Existing funds are untouched, only future deposits to poisoned addresses.
So it is not "Green is broken." It is "the recovery key does not reduce your trust in Blockstream as much as 2-of-3 implies, because the client never checks it." That is the whole point of a recovery key, and it is unverified.
Fix: pin and verify the recovery key client-side, independent of server data.
#BitcoinSecurity #WalletAudit
🟠 AUDIT · BitBox02
The BitBox app does all the SPV verification correctly, then ignores the result when it shows your balance.
A hostile or MITM'd Electrum server can present a transaction that was never mined as "Complete, 6 confirmations", credit the fake balance, and the flow runs all the way to a real signature on the device. The SPV check literally runs, proves the tx is not in any block, and the screen says "confirmed" anyway.
Amplifier: the Electrum TLS connection does no hostname verification and pins on blind TOFU, so a network attacker, not even the server, can MITM at first contact and make it stick.
Keys are safe, no drain: the fake tx is invalid on-chain, nothing you own moves. But it is fraud-on-receipt. Anyone accepting BTC on a BitBox and trusting the "confirmed" screen can be shown a payment that does not exist, and hand over goods for nothing.
Credit where due: the firmware itself is the cleanest hardware core in this series. Change, multisig, prevouts are all cryptographically sound. This is an app and network-layer bug, not a signing flaw.
@BitBoxSwiss
#BitcoinSecurity #WalletAudit
🚨 AUDIT · Nunchuk · THEFT
Your Nunchuk "2-of-3" multisig can secretly be a 1-of-1 that an attacker drains alone.
A collaborative vault is built by exchanging cosigner keys. Nothing stops one participant from supplying the SAME xpub for two of the three slots, under two different names. The open-source core validates counts only, never key uniqueness: Wallet::check_valid checks m, n and the signer count, ends with a literal // TODO: bitcoin core validation?, and never deduplicates (libnunchuk dto/wallet.cpp:127-152).
So a "2-of-3" where the attacker holds 2 of the 3 keys passes validation. At the join step the app renders slots as "Key #1 / #2 / #3" with the declared fingerprint, so the duplicate is invisible. The victim sees "2 of 3", deposits, and the attacker signs 2-of-3 alone and sweeps everything. The victim's key is never needed.
The elegant part: Nunchuk's own end-to-end encryption, "not even Nunchuk can access it", means the server literally cannot read the cosigner keys to catch this. The client is the only place the check can exist, and it is missing. Their trust-minimization guarantee is the proof that nothing backstops it.
Keys are not stolen. But every sat a victim deposits into a vault they were invited to join can be swept unilaterally. On a wallet marketing $1B+ secured, for families and inheritance.
@nunchuk_io @BitcoinMagazine @BitcoinNews @BitcoinArchive @crypto
#BitcoinSecurity #WalletAudit
🟠 AUDIT · BlueWallet (Android)
On Android, BlueWallet's Electrum connection does no TLS hostname verification. The native module it uses, react-native-tcp-socket, calls startHandshake() with no HostnameVerifier and no endpoint identification (TcpSocketClient.java:42-81), and the app pins nothing (network_security_config.xml). BlueWallet passes no TLS options either (BlueElectrum.ts:390).
Result: anyone in a network position (hostile WiFi, ISP, captive portal) with any valid cert, a free Let's Encrypt DV is enough, can MITM the connection. iOS is not affected.
Keys are safe, no direct drain. But combined with the lack of SPV, a network attacker can feed you fake balances and fake confirmations without ever touching the server. Fraud-on-receipt for anyone accepting BTC.
Root cause is in a shared dependency, so other apps may inherit it.
Exodus's secp256k1 fork (the one that drops the native binding, 100% JS) silently falls back to a frozen-2021 pure-JS elliptic implementation if the modern backend fails to load. This is the entire dispatch in index.js:
try { secp256k1 = require('./bitcoinerlab') }
catch (e) { secp256k1 = require('./elliptic') }
The catch swallows any load error and swaps in the old engine, which carries validation divergences from the modern one (accepts high-S, no on-curve check in ECDH). No log, no warning. You would not know which crypto you are actually running.
Honest scope: I could not demonstrate fund theft, reachability is limited to non-bundled Node runtimes, and the current latest is the healthiest artifact. This is a fail-silent design and supply-chain robustness issue, not a live drain.
Fix: fail loud. Throw if the modern backend is unavailable, never silently downgrade crypto.
#BitcoinSecurity #WalletAudit
🟠 AUDIT · Bitcoin Core
Bitcoin Core maps its port via NAT-PMP/PCP, and this is now ON by default (mapport.h:8, DEFAULT_NATPMP = true). It acts on unauthenticated UDP responses.
PCP carries a nonce, which gives some off-path protection. But a spoofed "unsupported version" reply downgrades it to NAT-PMP (pcp.cpp:492), which has no nonce at all. After the downgrade, any device on your LAN or on the path to the gateway can forge the response and influence the external address and port mapping the node believes it has.
Honest scope: this needs a LAN or on-path attacker, not a remote internet one, and the impact is address/port-mapping manipulation, not fund theft. Moderate, and it is on by default, which is the part worth flagging.
#BitcoinSecurity #WalletAudit
⚪ AUDIT · noble-curves
A comment in this crypto library (107M downloads a month) says its scalar-mult select is "data-oblivious," meaning it touches every table entry regardless of the secret. It does not.
curve.ts:600: sel = i === idx ? comp[start + i] : sel. The ternary short-circuits, so comp[start + i] is only read when i equals the secret index idx. Every other entry is skipped. The memory access pattern depends on the secret scalar digit, which is exactly what "data-oblivious" is supposed to prevent.
Honest severity: low. Cache-timing attacks in JS are hard in practice, and the secret path is additionally scalar-blinded (curve.ts:514), which mutes the leak. So this is a documented guarantee that the code does not actually deliver, not a live key-extraction.
Fix: read every entry unconditionally and select with arithmetic, not a branch.
Reported privately first.
#BitcoinSecurity #WalletAudit
🟠 AUDIT · SeedSigner
The air-gapped Bitcoin signer writes every QR it renders to /tmp/qrcode.png and never deletes it.
That file holds whatever was just encoded: your xpub, an address, a PSBT, a signed message (qr.py:99 and :105). There is no cleanup anywhere in the repo. If /tmp sits on the SD card rather than tmpfs, it survives reboot, so pulling the card reveals your last xpub or signed transaction. This is a privacy leak, not seed theft: the mnemonic is not written here.
Bonus, same two lines: the data is interpolated into a shell command run with shell=True (qr.py:99-100). Data carrying shell metacharacters becomes command injection on the device.
Fix: delete the temp file after use or write to tmpfs, and drop shell=True with interpolated input.
Reported privately first.
#BitcoinSecurity #WalletAudit
⚪ AUDIT · ecpair
A footgun in the bitcoinjs keypair library: you can "wipe" your private key and still export a valid-looking WIF for it.
get privateKey() returns the internal buffer by reference, not a copy (ecpair.ts:204). So keyPair.privateKey.fill(0) zeroes the live key inside the object. Then toWIF() only checks that the key is not undefined, never that it is a valid scalar (ecpair.ts:221), so it happily encodes the all-zero key into a well-formed WIF.
Why it bites: wipe-then-export, or export-then-wipe in the wrong order, and you back up a WIF that controls nothing, or you think you wiped a key that a stale reference still holds. Low severity, pure footgun, but a nasty one for anyone building key-handling on top.
Fix: return a copy from the getter, and validate the scalar in toWIF.
Reported privately first.
#BitcoinSecurity #WalletAudit
🟠 AUDIT · bitcoinj
The Java Bitcoin library accepts a P2WSH spend without checking any signature.
ScriptExecution.correctlySpends has branches for P2WPKH, P2PKH, P2PK, each verifying the signature. There is no branch for P2WSH (ScriptExecution.java:1019), so it falls to the legacy interpreter, which runs OP_0 <32-byte hash> as plain data pushes, leaves a truthy value on the stack, and returns success. No witness, no signature checked. The code even says so: "For segwit, full validation isn't implemented" (:1025).
Honest scope: this only bites on the full-validation path (FullPrunedBlockChain), which is rarely used. SPV is the default, and most bitcoinj wallets never call this on P2WSH. So it is a real accept-invalid, not a live wallet drain.
Reported privately first.
#BitcoinSecurity #WalletAudit
🟠 AUDIT · NBitcoin
The .NET Bitcoin library behind BTCPay and NBXplorer accepts a tapscript that Bitcoin Core rejects.
In Tapscript, Core disables OP_CHECKMULTISIG / OP_CHECKMULTISIGVERIFY (you must use OP_CHECKSIGADD) and fails the script with SCRIPT_ERR_TAPSCRIPT_CHECKMULTISIG. NBitcoin has no such guard: the CHECKMULTISIG case runs the legacy multisig logic regardless of version (ScriptEvaluationContext.cs:1608), even applying the old 201-op limit that Tapscript removed.
The tell that it is an oversight: OP_CHECKSIGADD a few lines up IS version-gated (:1590). They know how to gate opcodes by version. This one was missed.
Why it matters: any service that relies on NBitcoin to decide script validity will accept a spend Core considers invalid. That is a consensus divergence, the exact class that can split validators off the network.
Honest scope: most NBitcoin usage is transaction construction, where this is moot. The risk is for anything using it as a validator.
Reported privately first.
#BitcoinSecurity #WalletAudit
🟠 AUDIT · rust-secp256k1 (MuSig)
A debug log line can leak your private key.
SecretNonce and SessionSecretRand both derive Debug and print their raw bytes (musig.rs:55, musig.rs:676, via impl_raw_debug! in secp256k1-sys lib.rs:1451). So {:?} on either one, in a log, a panic message, or a tracing span, dumps the secret MuSig nonce.
Why that is fatal: nonce + the public partial signature lets you recompute the signing key. Leak the nonce once and the key is gone.
The tell that this is an oversight, not intent: the same crate deliberately masks secrets elsewhere. Keypair's Debug prints secret: "<hidden>" (key/mod.rs:719). The private key is protected, the nonce that reveals it is not.
Condition: an app has to actually log the nonce object. Fix: hand-write Debug to redact, like Keypair already does.
Reported privately first.
#BitcoinSecurity #WalletAudit
🟠 AUDIT · BTCPAY SERVER
In a blind audit I found an unfiltered SSRF in the webhook sender. When a store webhook fires, the server fetches the configured URL with no private-IP filter. The only special case is loopback (WebhookSender.cs:65-68).
That means a webhook pointed at http://169.254.169.254/... (cloud metadata) or an internal service gets fetched by the server, and the response shows up in the delivery log the store owner can read. SSRF straight to internal credentials.
Requires store-settings permission, so it is an authenticated vector, not anonymous. But it reaches cloud metadata and the internal network, which is the whole point of SSRF.
The fix is small: apply the existing IsLocalNetwork check (already in the codebase, just not wired into the webhook path) and block private ranges.
@BtcpayServer
#BitcoinSecurity #WalletAudit
🚨 AUDIT · HW WALLET
Your hardware wallet has one job: show you the truth even when your computer is hacked. What if the screen lies?
Let me explain the most dangerous class of bug in cold storage, "display-mismatch," in plain terms.
First, "signing." When you send crypto, your private key approves the transaction. On a hardware wallet, that key never leaves the device, so an attacker can't just copy it. The only thing they can weaponize is what you agree to sign.
That's why the screen matters. The whole promise is simple: even if your PC is riddled with malware, the little screen shows you the real transaction. You read it, you tap, you're safe. The screen is the last line of defense.
A display-mismatch attack breaks exactly that. The device signs MORE than the screen shows. You see a harmless "Approve transaction?", you tap, but the bytes you actually signed said "send everything to the attacker."
How it reaches you (this part matters): it's not sci-fi remote hacking. Your computer or wallet app gets compromised by malware, a malicious dApp, or a fake app. It feeds the device a poisoned transaction, the screen doesn't reveal the trap, and you approve it yourself, blind.
🔒 What it CANNOT do: drain you with zero interaction. It needs a compromised host AND your physical tap. No touch on the button, no theft. Anyone screaming "remote drain, no interaction" is wrong.
Why it's serious anyway: defending against a compromised computer is the ENTIRE reason you bought a hardware wallet. If the screen can lie, the device is just an expensive USB stick.
The fix is boring: show every fund-moving instruction on screen, or refuse to sign what you won't display. Blind signing should always be loud, never silent.
Rule of thumb: read your device screen every single time. If it doesn't show you an amount and a destination, don't tap.
#BitcoinSecurity #WalletAudit
🔒 What this means for you
Your defense against display-mismatch is one habit: read the device screen every single time, and never treat the tap as a formality.
Watch out especially on newer chains where "blind signing" is normalized. When a device shows you a hash or a generic "sign this transaction?" instead of an amount and a destination, that is exactly where this attack hides. If you cannot read what you are approving, do not approve it.
Practical rules:
- The screen must show an amount and a destination. No amount, no tap.
- Be suspicious of any flow that asks you to "just confirm" without details.
- Prefer wallets and firmware that render every fund-moving action, not just the simple transfers.
And to be clear on scope: this class needs a compromised host plus your physical tap. Your keys are never extracted. But the screen is the whole point of a hardware wallet, so a screen that omits what you sign is the failure that matters most.
I keep running into this class while auditing wallets. It is not theoretical.
#BitcoinSecurity #WalletAudit
🟠 AUDIT ELECTRUM · HIGH
🎯 A legacy Lightning peer can push the cooperative-close fee to 100% of your balance in 2 messages auto-signed and broadcast.
🔒 Cannot: steal your keys.
Attacker gain is zero → griefing / extortion.
🧩 Requires: a malicious channel peer with no fee_range TLV.
📄 Proof: lnpeer.py: 2776, 2705, 2825 guard skipped on the first round.
🛠️ Status: modern path is protected; reported.
#BitcoinSecurity #WalletAudit
🟠 The fix, and why it matters
The fix is small: bound their_fee on receipt against a sane ceiling (a multiple of your own proposed fee, or the channel balance), and force-close instead of accepting an absurd fee. The building blocks are already there in the modern fee_range path, they just are not enforced on the legacy branch.
And this is a pattern, not a one-off. Peer-controlled values that get auto-signed with no upper bound show up across Lightning implementations I have audited, not only here. Fee fields are a recurring soft spot.
Threat model, to be clear: this needs a malicious or colluding channel peer, and your keys are never exposed. The loss is your own channel balance being burned into the mining fee on close. Annoying and abusable, not a key compromise.
If you run a routing node, that is the exposure to watch: every peer you open a channel with can attempt this.
#BitcoinSecurity #WalletAudit
🚨 I audited Sparrow Wallet and found something that'll make you look at "✅ confirmed" differently.
In public-server mode, Sparrow never asks for a Merkle proof. It just trusts the server when it says your tx is confirmed.
@_craigraw @craigraw
🟠 What this means for you
If you run Sparrow on a public Electrum server, treat "confirmed" as "the server says so," not "proven in a block." The fix on your side is simple: point it at your own node (Bitcoin Core with the built-in server), where you are the source of truth.
And this is bigger than Sparrow. The same pattern, trust the server on confirmations with no Merkle proof, shows up in other wallets and libraries I have audited. It is a class, not a one-off.
To be clear on the threat model: this needs a malicious or MITM'd server, and your keys are never at risk. But if you accept payments and act on the confirmation count, the risk is real.
#BitcoinSecurity #WalletAudit
