#!/usr/bin/env python3 # For authorized penetration testing and lab environments only """iOS App Security Assessment Agent - Automates Frida-based iOS security testing including SSL pinning bypass, keychain extraction, IPA static analysis, and runtime method hooking.""" import argparse import json import logging import os import plistlib import re import subprocess import sys import zipfile from datetime import datetime from pathlib import Path logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger(__name__) # Frida JavaScript payloads for iOS instrumentation SSL_PINNING_BYPASS_SCRIPT = r""" 'use strict'; // Hook NSURLSessionDelegate certificate challenge methods if (ObjC.available) { try { // Bypass NSURLSession certificate pinning var NSURLSessionTask = ObjC.classes.NSURLSessionTask; var resolveMethod = ObjC.classes.NSURLSession['- URLSession:didReceiveChallenge:completionHandler:']; Interceptor.attach( ObjC.classes.NSURLSession['- URLSession:didReceiveChallenge:completionHandler:'].implementation, { onEnter: function(args) { send('[SSL Bypass] Intercepted NSURLSession challenge'); } }); } catch(e) {} // Hook SecTrustEvaluate to always return success try { var SecTrustEvaluate = Module.findExportByName('Security', 'SecTrustEvaluate'); if (SecTrustEvaluate) { Interceptor.attach(SecTrustEvaluate, { onLeave: function(retval) { retval.replace(0); // errSecSuccess send('[SSL Bypass] SecTrustEvaluate -> success'); } }); } } catch(e) {} // Hook SecTrustEvaluateWithError (iOS 12+) try { var SecTrustEvaluateWithError = Module.findExportByName('Security', 'SecTrustEvaluateWithError'); if (SecTrustEvaluateWithError) { Interceptor.attach(SecTrustEvaluateWithError, { onLeave: function(retval) { retval.replace(1); // true = trusted send('[SSL Bypass] SecTrustEvaluateWithError -> trusted'); } }); } } catch(e) {} // Hook SecTrustGetTrustResult try { var SecTrustGetTrustResult = Module.findExportByName('Security', 'SecTrustGetTrustResult'); if (SecTrustGetTrustResult) { Interceptor.attach(SecTrustGetTrustResult, { onLeave: function(retval) { retval.replace(0); send('[SSL Bypass] SecTrustGetTrustResult -> proceed'); } }); } } catch(e) {} // Bypass AFNetworking pinning try { var AFSecurityPolicy = ObjC.classes.AFSecurityPolicy; if (AFSecurityPolicy) { Interceptor.attach(AFSecurityPolicy['- setSSLPinningMode:'].implementation, { onEnter: function(args) { args[2] = ptr(0); // AFSSLPinningModeNone send('[SSL Bypass] AFNetworking pinning disabled'); } }); } } catch(e) {} // Bypass TrustKit pinning try { var TrustKit = ObjC.classes.TSKPinningValidator; if (TrustKit) { Interceptor.attach(TrustKit['- evaluateTrust:forHostname:'].implementation, { onLeave: function(retval) { retval.replace(0); // TSKTrustEvaluationSuccess send('[SSL Bypass] TrustKit pinning bypassed'); } }); } } catch(e) {} send('[SSL Bypass] All hooks installed successfully'); } else { send('[SSL Bypass] Objective-C runtime not available'); } """ KEYCHAIN_DUMP_SCRIPT = r""" 'use strict'; if (ObjC.available) { var SecItemCopyMatching = Module.findExportByName('Security', 'SecItemCopyMatching'); var results = []; // Enumerate keychain item classes var kSecClasses = [ 'genp', // kSecClassGenericPassword 'inet', // kSecClassInternetPassword 'cert', // kSecClassCertificate 'keys', // kSecClassKey 'idnt' // kSecClassIdentity ]; var classNames = { 'genp': 'GenericPassword', 'inet': 'InternetPassword', 'cert': 'Certificate', 'keys': 'CryptoKey', 'idnt': 'Identity' }; for (var i = 0; i < kSecClasses.length; i++) { try { var query = ObjC.classes.NSMutableDictionary.alloc().init(); query.setObject_forKey_(kSecClasses[i], 'class'); query.setObject_forKey_(ObjC.classes.__NSCFBoolean.numberWithBool_(true), 'r_Ref'); query.setObject_forKey_('m_LimitAll', 'm_Limit'); query.setObject_forKey_(ObjC.classes.__NSCFBoolean.numberWithBool_(true), 'r_Data'); query.setObject_forKey_(ObjC.classes.__NSCFBoolean.numberWithBool_(true), 'r_Attributes'); var resultPtr = Memory.alloc(Process.pointerSize); var status = new NativeFunction(SecItemCopyMatching, 'int', ['pointer', 'pointer']); var ret = status(query.handle, resultPtr); if (ret === 0) { var resultObj = new ObjC.Object(resultPtr.readPointer()); send({ type: 'keychain', class: classNames[kSecClasses[i]], count: resultObj.count ? resultObj.count() : 1 }); } } catch(e) { send({type: 'keychain_error', class: classNames[kSecClasses[i]], error: e.toString()}); } } send({type: 'keychain_complete'}); } """ JAILBREAK_DETECTION_BYPASS_SCRIPT = r""" 'use strict'; if (ObjC.available) { // Hook NSFileManager fileExistsAtPath: to hide jailbreak indicators var NSFileManager = ObjC.classes.NSFileManager; var fileExistsAtPath = NSFileManager['- fileExistsAtPath:']; var jailbreakPaths = [ '/Applications/Cydia.app', '/Applications/Sileo.app', '/Library/MobileSubstrate/MobileSubstrate.dylib', '/bin/bash', '/usr/sbin/sshd', '/etc/apt', '/usr/bin/ssh', '/private/var/lib/apt/', '/private/var/lib/cydia', '/private/var/tmp/cydia.log', '/var/lib/dpkg/info', '/usr/libexec/cydia/' ]; Interceptor.attach(fileExistsAtPath.implementation, { onEnter: function(args) { this.path = ObjC.Object(args[2]).toString(); }, onLeave: function(retval) { for (var i = 0; i < jailbreakPaths.length; i++) { if (this.path.indexOf(jailbreakPaths[i]) !== -1) { retval.replace(0); // false send('[JB Bypass] Hidden: ' + this.path); break; } } } }); // Hook canOpenURL to block cydia:// scheme detection var UIApplication = ObjC.classes.UIApplication; Interceptor.attach(UIApplication['- canOpenURL:'].implementation, { onEnter: function(args) { this.url = ObjC.Object(args[2]).toString(); }, onLeave: function(retval) { if (this.url.indexOf('cydia://') !== -1 || this.url.indexOf('sileo://') !== -1) { retval.replace(0); send('[JB Bypass] Blocked URL scheme: ' + this.url); } } }); // Hook fork() to prevent fork-based jailbreak detection var fork = Module.findExportByName(null, 'fork'); if (fork) { Interceptor.attach(fork, { onLeave: function(retval) { retval.replace(-1); // fork fails on non-jailbroken send('[JB Bypass] fork() returned -1'); } }); } send('[JB Bypass] All jailbreak detection bypasses installed'); } """ def analyze_ipa_static(ipa_path, output_dir): """Perform static analysis on an IPA file.""" findings = [] extract_dir = os.path.join(output_dir, "ipa_extracted") if not zipfile.is_zipfile(ipa_path): logger.error("Not a valid IPA/ZIP file: %s", ipa_path) return findings with zipfile.ZipFile(ipa_path, "r") as zf: zf.extractall(extract_dir) logger.info("Extracted IPA to %s", extract_dir) # Find the .app directory payload_dir = os.path.join(extract_dir, "Payload") if not os.path.isdir(payload_dir): logger.error("No Payload directory found in IPA") return findings app_dirs = [d for d in os.listdir(payload_dir) if d.endswith(".app")] if not app_dirs: logger.error("No .app bundle found in Payload") return findings app_path = os.path.join(payload_dir, app_dirs[0]) logger.info("Analyzing app bundle: %s", app_dirs[0]) # Parse Info.plist info_plist_path = os.path.join(app_path, "Info.plist") if os.path.exists(info_plist_path): with open(info_plist_path, "rb") as f: try: plist_data = plistlib.load(f) except Exception: plist_data = {} # Check App Transport Security configuration ats = plist_data.get("NSAppTransportSecurity", {}) if ats.get("NSAllowsArbitraryLoads", False): findings.append({ "id": "IOS-STATIC-001", "severity": "Medium", "title": "App Transport Security Disabled", "detail": "NSAllowsArbitraryLoads is set to true, allowing cleartext HTTP traffic", "masvs": "MASVS-NETWORK", "mastg": "MASTG-TEST-0066", }) # Check URL schemes for deep link hijacking potential url_types = plist_data.get("CFBundleURLTypes", []) custom_schemes = [] for url_type in url_types: schemes = url_type.get("CFBundleURLSchemes", []) custom_schemes.extend(schemes) if custom_schemes: findings.append({ "id": "IOS-STATIC-002", "severity": "Informational", "title": "Custom URL Schemes Registered", "detail": f"App registers URL schemes: {', '.join(custom_schemes)}. " "Test for deep link hijacking and parameter injection.", "masvs": "MASVS-PLATFORM", "mastg": "MASTG-TEST-0075", }) # Check for background modes that could leak data bg_modes = plist_data.get("UIBackgroundModes", []) if bg_modes: findings.append({ "id": "IOS-STATIC-003", "severity": "Informational", "title": "Background Execution Modes Enabled", "detail": f"Background modes: {', '.join(bg_modes)}. " "Verify sensitive operations are not exposed in background.", "masvs": "MASVS-STORAGE", "mastg": "MASTG-TEST-0058", }) # Scan binary strings for hardcoded secrets executable_name = plist_data.get("CFBundleExecutable", app_dirs[0].replace(".app", "")) executable_path = os.path.join(app_path, executable_name) if os.path.exists(executable_path): secret_patterns = [ (r'(?i)api[_-]?key\s*[:=]\s*["\'][A-Za-z0-9_\-]{16,}', "Hardcoded API Key"), (r'(?i)secret\s*[:=]\s*["\'][A-Za-z0-9_\-]{8,}', "Hardcoded Secret"), (r'(?i)password\s*[:=]\s*["\'][^"\']{4,}', "Hardcoded Password"), (r'https?://[a-zA-Z0-9._\-]+\.firebaseio\.com', "Firebase URL"), (r'AIza[0-9A-Za-z_\-]{35}', "Google API Key"), (r'AKIA[0-9A-Z]{16}', "AWS Access Key ID"), (r'-----BEGIN (?:RSA )?PRIVATE KEY-----', "Embedded Private Key"), ] try: result = subprocess.run( ["strings", executable_path], capture_output=True, text=True, timeout=60, ) binary_strings = result.stdout except (subprocess.SubprocessError, FileNotFoundError): binary_strings = "" try: with open(executable_path, "rb") as f: raw = f.read() binary_strings = raw.decode("ascii", errors="ignore") except Exception: pass for pattern, label in secret_patterns: matches = re.findall(pattern, binary_strings) if matches: findings.append({ "id": "IOS-STATIC-004", "severity": "High", "title": f"{label} Found in Binary", "detail": f"Pattern match for {label}: {len(matches)} occurrence(s) found. " f"Sample: {matches[0][:60]}...", "masvs": "MASVS-STORAGE", "mastg": "MASTG-TEST-0058", }) # Check for embedded provisioning profile provision_path = os.path.join(app_path, "embedded.mobileprovision") if os.path.exists(provision_path): try: result = subprocess.run( ["security", "cms", "-D", "-i", provision_path], capture_output=True, text=True, timeout=30, ) if "get-task-allow" in result.stdout and "" in result.stdout: findings.append({ "id": "IOS-STATIC-005", "severity": "Medium", "title": "Debug Entitlement Enabled (get-task-allow)", "detail": "The provisioning profile has get-task-allow=true, " "indicating a development/debug build that allows attaching debuggers.", "masvs": "MASVS-RESILIENCE", "mastg": "MASTG-TEST-0083", }) except (subprocess.SubprocessError, FileNotFoundError): pass # Check for unprotected frameworks frameworks_dir = os.path.join(app_path, "Frameworks") if os.path.isdir(frameworks_dir): frameworks = [f for f in os.listdir(frameworks_dir) if f.endswith(".framework")] if frameworks: findings.append({ "id": "IOS-STATIC-006", "severity": "Informational", "title": "Embedded Frameworks Inventory", "detail": f"App embeds {len(frameworks)} frameworks: {', '.join(frameworks[:10])}. " "Review for known vulnerable versions.", "masvs": "MASVS-RESILIENCE", "mastg": "MASTG-TEST-0083", }) logger.info("Static analysis complete: %d findings", len(findings)) return findings def run_frida_script(target_bundle, script_source, device_type="usb", timeout_sec=30): """Execute a Frida script against a target iOS application.""" messages = [] try: import frida except ImportError: logger.error("Frida Python bindings not installed: pip install frida") return messages def on_message(message, data): if message["type"] == "send": payload = message["payload"] messages.append(payload) logger.info("Frida: %s", payload) elif message["type"] == "error": logger.error("Frida error: %s", message.get("description", "")) try: if device_type == "usb": device = frida.get_usb_device(timeout=10) elif device_type == "remote": device = frida.get_remote_device() else: device = frida.get_local_device() logger.info("Attached to device: %s", device.name) # Try to attach to running process first try: pid = device.get_process(target_bundle).pid session = device.attach(pid) logger.info("Attached to running process PID %d", pid) except frida.ProcessNotFoundError: # Spawn the app pid = device.spawn([target_bundle]) session = device.attach(pid) device.resume(pid) logger.info("Spawned and attached to PID %d", pid) script = session.create_script(script_source) script.on("message", on_message) script.load() import time time.sleep(timeout_sec) script.unload() session.detach() except Exception as e: logger.error("Frida execution failed: %s", e) messages.append({"error": str(e)}) return messages def run_objection_command(bundle_id, command): """Execute an Objection command against a target app.""" try: result = subprocess.run( ["objection", "--gadget", bundle_id, "run", command], capture_output=True, text=True, timeout=60, ) return {"command": command, "stdout": result.stdout, "stderr": result.stderr, "returncode": result.returncode} except FileNotFoundError: logger.error("Objection not found: pip install objection") return {"command": command, "error": "objection not installed"} except subprocess.TimeoutExpired: logger.warning("Objection command timed out: %s", command) return {"command": command, "error": "timeout"} def assess_keychain_security(bundle_id): """Dump and analyze keychain items for insecure storage practices.""" findings = [] result = run_objection_command(bundle_id, "ios keychain dump --json") if result.get("error"): logger.warning("Keychain dump failed: %s", result.get("error")) return findings try: items = json.loads(result["stdout"]) except (json.JSONDecodeError, KeyError): logger.warning("Could not parse keychain dump output") return findings insecure_accessibility = [ "kSecAttrAccessibleAlways", "kSecAttrAccessibleAlwaysThisDeviceOnly", "kSecAttrAccessibleAfterFirstUnlock", ] for item in items: accessible = item.get("accessible", "") if any(a in accessible for a in insecure_accessibility): findings.append({ "id": "IOS-KEYCHAIN-001", "severity": "High", "title": "Insecure Keychain Accessibility Attribute", "detail": f"Keychain item '{item.get('service', 'unknown')}' uses " f"accessibility '{accessible}'. Data may be accessible " "without device unlock.", "masvs": "MASVS-STORAGE", "mastg": "MASTG-TEST-0055", }) # Check for unprotected password items if item.get("class") == "genp" and not item.get("accessControl"): findings.append({ "id": "IOS-KEYCHAIN-002", "severity": "Medium", "title": "Keychain Password Without Access Control", "detail": f"Generic password '{item.get('service', 'unknown')}' lacks " "biometric or passcode access control constraints.", "masvs": "MASVS-STORAGE", "mastg": "MASTG-TEST-0055", }) logger.info("Keychain analysis: %d findings from %d items", len(findings), len(items)) return findings def generate_report(findings, target_app, output_path): """Generate comprehensive iOS security assessment report.""" critical = [f for f in findings if f.get("severity") == "Critical"] high = [f for f in findings if f.get("severity") == "High"] medium = [f for f in findings if f.get("severity") == "Medium"] low = [f for f in findings if f.get("severity") in ("Low", "Informational")] report = { "assessment": "iOS Application Security Assessment", "target": target_app, "timestamp": datetime.utcnow().isoformat(), "summary": { "total_findings": len(findings), "critical": len(critical), "high": len(high), "medium": len(medium), "low_informational": len(low), }, "findings": findings, } with open(output_path, "w") as f: json.dump(report, f, indent=2) logger.info("Report saved to %s (%d findings)", output_path, len(findings)) return report def main(): parser = argparse.ArgumentParser( description="iOS App Security Assessment Agent - Frida/Objection-based testing" ) parser.add_argument("--bundle-id", help="Target app bundle identifier (e.g., com.target.app)") parser.add_argument("--ipa", help="Path to IPA file for static analysis") parser.add_argument("--device", choices=["usb", "remote", "local"], default="usb", help="Frida device type (default: usb)") parser.add_argument("--ssl-bypass", action="store_true", help="Run SSL pinning bypass script") parser.add_argument("--keychain", action="store_true", help="Dump and analyze keychain security") parser.add_argument("--jailbreak-bypass", action="store_true", help="Run jailbreak detection bypass") parser.add_argument("--frida-timeout", type=int, default=30, help="Frida script execution timeout in seconds") parser.add_argument("--output", default="ios_assessment_report.json", help="Output report file path") parser.add_argument("--output-dir", default=".", help="Directory for extracted IPA and artifacts") args = parser.parse_args() if not args.bundle_id and not args.ipa: parser.error("Provide --bundle-id for dynamic testing or --ipa for static analysis") findings = [] # Static analysis of IPA if args.ipa: logger.info("=== IPA Static Analysis ===") findings.extend(analyze_ipa_static(args.ipa, args.output_dir)) # Dynamic analysis with Frida/Objection if args.bundle_id: if args.ssl_bypass: logger.info("=== SSL Pinning Bypass ===") msgs = run_frida_script(args.bundle_id, SSL_PINNING_BYPASS_SCRIPT, args.device, args.frida_timeout) if any("success" in str(m).lower() for m in msgs): findings.append({ "id": "IOS-NET-001", "severity": "Informational", "title": "SSL Pinning Successfully Bypassed", "detail": "Certificate pinning was bypassed using Frida hooks on " "SecTrustEvaluate, SecTrustEvaluateWithError, and framework-specific " "trust evaluation methods. Traffic can be intercepted via proxy.", "masvs": "MASVS-NETWORK", "mastg": "MASTG-TEST-0068", }) if args.jailbreak_bypass: logger.info("=== Jailbreak Detection Bypass ===") msgs = run_frida_script(args.bundle_id, JAILBREAK_DETECTION_BYPASS_SCRIPT, args.device, args.frida_timeout) if any("bypass" in str(m).lower() for m in msgs): findings.append({ "id": "IOS-RES-001", "severity": "Medium", "title": "Jailbreak Detection Bypassed at Runtime", "detail": "Jailbreak detection checks (file existence, URL scheme, fork) " "were all bypassed using Frida method hooks.", "masvs": "MASVS-RESILIENCE", "mastg": "MASTG-TEST-0079", }) if args.keychain: logger.info("=== Keychain Security Analysis ===") findings.extend(assess_keychain_security(args.bundle_id)) target_name = args.bundle_id or args.ipa or "unknown" report = generate_report(findings, target_name, args.output) print(json.dumps(report["summary"], indent=2)) if __name__ == "__main__": main()