背景与问题
在实际进行iOS应用安全研究时,常需对App进行脱壳以获取可分析的二进制文件。标准的frida-ios-dump工具依赖于通过USB连接iPhone来与Frida通信。然而,在某些特定环境下(例如笔者遇到的Windows系统搭配iPhone 6 (iOS 12.5.7)的情况),USB连接可能无法正常工作,导致脱壳流程中断。

为了解决此问题,本文对官方的frida-ios-dump脚本(原项目地址:https://github.com/AloneMonkey/frida-ios-dump.git)进行了关键修改,使其能够摆脱USB限制,通过纯粹的网络连接(Wi-Fi)完成整个脱壳过程,为iOS应用逆向与安全分析提供了更灵活的方案。
核心修改:dump.py脚本
本次改造主要集中在对dump.py脚本的增强上,其余文件保持原样。修改后的脚本核心功能包括:
- 支持远程Frida服务器连接(
-R host:port参数)。
- 支持列出设备上的应用(
-l参数)。
- 支持通过PID直接附加到进程(
--pid参数)。
- 保持通过SSH/SCP从设备拉取dump文件并打包成IPA的逻辑。
- 增强了Windows平台兼容性,使用
os.chmod替代外部命令,更稳健地处理文件权限和清理。
以下是完整的修改后dump.py脚本代码:
#!/usr/bin/env python# -*- coding: utf-8 -*-"""Modified frida-ios-dump:- Support remote Frida connection (-R host:port)- Support listing apps (-l)- Support direct attach by PID (--pid)- Keep SSH/SCP pulling behavior to fetch dumped files and package into .ipaFixes for Windows:- Use os.chmod instead of external 'chmod'- Handle missing 'app' key when packaging IPA- Robust rmtree with onerror handler to fix permissions before deleting"""from __future__ import print_function, unicode_literalsimport sysimport codecsimport fridaimport threadingimport osimport shutilimport timeimport argparseimport tempfileimport subprocessimport reimport paramikofrom paramiko import SSHClientfrom scp import SCPClientfrom tqdm import tqdmimport tracebackimport statIS_PY2 = sys.version_info[0] < 3if IS_PY2: reload(sys) sys.setdefaultencoding('utf8')script_dir = os.path.dirname(os.path.realpath(__file__))DUMP_JS = os.path.join(script_dir, 'dump.js')# default SSH (iPhone) params - can be overridden by CLIUser = 'root'Password = 'alpine'Host = 'localhost'Port = 22KeyFileName = NoneTEMP_DIR = tempfile.gettempdir()PAYLOAD_DIR = 'Payload'PAYLOAD_PATH = os.path.join(TEMP_DIR, PAYLOAD_DIR)file_dict = {}finished = threading.Event()ssh = None # will be set in main before dumpdef get_usb_iphone(): """原始 USB 获取设备(保留以兼容)""" Type = 'usb' try: if int(frida.__version__.split('.')[0]) < 12: Type = 'tether' except Exception: Type = 'usb' device_manager = frida.get_device_manager() changed = threading.Event() def on_changed(): changed.set() device_manager.on('changed', on_changed) device = None while device is None: devices = [dev for dev in device_manager.enumerate_devices() if dev.type == Type] if len(devices) == 0: print('Waiting for USB device...') changed.wait() else: device = devices[0] device_manager.off('changed', on_changed) return devicedef safe_chmod(path, mode): """Cross-platform safe chmod (ignore failures on Windows).""" try: os.chmod(path, mode) except Exception: # On Windows this may fail for many reasons; ignore. passdef on_message(message, data): """Handle messages from dump.js; pull files over SCP using global ssh.""" t = tqdm(unit='B', unit_scale=True, unit_divisor=1024, miniters=1) last_sent = [0] def progress(filename, size, sent): baseName = os.path.basename(filename) if IS_PY2 or isinstance(baseName, bytes): t.desc = baseName.decode("utf-8") else: t.desc = baseName t.total = size t.update(sent - last_sent[0]) last_sent[0] = 0 if size == sent else sent try: if'payload'in message: payload = message['payload'] # payload structures: # { dump: "/var/.../something.fid", path: "/.../AppName.app/..." } # { app: "/var/.../AppName.app" } # { done: "ok" } if'dump'in payload: origin_path = payload.get('path') dump_path = payload.get('dump') scp_from = dump_path scp_to = PAYLOAD_PATH + '/' # ensure ssh exists if ssh is None: print("SSH client not ready - cannot SCP") else: try: with SCPClient(ssh.get_transport(), progress=progress, socket_timeout=60) as scp: scp.get(scp_from, scp_to) except Exception as e: print("SCP get failed:", e) traceback.print_exc() # set permission using os.chmod (cross-platform) chmod_dir = os.path.join(PAYLOAD_PATH, os.path.basename(dump_path)) try: # try set file mode to rw-r-xr-x (0o655) similar to original intent safe_chmod(chmod_dir, 0o655) except Exception: pass # map dumped filename -> relative path inside app (after ".app/") if origin_path: index = origin_path.find('.app/') if index != -1: file_dict[os.path.basename(dump_path)] = origin_path[index + 5:] else: file_dict[os.path.basename(dump_path)] = origin_path else: file_dict[os.path.basename(dump_path)] = os.path.basename(dump_path) if'app'in payload: app_path = payload.get('app') scp_from = app_path scp_to = PAYLOAD_PATH + '/' if ssh is None: print("SSH client not ready - cannot SCP app dir") else: try: with SCPClient(ssh.get_transport(), progress=progress, socket_timeout=60) as scp: scp.get(scp_from, scp_to, recursive=True) except Exception as e: print("SCP get (app) failed:", e) traceback.print_exc() chmod_dir = os.path.join(PAYLOAD_PATH, os.path.basename(app_path)) # try to set directory and children perms try: for root, dirs, files in os.walk(chmod_dir): safe_chmod(root, 0o755) for f in files: safe_chmod(os.path.join(root, f), 0o655) except Exception: pass file_dict['app'] = os.path.basename(app_path) if'done'in payload: finished.set() except Exception as e: print("on_message exception:", e) traceback.print_exc() finally: t.close()def compare_applications(a, b): a_is_running = a.pid != 0 b_is_running = b.pid != 0 if a_is_running == b_is_running: if a.name > b.name: return 1 elif a.name < b.name: return -1 else: return 0 elif a_is_running: return -1 else: return 1def cmp_to_key(mycmp): """Convert a cmp= function into a key= function""" class K: def __init__(self, obj): self.obj = obj def __lt__(self, other): return mycmp(self.obj, other.obj) < 0 def __gt__(self, other): return mycmp(self.obj, other.obj) > 0 def __eq__(self, other): return mycmp(self.obj, other.obj) == 0 def __le__(self, other): return mycmp(self.obj, other.obj) <= 0 def __ge__(self, other): return mycmp(self.obj, other.obj) >= 0 def __ne__(self, other): return mycmp(self.obj, other.obj) != 0 return Kdef get_applications(device): try: apps = device.enumerate_applications() except Exception as e: raise RuntimeError("Failed to enumerate applications: %s" % e) return appsdef list_applications(device): """列出应用;如果为空则退回列进程,帮助诊断远程设备状态""" attempts = 5 apps = [] for i in range(attempts): try: apps = device.enumerate_applications() if apps: break except Exception as e: if i == attempts - 1: print("Failed to enumerate applications after retries: %s" % e) apps = [] break time.sleep(0.5) if not apps: print("No installed-app list returned via enumerate_applications(). Trying enumerate_processes() to help diagnose:") try: procs = device.enumerate_processes() pid_column_width = max(map(lambda p: len(str(p.pid)), procs)) if procs else 0 name_column_width = max(map(lambda p: len(p.name), procs)) if procs else 0 header_format = '%' + str(pid_column_width) + 's ' + '%-' + str(name_column_width) + 's' print(header_format % ('PID', 'Process Name')) print('%s %s' % (pid_column_width * '-', name_column_width * '-')) for p in sorted(procs, key=lambda x: x.name): print(header_format % (p.pid, p.name)) except Exception as e: print("Also failed to enumerate processes: %s" % e) return pid_column_width = max(map(lambda app: len('{}'.format(app.pid)), apps)) name_column_width = max(map(lambda app: len(app.name), apps)) identifier_column_width = max(map(lambda app: len(app.identifier), apps)) header_format = '%' + str(pid_column_width) + 's ' + '%-' + str(name_column_width) + 's ' + '%-' + str(identifier_column_width) + 's' print(header_format % ('PID', 'Name', 'Identifier')) print('%s %s %s' % (pid_column_width * '-', name_column_width * '-', identifier_column_width * '-')) line_format = header_format for application in sorted(apps, key=cmp_to_key(compare_applications)): if application.pid == 0: print(line_format % ('-', application.name, application.identifier)) else: print(line_format % (application.pid, application.name, application.identifier))def load_js_file(session, filename): source = '' with codecs.open(filename, 'r', 'utf-8') as f: source = source + f.read() script = session.create_script(source) script.on('message', on_message) script.load() return scriptdef create_dir(path): path = path.strip() path = path.rstrip('\\') if os.path.exists(path): # remove previous payload path safely def on_rm_error(func, path2, exc_info): try: os.chmod(path2, stat.S_IWRITE) func(path2) except Exception: pass shutil.rmtree(path, onerror=on_rm_error) try: os.makedirs(path) except os.error as err: print(err)def open_target_app(device, name_or_bundleid): """ Flexible matching: - exact match on identifier or name - substring match (case-insensitive) """ print('Start the target app {}'.format(name_or_bundleid)) pid = '' session = None display_name = '' bundle_identifier = '' try: apps = get_applications(device) except Exception as e: print("Failed to get applications for matching: %s" % e) apps = [] needle = name_or_bundleid or '' needle_lower = needle.lower() for application in apps: if needle == application.identifier or needle == application.name: pid = application.pid display_name = application.name bundle_identifier = application.identifier break if needle_lower and (needle_lower in application.identifier.lower() or needle_lower in application.name.lower()): pid = application.pid display_name = application.name bundle_identifier = application.identifier break try: if not pid: if bundle_identifier: pid = device.spawn([bundle_identifier]) session = device.attach(pid) device.resume(pid) else: pid = device.spawn([name_or_bundleid]) session = device.attach(pid) device.resume(pid) else: session = device.attach(pid) except Exception as e: print("Error while spawning/attaching: %s" % e) return session, display_name, bundle_identifierdef start_dump(session, ipa_name): print('Dumping {} to {}'.format(ipa_name, TEMP_DIR)) script = load_js_file(session, DUMP_JS) script.post('dump') # wait until dump.js signals done finished.wait() generate_ipa(PAYLOAD_PATH, ipa_name) if session: try: session.detach() except Exception: passdef generate_ipa(path, display_name): """ Create Payload/<AppName>/... based on file_dict mappings, then zip into <display_name>.ipa Robust for cases where 'app' key missing from file_dict. """ ipa_filename = display_name + '.ipa' print('Generating "{}"'.format(ipa_filename)) try: # determine app_name (this is the folder name under Payload) if'app'in file_dict: app_name = file_dict['app'] # this is basename like 'iEC-O2O-Buyer.app' else: # try find any .app dir in PAYLOAD_PATH (scp may have copied whole .app) candidates = [d for d in os.listdir(path) if d.endswith('.app')] if candidates: app_name = candidates[0] else: # fallback: use display_name + ".app" app_name = display_name + '.app' # create the payload app dir payload_app_dir = os.path.join(path, app_name) if not os.path.exists(payload_app_dir): os.makedirs(payload_app_dir, exist_ok=True) # move each downloaded file into correct relative place under Payload/<app_name>/ for key, rel in list(file_dict.items()): if key == 'app': continue src = os.path.join(path, key) # if rel is an absolute origin path (no .app/ found earlier), put under root of app if rel is None: rel = os.path.basename(src) target_rel_path = rel # make sure directories exist dest = os.path.join(payload_app_dir, target_rel_path) dest_dir = os.path.dirname(dest) if not os.path.exists(dest_dir): os.makedirs(dest_dir, exist_ok=True) # if src exists, move it; otherwise ignore if os.path.exists(src): try: shutil.move(src, dest) except Exception as e: # if move fails, try copy then remove try: shutil.copy2(src, dest) os.remove(src) except Exception as e2: print("Failed to move or copy {} -> {}: {}".format(src, dest, e2)) else: # sometimes scp may have created a directory instead -- try handle dir if os.path.isdir(os.path.join(path, key)): try: shutil.move(os.path.join(path, key), dest) except Exception as e: print("Failed to move dir {} -> {}: {}".format(os.path.join(path, key), dest, e)) # now we need a Payload directory at TEMP_DIR/Payload (path already points to it) target_dir = os.path.join(os.path.dirname(path), PAYLOAD_DIR) # Ensure the payload dir exists and contains the app folder (payload_app_dir already in path) # Create zip at current working directory zip_out = os.path.join(os.getcwd(), ipa_filename) # Use zip command if available, otherwise use Python zipfile module try: # prefer system zip (if installed) subprocess.check_call(['zip', '-qr', zip_out, PAYLOAD_DIR], cwd=os.path.dirname(path)) except Exception: # fallback to zipfile import zipfile def zipdir(folder, ziph): for root, dirs, files in os.walk(folder): for f in files: fullpath = os.path.join(root, f) arcname = os.path.relpath(fullpath, os.path.dirname(path)) ziph.write(fullpath, arcname) with zipfile.ZipFile(zip_out, 'w', zipfile.ZIP_DEFLATED) as zipf: zipdir(os.path.join(os.path.dirname(path), PAYLOAD_DIR), zipf) # cleanup local payload path def on_rm_error(func, p, exc_info): try: os.chmod(p, stat.S_IWRITE) func(p) except Exception: pass if os.path.exists(PAYLOAD_PATH): shutil.rmtree(PAYLOAD_PATH, onerror=on_rm_error) print("Generated:", ipa_filename) except Exception as e: print("generate_ipa error:", e) traceback.print_exc() finished.set()def main(): global Host, Port, User, Password, KeyFileName, ssh parser = argparse.ArgumentParser(description='frida-ios-dump (modified for robust remote Frida connection)') parser.add_argument('-l', '--list', dest='list_applications', action='store_true', help='List the installed apps') parser.add_argument('-o', '--output', dest='output_ipa', help='Specify name of the decrypted IPA') parser.add_argument('-H', '--host', dest='ssh_host', help='Specify SSH hostname (for iPhone SSH)') parser.add_argument('-p', '--port', dest='ssh_port', help='Specify SSH port', type=int) parser.add_argument('-u', '--user', dest='ssh_user', help='Specify SSH username') parser.add_argument('-P', '--password', dest='ssh_password', help='Specify SSH password') parser.add_argument('-K', '--key_filename', dest='ssh_key_filename', help='Specify SSH private key file path') parser.add_argument('-R', '--remote', dest='frida_remote', help='Specify Frida server address (host:port)') parser.add_argument('--pid', type=int, help='PID of target process (attach directly)') parser.add_argument('target', nargs='?', help='Bundle identifier or display name of the target app') args = parser.parse_args() # Validate minimal args if not len(sys.argv[1:]): parser.print_help() sys.exit(0) # Connect to Frida remote (if provided) or USB device = None if args.frida_remote: parts = args.frida_remote.split(':') frida_host = parts[0] frida_port = int(parts[1]) if len(parts) > 1 and parts[1] else 27042 try: device = frida.get_remote_device(frida_host, frida_port) print("Connected to remote Frida device at %s:%s (via frida.get_remote_device)" % (frida_host, frida_port)) except Exception as e1: try: manager = frida.get_device_manager() device = manager.add_remote_device("%s:%s" % (frida_host, frida_port)) print("Connected to remote Frida device at %s:%s (via DeviceManager.add_remote_device)" % (frida_host, frida_port)) except Exception as e2: print("Failed to connect to remote Frida server (tried get_remote_device and add_remote_device).") print("get_remote_device error: %s" % e1) print("add_remote_device error: %s" % e2) sys.exit(1) else: device = get_usb_iphone() # If user requested listing, do it now and exit if args.list_applications: try: list_applications(device) except Exception as e: print("Error listing applications: %s" % e) sys.exit(0) # Update SSH params from args if args.ssh_host: Host = args.ssh_host if args.ssh_port: Port = args.ssh_port if args.ssh_user: User = args.ssh_user if args.ssh_password: Password = args.ssh_password if args.ssh_key_filename: KeyFileName = args.ssh_key_filename name_or_bundleid = args.target output_ipa = args.output_ipa # Establish SSH connection before starting dump (on_message will use ssh) try: ssh = SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) ssh.connect(Host, port=Port, username=User, password=Password, key_filename=KeyFileName, timeout=20) except Exception as e: print("SSH connection failed:", e) sys.exit(1) # Prepare payload dir create_dir(PAYLOAD_PATH) session = None display_name = None bundle_identifier = None # If PID provided, directly attach to that PID if args.pid: pid = args.pid try: print("Attaching to PID %d ..." % pid) session = device.attach(pid) display_name = "pid_%d" % pid bundle_identifier = None print("Attached to PID %d" % pid) except Exception as e: print("Failed to attach to PID %d: %s" % (pid, e)) ssh.close() sys.exit(1) else: # fallback to name/bundle-based attach (spawn/attach) (session, display_name, bundle_identifier) = open_target_app(device, name_or_bundleid) if output_ipa is None: output_ipa = display_name or (name_or_bundleid if name_or_bundleid else"dumped_app") output_ipa = re.sub(r'\.ipa$', '', output_ipa) if session: try: start_dump(session, output_ipa) except Exception as e: print("Dump failed:", e) traceback.print_exc() else: print("No session created. Aborting.") ssh.close() if os.path.exists(PAYLOAD_PATH): # cleanup def on_rm_error(func, p, exc): try: os.chmod(p, stat.S_IWRITE) func(p) except Exception: pass shutil.rmtree(PAYLOAD_PATH, onerror=on_rm_error) sys.exit(1) # Cleanup SSH and payload if ssh: ssh.close() if os.path.exists(PAYLOAD_PATH): def on_rm_error(func, p, exc): try: os.chmod(p, stat.S_IWRITE) func(p) except Exception: pass shutil.rmtree(PAYLOAD_PATH, onerror=on_rm_error)if __name__ == '__main__': main()
使用方法
脚本修改后,搭配 Frida 14.2.10 使用。操作流程如下:
- 在iOS设备上启动Frida服务器(
frida-server),并确保其监听在某个网络端口(例如1234)。
- 确保iOS设备与运行脚本的电脑在同一网络,并能通过SSH连接(默认用户
root,密码alpine)。
- 在命令行中执行修改后的脚本,指定Frida服务器地址、SSH连接参数以及目标进程的PID或应用名称。
例如,通过目标App的进程PID进行脱壳的命令如下:
python dump.py -H 192.168.148.224 -p 22 -R 192.168.148.224:1234 --pid 应用进程PID -u root -P alpine -o 输出文件名
执行成功后,会在当前Windows工作目录下生成脱壳后的IPA文件,其中即包含解密后的Mach-O可执行文件。
除了新增的-R和--pid参数,其他命令用法(如通过Bundle ID脱壳、列出应用等)均与原始的frida-ios-dump项目保持一致。这个基于Python的改造方案,有效解决了特定环境下USB连接的限制,提升了逆向工程工具的适用性和灵活性。