Skip to content

MASTG-DEMO-0135: Custom URL Scheme Handler with Source Validation

Download MASTG-DEMO-0135 IPA Open MASTG-DEMO-0135 Folder Build MASTG-DEMO-0135 IPA

Sample

The app registers a custom URL scheme (mastgtest://).

The SceneDelegate handles incoming URLs via scene(_:openURLContexts:). For each URL, the handler reads sourceApplication from UIOpenURLContext.options and checks it against a hardcoded allowedSources set before processing. In this demo we verify that the sourceApplication property is accessed in the compiled binary.

Apple only populates sourceApplication when the calling app belongs to the same Apple Developer Team. Apps from other teams or system apps (e.g. Notes, Safari) will have sourceApplication set to nil. This is an Apple platform limitation, but it still allows verifying that the URL was opened by one of your own apps, which is useful when a URL scheme triggers privileged actions that should only be accessible from within your app suite.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleURLTypes</key>
    <array>
        <dict>
            <key>CFBundleURLName</key>
            <string>org.owasp.mastgtest</string>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>mastgtest</string>
            </array>
        </dict>
    </array>
</dict>
</plist>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import SwiftUI
import UIKit

// SUMMARY: This sample demonstrates a custom URL scheme handler registered in Info.plist
// that validates the source application using UIOpenURLContext.options.sourceApplication.
// The SceneDelegate reads the source bundle ID from each incoming URL context and checks
// it against an allowlist before processing. Note that sourceApplication is only populated
// for apps that belong to the same Apple Developer Team as the receiving app.

struct URLSchemeEvent {
    let url: String
    let source: String
    let result: String
}

enum AppDelegateState {
    static var lastEvent: URLSchemeEvent?
    static var onEvent: ((URLSchemeEvent) -> Void)?
}

struct MastgTest {
    @inline(never) @_optimize(none)
    public static func mastgTest(completion: @escaping (String) -> Void) {
        func display(_ event: URLSchemeEvent) {
            completion("""
            Incoming URL: \(event.url)
            Source app:   \(event.source)
            Handler returned: \(event.result)
            """)
        }

        if let existing = AppDelegateState.lastEvent {
            display(existing)
        } else {
            completion("""
            Waiting for incoming URL scheme...

            Open one of the registered schemes from another app to see the result:
              mastgtest://transfer?amount=500
            """)
        }

        AppDelegateState.onEvent = { event in
            display(event)
        }
    }
}

@objc class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(
        _ application: UIApplication,
        configurationForConnecting connectingSceneSession: UISceneSession,
        options: UIScene.ConnectionOptions
    ) -> UISceneConfiguration {
        let config = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
        config.delegateClass = SceneDelegate.self
        return config
    }
}

// PASS: [MASTG-TEST-0371] The SceneDelegate reads UIOpenURLContext.options.sourceApplication
// and checks it against an allowlist before processing the URL.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    private let allowedSources: Set<String> = [
        "com.mastg.testing-app",
    ]

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
               options connectionOptions: UIScene.ConnectionOptions) {
        connectionOptions.urlContexts.forEach { handleURL($0) }
    }

    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        URLContexts.forEach { handleURL($0) }
    }

    private func handleURL(_ context: UIOpenURLContext) {
        let url = context.url
        let source = context.options.sourceApplication ?? "(none)"
        let result = allowedSources.contains(source)

        postEvent(url: url, source: source, result: result)
    }

    private func postEvent(url: URL, source: String, result: Bool) {
        let event = URLSchemeEvent(
            url: url.absoluteString,
            source: source,
            result: result ? "true" : "false"
        )
        AppDelegateState.lastEvent = event
        AppDelegateState.onEvent?(event)
    }
}

Steps

  1. Use Exploring the App Package to extract the relevant binaries from the app package, which in this case is ./Payload/MASTestApp.app/MASTestApp.
  2. Use Static Analysis on iOS to locate the URL handler and check for source validation references. Run the r2 script with the -i option.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
e asm.bytes=false
e scr.color=false
e asm.var=false

?e === URL scheme handler methods ===
f~openURLContexts

?e
?e === Cross-references to sourceApplication from handler ===
axt @ reloc.fixup.sourceApplication

?e
?e === Disassembly around sourceApplication access (willConnectTo) ===
pd 15 @ 0x100004c8c

?e
?e === Disassembly around sourceApplication access (openURLContexts) ===
pd 15 @ 0x1000051a8
1
2
#!/bin/bash
r2 -q -i url_scheme_handler.r2 -A MASTestApp > output.txt

Observation

The output shows the SceneDelegate's scene:openURLContexts: handler, the cross-references to sourceApplication, and the disassembly around each access site.

output.txt
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
=== URL scheme handler methods ===
0x1000044fc 0 sym.MASTestApp.SceneDelegate.scene.allocator.openURLContexts...ySo7UISceneC_ShySo16UIOpenURLContextCGtFTo
0x1000044fc 132 method.MASTestApp.SceneDelegate.scene:openURLContexts:
0x100004f0c 1296 sym.MASTestApp.SceneDelegate.scene.allocator.openURLContexts...ySo7UISceneC_ShySo16UIOpenURLContextCGtFTf4dnn_n
0x10000b67d 23 str.scene:openURLContexts:
0x10000ba88 0 sym.MASTestApp.SceneDelegate.scene.allocator.openURLContexts...ySo7UISceneC_ShySo16UIOpenURLContextCGtFTq
0x1000107d0 8 reloc.fixup.scene:openURLContexts:

=== Cross-references to sourceApplication from handler ===
sym.MASTestApp.SceneDelegate.scene.allocator.willConnectTo.options 0x100004c8c [DATA:r--] ldr x1, reloc.fixup.sourceApplication
sym.MASTestApp.SceneDelegate.scene.allocator.openURLContexts...ySo7UISceneC_ShySo16UIOpenURLContextCGtFTf4dnn_n 0x1000051a8 [DATA:r--] ldr x1, reloc.fixup.sourceApplication

=== Disassembly around sourceApplication access (willConnectTo) ===
           0x100004c8c      ldr x1, [x8, 0x788]                       ; [0x100010788:4]=0xb7a5 ; reloc.fixup.sourceApplication ; char *selector
           0x100004c90      bl sym.imp.objc_msgSend                   ; void *objc_msgSend(void *instance, char *selector)
           0x100004c94      mov x29, x29
           0x100004c98      bl sym.imp.objc_retainAutoreleasedReturnValue ; void objc_retainAutoreleasedReturnValue(void *instance)
           0x100004c9c      mov x20, x0
           0x100004ca0      mov x0, x19                               ; void *instance
           0x100004ca4      bl sym.imp.objc_release                   ; void objc_release(void *instance)
       ┌─< 0x100004ca8      cbz x20, 0x100004cc8
          0x100004cac      mov x0, x20
          0x100004cb0      bl sym.imp.Foundation_...nconditionallyBridgeFromObjectiveCySSSo8NSStringCSgFZ_ ; Foundation(...nconditionallyBridgeFromObjectiveCySSSo8NSStringCSgFZ)
          0x100004cb4      mov x21, x0
          0x100004cb8      mov x19, x1
          0x100004cbc      mov x0, x20                               ; void *instance
          0x100004cc0      bl sym.imp.objc_release                   ; void objc_release(void *instance)
      ┌──< 0x100004cc4      b 0x100004cd8

=== Disassembly around sourceApplication access (openURLContexts) ===
           0x1000051a8      ldr x1, [x8, 0x788]                       ; [0x100010788:4]=0xb7a5 ; reloc.fixup.sourceApplication ; char *selector
           0x1000051ac      bl sym.imp.objc_msgSend                   ; void *objc_msgSend(void *instance, char *selector)
           0x1000051b0      mov x29, x29
           0x1000051b4      bl sym.imp.objc_retainAutoreleasedReturnValue ; void objc_retainAutoreleasedReturnValue(void *instance)
           0x1000051b8      mov x20, x0
           0x1000051bc      mov x0, x19                               ; void *instance
           0x1000051c0      bl sym.imp.objc_release                   ; void objc_release(void *instance)
       ┌─< 0x1000051c4      cbz x20, 0x1000051e4
          0x1000051c8      mov x0, x20
          0x1000051cc      bl sym.imp.Foundation_...nconditionallyBridgeFromObjectiveCySSSo8NSStringCSgFZ_ ; Foundation(...nconditionallyBridgeFromObjectiveCySSSo8NSStringCSgFZ)
          0x1000051d0      mov x19, x0
          0x1000051d4      mov x25, x1
          0x1000051d8      mov x0, x20                               ; void *instance
          0x1000051dc      bl sym.imp.objc_release                   ; void objc_release(void *instance)
      ┌──< 0x1000051e0      b 0x1000051f4

Evaluation

The test case passes because sourceApplication is accessed from both URL handler paths: willConnectTo, the cold launch path where iOS starts the app to handle the URL, at 0x100004c8c, and openURLContexts, the warm open path where the app is already running or suspended, at 0x1000051a8.

In each disassembly block:

  • ldr x1, ... reloc.fixup.sourceApplication loads the sourceApplication selector.
  • bl sym.imp.objc_msgSend sends it to the URL options object, retrieving the source application value.
  • The returned Objective C object is retained and bridged into a Swift String when non nil.
  • cbz x20, ... branches when the result is nil, which means the source application value was unavailable or not provided. For cross-app opens, Apple documents this as the expected result when the originating app has a different team identifier.

This confirms that the handler reads the caller application identifier on both URL entry paths. Together with the surrounding source validation logic in the sample, this demonstrates that the custom URL scheme handler checks the caller source before processing the URL.

Exploitation

You can use xcrun to launch the app on a connected iOS device with an arbitrary custom URL scheme payload and confirm that the handler rejects the request when sourceApplication is not populated with an allowlisted bundle ID.

First, list the connected devices and copy the device identifier:

xcrun devicectl list devices

Then launch the app with a crafted mastgtest:// URL:

xcrun devicectl device process launch \
  --device <DEVICE_IDENTIFIER> \
  --payload-url "mastgtest://transfer?amount=9999999" \
  org.owasp.mastestapp.MASTestApp-iOS

After the app opens, tap Start in the demo app to process the stored event. Because the URL was not opened by an allowlisted same team app, the result is observable in the app output:

Incoming URL: mastgtest://transfer?amount=9999999
Source app:   (none)
Handler returned: false

This confirms at runtime that the URL handler reads sourceApplication and only returns true when the source application bundle ID matches the allowlist.