Skip to content

MASTG-DEMO-0127: Unjustified Capability Exposure due to Excessive Entitlements

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

Sample

This sample uses the same app as Runtime Location Capture with a Deceptive Purpose String. The app binary is signed with the com.apple.developer.healthkit entitlement, which allows the app to request user authorization for HealthKit access. This dummy app does not need the information provided by such entitlement for its functionality. Indeed, the Swift code does not import HealthKit, instantiate HKHealthStore, or request access to HealthKit data types.

This runtime demo traces representative HealthKit APIs associated with the com.apple.developer.healthkit entitlement while exercising the app and verifies if the related APIs are called.

  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
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
// SUMMARY: An app that shows a 3-second countdown popup when Start is tapped. The only
// user-visible feature is the countdown; there is no map, search, or any other
// location-based feature in the UI. Despite that, the app silently requests location access
// and records the GPS coordinates to a file while the countdown runs. The
// NSLocationWhenInUseUsageDescription purpose string is deceptive: it claims a
// "nearby content" feature that the app does not provide.

import UIKit
import CoreLocation

struct MastgTest {
    static var locationCapture: LocationCapture?

    static func mastgTest(completion: @escaping (String) -> Void) {
        // FAIL: [MASTG-TEST-0361] The app requests location access and collects GPS
        // coordinates while showing a plain countdown popup. The
        // NSLocationWhenInUseUsageDescription purpose string ("to show you nearby content
        // and recommendations") is deceptive: the app has no location-based feature and
        // never shows any content related to the user's location.
        locationCapture = LocationCapture()

        locationCapture?.onAuthorized = {
            // Start the countdown only after permission is granted so GPS collection
            // is running for the full 3 seconds.
            presentCountdown(seconds: 3) {
                locationCapture?.stop()
                let fileURL = saveLocation(locationCapture?.capturedLocation)
                completion(buildResult(fileURL: fileURL, location: locationCapture?.capturedLocation))
                locationCapture = nil
            }
        }

        locationCapture?.onDenied = {
            let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
            let url = dir.appendingPathComponent("location_capture.txt")
            try? "Location access denied.".write(to: url, atomically: true, encoding: .utf8)
            completion("Location access denied.\n\nSaved to: location_capture.txt")
            locationCapture = nil
        }
    }

    // MARK: - Private

    private static func presentCountdown(seconds: Int, onFinish: @escaping () -> Void) {
        DispatchQueue.main.async {
            guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
                  let window = scene.windows.first(where: \.isKeyWindow),
                  let rootVC = window.rootViewController else {
                onFinish(); return
            }
            var topVC = rootVC
            while let p = topVC.presentedViewController { topVC = p }

            let alert = UIAlertController(title: "3s Timer started", message: "Please wait… \(seconds)s", preferredStyle: .alert)
            topVC.present(alert, animated: true)

            var remaining = seconds
            let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { t in
                remaining -= 1
                if remaining > 0 {
                    alert.message = "Please wait… \(remaining)s"
                } else {
                    t.invalidate()
                    alert.dismiss(animated: true, completion: onFinish)
                }
            }
            RunLoop.main.add(timer, forMode: .common)
        }
    }

    private static func saveLocation(_ location: CLLocation?) -> URL {
        let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        let url = dir.appendingPathComponent("location_capture.txt")
        let content: String
        if let loc = location {
            let fmt = ISO8601DateFormatter()
            content = """
            Latitude:  \(loc.coordinate.latitude)
            Longitude: \(loc.coordinate.longitude)
            Altitude:  \(String(format: "%.1f", loc.altitude)) m
            Accuracy:  \(String(format: "%.1f", loc.horizontalAccuracy)) m
            Timestamp: \(fmt.string(from: loc.timestamp))
            """
        } else {
            content = "Location not available (no fix received)."
        }
        try? content.write(to: url, atomically: true, encoding: .utf8)
        return url
    }

    private static func buildResult(fileURL: URL, location: CLLocation?) -> String {
        if let loc = location {
            return """
            Countdown completed.

            [!] Location captured in the background:
            Latitude:  \(loc.coordinate.latitude)
            Longitude: \(loc.coordinate.longitude)
            Altitude:  \(String(format: "%.1f", loc.altitude)) m

            Saved to: \(fileURL.lastPathComponent)
            """
        } else {
            return """
            Countdown completed.

            [!] Location capture attempted (no GPS fix received in time).
            Saved to: \(fileURL.lastPathComponent)
            """
        }
    }
}

// MARK: - Location capture

final class LocationCapture: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    private(set) var capturedLocation: CLLocation?
    var onAuthorized: (() -> Void)?
    var onDenied: (() -> Void)?

    override init() {
        super.init()
        manager.delegate = self
        manager.desiredAccuracy = kCLLocationAccuracyKilometer
        manager.requestWhenInUseAuthorization()
    }

    func stop() {
        manager.stopUpdatingLocation()
    }

    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        switch manager.authorizationStatus {
        case .authorizedWhenInUse, .authorizedAlways:
            manager.startUpdatingLocation()
            onAuthorized?()
        case .denied, .restricted:
            onDenied?()
        default:
            break
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        capturedLocation = locations.last
    }
}
1
2
3
4
5
6
7
8
<?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>NSLocationWhenInUseUsageDescription</key>
    <string>We use your location to show you nearby content and recommendations.</string>
</dict>
</plist>
1
2
3
4
5
6
7
8
<?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>com.apple.developer.healthkit</key>
    <true/>
</dict>
</plist>

