Weak Biometric Authentication

ID

swift.weak_biometric_authentication

Severity

high

Resource

Authentication

Language

Swift

Tags

CWE:288, CWE:303, MASVS:auth-8, MASWE:0014, NIST.SP.800-53, OWASP:2021:A07

Description

The LocalAuthentication framework provides APIs for implementing biometric authentication (Face ID, Touch ID) in iOS applications. However, using LAContext.evaluatePolicy() creates a critical security vulnerability: it returns a simple boolean value that can be trivially bypassed by runtime instrumentation tools like Frida.

When authentication logic relies on checking a boolean return value in application code, an attacker can use runtime manipulation to force that boolean to always return true, completely bypassing the authentication mechanism. This happens because the authentication result validation occurs in userspace application code, not in the hardware-backed Secure Enclave.

Vulnerable patterns include:

  • LAContext() - Creating a LocalAuthentication context to evaluate biometric policies

  • evaluatePolicy() - Evaluating biometric authentication with a boolean result

  • .deviceOwnerAuthentication - Authentication policy that returns a boolean

Rationale

The following example demonstrates vulnerable code that relies on boolean-based biometric authentication:

import LocalAuthentication

class BiometricAuthManager {
    // VULNERABLE: Boolean-based authentication can be bypassed
    func authenticateUser_vulnerable(completion: @escaping (Bool) -> Void) {
        let context = LAContext()  // WEAK! Creates bypassable auth context
        var error: NSError?

        // Check if biometric authentication is available
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            print("Biometric authentication not available")
            completion(false)
            return
        }

        // VULNERABLE: evaluatePolicy returns a boolean that can be hooked
        context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "Authenticate to access your account"
        ) { success, error in
            if success {
                // Attacker can force 'success' to true using Frida!
                print("✅ Authentication successful")
                completion(true)
            } else {
                print("❌ Authentication failed: \(error?.localizedDescription ?? "Unknown error")")
                completion(false)
            }
        }
    }

    // VULNERABLE: Using boolean result to gate access to sensitive operations
    func accessSensitiveData_vulnerable() {
        authenticateUser_vulnerable { authenticated in
            if authenticated {
                // WEAK! This check can be bypassed
                self.loadSensitiveUserData()
                self.displayAccountBalance()
            } else {
                print("Access denied")
            }
        }
    }

    private func loadSensitiveUserData() {
        // Load sensitive data...
    }

    private func displayAccountBalance() {
        // Display financial information...
    }
}

// VULNERABLE: Simple boolean check for authentication
class LoginViewController {
    let authManager = BiometricAuthManager()

    func loginWithBiometrics_vulnerable() {
        authManager.authenticateUser_vulnerable { success in
            if success {
                // WEAK! Attacker can bypass this by hooking evaluatePolicy
                self.transitionToHomeScreen()
            } else {
                self.showErrorAlert()
            }
        }
    }

    private func transitionToHomeScreen() {
        // Navigate to authenticated area...
    }

    private func showErrorAlert() {
        // Show error message...
    }
}

This code has several critical security problems:

  1. Trivial Frida bypass: An attacker can write a simple Frida script to hook evaluatePolicy and force it to return true:

    // Frida script to bypass biometric authentication
    Interceptor.attach(
        Module.findExportByName(
            "LocalAuthentication",
            "evaluatePolicy:localizedReason:reply:"
        ),
        {
            onLeave: function(retval) {
                // Force authentication to always succeed
                retval.replace(ptr(1)); // true
            }
        }
    );
  2. No hardware-backed verification: The authentication result is validated in userspace application code, not by the Secure Enclave.

  3. Boolean manipulation: Runtime tools can manipulate boolean values anywhere in the execution flow, not just at the framework boundary.

  4. No cryptographic binding: There’s no cryptographic proof that authentication actually occurred - just a boolean flag that can be set arbitrarily.

  5. Persistent bypass: Once bypassed, the attacker has complete access to all "protected" functionality.

Remediation

Use Keychain with biometric access control instead of checking boolean return values:

Recommended Approach: Keychain + Biometric ACL

Store sensitive data in the Keychain with .biometryCurrentSet access control. The Secure Enclave will enforce authentication:

