找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1093

积分

0

好友

159

主题
发表于 前天 02:04 | 查看: 5| 回复: 0

背景与问题

在实际进行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 使用。操作流程如下:

  1. 在iOS设备上启动Frida服务器(frida-server),并确保其监听在某个网络端口(例如1234)。
  2. 确保iOS设备与运行脚本的电脑在同一网络,并能通过SSH连接(默认用户root,密码alpine)。
  3. 在命令行中执行修改后的脚本,指定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连接的限制,提升了逆向工程工具的适用性和灵活性。




上一篇:Qt4到Qt5迁移实战:启用废弃API解决QHeaderView兼容性问题
下一篇:Nginx反向代理性能优化实践:百万并发下HTTP协议处理与缓存配置
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-17 13:11 , Processed in 0.130360 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表