Autarch/core/iphone_exploit.py

684 lines
30 KiB
Python
Raw Permalink Normal View History

"""
AUTARCH iPhone Exploitation Manager
Local USB device access via libimobiledevice tools.
Device info, screenshots, syslog, app management, backup extraction,
filesystem mounting, provisioning profiles, port forwarding.
"""
import os
import re
import json
import time
import shutil
import sqlite3
import subprocess
import plistlib
from pathlib import Path
from typing import Optional, Dict, Any
from core.paths import get_data_dir, find_tool
class IPhoneExploitManager:
"""All iPhone USB exploitation logic using libimobiledevice."""
# Tools we look for
TOOLS = [
'idevice_id', 'ideviceinfo', 'idevicepair', 'idevicename',
'idevicedate', 'idevicescreenshot', 'idevicesyslog',
'idevicecrashreport', 'idevicediagnostics', 'ideviceinstaller',
'idevicebackup2', 'ideviceprovision', 'idevicedebug',
'ideviceactivation', 'ifuse', 'iproxy',
]
def __init__(self):
self._base = get_data_dir() / 'iphone_exploit'
for sub in ('backups', 'screenshots', 'recon', 'apps', 'crash_reports'):
(self._base / sub).mkdir(parents=True, exist_ok=True)
# Find available tools
self._tools = {}
for name in self.TOOLS:
path = find_tool(name)
if not path:
path = shutil.which(name)
if path:
self._tools[name] = path
def _udid_dir(self, category, udid):
d = self._base / category / udid
d.mkdir(parents=True, exist_ok=True)
return d
def _run(self, tool_name, args, timeout=30):
"""Run a libimobiledevice tool."""
path = self._tools.get(tool_name)
if not path:
return '', f'{tool_name} not found', 1
cmd = [path] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return result.stdout, result.stderr, result.returncode
except subprocess.TimeoutExpired:
return '', 'Command timed out', 1
except Exception as e:
return '', str(e), 1
def _run_udid(self, tool_name, udid, args, timeout=30):
"""Run tool with -u UDID flag."""
return self._run(tool_name, ['-u', udid] + args, timeout=timeout)
def get_status(self):
"""Get availability of libimobiledevice tools."""
available = {name: bool(path) for name, path in self._tools.items()}
total = len(self.TOOLS)
found = sum(1 for v in available.values() if v)
return {
'tools': available,
'total': total,
'found': found,
'ready': found >= 3, # At minimum need idevice_id, ideviceinfo, idevicepair
}
# ── Device Management ────────────────────────────────────────────
def list_devices(self):
"""List connected iOS devices."""
stdout, stderr, rc = self._run('idevice_id', ['-l'])
if rc != 0:
return []
devices = []
for line in stdout.strip().split('\n'):
udid = line.strip()
if udid:
info = self.device_info_brief(udid)
devices.append({
'udid': udid,
'name': info.get('DeviceName', ''),
'model': info.get('ProductType', ''),
'ios_version': info.get('ProductVersion', ''),
})
return devices
def device_info(self, udid):
"""Get full device information."""
stdout, stderr, rc = self._run_udid('ideviceinfo', udid, [])
if rc != 0:
return {'error': stderr or 'Cannot get device info'}
info = {}
for line in stdout.split('\n'):
if ':' in line:
key, _, val = line.partition(':')
info[key.strip()] = val.strip()
return info
def device_info_brief(self, udid):
"""Get key device info (name, model, iOS version)."""
keys = ['DeviceName', 'ProductType', 'ProductVersion', 'BuildVersion',
'SerialNumber', 'UniqueChipID', 'WiFiAddress', 'BluetoothAddress']
info = {}
for key in keys:
stdout, _, rc = self._run_udid('ideviceinfo', udid, ['-k', key])
if rc == 0:
info[key] = stdout.strip()
return info
def device_info_domain(self, udid, domain):
"""Get device info for a specific domain."""
stdout, stderr, rc = self._run_udid('ideviceinfo', udid, ['-q', domain])
if rc != 0:
return {'error': stderr}
info = {}
for line in stdout.split('\n'):
if ':' in line:
key, _, val = line.partition(':')
info[key.strip()] = val.strip()
return info
def pair_device(self, udid):
"""Pair with device (requires user trust on device)."""
stdout, stderr, rc = self._run_udid('idevicepair', udid, ['pair'])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def unpair_device(self, udid):
"""Unpair from device."""
stdout, stderr, rc = self._run_udid('idevicepair', udid, ['unpair'])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def validate_pair(self, udid):
"""Check if device is properly paired."""
stdout, stderr, rc = self._run_udid('idevicepair', udid, ['validate'])
return {'success': rc == 0, 'paired': rc == 0, 'output': (stdout or stderr).strip()}
def get_name(self, udid):
"""Get device name."""
stdout, stderr, rc = self._run_udid('idevicename', udid, [])
return {'success': rc == 0, 'name': stdout.strip()}
def set_name(self, udid, name):
"""Set device name."""
stdout, stderr, rc = self._run_udid('idevicename', udid, [name])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def get_date(self, udid):
"""Get device date/time."""
stdout, stderr, rc = self._run_udid('idevicedate', udid, [])
return {'success': rc == 0, 'date': stdout.strip()}
def set_date(self, udid, timestamp):
"""Set device date (epoch timestamp)."""
stdout, stderr, rc = self._run_udid('idevicedate', udid, ['-s', str(timestamp)])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def restart_device(self, udid):
"""Restart device."""
stdout, stderr, rc = self._run_udid('idevicediagnostics', udid, ['restart'])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def shutdown_device(self, udid):
"""Shutdown device."""
stdout, stderr, rc = self._run_udid('idevicediagnostics', udid, ['shutdown'])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def sleep_device(self, udid):
"""Put device to sleep."""
stdout, stderr, rc = self._run_udid('idevicediagnostics', udid, ['sleep'])
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
# ── Screenshot & Syslog ──────────────────────────────────────────
def screenshot(self, udid):
"""Take a screenshot."""
out_dir = self._udid_dir('screenshots', udid)
filename = f'screen_{int(time.time())}.png'
filepath = str(out_dir / filename)
stdout, stderr, rc = self._run_udid('idevicescreenshot', udid, [filepath])
if rc == 0 and os.path.exists(filepath):
return {'success': True, 'path': filepath, 'size': os.path.getsize(filepath)}
return {'success': False, 'error': (stderr or stdout).strip()}
def syslog_dump(self, udid, duration=5):
"""Capture syslog for a duration."""
out_dir = self._udid_dir('recon', udid)
logfile = str(out_dir / f'syslog_{int(time.time())}.txt')
try:
proc = subprocess.Popen(
[self._tools.get('idevicesyslog', 'idevicesyslog'), '-u', udid],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
time.sleep(duration)
proc.terminate()
stdout, _ = proc.communicate(timeout=3)
with open(logfile, 'w') as f:
f.write(stdout)
return {'success': True, 'path': logfile, 'lines': len(stdout.split('\n'))}
except Exception as e:
return {'success': False, 'error': str(e)}
def syslog_grep(self, udid, pattern, duration=5):
"""Capture syslog and grep for pattern (passwords, tokens, etc)."""
result = self.syslog_dump(udid, duration=duration)
if not result['success']:
return result
matches = []
try:
with open(result['path']) as f:
for line in f:
if re.search(pattern, line, re.IGNORECASE):
matches.append(line.strip())
except Exception:
pass
return {'success': True, 'matches': matches, 'count': len(matches), 'pattern': pattern}
def crash_reports(self, udid):
"""Pull crash reports from device."""
out_dir = self._udid_dir('crash_reports', udid)
stdout, stderr, rc = self._run_udid('idevicecrashreport', udid,
['-e', str(out_dir)], timeout=60)
if rc == 0:
files = list(out_dir.iterdir()) if out_dir.exists() else []
return {'success': True, 'output_dir': str(out_dir),
'count': len(files), 'output': stdout.strip()}
return {'success': False, 'error': (stderr or stdout).strip()}
# ── App Management ───────────────────────────────────────────────
def list_apps(self, udid, app_type='user'):
"""List installed apps. type: user, system, all."""
flags = {
'user': ['-l', '-o', 'list_user'],
'system': ['-l', '-o', 'list_system'],
'all': ['-l', '-o', 'list_all'],
}
args = flags.get(app_type, ['-l'])
stdout, stderr, rc = self._run_udid('ideviceinstaller', udid, args, timeout=30)
if rc != 0:
return {'success': False, 'error': (stderr or stdout).strip(), 'apps': []}
apps = []
for line in stdout.strip().split('\n'):
line = line.strip()
if not line or line.startswith('CFBundle') or line.startswith('Total'):
continue
# Format: com.example.app, "App Name", "1.0"
parts = line.split(',', 2)
if parts:
app = {'bundle_id': parts[0].strip().strip('"')}
if len(parts) >= 2:
app['name'] = parts[1].strip().strip('"')
if len(parts) >= 3:
app['version'] = parts[2].strip().strip('"')
apps.append(app)
return {'success': True, 'apps': apps, 'count': len(apps)}
def install_app(self, udid, ipa_path):
"""Install an IPA on device."""
if not os.path.isfile(ipa_path):
return {'success': False, 'error': f'File not found: {ipa_path}'}
stdout, stderr, rc = self._run_udid('ideviceinstaller', udid,
['-i', ipa_path], timeout=120)
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def uninstall_app(self, udid, bundle_id):
"""Uninstall an app by bundle ID."""
stdout, stderr, rc = self._run_udid('ideviceinstaller', udid,
['-U', bundle_id], timeout=30)
return {'success': rc == 0, 'bundle_id': bundle_id, 'output': (stdout or stderr).strip()}
# ── Backup & Data Extraction ─────────────────────────────────────
def create_backup(self, udid, encrypted=False, password=''):
"""Create a full device backup."""
backup_dir = str(self._base / 'backups')
args = ['backup', '--full', backup_dir]
if encrypted and password:
args = ['backup', '--full', backup_dir, '-p', password]
stdout, stderr, rc = self._run_udid('idevicebackup2', udid, args, timeout=600)
backup_path = os.path.join(backup_dir, udid)
success = os.path.isdir(backup_path)
return {
'success': success,
'backup_path': backup_path if success else None,
'encrypted': encrypted,
'output': (stdout or stderr).strip()[:500],
}
def list_backups(self):
"""List available local backups."""
backup_dir = self._base / 'backups'
backups = []
if backup_dir.exists():
for d in backup_dir.iterdir():
if d.is_dir():
manifest = d / 'Manifest.db'
info_plist = d / 'Info.plist'
backup_info = {'udid': d.name, 'path': str(d)}
if manifest.exists():
backup_info['has_manifest'] = True
backup_info['size_mb'] = sum(
f.stat().st_size for f in d.rglob('*') if f.is_file()
) / (1024 * 1024)
if info_plist.exists():
try:
with open(info_plist, 'rb') as f:
plist = plistlib.load(f)
backup_info['device_name'] = plist.get('Device Name', '')
backup_info['product_type'] = plist.get('Product Type', '')
backup_info['ios_version'] = plist.get('Product Version', '')
backup_info['date'] = str(plist.get('Last Backup Date', ''))
except Exception:
pass
backups.append(backup_info)
return {'backups': backups, 'count': len(backups)}
def extract_backup_sms(self, backup_path):
"""Extract SMS/iMessage from a backup."""
manifest = os.path.join(backup_path, 'Manifest.db')
if not os.path.exists(manifest):
return {'success': False, 'error': 'Manifest.db not found'}
try:
conn = sqlite3.connect(manifest)
cur = conn.cursor()
# Find SMS database file hash
cur.execute("SELECT fileID FROM Files WHERE relativePath = 'Library/SMS/sms.db' AND domain = 'HomeDomain'")
row = cur.fetchone()
conn.close()
if not row:
return {'success': False, 'error': 'SMS database not found in backup'}
file_hash = row[0]
sms_db = os.path.join(backup_path, file_hash[:2], file_hash)
if not os.path.exists(sms_db):
return {'success': False, 'error': f'SMS db file not found: {file_hash}'}
# Query messages
conn = sqlite3.connect(sms_db)
cur = conn.cursor()
cur.execute('''
SELECT m.rowid, m.text, m.date, m.is_from_me,
h.id AS handle_id, h.uncanonicalized_id
FROM message m
LEFT JOIN handle h ON m.handle_id = h.rowid
ORDER BY m.date DESC LIMIT 500
''')
messages = []
for row in cur.fetchall():
# Apple timestamps: seconds since 2001-01-01
apple_epoch = 978307200
ts = row[2]
if ts and ts > 1e17:
ts = ts / 1e9 # nanoseconds
date_readable = ''
if ts:
try:
date_readable = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts + apple_epoch))
except (ValueError, OSError):
pass
messages.append({
'id': row[0], 'text': row[1] or '', 'date': date_readable,
'is_from_me': bool(row[3]),
'handle': row[4] or row[5] or '',
})
conn.close()
return {'success': True, 'messages': messages, 'count': len(messages)}
except Exception as e:
return {'success': False, 'error': str(e)}
def extract_backup_contacts(self, backup_path):
"""Extract contacts from backup."""
manifest = os.path.join(backup_path, 'Manifest.db')
if not os.path.exists(manifest):
return {'success': False, 'error': 'Manifest.db not found'}
try:
conn = sqlite3.connect(manifest)
cur = conn.cursor()
cur.execute("SELECT fileID FROM Files WHERE relativePath = 'Library/AddressBook/AddressBook.sqlitedb' AND domain = 'HomeDomain'")
row = cur.fetchone()
conn.close()
if not row:
return {'success': False, 'error': 'AddressBook not found in backup'}
file_hash = row[0]
ab_db = os.path.join(backup_path, file_hash[:2], file_hash)
if not os.path.exists(ab_db):
return {'success': False, 'error': 'AddressBook file not found'}
conn = sqlite3.connect(ab_db)
cur = conn.cursor()
cur.execute('''
SELECT p.rowid, p.First, p.Last, p.Organization,
mv.value AS phone_or_email
FROM ABPerson p
LEFT JOIN ABMultiValue mv ON p.rowid = mv.record_id
ORDER BY p.Last, p.First
''')
contacts = {}
for row in cur.fetchall():
rid = row[0]
if rid not in contacts:
contacts[rid] = {
'first': row[1] or '', 'last': row[2] or '',
'organization': row[3] or '', 'values': []
}
if row[4]:
contacts[rid]['values'].append(row[4])
conn.close()
contact_list = list(contacts.values())
return {'success': True, 'contacts': contact_list, 'count': len(contact_list)}
except Exception as e:
return {'success': False, 'error': str(e)}
def extract_backup_call_log(self, backup_path):
"""Extract call history from backup."""
manifest = os.path.join(backup_path, 'Manifest.db')
if not os.path.exists(manifest):
return {'success': False, 'error': 'Manifest.db not found'}
try:
conn = sqlite3.connect(manifest)
cur = conn.cursor()
cur.execute("SELECT fileID FROM Files WHERE relativePath LIKE '%CallHistory%' AND domain = 'HomeDomain'")
row = cur.fetchone()
conn.close()
if not row:
return {'success': False, 'error': 'Call history not found in backup'}
file_hash = row[0]
ch_db = os.path.join(backup_path, file_hash[:2], file_hash)
if not os.path.exists(ch_db):
return {'success': False, 'error': 'Call history file not found'}
conn = sqlite3.connect(ch_db)
cur = conn.cursor()
cur.execute('''
SELECT ROWID, address, date, duration, flags, country_code
FROM ZCALLRECORD ORDER BY ZDATE DESC LIMIT 200
''')
flag_map = {4: 'incoming', 5: 'outgoing', 8: 'missed'}
calls = []
apple_epoch = 978307200
for row in cur.fetchall():
ts = row[2]
date_readable = ''
if ts:
try:
date_readable = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts + apple_epoch))
except (ValueError, OSError):
pass
calls.append({
'id': row[0], 'address': row[1] or '', 'date': date_readable,
'duration': row[3] or 0, 'type': flag_map.get(row[4], str(row[4])),
'country': row[5] or '',
})
conn.close()
return {'success': True, 'calls': calls, 'count': len(calls)}
except Exception as e:
return {'success': False, 'error': str(e)}
def extract_backup_notes(self, backup_path):
"""Extract notes from backup."""
manifest = os.path.join(backup_path, 'Manifest.db')
if not os.path.exists(manifest):
return {'success': False, 'error': 'Manifest.db not found'}
try:
conn = sqlite3.connect(manifest)
cur = conn.cursor()
cur.execute("SELECT fileID FROM Files WHERE relativePath LIKE '%NoteStore.sqlite%' AND domain = 'AppDomainGroup-group.com.apple.notes'")
row = cur.fetchone()
conn.close()
if not row:
return {'success': False, 'error': 'Notes database not found in backup'}
file_hash = row[0]
notes_db = os.path.join(backup_path, file_hash[:2], file_hash)
if not os.path.exists(notes_db):
return {'success': False, 'error': 'Notes file not found'}
conn = sqlite3.connect(notes_db)
cur = conn.cursor()
cur.execute('''
SELECT n.Z_PK, n.ZTITLE, nb.ZDATA, n.ZMODIFICATIONDATE
FROM ZICCLOUDSYNCINGOBJECT n
LEFT JOIN ZICNOTEDATA nb ON n.Z_PK = nb.ZNOTE
WHERE n.ZTITLE IS NOT NULL
ORDER BY n.ZMODIFICATIONDATE DESC LIMIT 100
''')
apple_epoch = 978307200
notes = []
for row in cur.fetchall():
body = ''
if row[2]:
try:
body = row[2].decode('utf-8', errors='replace')[:500]
except Exception:
body = '[binary data]'
date_readable = ''
if row[3]:
try:
date_readable = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(row[3] + apple_epoch))
except (ValueError, OSError):
pass
notes.append({'id': row[0], 'title': row[1] or '', 'body': body, 'date': date_readable})
conn.close()
return {'success': True, 'notes': notes, 'count': len(notes)}
except Exception as e:
return {'success': False, 'error': str(e)}
def list_backup_files(self, backup_path, domain='', path_filter=''):
"""List all files in a backup's Manifest.db."""
manifest = os.path.join(backup_path, 'Manifest.db')
if not os.path.exists(manifest):
return {'success': False, 'error': 'Manifest.db not found'}
try:
conn = sqlite3.connect(manifest)
cur = conn.cursor()
query = 'SELECT fileID, domain, relativePath, flags FROM Files'
conditions = []
params = []
if domain:
conditions.append('domain LIKE ?')
params.append(f'%{domain}%')
if path_filter:
conditions.append('relativePath LIKE ?')
params.append(f'%{path_filter}%')
if conditions:
query += ' WHERE ' + ' AND '.join(conditions)
query += ' LIMIT 500'
cur.execute(query, params)
files = []
for row in cur.fetchall():
files.append({
'hash': row[0], 'domain': row[1],
'path': row[2], 'flags': row[3],
})
conn.close()
return {'success': True, 'files': files, 'count': len(files)}
except Exception as e:
return {'success': False, 'error': str(e)}
def extract_backup_file(self, backup_path, file_hash, output_name=None):
"""Extract a specific file from backup by its hash."""
src = os.path.join(backup_path, file_hash[:2], file_hash)
if not os.path.exists(src):
return {'success': False, 'error': f'File not found: {file_hash}'}
out_dir = self._base / 'recon' / 'extracted'
out_dir.mkdir(parents=True, exist_ok=True)
dest = str(out_dir / (output_name or file_hash))
shutil.copy2(src, dest)
return {'success': True, 'path': dest, 'size': os.path.getsize(dest)}
# ── Filesystem ───────────────────────────────────────────────────
def mount_filesystem(self, udid, mountpoint=None):
"""Mount device filesystem via ifuse."""
if 'ifuse' not in self._tools:
return {'success': False, 'error': 'ifuse not installed'}
if not mountpoint:
mountpoint = str(self._base / 'mnt' / udid)
os.makedirs(mountpoint, exist_ok=True)
stdout, stderr, rc = self._run('ifuse', ['-u', udid, mountpoint])
return {'success': rc == 0, 'mountpoint': mountpoint, 'output': (stderr or stdout).strip()}
def mount_app_documents(self, udid, bundle_id, mountpoint=None):
"""Mount a specific app's Documents folder via ifuse."""
if 'ifuse' not in self._tools:
return {'success': False, 'error': 'ifuse not installed'}
if not mountpoint:
mountpoint = str(self._base / 'mnt' / udid / bundle_id)
os.makedirs(mountpoint, exist_ok=True)
stdout, stderr, rc = self._run('ifuse', ['-u', udid, '--documents', bundle_id, mountpoint])
return {'success': rc == 0, 'mountpoint': mountpoint, 'output': (stderr or stdout).strip()}
def unmount_filesystem(self, mountpoint):
"""Unmount a previously mounted filesystem."""
try:
subprocess.run(['fusermount', '-u', mountpoint], capture_output=True, timeout=10)
return {'success': True, 'mountpoint': mountpoint}
except Exception as e:
return {'success': False, 'error': str(e)}
# ── Provisioning Profiles ────────────────────────────────────────
def list_profiles(self, udid):
"""List provisioning profiles on device."""
stdout, stderr, rc = self._run_udid('ideviceprovision', udid, ['list'], timeout=15)
if rc != 0:
return {'success': False, 'error': (stderr or stdout).strip(), 'profiles': []}
profiles = []
current = {}
for line in stdout.split('\n'):
line = line.strip()
if line.startswith('ProvisionedDevices'):
continue
if ' - ' in line and not current:
current = {'id': line.split(' - ')[0].strip(), 'name': line.split(' - ', 1)[1].strip()}
elif line == '' and current:
profiles.append(current)
current = {}
if current:
profiles.append(current)
return {'success': True, 'profiles': profiles, 'count': len(profiles)}
def install_profile(self, udid, profile_path):
"""Install a provisioning/configuration profile."""
if not os.path.isfile(profile_path):
return {'success': False, 'error': f'File not found: {profile_path}'}
stdout, stderr, rc = self._run_udid('ideviceprovision', udid,
['install', profile_path], timeout=15)
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
def remove_profile(self, udid, profile_id):
"""Remove a provisioning profile."""
stdout, stderr, rc = self._run_udid('ideviceprovision', udid,
['remove', profile_id], timeout=15)
return {'success': rc == 0, 'output': (stdout or stderr).strip()}
# ── Port Forwarding ──────────────────────────────────────────────
def port_forward(self, udid, local_port, device_port):
"""Set up port forwarding via iproxy (runs in background)."""
if 'iproxy' not in self._tools:
return {'success': False, 'error': 'iproxy not installed'}
try:
proc = subprocess.Popen(
[self._tools['iproxy'], '-u', udid, str(local_port), str(device_port)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
time.sleep(0.5)
if proc.poll() is not None:
_, err = proc.communicate()
return {'success': False, 'error': err.decode().strip()}
return {'success': True, 'pid': proc.pid,
'local': local_port, 'device': device_port}
except Exception as e:
return {'success': False, 'error': str(e)}
# ── Device Fingerprint ───────────────────────────────────────────
def full_fingerprint(self, udid):
"""Get comprehensive device fingerprint."""
fp = self.device_info(udid)
# Add specific domains
for domain in ['com.apple.disk_usage', 'com.apple.mobile.battery',
'com.apple.mobile.internal', 'com.apple.international']:
domain_info = self.device_info_domain(udid, domain)
if 'error' not in domain_info:
fp[f'domain_{domain.split(".")[-1]}'] = domain_info
return fp
def export_recon_report(self, udid):
"""Export full reconnaissance report."""
out_dir = self._udid_dir('recon', udid)
report = {
'udid': udid,
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
'device_info': self.device_info(udid),
'pair_status': self.validate_pair(udid),
'apps': self.list_apps(udid),
'profiles': self.list_profiles(udid),
}
report_path = str(out_dir / f'report_{int(time.time())}.json')
with open(report_path, 'w') as f:
json.dump(report, f, indent=2, default=str)
return {'success': True, 'report_path': report_path}
# ── Singleton ──────────────────────────────────────────────────────
_manager = None
def get_iphone_manager():
global _manager
if _manager is None:
_manager = IPhoneExploitManager()
return _manager