import Security
import LocalAuthentication

enum KeychainError: Error {
    case accessControlCreationFailed
    case keyGenerationFailed
    case dataRetrievalFailed
    case authenticationRequired
}

class SecureBiometricAuth {
    // SECURE: Store encryption key in Keychain with biometric ACL
    func createBiometricProtectedKey() throws {
        // Create access control with biometric requirement
        guard let accessControl = SecAccessControlCreateWithFlags(
            kCFAllocatorDefault,
            kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            .biometryCurrentSet,  // Secure Enclave enforces this
            nil
        ) else {
            throw KeychainError.accessControlCreationFailed
        }

        let privateKeyAttrs: [String: Any] = [
            kSecAttrIsPermanent as String: true,
            kSecAttrApplicationTag as String: "com.example.authkey".data(using: .utf8)!,
            kSecAttrAccessControl as String: accessControl
        ]

        let attributes: [String: Any] = [
            kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
            kSecAttrKeySizeInBits as String: 256,
            kSecPrivateKeyAttrs as String: privateKeyAttrs
        ]

        var error: Unmanaged<CFError>?
        guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
            throw KeychainError.keyGenerationFailed
        }

        print("✅ Biometric-protected key created in Secure Enclave")
        print("   • Authentication enforced by hardware, not app code")
        print("   • Cannot be bypassed by Frida or similar tools")
    }

    // SECURE: Access to key automatically requires biometric authentication
    func accessProtectedKey() throws -> SecKey {
        let query: [String: Any] = [
            kSecClass as String: kSecClassKey,
            kSecAttrApplicationTag as String: "com.example.authkey".data(using: .utf8)!,
            kSecReturnRef as String: true
        ]

        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)

        guard status == errSecSuccess else {
            if status == errSecInteractionNotAllowed {
                // Biometric authentication required but not provided
                throw KeychainError.authenticationRequired
            }
            throw KeychainError.dataRetrievalFailed
        }

        guard let key = item else {
            throw KeychainError.dataRetrievalFailed
        }

        print("✅ Key accessed - Secure Enclave verified biometric authentication")
        return (key as! SecKey)
    }

    // SECURE: Store sensitive data with biometric protection
    func storeSensitiveData(data: Data) throws {
        guard let accessControl = SecAccessControlCreateWithFlags(
            kCFAllocatorDefault,
            kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            .biometryCurrentSet,
            nil
        ) else {
            throw KeychainError.accessControlCreationFailed
        }

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: "sensitive_data",
            kSecValueData as String: data,
            kSecAttrAccessControl as String: accessControl
        ]

        // Delete existing item
        SecItemDelete(query as CFDictionary)

        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.accessControlCreationFailed
        }

        print("✅ Sensitive data stored with biometric protection")
    }

    // SECURE: Retrieve sensitive data (biometric auth enforced by Secure Enclave)
    func retrieveSensitiveData() throws -> Data {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: "sensitive_data",
            kSecReturnData as String: true
        ]

        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)

        guard status == errSecSuccess else {
            if status == errSecInteractionNotAllowed {
                throw KeychainError.authenticationRequired
            }
            throw KeychainError.dataRetrievalFailed
        }

        guard let data = item as? Data else {
            throw KeychainError.dataRetrievalFailed
        }

        print("✅ Data retrieved after Secure Enclave verified authentication")
        return data
    }
}

Alternative: LAContext with explicit prompt (still weaker)

If you must use LAContext for specific UI requirements, understand its limitations:

class LAContextWithWarning {
    // ACCEPTABLE but WEAKER: Using LAContext with explicit UI
    // NOTE: This is still vulnerable to Frida bypass!
    func authenticateForExplicitAction() {
        let context = LAContext()
        context.localizedReason = "Authenticate to delete your account"

        var error: NSError?
        guard context.canEvaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            error: &error
        ) else {
            print("Biometrics not available")
            return
        }

        // This boolean can still be bypassed by Frida
        context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "Confirm account deletion"
        ) { success, error in
            if success {
                // ⚠️ WARNING: Still bypassable!
                // For critical operations, use Keychain + biometric ACL instead
                print("User authenticated for action")
            }
        }
    }
}