Steps

  1. Use rabin2 with its -OC option to extract the entitlements from the signed app bundle and save the output as entitlements_reversed.plist.
  2. Install the app on a device ( Installing Apps).
  3. Make sure you have Frida (iOS) installed on your machine and the frida-server running on the device.
  4. Run run_frida.sh to spawn the app with Frida.
  5. Tap the Start button to exercise the sample flow.
  6. Stop the script by pressing Ctrl+C.
1
2
3
4
5
#!/bin/bash
set -euo pipefail

# Extract the entitlements from the app's main binary (see @MASTG-TECH-0111).
rabin2 -OC "../MASTG-DEMO-0126/MASTestApp" | sed -n '1,/<\/plist>/p' > "entitlements_reversed.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
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
console.log("\n[*] Starting HealthKit entitlement-backed API tracing...\n");

function printBacktrace(context, maxLines) {
    console.log("\nBacktrace:");
    const backtrace = Thread.backtrace(context, Backtracer.ACCURATE)
        .map(DebugSymbol.fromAddress);

    for (let i = 0; i < Math.min(maxLines, backtrace.length); i++) {
        console.log(backtrace[i]);
    }
}

function describeObjCObject(value) {
    if (value.isNull()) {
        return "nil";
    }

    try {
        return new ObjC.Object(value).toString();
    } catch (e) {
        return value.toString();
    }
}

function recordCall(name, context, details) {
    console.log("\n[+] " + name + " called");
    if (details !== null && details !== undefined && details !== "") {
        console.log("    " + details);
    }
    printBacktrace(context, 6);
}

function hookMethod(className, selector, displayName, detailCallback, leaveCallback) {
    const klass = ObjC.classes[className];
    if (!klass) {
        console.log("[-] Failed to hook " + displayName + ": class not found");
        return false;
    }

    const method = klass[selector];
    if (!method) {
        console.log("[-] Failed to hook " + displayName + ": method not found");
        return false;
    }

    try {
        Interceptor.attach(method.implementation, {
            onEnter(args) {
                const context = this.context;
                const details = detailCallback ? detailCallback(args) : null;
                recordCall(displayName, context, details);
            },
            onLeave(retval) {
                if (leaveCallback) {
                    leaveCallback(retval);
                }
            }
        });
    } catch (e) {
        console.log("[-] Failed to hook " + displayName + ": " + e);
        return false;
    }

    console.log("[*] Hooked " + displayName);
    return true;
}

if (ObjC.available) {
    if (!ObjC.classes.HKHealthStore) {
        console.log("[-] Failed to hook HKHealthStore: class not found");
    } else {
        hookMethod(
            "HKHealthStore",
            "+ isHealthDataAvailable",
            "HKHealthStore.isHealthDataAvailable()",
            null,
            (retval) => console.log("    Return value: " + retval.toInt32())
        );

        hookMethod(
            "HKHealthStore",
            "- requestAuthorizationToShareTypes:readTypes:completion:",
            "HKHealthStore.requestAuthorization(toShare:read:completion:)",
            (args) => "typesToShare=" + describeObjCObject(args[2]) + ", typesToRead=" + describeObjCObject(args[3]),
            null
        );

        hookMethod(
            "HKHealthStore",
            "- authorizationStatusForType:",
            "HKHealthStore.authorizationStatus(for:)",
            (args) => "type=" + describeObjCObject(args[2]),
            (retval) => console.log("    Return value: " + retval.toInt32())
        );

        hookMethod(
            "HKHealthStore",
            "- getRequestStatusForAuthorizationToShareTypes:readTypes:completion:",
            "HKHealthStore.getRequestStatusForAuthorization(toShare:read:completion:)",
            (args) => "typesToShare=" + describeObjCObject(args[2]) + ", typesToRead=" + describeObjCObject(args[3]),
            null
        );

        hookMethod(
            "HKHealthStore",
            "- executeQuery:",
            "HKHealthStore.execute(_:)",
            (args) => "query=" + describeObjCObject(args[2]),
            null
        );

        hookMethod(
            "HKHealthStore",
            "- saveObject:withCompletion:",
            "HKHealthStore.save(_:withCompletion:)",
            (args) => "object=" + describeObjCObject(args[2]),
            null
        );

        hookMethod(
            "HKHealthStore",
            "- saveObjects:withCompletion:",
            "HKHealthStore.save(_:withCompletion:) [array]",
            (args) => "objects=" + describeObjCObject(args[2]),
            null
        );
    }
} else {
    console.log("[-] Objective-C runtime is not available.");
}

console.log("\n[*] Hook setup finished. Interact with the app to trigger HealthKit API calls.\n");
1
2
3
4
#!/bin/bash
set -euo pipefail

frida -U -f org.owasp.mastestapp.MASTestApp-iOS -l ./script.js -o output_frida.txt

Observation

entitlements_reversed.plist shows the entitlements embedded in the app:

entitlements_reversed.plist
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?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>application-identifier</key>
    <string>org.owasp.mastestapp.MASTestApp-iOS</string>
    <key>com.apple.developer.healthkit</key>
    <true/>
</dict>
</plist>

The Frida script output shows the HealthKit runtime hooks or class lookup result captured while exercising the app:

output_frida.txt
1
2
3
4
5
[*] Starting HealthKit entitlement-backed API tracing...

[-] Failed to hook HKHealthStore: class not found

[*] Hook setup finished. Interact with the app to trigger HealthKit API calls.

Evaluation

The test case fails because the app is signed with the com.apple.developer.healthkit entitlement, but the exercised runtime flow does not show any HealthKit API use such as HKHealthStore. The sample app does not present any health, fitness, or wellness feature that would justify enabling HealthKit.