# 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.

## Custom URL Schemes vs Universal Links

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:

```javascript
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:

```javascript
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):

```javascript
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:

```javascript
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());
            }
        }
    }
});
```

### Enumerating Schemes and Checking for Universal Links

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:

```javascript
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](https://github.com/az0mb13/frida_setup/blob/master/frida-url-hijack.js)

attached the script to the running process:

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

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

```plaintext
[+] 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...
```

![](https://cdn.hashnode.com/uploads/covers/61fb7af343e34d11816986a7/ccd3b8cb-ce3a-480c-b42b-f8b6b3848ee1.png align="center")

## Triggering the Deep Links

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

```html
<!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>
```

![](https://cdn.hashnode.com/uploads/covers/61fb7af343e34d11816986a7/3caf947c-748d-49f8-91fa-565469030efa.png align="center")

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:

```plaintext
[>] 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.