Comparison: Boolean Auth vs. Keychain + Biometric ACL:

Approach Security Level Bypassable by Frida?

LAContext.evaluatePolicy() returns boolean

⚠️ Weak

✅ Yes - trivial to bypass

Boolean check in app code

⚠️ Weak

✅ Yes - can hook any boolean

Keychain with .biometryCurrentSet

✅ Strong

❌ No - Secure Enclave enforced

Keychain with hardware-backed key

✅ Strong

❌ No - cryptographic proof required

Best Practices:

  1. Never rely on boolean return values for authentication: Boolean checks in application code can always be bypassed by runtime instrumentation.

  2. Use Keychain + Biometric ACL: Store sensitive data or cryptographic keys in the Keychain with .biometryCurrentSet access control.

  3. Let Secure Enclave handle authentication: The Secure Enclave performs hardware-backed authentication that cannot be bypassed by userspace tools.

  4. Understand the threat model: LAContext is appropriate for:

    • Convenience authentication (auto-fill passwords)

    • Non-critical UI gating

    • User preference "shortcuts"

      But NOT for:
      - Protecting sensitive data
      - Financial transactions
      - Account security operations
      - Access control to confidential information
  5. Implement defense in depth: Even with Keychain protection, implement:

    • Jailbreak detection

    • Runtime integrity checks

    • Certificate pinning for network communications

    • Secure coding practices throughout the app

  6. Document security architecture: Clearly document which authentication mechanisms are used where and why.

  7. Test with Frida: During security testing, verify that your authentication cannot be bypassed with simple Frida scripts.

Attack Scenario: Frida Bypass

Understanding how easily LAContext can be bypassed:

Step 1: Normal App Flow

// App code
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics) { success, error in
    if success {
        self.showSensitiveData()  // Should require biometric
    }
}

Step 2: Attacker’s Frida Script

// Frida script - attacker's device
Java.perform(function() {
    var LAContext = ObjC.classes.LAContext;

    Interceptor.attach(LAContext['- evaluatePolicy:localizedReason:reply:'].implementation, {
        onEnter: function(args) {
            console.log("[+] LAContext.evaluatePolicy called");
        },
        onLeave: function(retval) {
            console.log("[+] Forcing authentication to succeed");
            // Force the reply block to receive true
            // This bypasses ALL biometric checks
        }
    });
});

Step 3: Result - All evaluatePolicy calls return success - No actual biometric authentication occurs - Attacker gains full access to "protected" features

Step 4: Why Keychain + Biometric ACL Cannot Be Bypassed

// With Keychain + biometric ACL
let key = try accessProtectedKey()  // Secure Enclave checks biometric

Even if Frida hooks this method and forces it to return a fake key object, the key won’t work for cryptographic operations because: - The real key never leaves the Secure Enclave - Cryptographic operations fail without valid hardware authentication - There’s no boolean to manipulate - either you have the key or you don’t

When LAContext is Acceptable

LAContext.evaluatePolicy() is appropriate for:

Non-Critical UI Convenience

// OK: Convenience for auto-filling username
func autofillUsername() {
    let context = LAContext()
    context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
                          localizedReason: "Autofill your username") { success, _ in
        if success {
            // OK: Just a convenience, not security-critical
            self.usernameField.text = self.savedUsername
        }
    }
}

User Preference Shortcuts

// OK: Shortcut to user preferences
func quickAccessSettings() {
    let context = LAContext()
    context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
                          localizedReason: "Quick access to settings") { success, _ in
        if success {
            // OK: Just a UX shortcut, settings aren't sensitive
            self.showSettingsScreen()
        }
    }
}

NOT Acceptable for: - Protecting financial data - Authorizing transactions - Accessing user credentials - Viewing confidential information - Any security-critical operation

Configuration

This detector identifies all uses of LAContext and evaluatePolicy to raise awareness of the security implications. In some cases, these APIs are used appropriately for non-critical convenience features.

Review each detection to determine if: 1. The authentication protects sensitive/critical functionality → Should use Keychain + biometric ACL 2. The authentication is only for convenience/UX → Current implementation may be acceptable

References