Files
Anthropic-Cybersecurity-Skills/skills/performing-ios-app-security-assessment/scripts/agent.py
T

617 lines
23 KiB
Python

#!/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 "<true/>" 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()