Deep Link + JS Bridge Chain to Full Account Takeover
The Risk
A single tap on a link was all it took for an attacker to gain full control of any user's account on a cryptocurrency exchange. From there, they could view all balances, access personal information, and execute trades on the victim's behalf. The attack required no special access, no passwords, and no interaction beyond that one tap. Any of the exchange's users could have been targeted.
The Vulnerability
1. No URL validation on deep links
The app's deep link handler accepted a url query parameter and loaded it directly into a WebView with no domain allowlist. Any app or webpage could trigger it:
appscheme://host/path?url=https://attacker-controlled.com/ The activity dispatched the URI to an internal router which passed it straight to a WebView activity. No confirmation dialog, no user prompt.
2. Broken origin regex on JS bridge
The WebView injected a JavaScript bridge object (window.control) with 27+ methods. An origin filter was applied using a regex fetched from a public config endpoint:
.*exchange[.]com No start anchor. This means evil-exchange.com, testexchange.com, or any domain ending in the exchange's name would pass the check. Registering a matching domain costs about $10.
3. Unrestricted authenticated API proxy
One bridge method, requestAction, forwarded attacker-supplied JSON (an API path and parameters) to a Flutter service that made authenticated API calls using the app's full access token. Responses were returned directly to the calling JavaScript. Another method, sendTokenToWeb, returned the user's refresh token, enabling cross-platform session hijack.
The Attack
- Attacker registers a domain matching the regex (e.g.,
test-exchange.com) - Attacker hosts a simple HTTPS page with a deep link button
- Victim visits the page and taps the button. The deep link opens the exchange app
- The app loads the attacker's page in its privileged WebView. No prompt shown
- JavaScript calls
control.sendTokenToWeb()to capture the refresh token, user ID, and device ID - JavaScript calls
control.requestAction()to hit authenticated API endpoints: balances, order placement, order cancellation, user profile - All exfiltrated data is sent to the attacker's server in real time
The Impact
From a single tap, the following was confirmed on a live account:
| Action | Result |
|---|---|
| Full profile extraction (email, phone, login IP) | Confirmed |
| All spot balances (400+ entries) | Confirmed |
| All derivatives balances | Confirmed |
| Cancel any open order | Auth passed, business logic rejection only |
| Place orders | Auth not rejected |
| Refresh token theft (cross-platform ATO) | Confirmed |
| Navigate app to any screen | Confirmed |
| Force logout (DoS) | Available |
The order cancellation proof was key. The API returned a business logic error ("Order does not exist"), not an authentication error, confirming requestAction makes fully authenticated calls. An attacker could place adverse trades on illiquid pairs from their own account, then execute against them using the victim's session.
Why This Matters
Deep link handlers in mobile apps are rarely audited. This vulnerability existed because three separate assumptions all failed:
- The deep link handler assumed only trusted sources would trigger it
- The JS bridge assumed its regex would filter malicious origins
- The API proxy assumed only the app's own pages would call it
Each assumption was reasonable in isolation. Together, they created a one-tap path to full account takeover on a live cryptocurrency exchange.
Remediation
- Allowlist URLs in the WebView activity to only load domains the app owns
- Anchor the JS bridge regex:
^https?://([\w-]+\.)?exchange\.com(/|$) - Remove or heavily restrict the
requestActionbridge method - Bind session tokens to their originating platform server-side
- Implement
android:autoVerifyon App Links to prevent deep link spoofing