iOS Custom URI Handlers / Deeplinks / Custom Schemes
Basic Information
Custom URL schemes enable apps to communicate using a custom protocol, as detailed in the Apple Developer Documentation. These schemes must be declared by the app, which then handles incoming URLs following those schemes. It's crucial to validate all URL parameters and discard any malformed URLs to prevent attacks through this vector.
An example is given where the URI myapp://hostname?data=123876123 invokes a specific application action. A noted vulnerability was in the Skype Mobile app, which allowed unpermitted call actions via the skype:// protocol. The registered schemes can be found in the app's Info.plist under CFBundleURLTypes. Malicious applications can exploit this by re-registering URIs to intercept sensitive information.
Application Query Schemes Registration
From iOS 9.0, to check if an app is available, canOpenURL: requires declaring URL schemes in the Info.plist under LSApplicationQueriesSchemes. Apps linked on or after iOS 15 are limited to 50 entries in this allowlist, which reduces app-enumeration abuse but is still useful to pentesters because it exposes which third-party handlers the app expects to interact with.
<key>LSApplicationQueriesSchemes</key>
<array>
<string>url_scheme1</string>
<string>url_scheme2</string>
</array>
Testing URL Handling and Validation
Developers should inspect specific methods in the source code to understand URL path construction and validation, such as application:didFinishLaunchingWithOptions: and application:openURL:options:. For modern scene-based apps, also inspect scene:willConnectToSession:options: and scene:openURLContexts: because a lot of current iOS apps no longer route custom schemes through UIApplicationDelegate.
For instance, Telegram employs various methods for opening URLs:
func application(_ application: UIApplication, open url: URL, sourceApplication: String?) -> Bool {
self.openUrl(url: url)
return true
}
func application(_ application: UIApplication, open url: URL, sourceApplication: String?,
annotation: Any) -> Bool {
self.openUrl(url: url)
return true
}
func application(_ app: UIApplication, open url: URL,
options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {
self.openUrl(url: url)
return true
}
func application(_ application: UIApplication, handleOpen url: URL) -> Bool {
self.openUrl(url: url)
return true
}
If the app uses scenes, look for code such as:
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
if let urlContext = connectionOptions.urlContexts.first {
let url = urlContext.url
// parse and route the URL
}
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url else { return }
// parse and route the URL
}
Modern Framework Entry Points
A lot of current iOS targets are not pure UIKit anymore, so custom-scheme handling may be split across native and JS/router layers:
- SwiftUI apps can receive deeplinks inside
.onOpenURL { url in ... }attached to aWindowGroupor anotherScene. Review every scene, not onlyAppDelegate, because a secondary scene may parse URLs differently. - React Native / Expo apps often forward the URL through
RCTLinkingManager,RCTOpenURLNotification,Linking.getInitialURL(), and runtimeurlevents. In Expo-managed apps, if the developer does not define an explicit custom scheme, the generated iOS bundle identifier commonly becomes a reachable default scheme. - Capacitor apps commonly expose deeplinks through
App.getLaunchUrl()andApp.addListener('appUrlOpen', ...).
From a pentest perspective, the interesting question is whether the framework router later turns attacker-controlled path/query data into navigation, auth state changes, feature flags, or WebView destinations. If the same URL is later reused in a browser/WebView sink, continue with iOS protocol handlers.
Static Triage in Compiled Apps
Without source code, start from Info.plist and then pivot into the handlers that consume the URL. Useful checks:
# Extract custom schemes and canOpenURL allowlist from an IPA/app bundle
plutil -p Payload/App.app/Info.plist | rg 'CFBundleURLTypes|CFBundleURLSchemes|LSApplicationQueriesSchemes'
# If you only have the IPA:
unzip -p app.ipa 'Payload/*.app/Info.plist' > /tmp/Info.plist
plutil -p /tmp/Info.plist | rg 'CFBundleURLTypes|CFBundleURLSchemes|LSApplicationQueriesSchemes'
# Find relevant handlers and outbound opens in ObjC/Swift symbols/strings
rabin2 -zzq Payload/App.app/AppBinary | rg 'openURL|canOpenURL|openURLContexts|continueUserActivity|x-success|x-error|x-cancel|RCTOpenURLNotification|appUrlOpen|getLaunchUrl|onOpenURL'
# If you have source code, include framework-specific routers as well
rg -n '\.onOpenURL|OpenURLAction|RCTLinkingManager|RCTOpenURLNotification|getInitialURL|appUrlOpen|getLaunchUrl' .
Interesting findings during static analysis:
- Custom schemes carrying reusable secrets such as OAuth codes, magic links, password-reset tokens, device-binding tokens, or invitation tokens.
- Router code that maps attacker-controlled path/query values directly into privileged actions such as logout, wallet transfer, KYC steps, or account linking.
- URL parameters later reused as network targets,
WKWebViewdestinations, or file paths. x-success,x-error, orx-cancelcallback parameters accepted from untrusted sources and then reopened without strict validation.- Framework-generated or framework-forwarded handlers (for example bundle-ID schemes, JS bridge events, or router adapters) that developers may not realize are still part of the external attack surface.
Testing URL Requests to Other Apps
Methods like openURL:options:completionHandler: are crucial for opening URLs to interact with other apps. Identifying usage of such methods in the app's source code is key for understanding external communications.
Testing for Deprecated Methods
Deprecated methods handling URL openings, such as application:handleOpenURL: and openURL:, should be identified and reviewed for security implications.
Triggering Custom Schemes During Dynamic Analysis
Custom schemes are easy to exercise repeatedly in the simulator:
xcrun simctl openurl booted 'myapp://debug?action=reset&token=AAAA'
# Hybrid stacks often expose convenient test helpers as well
npx uri-scheme open 'myapp://debug?action=reset&token=AAAA' --ios
This is useful for quickly replaying payloads while observing logs, breakpoints, Frida hooks, or proxy traffic. On a real device, the same payloads can be delivered from Notes, Safari, Messages, QR codes, or a helper application that calls UIApplication.open. For Expo-style targets, also try the bundle identifier as the scheme if the project never defined a custom one explicitly.
When instrumenting the target, hook both inbound handlers and outbound launches:
application:openURL:options:scene:openURLContexts:application:continueUserActivity:restorationHandler:when the same router also handles universal links+[RCTLinkingManager application:openURL:options:]in React Native buildsopenURL:options:completionHandler:to see which third-party apps or callbacks the target invokes
If the app is hybrid, compare cold start and warm start behaviour separately: getInitialURL() / getLaunchUrl() often parses the launch URL through a different code path than runtime URL events, and bugs sometimes appear in only one of them.
Fuzzing URL Schemes
Fuzzing URL schemes can identify parsing bugs and, in rare cases, memory-corruption bugs. Tools like Frida can automate this process by opening URLs with varying payloads to monitor for crashes, exemplified by the manipulation of URLs in the iGoat-Swift app:
$ frida -U SpringBoard -l ios-url-scheme-fuzzing.js
[iPhone::SpringBoard]-> fuzz("iGoat", "iGoat://?contactNumber={0}&message={0}")
Watching for crashes from iGoat...
No logs were moved.
Opened URL: iGoat://?contactNumber=0&message=0
If you already know which handler is used, furlzz is useful because it fuzzes in-process through multiple entry points, including application:openURL:options:, scene:openURLContexts:, and universal-link handlers. This is practical when SpringBoard-driven delivery is too noisy and you want to focus on the target parser itself.
Useful payload classes:
- Overlong path segments and query values.
- Mixed encodings (
%00, double-encoded%252f, invalid UTF-8). - Duplicate keys (
id=1&id=2) to catch inconsistent parsing between router and business logic. - Unexpected callback values such as
x-success=otherapp://cbor nestedredirect=parameters.
x-callback-url style abuse
Many iOS apps implement x-callback-url semantics on top of custom schemes, commonly exposing x-success, x-error, and x-cancel parameters. From a pentest perspective, this creates two recurring attack surfaces:
- Callback redirection / app bouncing: If the target app accepts an arbitrary callback URL and later re-opens it, you can chain execution into another app or into a malicious scheme you control.
- Data exfiltration through callbacks: If sensitive results are appended to
x-success(IDs, auth artifacts, search results, file locations, prefilled content, etc.), a rogue app can register the callback scheme and harvest them.
Testers should verify whether the app:
- Restricts callbacks to a strict allowlist of schemes/hosts.
- Avoids placing secrets or bearer-like tokens in callback URLs.
- Requires user interaction before performing destructive or externally visible actions triggered from a URL.
Custom URL scheme hijacking
Apple explicitly notes that if multiple apps register the same scheme, the app chosen by the system is undefined. Therefore, any security-sensitive flow that returns data via a custom scheme must be treated as hijackable.
According to this post, a malicious app can register another app's custom scheme and then abuse ASWebAuthenticationSession to run OAuth in a browser context that still has Safari cookies.
The attack flow is:
- The malicious app starts an
ASWebAuthenticationSessionand sets the victim's custom scheme as the callback scheme. - The session loads an attacker-controlled page that the user is willing to open.
- That page redirects to the victim's OAuth authorization endpoint, often with
prompt=noneto avoid user interaction. - If the victim is already authenticated, the authorization server redirects with an authorization code (or another secret) to the victim's custom scheme.
- Because the attacker's app also registered that scheme and owns the active
ASWebAuthenticationSession, the attacker receives the callback and can exchange the code.
This is important because PKCE alone does not save the flow when the attacker originates the entire OAuth request and chooses its own code_challenge / code_verifier. RFC 8252 still allows private-use URI schemes for native apps, but explicitly calls out that multiple apps can register the same scheme and recommends app-claimed https redirects where the platform supports them. In practice, that means custom schemes are still common, but they should be treated as an attacker-reachable IPC boundary and not as proof of app identity. See also this other page about Universal Links.