I want to show you something that doesn't get shown often enough. Most mobile security writing talks about IPC bugs in the abstract — "exported activities are dangerous," "URL schemes can be hijacked," "validate inputs." Then your developer goes back to writing AndroidManifest.xml and the abstraction doesn't help them.
This is a tour of the actual code. Each section shows you what a vulnerable version looks like, what an attacker does with it, and what the fixed version looks like — side by side, in the exact shape it ships in real apps. If you write mobile code, by the end of this article you'll know what to grep for in your own repo this afternoon.
Exhibit A — the AndroidManifest line that opens the front door
Open any Android app's manifest. You will see entries like this:
<activity
android:name=".TransferActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="banking" android:host="transfer" />
</intent-filter>
</activity>
That single line — android:exported="true" — declares the activity reachable by any other app installed on the device. The intent-filter says: handle any URL starting with banking://transfer. The deep link is now a public API. Any app, web page, QR code, or NFC tag that can produce a URI matching that scheme can launch this activity.
The activity, inside, probably looks like this:
class TransferActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri = intent.data
val toAccount = uri?.getQueryParameter("to")
val amount = uri?.getQueryParameter("amount")?.toDoubleOrNull()
if (toAccount != null && amount != null) {
performTransfer(toAccount, amount) // uses the logged-in user's session
}
}
}
The attacker installs any benign-looking app on the same device — a flashlight, a wallpaper picker, a free game. The app contains this single line:
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("banking://transfer?to=attacker_account&amount=10000")))
That's the entire exploit. The user's banking app launches with the attacker's parameters. If the bank's session is still active — and on mobile it almost always is — the transfer happens. No phishing, no credential theft, no privilege escalation. The bank built a deep link and forgot it was a deep link.
Here is the patched version, with the changes I'd ship:
// AndroidManifest.xml — use App Links with verified host
<activity
android:name=".TransferActivity"
android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="bank.example.com"
android:pathPrefix="/app/transfer" />
</intent-filter>
</activity>
// TransferActivity.kt — re-authenticate, then confirm
class TransferActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri = intent.data
val toAccount = uri?.getQueryParameter("to")
val amount = uri?.getQueryParameter("amount")?.toDoubleOrNull()
if (toAccount == null || amount == null) {
finish(); return
}
// Step 1: re-prompt for authentication
biometricPrompt.authenticate { authenticated ->
if (!authenticated) { finish(); return@authenticate }
// Step 2: show a confirmation UI with the parsed values
showTransferConfirmation(toAccount, amount) { confirmed ->
if (confirmed) performTransfer(toAccount, amount)
}
}
}
}
Two changes. The deep link is now an Android App Link backed by domain verification — only your bank's domain can register for https://bank.example.com/app/transfer/..., because Android verifies the assetlinks.json file on your domain before honoring the registration. The activity, on receiving an intent, re-prompts for biometric authentication and shows the user what's about to happen. The deep link can no longer perform an action without the user explicitly seeing and confirming it.
Most banking apps now do something like this. Most non-banking apps do not.
Exhibit B — the iOS URL scheme that any other app can claim
Open Info.plist. Most apps with deep linking will have something like this:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.example.banking</string>
<key>CFBundleURLSchemes</key>
<array>
<string>banking</string>
</array>
</dict>
</array>
The app registers banking:// as its URL scheme. Now look at the handler:
func application(_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
guard url.scheme == "banking" else { return false }
if url.host == "transfer" {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let to = components?.queryItems?.first(where: { $0.name == "to" })?.value
let amount = components?.queryItems?.first(where: { $0.name == "amount" })?.value
if let to = to, let amountValue = amount.flatMap(Double.init) {
transferService.send(to: to, amount: amountValue) // logged-in session
}
}
return true
}
The iOS-specific problem here is that URL scheme registration is not exclusive. Apple's documentation says it directly: "if more than one third-party app registers to handle the same URL scheme, there is currently no process for determining which app will be given that scheme." A malicious app installed on the device can claim banking:// too. Whichever app the system picks gets the URL.
Worse: an attacker doesn't need to register the same scheme. They can simply invoke yours. A malicious SMS, a malicious email, a malicious webpage, an NFC tag, a QR code — anything that can produce a tappable link — can trigger the action.
The Evan Connelly research on iOS URL Scheme Hijacking (2024) walks through the OAuth variant of this attack: the legitimate app uses a URL scheme as the OAuth redirect URI, the malicious app registers the same scheme, the OAuth provider sends the authorization code to the wrong app. Account takeover via deep link interception, with no bug in the OAuth flow itself.
The fixed iOS version:
// Info.plist — drop CFBundleURLSchemes, use associated domains instead
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:bank.example.com</string>
</array>
// AppDelegate or SceneDelegate
func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL,
url.host == "bank.example.com" else {
return false
}
// Universal link is verified by Apple via apple-app-site-association
// The URL came from a trusted source: a real link to your domain
if url.path == "/app/transfer" {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let to = components?.queryItems?.first(where: { $0.name == "to" })?.value
let amount = components?.queryItems?.first(where: { $0.name == "amount" })?.value.flatMap(Double.init)
guard let to = to, let amount = amount else { return false }
// Always re-authenticate and confirm — same as Android
let context = LAContext()
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Confirm transfer") { success, _ in
DispatchQueue.main.async {
guard success else { return }
self.showTransferConfirmation(to: to, amount: amount)
}
}
}
return true
}
Two patches, same shape as Android. Move to Universal Links (the iOS equivalent of App Links) backed by Apple's verification of your apple-app-site-association file. Re-authenticate before acting. Show the user what's about to happen.
If you're still on URL schemes for anything sensitive in 2026 — payment, identity, account binding, OAuth callbacks — that's not legacy. That's a finding.
Exhibit C — the JavaScript bridge that became RCE
This one is the most underestimated bug in the mobile ecosystem. Most apps embed a WebView. Most WebViews expose at least one JavaScript bridge so the web content can call native code. The bridge is, almost always, more powerful than the developer realizes.
Here's the Android version:
val webView = WebView(this)
webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(NativeBridge(this), "Native")
webView.loadUrl("https://app.example.com/dashboard")
class NativeBridge(private val context: Context) {
@JavascriptInterface
fun saveFile(filename: String, content: String) {
File(context.filesDir, filename).writeText(content)
}
@JavascriptInterface
fun openExternalUrl(url: String) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
@JavascriptInterface
fun getAuthToken(): String {
return SecureStorage.getAuthToken()
}
}
Three bridge methods. Each one looks reasonable in isolation. Combined, they are a near-complete attack toolkit. Whoever runs JavaScript in this WebView can write arbitrary files into the app's sandbox, launch arbitrary intents, and extract the user's auth token.
The defense most teams rely on is "we only load our own URL." This works exactly as well as you'd expect. A few ways it breaks:
- The page you load includes a third-party script — an analytics library, an ad SDK, a marketing pixel. Compromise the script source, compromise the bridge.
- The page redirects to an external URL because of a misconfigured 302.
- The page has an XSS bug. Now the attacker's JavaScript runs inside your "trusted" origin.
- The WebView allows file:// URLs (default behavior in older Android versions). The attacker can plant a file on the device via any other vector and load it.
- Your app loads a separate iframe inside the WebView for, say, a customer-support widget. The iframe is same-origin to the WebView's host. It can call the bridge.
The 2021 disclosure of CVE-2021-0334 in Android's WebView ($5,000 bounty) is illustrative — a researcher chained a series of these patterns into a fully working RCE against the WebView component itself. The patterns recur every year in apps that build their own bridges.
Here is the version I'd ship:
// 1. Restrict WebView capabilities
webView.settings.javaScriptEnabled = true
webView.settings.allowFileAccess = false
webView.settings.allowContentAccess = false
webView.settings.allowFileAccessFromFileURLs = false
webView.settings.allowUniversalAccessFromFileURLs = false
// 2. Restrict navigation to your exact origin
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url ?: return true
return url.host != "app.example.com" // block any non-allowlisted host
}
}
// 3. Bridge only what you absolutely need, with explicit auth
class NativeBridge(private val context: Context) {
@JavascriptInterface
fun requestSaveFile(filename: String, content: String) {
// Validate filename: no path traversal, no system paths
if (filename.contains("/") || filename.contains("..")) return
// Require the user to confirm
runOnUiThread {
confirmDialog("Save file ${'$'}filename?") { confirmed ->
if (confirmed) File(context.filesDir, filename).writeText(content)
}
}
}
// Remove openExternalUrl entirely — use intents from native code
// Remove getAuthToken entirely — the WebView should not have access
}
Three changes. Lock down the WebView's capabilities (no file access, no universal access). Allowlist the exact host you load, refuse everything else. Strip the bridge down to the minimum, validate every parameter, and require user confirmation for anything that touches the filesystem.
The bigger pattern: bridges are APIs. Every parameter is attacker-controlled the moment the WebView loads anything other than literal static HTML you compiled into the app. Treat the bridge like a public REST endpoint, because functionally that's what it is.
Exhibit D — the ContentProvider that exposed everything
Android has a feature called ContentProviders for apps that want to share data with other apps. The canonical use case is "the gallery app exposes images to anyone who asks." The canonical bug is "the app exposes more than it meant to."
Vulnerable manifest entry:
<provider
android:name=".UserDataProvider"
android:authorities="com.example.app.users"
android:exported="true" />
Vulnerable provider code:
class UserDataProvider : ContentProvider() {
override fun query(uri: Uri, projection: Array<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
val db = DatabaseHelper(context!!).readableDatabase
// Whatever query the caller wants, against whatever table the URI says
val table = uri.lastPathSegment ?: return null
return db.query(table, projection, selection, selectionArgs, null, null, sortOrder)
}
}
Now any app on the device can run SQL against your database. The malicious app constructs a query for the users table, selects all columns, and gets back every user's data: usernames, password hashes, session tokens, internal flags. The app's developer added the provider because one feature needed it and forgot that the feature did not need arbitrary queries.
The fix is to make the provider not be a generic SQL passthrough:
<provider
android:name=".UserDataProvider"
android:authorities="com.example.app.users"
android:exported="true"
android:readPermission="com.example.app.permission.READ_USER_PROFILE" />
class UserDataProvider : ContentProvider() {
override fun query(uri: Uri, projection: Array<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
// Only one specific URI is supported
if (uri.path != "/profile/me") return null
// Caller's package must be one we trust
val callingPackage = callingPackage ?: return null
if (callingPackage !in TRUSTED_CALLERS) return null
// Return ONLY the current user's safe-to-share fields
return db.query("users",
arrayOf("display_name", "avatar_url"), // explicit projection
"id = ?", arrayOf(currentUserId.toString()),
null, null, null)
}
}
Permission gate, URI gate, caller-package gate, explicit allowlisted columns, parameterized query bound to the current user. Five changes that together make the provider do exactly what it was supposed to do, and nothing else.
What to grep for
If you came here looking for a list of things to search your repo for this afternoon, here it is.
Android:
android:exported="true"in your manifest — every match deserves explanation.android:scheme=on intent-filters — anything that isn'thttpswith autoVerify is a candidate for migration.addJavascriptInterface— every bridge method should be re-audited.setAllowFileAccess,setAllowUniversalAccessFromFileURLs— these should befalse.WebViewClientwithout ashouldOverrideUrlLoadingthat blocks navigation.android:authorities=for ContentProviders — read each one's query method carefully.
iOS:
CFBundleURLSchemesin Info.plist — for anything sensitive, migrate to Universal Links.WKScriptMessageHandler— same audit as Android's JavascriptInterface.NSAppTransportSecurityexceptions that disable HTTPS enforcement.UIDocumentInteractionControllerhandlers that act on untrusted documents.
Each match is a five-minute conversation with the developer who wrote it. Most of the time, the developer didn't know the manifest entry made the activity public. Most of the time, the fix takes an hour.
The bigger pattern across all of these exhibits: mobile IPC bugs share a single shape. The app trusts input because of where it came from — "it's from another app on this device, that must be okay" or "it's from a URL scheme I registered, that must be ours" or "it's from our WebView, that must be safe." None of those assumptions hold. The attacker is always another app on the same device, or a web page the user visits, or an SMS the user receives. The IPC channel is a public API. Build it like one.
If your last mobile assessment didn't include a manifest audit and a bridge review, it didn't include the bug class that ships in roughly two out of every three mobile apps we test.