├── ipadumper ├── __init__.py ├── __main__.py ├── appstore_images │ ├── dark │ │ ├── cloud.png │ │ ├── de │ │ │ ├── get.png │ │ │ ├── install.png │ │ │ └── dissallow.png │ │ └── en │ │ │ ├── get.png │ │ │ ├── install.png │ │ │ └── dissallow.png │ └── light │ │ ├── cloud.png │ │ ├── de │ │ ├── get.png │ │ ├── dissallow.png │ │ └── install.png │ │ └── en │ │ ├── get.png │ │ ├── dissallow.png │ │ └── install.png ├── controller.py ├── utils.py ├── main.py ├── dump.js └── appledl.py ├── MANIFEST.in ├── setup.py ├── .gitignore ├── pyproject.toml ├── .gitlab-ci.yml ├── setup.cfg ├── LICENSE.md └── README.md /ipadumper/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include ipadumper * 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.log 3 | __pycache__/ 4 | build/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /ipadumper/__main__.py: -------------------------------------------------------------------------------- 1 | from ipadumper.main import main 2 | 3 | if __name__ == '__main__': 4 | main() 5 | -------------------------------------------------------------------------------- /ipadumper/appstore_images/dark/cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/dark/cloud.png -------------------------------------------------------------------------------- /ipadumper/appstore_images/dark/de/get.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/dark/de/get.png -------------------------------------------------------------------------------- /ipadumper/appstore_images/dark/en/get.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/dark/en/get.png -------------------------------------------------------------------------------- /ipadumper/appstore_images/light/cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/light/cloud.png -------------------------------------------------------------------------------- /ipadumper/appstore_images/light/de/get.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/light/de/get.png -------------------------------------------------------------------------------- /ipadumper/appstore_images/light/en/get.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/light/en/get.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = [ 4 | "setuptools>=42", 5 | "wheel", 6 | ] 7 | -------------------------------------------------------------------------------- /ipadumper/appstore_images/dark/de/install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/dark/de/install.png -------------------------------------------------------------------------------- /ipadumper/appstore_images/dark/en/install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/dark/en/install.png -------------------------------------------------------------------------------- /ipadumper/appstore_images/dark/de/dissallow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/dark/de/dissallow.png -------------------------------------------------------------------------------- /ipadumper/appstore_images/dark/en/dissallow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/dark/en/dissallow.png -------------------------------------------------------------------------------- /ipadumper/appstore_images/light/de/dissallow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/light/de/dissallow.png -------------------------------------------------------------------------------- /ipadumper/appstore_images/light/de/install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/light/de/install.png -------------------------------------------------------------------------------- /ipadumper/appstore_images/light/en/dissallow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/light/en/dissallow.png -------------------------------------------------------------------------------- /ipadumper/appstore_images/light/en/install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marzzzello/ipa-dumper/HEAD/ipadumper/appstore_images/light/en/install.png -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - release 3 | 4 | pypi_upload: 5 | stage: release 6 | image: python:latest 7 | script: 8 | - pip install twine 9 | - python setup.py sdist bdist_wheel 10 | - python -m twine upload dist/* 11 | only: 12 | - tags 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = ipadumper 3 | version = 0.0.14 4 | author = marzzzello 5 | author_email = ipa_dumper@07f.de 6 | description = Automatically install apps on a jailbroken device iOS device and generate decrypted IPAs 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://gitlab.com/marzzzello/ipa-dumper 10 | project_urls = 11 | Bug Tracker = https://gitlab.com/marzzzello/ipa-dumper/issues 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | License :: OSI Approved :: MIT License 15 | 16 | [options] 17 | packages = ipadumper 18 | python_requires = >=3.7 19 | include_package_data = True 20 | install_requires = 21 | cachetools 22 | coloredlogs 23 | commentjson 24 | frida 25 | paramiko 26 | scp 27 | tqdm 28 | zxtouch 29 | 30 | [options.entry_points] 31 | console_scripts = 32 | ipadumper = ipadumper.main:main 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) marzzzello 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | 11 | -------------------------------------------------------------------------------- /ipadumper/controller.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import commentjson 4 | 5 | import ipadumper 6 | from ipadumper.appledl import AppleDL 7 | from ipadumper.utils import itunes_info, get_logger 8 | 9 | 10 | class MultiDevice: 11 | ''' 12 | Mass downloading and dumping with multiple devices 13 | ''' 14 | 15 | def __init__(self, config_file, itunes_ids_file, log_level='info'): 16 | self.log_level = log_level 17 | self.log = get_logger(log_level, name=__name__) 18 | 19 | try: 20 | with open(config_file) as f: 21 | self.config = commentjson.load(f) 22 | with open(itunes_ids_file) as f: 23 | self.itunes_ids = f 24 | except FileNotFoundError: 25 | self.log.error(f'File {config_file} not found') 26 | return 27 | 28 | default = self.config['default'] 29 | 30 | devices = [] 31 | for device in self.config['devices']: 32 | for key in default: 33 | device[key] = device.get(key, default[key]) 34 | devices.append(device) 35 | 36 | self.log.debug(commentjson.dumps(devices, indent=2)) 37 | 38 | countries = set() 39 | for device in devices: 40 | try: 41 | name = device['name'] 42 | udid = device['udid'] 43 | address = device['address'] 44 | local_ssh_port = device['local_ssh_port'] 45 | ssh_key_filename = device['ssh_key_filename'] 46 | local_zxtouch_port = device['local_zxtouch_port'] 47 | image_base_path_device = device['image_base_path_device'] 48 | image_base_path_local = device['image_base_path_local'] 49 | theme = device['theme'] 50 | lang = device['lang'] 51 | timeout = device['timeout'] 52 | log_level = device['log_level'] 53 | 54 | country = device['country'] 55 | parallel = device['parallel'] 56 | timeout_per_MiB = device['timeout_per_MiB'] 57 | output_directory = device['output_directory'] 58 | except KeyError as e: 59 | self.log.error(f'Config entry {str(e)} is missing') 60 | return 61 | 62 | countries.add(country) 63 | 64 | if image_base_path_local == '': 65 | image_base_path_local = os.path.join(os.path.dirname(ipadumper.__file__), 'appstore_images') 66 | 67 | if udid == '' and len(devices) > 1: 68 | self.log.error('Please specify UDID when multiple devices are used') 69 | return 70 | 71 | self.log.warning('Not implemented') 72 | return 73 | # TODO 74 | 75 | self.log.info(f'Initialising device {name}...') 76 | AppleDL( 77 | udid=udid, 78 | device_address=address, 79 | local_ssh_port=local_ssh_port, 80 | ssh_key_filename=ssh_key_filename, 81 | local_zxtouch_port=local_zxtouch_port, 82 | image_base_path_device=image_base_path_device, 83 | image_base_path_local=image_base_path_local, 84 | theme=theme, 85 | lang=lang, 86 | timeout=timeout, 87 | log_level=log_level, 88 | ) 89 | for itunes_id in itunes_ids: 90 | info 91 | for country in countries: 92 | itunes_info(intunes_id, country) 93 | -------------------------------------------------------------------------------- /ipadumper/utils.py: -------------------------------------------------------------------------------- 1 | # stdlib 2 | from datetime import datetime 3 | import logging 4 | import os 5 | import requests 6 | import socket 7 | 8 | # external 9 | import coloredlogs # colored logs 10 | 11 | 12 | def itunes_info(itunes_id, log_level='info', country='us'): 13 | ''' 14 | return: trackName, trackId, version, bundleId, fileSizeMiB, price, currency 15 | ''' 16 | log = get_logger(log_level, name=__name__) 17 | log.debug('Get app info from itunes.apple.com') 18 | url = f'https://itunes.apple.com/{country}/search?limit=200&term={str(itunes_id)}&media=software' 19 | j = requests.get(url).json() 20 | if j['resultCount'] == 0: 21 | log.error('no result with that itunes id found') 22 | return 23 | 24 | if j['resultCount'] > 1: 25 | log.warning('multiple results with that itunes id found') 26 | result = j['results'][0] 27 | trackName = result['trackName'] 28 | trackId = result['trackId'] 29 | version = result['version'] 30 | bundleId = result['bundleId'] 31 | fileSizeMiB = int(result['fileSizeBytes']) // (2 ** 20) 32 | price = result['price'] 33 | currency = result['currency'] 34 | 35 | log.debug( 36 | f'Name: {trackName}, trackId: {trackId}, version: {version}, bundleId: {bundleId}, size: {fileSizeMiB}MiB' 37 | ) 38 | if trackId != itunes_id: 39 | log.warning(f'trackId ({trackId}) != itunes_id ({itunes_id})') 40 | 41 | return trackName, version, bundleId, fileSizeMiB, price, currency 42 | 43 | 44 | def get_logger(log_level, name=__name__): 45 | ''' 46 | Colored logging 47 | 48 | :param log_level: 'warning', 'info', 'debug' 49 | :param name: logger name (use __name__ variable) 50 | :return: Logger 51 | ''' 52 | 53 | fmt = '%(asctime)s %(threadName)-16s %(levelname)-8s %(message)s' 54 | datefmt = '%Y-%m-%d %H:%M:%S' 55 | 56 | fs = { 57 | 'asctime': {'color': 'green'}, 58 | 'hostname': {'color': 'magenta'}, 59 | 'levelname': {'color': 'red', 'bold': True}, 60 | 'name': {'color': 'magenta'}, 61 | 'programname': {'color': 'cyan'}, 62 | 'username': {'color': 'yellow'}, 63 | } 64 | 65 | ls = { 66 | 'critical': {'color': 'red', 'bold': True}, 67 | 'debug': {'color': 'green'}, 68 | 'error': {'color': 'red'}, 69 | 'info': {}, 70 | 'notice': {'color': 'magenta'}, 71 | 'spam': {'color': 'green', 'faint': True}, 72 | 'success': {'color': 'green', 'bold': True}, 73 | 'verbose': {'color': 'blue'}, 74 | 'warning': {'color': 'yellow'}, 75 | } 76 | 77 | logger = logging.getLogger(name) 78 | 79 | # log to file 80 | now_str = datetime.now().strftime('%F_%T') 81 | handler = logging.FileHandler(f'{now_str}.log') 82 | formatter = logging.Formatter(fmt, datefmt) 83 | handler.setFormatter(formatter) 84 | logger.addHandler(handler) 85 | 86 | # logger.propagate = False # no logging of libs 87 | coloredlogs.install(level=log_level, logger=logger, fmt=fmt, datefmt=datefmt, level_styles=ls, field_styles=fs) 88 | return logger 89 | 90 | 91 | def progress_helper(t): 92 | ''' 93 | returns progress function 94 | ''' 95 | last_sent = [0] 96 | 97 | def progress(filename, size, sent): 98 | if isinstance(filename, bytes): 99 | filename = filename.decode('utf-8') 100 | t.desc = os.path.basename(filename) 101 | t.total = size 102 | displayed = t.update(sent - last_sent[0]) 103 | last_sent[0] = 0 if size == sent else sent 104 | return displayed 105 | 106 | return progress 107 | 108 | 109 | def free_port(): 110 | ''' 111 | Determines a free port using sockets. 112 | ''' 113 | free_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 114 | free_socket.bind(('0.0.0.0', 0)) 115 | free_socket.listen(5) 116 | port = free_socket.getsockname()[1] 117 | free_socket.close() 118 | return port 119 | -------------------------------------------------------------------------------- /ipadumper/main.py: -------------------------------------------------------------------------------- 1 | # stdlib 2 | from argparse import ArgumentParser, HelpFormatter 3 | from importlib.metadata import metadata 4 | from os import path 5 | 6 | # internal 7 | import ipadumper 8 | from ipadumper.appledl import AppleDL 9 | from ipadumper.utils import itunes_info 10 | from ipadumper.controller import MultiDevice 11 | 12 | 13 | class F(HelpFormatter): 14 | def __init__(self, *args, **kwargs): 15 | kwargs['max_help_position'] = 30 16 | super().__init__(*args, **kwargs) 17 | 18 | 19 | def main(): 20 | parser = ArgumentParser(description=metadata(__package__)['Summary']) 21 | parser.add_argument( 22 | '-v', 23 | '--verbosity', 24 | help='Set verbosity level (default: %(default)s)', 25 | choices=['warning', 'info', 'debug'], 26 | default='info', 27 | ) 28 | subparsers = parser.add_subparsers(help='Desired action to perform', dest='command') 29 | 30 | # help 31 | subparsers.add_parser('help', help='Print this help message') 32 | 33 | # usage 34 | subparsers.add_parser('usage', help='Print full usage') 35 | 36 | # itunes_info 37 | d = 'Downloads info about app from iTunes site' 38 | parser_itunes_info = subparsers.add_parser('itunes_info', help=d, description=d) 39 | parser_itunes_info.add_argument('itunes_id', help='iTunes ID', type=int) 40 | parser_itunes_info.add_argument('--country', help='Two letter country code (default: %(default)s)', default='us') 41 | 42 | # multi dump 43 | d = 'Download, install,dump and uninstall apps using multiple devices in parallel' 44 | parser_itunes_info = subparsers.add_parser('multidump', help=d, description=d) 45 | parser_itunes_info.add_argument('config_file', help='config file', default='config.json', metavar='PATH') 46 | 47 | # Create parent subparser for with common arguments 48 | parent_parser = ArgumentParser(add_help=False, formatter_class=F) 49 | parent_parser.add_argument( 50 | '--device_address', help='device address (default: %(default)s)', default='localhost', metavar='HOSTNAME' 51 | ) 52 | parent_parser.add_argument( 53 | '--local_ssh_port', 54 | help='local port to be forwarded to SSH on the device. 0 means random free port (default: %(default)s)', 55 | default=0, 56 | type=int, 57 | metavar='PORT', 58 | ) 59 | parent_parser.add_argument( 60 | '--local_zxtouch_port', 61 | help='local port to be forwarded to ZXTouch on the device. 0 means random free port (default: %(default)s)', 62 | default=0, 63 | type=int, 64 | metavar='PORT', 65 | ) 66 | parent_parser.add_argument( 67 | '--ssh_key', help='Path to ssh keyfile (default: %(default)s)', default='iphone', metavar='PATH' 68 | ) 69 | imagedir = path.join(path.dirname(ipadumper.__file__), 'appstore_images') 70 | parent_parser.add_argument( 71 | '--imagedir', help='Path to appstore images (default: %(default)s)', default=imagedir, metavar='PATH' 72 | ) 73 | parent_parser.add_argument('--theme', help='Theme of device dark/light (default: %(default)s)', default='dark') 74 | parent_parser.add_argument('--lang', help='Language of device (2 letter code) (default: %(default)s)', default='en') 75 | parent_parser.add_argument( 76 | '--udid', help='UDID (Unique Device Identifier) of device (default: %(default)s)', default=None, metavar='UDID' 77 | ) 78 | parent_parser.add_argument( 79 | '--base_timeout', 80 | help='Base timeout for various things (default: %(default)s)', 81 | type=float, 82 | default=15, 83 | metavar='SECONDS', 84 | ) 85 | 86 | # Subparsers based on parent 87 | 88 | # bulk_decrypt 89 | d = 'Installs apps, decrypts and uninstalls them' 90 | parser_bulk_decrypt = subparsers.add_parser( 91 | 'bulk_decrypt', parents=[parent_parser], help=d, description=d, formatter_class=F 92 | ) 93 | parser_bulk_decrypt.add_argument('itunes_ids', help='File containing lines with iTunes IDs') 94 | parser_bulk_decrypt.add_argument('output', help='Output directory') 95 | parser_bulk_decrypt.add_argument( 96 | '--parallel', help='How many apps get installed in parallel (default: %(default)s)', type=int, default=3 97 | ) 98 | parser_bulk_decrypt.add_argument( 99 | '--timeout_per_MiB', help='Timeout per MiB (default: %(default)s)', type=float, default=0.5, metavar='SECONDS' 100 | ) 101 | parser_bulk_decrypt.add_argument('--country', help='Two letter country code (default: %(default)s)', default='us') 102 | 103 | # dump 104 | d = 'Decrypt app binary und dump IPA' 105 | parser_dump = subparsers.add_parser('dump', parents=[parent_parser], help=d, description=d, formatter_class=F) 106 | parser_dump.add_argument('bundleID', help='Bundle ID from app like com.app.name') 107 | parser_dump.add_argument('output', help='Output filename', metavar='PATH') 108 | parser_dump.add_argument( 109 | '--frida', help='Use Frida instead of FoulDecrypt (default: %(default)s)', action='store_true' 110 | ) 111 | parser_dump.add_argument( 112 | '--nocopy', 113 | help='FoulDecrypt: decrypt and package inplace without copying ' 114 | + '(faster but app is broken afterwards) (default: %(default)s)', 115 | action='store_true', 116 | ) 117 | parser_dump.add_argument( 118 | '--timeout', 119 | help='Dump timeout (default: %(default)s)', 120 | type=float, 121 | default=120, 122 | metavar='SECONDS', 123 | ) 124 | # ssh_cmd 125 | d = 'Execute ssh command on device' 126 | parser_ssh_cmd = subparsers.add_parser('ssh_cmd', parents=[parent_parser], help=d, description=d, formatter_class=F) 127 | parser_ssh_cmd.add_argument('cmd', help='command') 128 | 129 | # install 130 | d = 'Opens app in appstore on device and simulates touch input to download and installs the app' 131 | parser_install = subparsers.add_parser('install', parents=[parent_parser], help=d, description=d, formatter_class=F) 132 | parser_install.add_argument('itunes_id', help='iTunes ID', type=int) 133 | 134 | args = parser.parse_args() 135 | # print(vars(args)) 136 | 137 | if args.command == 'help' or args.command is None: 138 | parser.print_help() 139 | exit() 140 | 141 | if args.command == 'usage': 142 | parser.print_help() 143 | print('\n\nAll commands in detail:\nitunes_info:') 144 | parser_itunes_info.print_help() 145 | 146 | parentsubparsers = [parser_bulk_decrypt, parser_dump, parser_ssh_cmd, parser_install] 147 | commonargs = ['-h, --help', '--device_address', '--local_ssh_port', '--ssh_key', '--imagedir', '--base_timeout'] 148 | parentsubparsers_str = [] 149 | for p in parentsubparsers: 150 | parentsubparsers_str.append(p.prog.split(' ')[1]) 151 | 152 | print(f'\n\nCommon optional arguments for {", ".join(parentsubparsers_str)}:') 153 | print('\n'.join(parent_parser.format_help().splitlines()[4:])) 154 | match = f'(default: {imagedir})' 155 | for p, p_str in zip(parentsubparsers, parentsubparsers_str): 156 | h = p.format_help() 157 | hn = '' 158 | for line in h.splitlines(): 159 | add = True 160 | for arg in commonargs: 161 | if line.lstrip().startswith(arg) or (line.lstrip() != '' and line.lstrip() in match): 162 | add = False 163 | if add: 164 | hn += line + '\n' 165 | hn = hn.rstrip('optional arguments:\n') 166 | print(f"\n\n{p_str}:\n{hn}") 167 | exit() 168 | exitcode = 0 169 | if args.command == 'itunes_info': 170 | itunes_info(args.itunes_id, log_level='debug', country=args.country) 171 | elif args.command == 'multidump': 172 | MultiDevice(args.config_file, log_level=args.verbosity) 173 | else: 174 | a = AppleDL( 175 | udid=args.udid, 176 | device_address=args.device_address, 177 | ssh_key_filename=args.ssh_key, 178 | local_ssh_port=args.local_ssh_port, 179 | local_zxtouch_port=args.local_zxtouch_port, 180 | image_base_path_local=args.imagedir, 181 | theme=args.theme, 182 | lang=args.lang, 183 | timeout=args.base_timeout, 184 | log_level=args.verbosity, 185 | init=False, 186 | ) 187 | if not a.running: 188 | exit(1) 189 | if args.command == 'bulk_decrypt': 190 | if a.init_all(): 191 | with open(args.itunes_ids) as fp: 192 | itunes_ids = fp.read().splitlines() 193 | itunes_ids = [int(i) for i in itunes_ids] 194 | 195 | a.bulk_decrypt( 196 | itunes_ids, 197 | timeout_per_MiB=args.timeout_per_MiB, 198 | parallel=args.parallel, 199 | output_directory=args.output, 200 | ) 201 | elif args.command == 'dump': 202 | if args.frida: 203 | exitcode = a.dump_frida(args.bundleID, args.output, args.timeout) 204 | else: 205 | exitcode = a.dump_fouldecrypt(args.bundleID, args.output, args.timeout, copy=not args.nocopy) 206 | elif args.command == 'ssh_cmd': 207 | exitcode, stdout, stderr = a.ssh_cmd(args.cmd) 208 | print(stdout) 209 | print(stderr) 210 | elif args.command == 'install': 211 | exitcode = a.install(args.itunes_id) 212 | 213 | a.cleanup() 214 | 215 | exit(exitcode) 216 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://forthebadge.com/images/badges/built-with-love.svg) 2 | ![](https://forthebadge.com/images/badges/fuck-it-ship-it.svg) 3 | ![](https://forthebadge.com/images/badges/contains-Cat-GIFs.svg) 4 | 5 | [![Repo on GitLab](https://img.shields.io/badge/repo-GitLab-fc6d26.svg?style=for-the-badge&logo=gitlab)](https://gitlab.com/marzzzello/ipa-dumper) 6 | [![Repo on GitHub](https://img.shields.io/badge/repo-GitHub-4078c0.svg?style=for-the-badge&logo=github)](https://github.com/marzzzello/ipa-dumper) 7 | [![license](https://img.shields.io/github/license/marzzzello/ipa-dumper.svg?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIHN0eWxlPSJmaWxsOiNkZGRkZGQiIGQ9Ik03IDRjLS44MyAwLTEuNS0uNjctMS41LTEuNVM2LjE3IDEgNyAxczEuNS42NyAxLjUgMS41UzcuODMgNCA3IDR6bTcgNmMwIDEuMTEtLjg5IDItMiAyaC0xYy0xLjExIDAtMi0uODktMi0ybDItNGgtMWMtLjU1IDAtMS0uNDUtMS0xSDh2OGMuNDIgMCAxIC40NSAxIDFoMWMuNDIgMCAxIC40NSAxIDFIM2MwLS41NS41OC0xIDEtMWgxYzAtLjU1LjU4LTEgMS0xaC4wM0w2IDVINWMwIC41NS0uNDUgMS0xIDFIM2wyIDRjMCAxLjExLS44OSAyLTIgMkgyYy0xLjExIDAtMi0uODktMi0ybDItNEgxVjVoM2MwLS41NS40NS0xIDEtMWg0Yy41NSAwIDEgLjQ1IDEgMWgzdjFoLTFsMiA0ek0yLjUgN0wxIDEwaDNMMi41IDd6TTEzIDEwbC0xLjUtMy0xLjUgM2gzeiIvPjwvc3ZnPgo=)](LICENSE.md) 8 | [![commit-activity](https://img.shields.io/github/commit-activity/m/marzzzello/ipa-dumper.svg?style=for-the-badge)](https://img.shields.io/github/commit-activity/m/marzzzello/ipa-dumper.svg?style=for-the-badge) 9 | [![Mastodon Follow](https://img.shields.io/mastodon/follow/103207?domain=https%3A%2F%2Fsocial.tchncs.de&logo=mastodon&style=for-the-badge)](https://social.tchncs.de/@marzzzello) 10 | 11 | # ipa-dumper 12 | 13 | Automatically install apps on a jailbroken device iOS device and generate decrypted IPAs 14 | 15 | ## Requirements 16 | 17 | - Linux/macOS device (tested on Arch Linux and macOS 12) with Python 3.7+ 18 | - Jailbroken iOS device (tested on [iPhone 6s, iOS 14.2, iPhone 6, iOS 12.5.4 and iPhone Xʀ iOS 14.5]) 19 | 20 | ## Setup 21 | 22 | ### iOS device 23 | 24 | - Set device language to English or German or **alternativly** make a folder with images of the buttons of your language and theme. Take the existing folder as an [example](https://gitlab.com/marzzzello/ipa-dumper/-/tree/master/ipadumper/appstore_images) and use the `--imagedir` argument. 25 | - Disable password prompt for installing free apps under settings (Apple account -> Media & Purchases -> Password Settings) 26 | - Connect the device to your computer and make sure to accept the trust dialog 27 | - Install the following packages from Cydia: 28 | - OpenSSH 29 | - Open for iOS 11 30 | - Frida from https://build.frida.re 31 | - FoulDecrypt from https://repo.misty.moe/apt 32 | - NoAppThinning from https://n3d1117.github.io 33 | - ZXTouch from https://zxtouch.net 34 | 35 | ### Linux/macOS device 36 | 37 | - connect to iOS device via USB 38 | - Setup OpenSSH (needs to work with keyfile): 39 | 40 | - run `ssh-keygen -t ed25519 -f iphone` (don't use a passphrase) 41 | - run `iproxy 22222 22` (Run this background/another terminal session) 42 | - run `ssh-copy-id -p 22222 -i iphone root@localhost` (default password is `alpine`) 43 | 44 | - Install [ideviceinstaller](https://github.com/libimobiledevice/ideviceinstaller) (this should also install iproxy/libusbmuxd as requirement) 45 | - On macOS install using brew `brew install libusbmuxd` and `brew install libimobiledevice` 46 | - Install ipadumper with `pip install ipadumper` 47 | - Run `ipadumper help` 48 | 49 | ## Usage 50 | 51 | ``` 52 | usage: ipadumper [-h] [-v {warning,info,debug}] 53 | {help,usage,itunes_info,bulk_decrypt,dump,ssh_cmd,install} 54 | ... 55 | 56 | Automatically install apps on a jailbroken device iOS device and generate 57 | decrypted IPAs 58 | 59 | positional arguments: 60 | {help,usage,itunes_info,bulk_decrypt,dump,ssh_cmd,install} 61 | Desired action to perform 62 | help Print this help message 63 | usage Print full usage 64 | itunes_info Downloads info about app from iTunes site 65 | bulk_decrypt Installs apps, decrypts and uninstalls them 66 | dump Decrypt app binary und dump IPA 67 | ssh_cmd Execute ssh command on device 68 | install Opens app in appstore on device and simulates touch 69 | input to download and installs the app 70 | 71 | optional arguments: 72 | -h, --help show this help message and exit 73 | -v {warning,info,debug}, --verbosity {warning,info,debug} 74 | Set verbosity level (default: info) 75 | 76 | 77 | All commands in detail: 78 | itunes_info: 79 | usage: ipadumper itunes_info [-h] [--country COUNTRY] itunes_id 80 | 81 | Downloads info about app from iTunes site 82 | 83 | positional arguments: 84 | itunes_id iTunes ID 85 | 86 | optional arguments: 87 | -h, --help show this help message and exit 88 | --country COUNTRY Two letter country code (default: us) 89 | 90 | 91 | Common optional arguments for bulk_decrypt, dump, ssh_cmd, install: 92 | optional arguments: 93 | --device_address HOSTNAME device address (default: localhost) 94 | --device_port PORT device port (default: 22222) 95 | --ssh_key PATH Path to ssh keyfile (default: iphone) 96 | --imagedir PATH Path to appstore images (default: 97 | $HOME/.local/lib/python3.9/site- 98 | packages/ipadumper/appstore_images) 99 | --theme THEME Theme of device dark/light (default: dark) 100 | --lang LANG Language of device (2 letter code) (default: en) 101 | --udid UDID UDID (Unique Device Identifier) of device 102 | (default: None) 103 | --base_timeout SECONDS Base timeout for various things (default: 15) 104 | 105 | 106 | bulk_decrypt: 107 | usage: ipadumper bulk_decrypt [-h] [--device_address HOSTNAME] 108 | [--device_port PORT] [--ssh_key PATH] 109 | [--imagedir PATH] [--theme THEME] [--lang LANG] 110 | [--udid UDID] [--base_timeout SECONDS] 111 | [--parallel PARALLEL] 112 | [--timeout_per_MiB SECONDS] [--country COUNTRY] 113 | itunes_ids output 114 | 115 | Installs apps, decrypts and uninstalls them 116 | 117 | positional arguments: 118 | itunes_ids File containing lines with iTunes IDs 119 | output Output directory 120 | 121 | optional arguments: 122 | --theme THEME Theme of device dark/light (default: dark) 123 | --lang LANG Language of device (2 letter code) (default: en) 124 | --udid UDID UDID (Unique Device Identifier) of device 125 | (default: None) 126 | --parallel PARALLEL How many apps get installed in parallel (default: 127 | 3) 128 | --timeout_per_MiB SECONDS Timeout per MiB (default: 0.5) 129 | --country COUNTRY Two letter country code (default: us) 130 | 131 | 132 | dump: 133 | usage: ipadumper dump [-h] [--device_address HOSTNAME] [--device_port PORT] 134 | [--ssh_key PATH] [--imagedir PATH] [--theme THEME] 135 | [--lang LANG] [--udid UDID] [--base_timeout SECONDS] 136 | [--frida] [--timeout SECONDS] 137 | bundleID PATH 138 | 139 | Decrypt app binary und dump IPA 140 | 141 | positional arguments: 142 | bundleID Bundle ID from app like com.app.name 143 | PATH Output filename 144 | 145 | optional arguments: 146 | --theme THEME Theme of device dark/light (default: dark) 147 | --lang LANG Language of device (2 letter code) (default: en) 148 | --udid UDID UDID (Unique Device Identifier) of device 149 | (default: None) 150 | --frida Use Frida instead of FoulDecrypt (default: False) 151 | --timeout SECONDS Dump timeout (default: 120) 152 | 153 | 154 | ssh_cmd: 155 | usage: ipadumper ssh_cmd [-h] [--device_address HOSTNAME] [--device_port PORT] 156 | [--ssh_key PATH] [--imagedir PATH] [--theme THEME] 157 | [--lang LANG] [--udid UDID] [--base_timeout SECONDS] 158 | cmd 159 | 160 | Execute ssh command on device 161 | 162 | positional arguments: 163 | cmd command 164 | 165 | optional arguments: 166 | --theme THEME Theme of device dark/light (default: dark) 167 | --lang LANG Language of device (2 letter code) (default: en) 168 | --udid UDID UDID (Unique Device Identifier) of device 169 | (default: None) 170 | 171 | 172 | install: 173 | usage: ipadumper install [-h] [--device_address HOSTNAME] [--device_port PORT] 174 | [--ssh_key PATH] [--imagedir PATH] [--theme THEME] 175 | [--lang LANG] [--udid UDID] [--base_timeout SECONDS] 176 | itunes_id 177 | 178 | Opens app in appstore on device and simulates touch input to download and 179 | installs the app 180 | 181 | positional arguments: 182 | itunes_id iTunes ID 183 | 184 | optional arguments: 185 | --theme THEME Theme of device dark/light (default: dark) 186 | --lang LANG Language of device (2 letter code) (default: en) 187 | --udid UDID UDID (Unique Device Identifier) of device 188 | (default: None) 189 | ``` 190 | -------------------------------------------------------------------------------- /ipadumper/dump.js: -------------------------------------------------------------------------------- 1 | /* 2 | Original from https://github.com/AloneMonkey/frida-ios-dump/blob/f606152240ef0b284f9367395823c0e0eaa2a7ee/dump.js 3 | Changes: 4 | - replace console.log() with send() 5 | 6 | MIT License 7 | 8 | Copyright (c) 2017 Alone_Monkey 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. 27 | */ 28 | 29 | Module.ensureInitialized('Foundation'); 30 | 31 | var O_RDONLY = 0; 32 | var O_WRONLY = 1; 33 | var O_RDWR = 2; 34 | var O_CREAT = 512; 35 | 36 | var SEEK_SET = 0; 37 | var SEEK_CUR = 1; 38 | var SEEK_END = 2; 39 | 40 | function allocStr(str) { 41 | return Memory.allocUtf8String(str); 42 | } 43 | 44 | function putStr(addr, str) { 45 | if (typeof addr == "number") { 46 | addr = ptr(addr); 47 | } 48 | return Memory.writeUtf8String(addr, str); 49 | } 50 | 51 | function getByteArr(addr, l) { 52 | if (typeof addr == "number") { 53 | addr = ptr(addr); 54 | } 55 | return Memory.readByteArray(addr, l); 56 | } 57 | 58 | function getU8(addr) { 59 | if (typeof addr == "number") { 60 | addr = ptr(addr); 61 | } 62 | return Memory.readU8(addr); 63 | } 64 | 65 | function putU8(addr, n) { 66 | if (typeof addr == "number") { 67 | addr = ptr(addr); 68 | } 69 | return Memory.writeU8(addr, n); 70 | } 71 | 72 | function getU16(addr) { 73 | if (typeof addr == "number") { 74 | addr = ptr(addr); 75 | } 76 | return Memory.readU16(addr); 77 | } 78 | 79 | function putU16(addr, n) { 80 | if (typeof addr == "number") { 81 | addr = ptr(addr); 82 | } 83 | return Memory.writeU16(addr, n); 84 | } 85 | 86 | function getU32(addr) { 87 | if (typeof addr == "number") { 88 | addr = ptr(addr); 89 | } 90 | return Memory.readU32(addr); 91 | } 92 | 93 | function putU32(addr, n) { 94 | if (typeof addr == "number") { 95 | addr = ptr(addr); 96 | } 97 | return Memory.writeU32(addr, n); 98 | } 99 | 100 | function getU64(addr) { 101 | if (typeof addr == "number") { 102 | addr = ptr(addr); 103 | } 104 | return Memory.readU64(addr); 105 | } 106 | 107 | function putU64(addr, n) { 108 | if (typeof addr == "number") { 109 | addr = ptr(addr); 110 | } 111 | return Memory.writeU64(addr, n); 112 | } 113 | 114 | function getPt(addr) { 115 | if (typeof addr == "number") { 116 | addr = ptr(addr); 117 | } 118 | return Memory.readPointer(addr); 119 | } 120 | 121 | function putPt(addr, n) { 122 | if (typeof addr == "number") { 123 | addr = ptr(addr); 124 | } 125 | if (typeof n == "number") { 126 | n = ptr(n); 127 | } 128 | return Memory.writePointer(addr, n); 129 | } 130 | 131 | function malloc(size) { 132 | return Memory.alloc(size); 133 | } 134 | 135 | function getExportFunction(type, name, ret, args) { 136 | var nptr; 137 | nptr = Module.findExportByName(null, name); 138 | if (nptr === null) { 139 | send({ warn: "cannot find " + name }); 140 | return null; 141 | } else { 142 | if (type === "f") { 143 | var funclet = new NativeFunction(nptr, ret, args); 144 | if (typeof funclet === "undefined") { 145 | send({ warn: "parse error " + name }); 146 | return null; 147 | } 148 | return funclet; 149 | } else if (type === "d") { 150 | var datalet = Memory.readPointer(nptr); 151 | if (typeof datalet === "undefined") { 152 | send({ warn: "parse error " + name }); 153 | return null; 154 | } 155 | return datalet; 156 | } 157 | } 158 | } 159 | 160 | var NSSearchPathForDirectoriesInDomains = getExportFunction("f", "NSSearchPathForDirectoriesInDomains", "pointer", ["int", "int", "int"]); 161 | var wrapper_open = getExportFunction("f", "open", "int", ["pointer", "int", "int"]); 162 | var read = getExportFunction("f", "read", "int", ["int", "pointer", "int"]); 163 | var write = getExportFunction("f", "write", "int", ["int", "pointer", "int"]); 164 | var lseek = getExportFunction("f", "lseek", "int64", ["int", "int64", "int"]); 165 | var close = getExportFunction("f", "close", "int", ["int"]); 166 | var remove = getExportFunction("f", "remove", "int", ["pointer"]); 167 | var access = getExportFunction("f", "access", "int", ["pointer", "int"]); 168 | var dlopen = getExportFunction("f", "dlopen", "pointer", ["pointer", "int"]); 169 | 170 | function getDocumentDir() { 171 | var NSDocumentDirectory = 9; 172 | var NSUserDomainMask = 1; 173 | var npdirs = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, 1); 174 | return ObjC.Object(npdirs).objectAtIndex_(0).toString(); 175 | } 176 | 177 | function open(pathname, flags, mode) { 178 | if (typeof pathname == "string") { 179 | pathname = allocStr(pathname); 180 | } 181 | return wrapper_open(pathname, flags, mode); 182 | } 183 | 184 | var modules = null; 185 | function getAllAppModules() { 186 | modules = new Array(); 187 | var tmpmods = Process.enumerateModulesSync(); 188 | for (var i = 0; i < tmpmods.length; i++) { 189 | if (tmpmods[i].path.indexOf(".app") != -1) { 190 | modules.push(tmpmods[i]); 191 | } 192 | } 193 | return modules; 194 | } 195 | 196 | var FAT_MAGIC = 0xcafebabe; 197 | var FAT_CIGAM = 0xbebafeca; 198 | var MH_MAGIC = 0xfeedface; 199 | var MH_CIGAM = 0xcefaedfe; 200 | var MH_MAGIC_64 = 0xfeedfacf; 201 | var MH_CIGAM_64 = 0xcffaedfe; 202 | var LC_SEGMENT = 0x1; 203 | var LC_SEGMENT_64 = 0x19; 204 | var LC_ENCRYPTION_INFO = 0x21; 205 | var LC_ENCRYPTION_INFO_64 = 0x2C; 206 | 207 | function pad(str, n) { 208 | return Array(n - str.length + 1).join("0") + str; 209 | } 210 | 211 | function swap32(value) { 212 | value = pad(value.toString(16), 8) 213 | var result = ""; 214 | for (var i = 0; i < value.length; i = i + 2) { 215 | result += value.charAt(value.length - i - 2); 216 | result += value.charAt(value.length - i - 1); 217 | } 218 | return parseInt(result, 16) 219 | } 220 | 221 | function dumpModule(name) { 222 | if (modules == null) { 223 | modules = getAllAppModules(); 224 | } 225 | 226 | var targetmod = null; 227 | for (var i = 0; i < modules.length; i++) { 228 | if (modules[i].path.indexOf(name) != -1) { 229 | targetmod = modules[i]; 230 | break; 231 | } 232 | } 233 | if (targetmod == null) { 234 | send({ warn: "Cannot find module" }); 235 | return; 236 | } 237 | var modbase = modules[i].base; 238 | var modsize = modules[i].size; 239 | var newmodname = modules[i].name; 240 | var newmodpath = getDocumentDir() + "/" + newmodname + ".fid"; 241 | var oldmodpath = modules[i].path; 242 | 243 | 244 | if (!access(allocStr(newmodpath), 0)) { 245 | remove(allocStr(newmodpath)); 246 | } 247 | 248 | var fmodule = open(newmodpath, O_CREAT | O_RDWR, 0); 249 | var foldmodule = open(oldmodpath, O_RDONLY, 0); 250 | 251 | if (fmodule == -1 || foldmodule == -1) { 252 | send({ warn: "Cannot open file" + newmodpath }); 253 | return; 254 | } 255 | 256 | var is64bit = false; 257 | var size_of_mach_header = 0; 258 | var magic = getU32(modbase); 259 | var cur_cpu_type = getU32(modbase.add(4)); 260 | var cur_cpu_subtype = getU32(modbase.add(8)); 261 | if (magic == MH_MAGIC || magic == MH_CIGAM) { 262 | is64bit = false; 263 | size_of_mach_header = 28; 264 | } else if (magic == MH_MAGIC_64 || magic == MH_CIGAM_64) { 265 | is64bit = true; 266 | size_of_mach_header = 32; 267 | } 268 | 269 | var BUFSIZE = 4096; 270 | var buffer = malloc(BUFSIZE); 271 | 272 | read(foldmodule, buffer, BUFSIZE); 273 | 274 | var fileoffset = 0; 275 | var filesize = 0; 276 | magic = getU32(buffer); 277 | if (magic == FAT_CIGAM || magic == FAT_MAGIC) { 278 | var off = 4; 279 | var archs = swap32(getU32(buffer.add(off))); 280 | for (var i = 0; i < archs; i++) { 281 | var cputype = swap32(getU32(buffer.add(off + 4))); 282 | var cpusubtype = swap32(getU32(buffer.add(off + 8))); 283 | if (cur_cpu_type == cputype && cur_cpu_subtype == cpusubtype) { 284 | fileoffset = swap32(getU32(buffer.add(off + 12))); 285 | filesize = swap32(getU32(buffer.add(off + 16))); 286 | break; 287 | } 288 | off += 20; 289 | } 290 | 291 | if (fileoffset == 0 || filesize == 0) 292 | return; 293 | 294 | lseek(fmodule, 0, SEEK_SET); 295 | lseek(foldmodule, fileoffset, SEEK_SET); 296 | for (var i = 0; i < parseInt(filesize / BUFSIZE); i++) { 297 | read(foldmodule, buffer, BUFSIZE); 298 | write(fmodule, buffer, BUFSIZE); 299 | } 300 | if (filesize % BUFSIZE) { 301 | read(foldmodule, buffer, filesize % BUFSIZE); 302 | write(fmodule, buffer, filesize % BUFSIZE); 303 | } 304 | } else { 305 | var readLen = 0; 306 | lseek(foldmodule, 0, SEEK_SET); 307 | lseek(fmodule, 0, SEEK_SET); 308 | while (readLen = read(foldmodule, buffer, BUFSIZE)) { 309 | write(fmodule, buffer, readLen); 310 | } 311 | } 312 | 313 | var ncmds = getU32(modbase.add(16)); 314 | var off = size_of_mach_header; 315 | var offset_cryptid = -1; 316 | var crypt_off = 0; 317 | var crypt_size = 0; 318 | var segments = []; 319 | for (var i = 0; i < ncmds; i++) { 320 | var cmd = getU32(modbase.add(off)); 321 | var cmdsize = getU32(modbase.add(off + 4)); 322 | if (cmd == LC_ENCRYPTION_INFO || cmd == LC_ENCRYPTION_INFO_64) { 323 | offset_cryptid = off + 16; 324 | crypt_off = getU32(modbase.add(off + 8)); 325 | crypt_size = getU32(modbase.add(off + 12)); 326 | } 327 | off += cmdsize; 328 | } 329 | 330 | if (offset_cryptid != -1) { 331 | var tpbuf = malloc(8); 332 | putU64(tpbuf, 0); 333 | lseek(fmodule, offset_cryptid, SEEK_SET); 334 | write(fmodule, tpbuf, 4); 335 | lseek(fmodule, crypt_off, SEEK_SET); 336 | write(fmodule, modbase.add(crypt_off), crypt_size); 337 | } 338 | 339 | close(fmodule); 340 | close(foldmodule); 341 | return newmodpath 342 | } 343 | 344 | function loadAllDynamicLibrary(app_path) { 345 | var defaultManager = ObjC.classes.NSFileManager.defaultManager(); 346 | var errorPtr = Memory.alloc(Process.pointerSize); 347 | Memory.writePointer(errorPtr, NULL); 348 | var filenames = defaultManager.contentsOfDirectoryAtPath_error_(app_path, errorPtr); 349 | for (var i = 0, l = filenames.count(); i < l; i++) { 350 | var file_name = filenames.objectAtIndex_(i); 351 | var file_path = app_path.stringByAppendingPathComponent_(file_name); 352 | if (file_name.hasSuffix_(".framework")) { 353 | var bundle = ObjC.classes.NSBundle.bundleWithPath_(file_path); 354 | if (bundle.isLoaded()) { 355 | send({ info: "[frida-ios-dump]: " + file_name + " has been loaded. " }); 356 | } else { 357 | if (bundle.load()) { 358 | send({ info: "[frida-ios-dump]: Load " + file_name + " success. " }); 359 | } else { 360 | send({ warn: "[frida-ios-dump]: Load " + file_name + " failed. " }); 361 | } 362 | } 363 | } else if (file_name.hasSuffix_(".bundle") || 364 | file_name.hasSuffix_(".momd") || 365 | file_name.hasSuffix_(".strings") || 366 | file_name.hasSuffix_(".appex") || 367 | file_name.hasSuffix_(".app") || 368 | file_name.hasSuffix_(".lproj") || 369 | file_name.hasSuffix_(".storyboardc")) { 370 | continue; 371 | } else { 372 | var isDirPtr = Memory.alloc(Process.pointerSize); 373 | Memory.writePointer(isDirPtr, NULL); 374 | defaultManager.fileExistsAtPath_isDirectory_(file_path, isDirPtr); 375 | if (Memory.readPointer(isDirPtr) == 1) { 376 | loadAllDynamicLibrary(file_path); 377 | } else { 378 | if (file_name.hasSuffix_(".dylib")) { 379 | var is_loaded = 0; 380 | for (var j = 0; j < modules.length; j++) { 381 | if (modules[j].path.indexOf(file_name) != -1) { 382 | is_loaded = 1; 383 | send({ info: "[frida-ios-dump]: " + file_name + " has been dlopen." }); 384 | break; 385 | } 386 | } 387 | 388 | if (!is_loaded) { 389 | if (dlopen(allocStr(file_path.UTF8String()), 9)) { 390 | send({ info: "[frida-ios-dump]: dlopen " + file_name + " success. " }); 391 | } else { 392 | send({ warn: "[frida-ios-dump]: dlopen " + file_name + " failed. " }); 393 | } 394 | } 395 | } 396 | } 397 | } 398 | } 399 | } 400 | 401 | function handleMessage(message) { 402 | modules = getAllAppModules(); 403 | var app_path = ObjC.classes.NSBundle.mainBundle().bundlePath(); 404 | loadAllDynamicLibrary(app_path); 405 | // start dump 406 | modules = getAllAppModules(); 407 | for (var i = 0; i < modules.length; i++) { 408 | send({ info: "start dump " + modules[i].path }); 409 | var result = dumpModule(modules[i].path); 410 | send({ dump: result, path: modules[i].path }); 411 | } 412 | send({ app: app_path.toString() }); 413 | send({ done: "ok" }); 414 | recv(handleMessage); 415 | } 416 | 417 | recv(handleMessage); 418 | -------------------------------------------------------------------------------- /ipadumper/appledl.py: -------------------------------------------------------------------------------- 1 | # stdlib 2 | import os 3 | import pathlib 4 | import shutil 5 | import signal 6 | import subprocess 7 | import tempfile 8 | import threading 9 | import time 10 | 11 | # external 12 | from cachetools import TTLCache # dict with timout 13 | from scp import SCPClient # ssh copy directories 14 | from tqdm import tqdm # progress bar 15 | from zxtouch import touchtypes, toasttypes 16 | from zxtouch.client import zxtouch # simulate touch input on device 17 | import frida # run scripts on device 18 | import paramiko # ssh 19 | 20 | # internal 21 | import ipadumper 22 | from ipadumper.utils import get_logger, itunes_info, progress_helper, free_port 23 | 24 | 25 | class AppleDL: 26 | ''' 27 | Downloader instance for a single device 28 | On inititalization two iproxy process are started: one for ssh and one for zxtouch 29 | Then a ssh and a frida connection will get established and the template images are copied with scp to the device 30 | ''' 31 | 32 | def __init__( 33 | self, 34 | udid=None, 35 | device_address='localhost', 36 | ssh_key_filename='iphone', 37 | local_ssh_port=0, 38 | local_zxtouch_port=0, 39 | image_base_path_device='/private/var/mobile/Library/ZXTouch/scripts/appstoredownload.bdl', 40 | image_base_path_local=os.path.join(os.path.dirname(ipadumper.__file__), 'appstore_images'), 41 | theme='dark', 42 | lang='en', 43 | timeout=15, 44 | log_level='info', 45 | init=True, 46 | ): 47 | self.udid = udid 48 | self.device_address = device_address 49 | self.ssh_key_filename = ssh_key_filename 50 | self.local_ssh_port = local_ssh_port 51 | self.local_zxtouch_port = local_zxtouch_port 52 | self.image_base_path_device = image_base_path_device 53 | self.image_base_path_local = image_base_path_local 54 | self.theme = theme 55 | self.lang = lang 56 | self.timeout = timeout 57 | self.log_level = log_level 58 | self.log = get_logger(log_level, name=__name__) 59 | 60 | signal.signal(signal.SIGINT, self.__signal_handler) 61 | signal.signal(signal.SIGTERM, self.__signal_handler) 62 | 63 | self.running = True 64 | self.processes = [] 65 | # self.file_dict = {} 66 | self.installed_cached = TTLCache(maxsize=1, ttl=2) 67 | 68 | self.log.debug('Logging is set to debug') 69 | 70 | self.init_frida_done = False 71 | self.init_ssh_done = False 72 | self.init_zxtouch_done = False 73 | self.init_images_done = False 74 | 75 | if not self.device_connected(): 76 | self.cleanup() 77 | elif init is True: 78 | if not self.init_all(): 79 | self.cleanup() 80 | 81 | def __del__(self): 82 | if self.running: 83 | self.cleanup() 84 | 85 | def __signal_handler(self, signum, frame): 86 | self.log.info('Received exit signal') 87 | self.cleanup() 88 | 89 | def cleanup(self): 90 | self.log.debug('Clean up...') 91 | self.running = False 92 | 93 | self.log.info('Disconnecting from device') 94 | try: 95 | self.finished.set() 96 | self.device.disconnect() 97 | self.sshclient.close() 98 | except AttributeError: 99 | pass 100 | 101 | # close all processes 102 | for idx, p in enumerate(self.processes, start=1): 103 | self.log.debug(f'Stopping process {idx}/{len(self.processes)}') 104 | p.terminate() 105 | p.wait() 106 | 107 | # threads 108 | for t in threading.enumerate(): 109 | if t.name != 'MainThread' and t.is_alive(): 110 | self.log.debug(f'Running thread: {t.name}') 111 | self.log.debug('Clean up done') 112 | 113 | def init_all(self): 114 | ''' 115 | return success 116 | ''' 117 | self.log.debug('Starting initialization') 118 | if not self.init_frida() or not self.init_ssh() or not self.init_zxtouch or not self.init_images(): 119 | return False 120 | return True 121 | 122 | def device_connected(self): 123 | ''' 124 | return True if a device is available else return False 125 | ''' 126 | if self.udid is None: 127 | returncode = subprocess.call( 128 | ['ideviceinfo'], encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.PIPE 129 | ) 130 | if returncode == 0: 131 | return True 132 | else: 133 | self.log.error('No device found') 134 | return False 135 | else: 136 | returncode = subprocess.call( 137 | ['ideviceinfo', '--udid', self.udid], encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.PIPE 138 | ) 139 | if returncode == 0: 140 | return True 141 | else: 142 | self.log.error(f'Device {self.udid} not found') 143 | return False 144 | 145 | def init_frida(self): 146 | ''' 147 | set frida device 148 | return success 149 | ''' 150 | self.log.debug('Setting frida device') 151 | try: 152 | if self.udid is None: 153 | self.frida_device = frida.get_usb_device() 154 | else: 155 | self.frida_device = frida.get_device(self.udid) 156 | except frida.InvalidArgumentError: 157 | self.log.error('No Frida USB device found') 158 | return False 159 | 160 | self.init_frida_done = True 161 | return True 162 | 163 | def init_ssh(self): 164 | ''' 165 | Initializing SSH connection to device 166 | return success 167 | ''' 168 | self.log.debug('Initializing SSH connection to device') 169 | # start iproxy for SSH 170 | if self.local_ssh_port == 0: 171 | self.local_ssh_port = free_port() 172 | if self.udid is None: 173 | self.__run_cmd(['iproxy', str(self.local_ssh_port), '22']) 174 | else: 175 | self.__run_cmd(['iproxy', '--udid', self.udid, str(self.local_ssh_port), '22']) 176 | time.sleep(0.1) 177 | 178 | self.log.debug('Connecting to device via SSH') 179 | # pkey = paramiko.Ed25519Key.from_private_key_file(self.ssh_key_filename) 180 | self.sshclient = paramiko.SSHClient() 181 | # client.load_system_host_keys() 182 | self.sshclient.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 183 | try: 184 | self.sshclient.connect( 185 | 'localhost', port=self.local_ssh_port, username='root', key_filename=self.ssh_key_filename 186 | ) 187 | except FileNotFoundError: 188 | self.log.error(f'Could not find ssh keyfile "{self.ssh_key_filename}"') 189 | return False 190 | except (EOFError, ConnectionResetError, paramiko.ssh_exception.SSHException) as e: 191 | self.log.error('Could not connect to establish SSH connection') 192 | self.log.debug(str(e)) 193 | return False 194 | 195 | self.init_ssh_done = True 196 | return True 197 | 198 | def init_zxtouch(self): 199 | self.log.debug('initialization zxtouch') 200 | # start iproxy for zxtouch 201 | if self.local_zxtouch_port == 0: 202 | self.local_zxtouch_port = free_port() 203 | if self.udid is None: 204 | self.__run_cmd(['iproxy', str(self.local_zxtouch_port), '6000']) 205 | else: 206 | self.__run_cmd(['iproxy', '--udid', self.udid, str(self.local_zxtouch_port), '6000']) 207 | 208 | time.sleep(1) 209 | self.log.info(f'Connecting to device at {self.device_address}:{self.local_zxtouch_port}') 210 | try: 211 | self.device = zxtouch(self.device_address, port=self.local_zxtouch_port) 212 | except ConnectionRefusedError: 213 | self.log.error('Error connecting to zxtouch on device. Make sure iproxy is running') 214 | self.cleanup() 215 | return False 216 | 217 | self.init_zxtouch_done = True 218 | return True 219 | 220 | def init_images(self): 221 | ''' 222 | Copy template images from local folder to device 223 | return success 224 | ''' 225 | self.log.debug('Copy template images from local folder to device') 226 | 227 | # check directory structure 228 | try: 229 | _, dirnames_themes, _ = next(os.walk(self.image_base_path_local)) 230 | except StopIteration: 231 | self.log.error(f'Image directory not found: {self.image_base_path_local}') 232 | return False 233 | theme_path = os.path.join(self.image_base_path_local, self.theme) 234 | lang_path = os.path.join(self.image_base_path_local, self.theme, self.lang) 235 | 236 | if self.theme in dirnames_themes: 237 | _, dirnames_langs, filenames_theme = next(os.walk(theme_path)) 238 | if self.lang not in dirnames_langs: 239 | self.log.error(f'Language directory "{self.lang}" not found in {theme_path}') 240 | return False 241 | else: 242 | self.log.error(f'Theme directory "{self.theme}" not found in {self.image_base_path_local}') 243 | return False 244 | 245 | # check if all images exist locally 246 | image_names_unlabeled = ['cloud.png'] 247 | image_names_labeled = ['dissallow.png', 'get.png', 'install.png'] 248 | 249 | _, _, filenames_lang = next(os.walk(lang_path)) 250 | for image_name_labeled in image_names_labeled: 251 | if image_name_labeled not in filenames_lang: 252 | self.log.error(f'Image {image_name_labeled} not found in {lang_path}') 253 | return False 254 | 255 | for image_name_unlabeled in image_names_unlabeled: 256 | if image_name_unlabeled not in filenames_theme: 257 | self.log.error(f'Image {image_name_unlabeled} not found in {theme_path}') 258 | return False 259 | 260 | # transfer images over SSH 261 | try: 262 | with SCPClient(self.sshclient.get_transport(), socket_timeout=self.timeout) as scp: 263 | for labeled_img in image_names_labeled: 264 | scp.put(os.path.join(lang_path, labeled_img), self.image_base_path_device) 265 | for unlabeled_img in image_names_unlabeled: 266 | unlabeled_img_path = os.path.join(theme_path, unlabeled_img) 267 | scp.put(unlabeled_img_path, self.image_base_path_device) 268 | except OSError: 269 | self.log.error('Could not copy template images to device') 270 | return False 271 | 272 | self.init_images_done = True 273 | return True 274 | 275 | def ssh_cmd(self, cmd): 276 | ''' 277 | execute command via ssh and iproxy 278 | return exitcode, stdout, stderr 279 | ''' 280 | if not self.init_ssh_done: 281 | if not self.init_ssh(): 282 | return 1, '', '' 283 | 284 | self.log.debug(f'Run ssh cmd: {cmd}') 285 | stdin, stdout, stderr = self.sshclient.exec_command(cmd) 286 | 287 | exitcode = stdout.channel.recv_exit_status() 288 | 289 | out = '' 290 | err = '' 291 | for line in stdout: 292 | out += line 293 | for line in stderr: 294 | err += line 295 | 296 | if exitcode != 0 or out != '' or err != '': 297 | self.log.debug(f'Exitcode: {exitcode}\nSTDOUT:\n{out}STDERR:\n{err}DONE') 298 | return exitcode, out, err 299 | 300 | def __log_cmd(self, pipe, err): 301 | with pipe: 302 | for line in iter(pipe.readline, b''): # b'\n'-separated lines 303 | if err is True: 304 | self.log.warning(f"got err line from subprocess: {line.decode('utf-8').rstrip()}") 305 | else: 306 | self.log.info(f"got out line from subprocess: {line.decode('utf-8').rstrip()}") 307 | 308 | if err is True: 309 | self.log.debug('Terminating stderr output thread') 310 | else: 311 | self.log.debug('Terminating stdout output thread') 312 | 313 | def __run_cmd(self, cmd): 314 | ''' 315 | Start external program and log stdout + stderr 316 | ''' 317 | cmd_str = ' '.join(cmd) 318 | self.log.info(f'Starting: {cmd_str}') 319 | 320 | p = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE) 321 | # start logging threads: one for stderr and one for stdout 322 | t_out = threading.Thread(target=self.__log_cmd, args=(p.stdout, False)) 323 | t_err = threading.Thread(target=self.__log_cmd, args=(p.stderr, True)) 324 | 325 | self.processes.append(p) 326 | 327 | # t_out.daemon = True 328 | # t_err.daemon = True 329 | 330 | t_out.name = ' '.join(cmd[:3]) # + '-out' 331 | t_err.name = ' '.join(cmd[:3]) # + '-err' 332 | 333 | t_out.start() 334 | t_err.start() 335 | 336 | def __is_installed(self, bundleId): 337 | ''' 338 | return version code if app is installed else return False 339 | ''' 340 | try: 341 | out = self.installed_cached[0] 342 | except KeyError: 343 | if self.udid is None: 344 | out = subprocess.check_output(['ideviceinstaller', '-l'], encoding='utf-8') 345 | else: 346 | out = subprocess.check_output(['ideviceinstaller', '--udid', self.udid, '-l'], encoding='utf-8') 347 | # cache output 348 | self.installed_cached[0] = out 349 | 350 | for line in out.splitlines()[1:]: 351 | CFBundleIdentifier, CFBundleVersion, CFBundleDisplayName = line.split(', ') 352 | if CFBundleIdentifier == bundleId: 353 | version = CFBundleVersion.strip('"') 354 | displayName = CFBundleDisplayName.strip('"') 355 | self.log.debug(f'Found installed app {bundleId}: {version} ({displayName})') 356 | return version 357 | return False 358 | 359 | def __match_image(self, image_name, acceptable_value=0.9, max_try_times=1, scaleRation=1): 360 | ''' 361 | get image from image_dir_device + image_name 362 | 363 | if matching return x,y coordinates from the middle 364 | else return False 365 | ''' 366 | path = f'{self.image_base_path_device}/{image_name}' 367 | result_tuple = self.device.image_match(path, acceptable_value, max_try_times, scaleRation) 368 | 369 | if result_tuple[0] is not True: 370 | raise Exception(f'Error while matching {image_name}: {result_tuple[1]}') 371 | else: 372 | result_dict = result_tuple[1] 373 | width = int(float(result_dict['width'])) 374 | height = int(float(result_dict['height'])) 375 | x = int(float(result_dict['x'])) 376 | y = int(float(result_dict['y'])) 377 | if width != 0 and height != 0: 378 | middleX = x + (width // 2) 379 | middleY = y + (height // 2) 380 | self.log.debug( 381 | f'Matched {image_name}: x,y: {x},{y}\t size: {width},{height}\t middle: {middleX},{middleY}' 382 | ) 383 | return middleX, middleY 384 | else: 385 | self.log.debug(f'Match failed. Cannot find {image_name} on screen.') 386 | return False 387 | 388 | def __tap(self, xy, message=''): 389 | ''' 390 | Simulate touch input (single tap) and show toast message on device 391 | ''' 392 | x, y = xy 393 | self.log.debug(f'Tapping {xy} {message}') 394 | self.device.show_toast(toasttypes.TOAST_WARNING, f'{message} ({x},{y})', 1.5) 395 | self.device.touch(touchtypes.TOUCH_DOWN, 1, x, y) 396 | time.sleep(0.1) 397 | self.device.touch(touchtypes.TOUCH_UP, 1, x, y) 398 | 399 | def __wake_up_device(self): 400 | ''' 401 | Normally not needed. 402 | Install (uiopen) wakes up device too 403 | ''' 404 | self.log.info('Unlocking device if not awake..') 405 | self.ssh_cmd('activator send libactivator.system.homebutton') 406 | time.sleep(0.5) 407 | self.ssh_cmd('activator send libactivator.system.homebutton') 408 | time.sleep(0.5) 409 | 410 | def already_dumped(self, itunes_id, directory): 411 | for filename in os.listdir(f'{directory}/.'): 412 | if filename.startswith(f'{itunes_id}_'): 413 | return True 414 | return False 415 | 416 | def dump_fouldecrypt(self, target, output, timeout=120, disable_progress=False, copy=True): 417 | ''' 418 | Dump IPA by using FoulDecrypt 419 | When copy is False, the app directory on the device is overwritten which is faster than copying everything 420 | Return success 421 | ''' 422 | if not self.init_ssh_done: 423 | if not self.init_ssh(): 424 | return False 425 | 426 | self.log.debug(f'{target}: Start dumping with FoulDecrypt.') 427 | 428 | # get path of app 429 | apps_dir = '/private/var/containers/Bundle/Application/' 430 | cmd = f'grep --only-matching {target} {apps_dir}*/iTunesMetadata.plist' 431 | ret, stdout, stderr = self.ssh_cmd(cmd) 432 | if ret != 0: 433 | self.log.error(f'grep returned {ret} {stderr}') 434 | return False 435 | 436 | target_dir = stdout.split('/iTunesMetadata.plist ')[0].split(' ')[-1] 437 | 438 | # get app directory name 439 | cmd = f'ls -d {target_dir}/*/' 440 | ret, stdout, stderr = self.ssh_cmd(cmd) 441 | if ret != 0: 442 | self.log.error(f'ls -d returned {ret} {stderr}') 443 | return False 444 | 445 | app_dir = stdout.strip().rstrip('/').split('/')[-1] 446 | if not app_dir.endswith('.app'): 447 | self.log.error(f'App directory does not end with .app: {app_dir}') 448 | return False 449 | 450 | app_bin = app_dir[:-4] 451 | 452 | if copy is True: 453 | orig_target_dir = target_dir 454 | target_dir = target_dir + '_tmp' 455 | cmd = f'cp -r {orig_target_dir} {target_dir}' 456 | ret, stdout, stderr = self.ssh_cmd(cmd) 457 | if ret != 0: 458 | self.log.error(f'cp -r returned {ret} {stderr}') 459 | return False 460 | 461 | bin_path = target_dir + '/' + app_dir + '/' + app_bin 462 | 463 | # decrypt binary and replace 464 | self.log.debug(f'{target}: Decrypting binary with fouldecrypt') 465 | cmd = f'/usr/local/bin/fouldecrypt -v {bin_path} {bin_path}' 466 | ret, stdout, stderr = self.ssh_cmd(cmd) 467 | if ret != 0: 468 | self.log.error(f'fouldecrypt returned {ret} {stderr}') 469 | return False 470 | 471 | # prepare for zipping, create Payload folder 472 | cmd = f'mkdir {target_dir}/Payload' 473 | ret, stdout, stderr = self.ssh_cmd(cmd) 474 | if ret != 0: 475 | self.log.error(f'mkdir returned {ret} {stderr}') 476 | return False 477 | 478 | cmd = f'mv "{target_dir}/{app_dir}" "{target_dir}/Payload"' 479 | ret, stdout, stderr = self.ssh_cmd(cmd) 480 | if ret != 0: 481 | self.log.error(f'mv returned {ret} {stderr}') 482 | return False 483 | 484 | self.log.debug(f'{target}: Set access and modified date to 0 for reproducible zip files') 485 | cmd = f'find "{target_dir}" -exec touch -m -d "1/1/1980" {{}} +' 486 | ret, stdout, stderr = self.ssh_cmd(cmd) 487 | if ret != 0: 488 | self.log.error(f'find+touch returned {ret} {stderr}') 489 | return False 490 | 491 | # zip 492 | self.log.debug(f'{target}: Creating zip') 493 | cmd = f'cd "{target_dir}" && zip -qrX out.zip . -i "Payload/*"' 494 | ret, stdout, stderr = self.ssh_cmd(cmd) 495 | if ret != 0: 496 | self.log.error(f'zip returned {ret} {stderr}') 497 | return False 498 | 499 | # transfer out.zip 500 | bar_fmt = '{desc:20.20} {percentage:3.0f}%|{bar:20}{r_bar}' 501 | self.log.debug(f'{target}: Start transfer. {output}') 502 | 503 | with tqdm(unit="B", unit_scale=True, miniters=1, bar_format=bar_fmt, disable=disable_progress) as t: 504 | pr = progress_helper(t) 505 | with SCPClient(self.sshclient.get_transport(), socket_timeout=self.timeout, progress=pr) as scp: 506 | scp.get(target_dir + '/out.zip', output) 507 | 508 | if copy is True: 509 | self.log.debug('Clean up temp directory on device') 510 | cmd = f'rm -rf "{target_dir}"' 511 | ret, stdout, stderr = self.ssh_cmd(cmd) 512 | if ret != 0: 513 | self.log.error(f'rm returned {ret} {stderr}') 514 | return False 515 | 516 | return True 517 | 518 | def dump_frida( 519 | self, 520 | target, 521 | output, 522 | timeout=120, 523 | disable_progress=False, 524 | dumpjs_path=os.path.join(os.path.dirname(ipadumper.__file__), 'dump.js'), 525 | ): 526 | 527 | ''' 528 | target: Bundle identifier of the target app 529 | output: Specify name of the decrypted IPA 530 | dumpjs_path: path to dump.js 531 | timeout: timeout in for dump to finish 532 | disable_progress: disable progress bars 533 | return success 534 | 535 | 536 | partly copied from 537 | https://github.com/AloneMonkey/frida-ios-dump/blob/9e75f6bca34f649aa6fcbafe464eca5d624784d6/dump.py 538 | 539 | MIT License 540 | 541 | Copyright (c) 2017 Alone_Monkey 542 | 543 | Permission is hereby granted, free of charge, to any person obtaining a copy 544 | of this software and associated documentation files (the "Software"), to deal 545 | in the Software without restriction, including without limitation the rights 546 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 547 | copies of the Software, and to permit persons to whom the Software is 548 | furnished to do so, subject to the following conditions: 549 | 550 | The above copyright notice and this permission notice shall be included in all 551 | copies or substantial portions of the Software. 552 | 553 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 554 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 555 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 556 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 557 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 558 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 559 | SOFTWARE. 560 | ''' 561 | if not self.init_ssh_done: 562 | if not self.init_ssh(): 563 | return False 564 | if not self.init_frida_done: 565 | if not self.init_frida(): 566 | return False 567 | 568 | bar_fmt = '{desc:20.20} {percentage:3.0f}%|{bar:20}{r_bar}' 569 | temp_dir = tempfile.mkdtemp() 570 | self.log.debug(f'{target}: Start dumping with Frida. Temp dir: {temp_dir}') 571 | payload_dir = os.path.join(temp_dir, 'Payload') 572 | os.mkdir(payload_dir) 573 | 574 | self.finished = threading.Event() 575 | file_dict = {} 576 | 577 | def generate_ipa(): 578 | self.log.debug(f'{target}: Generate ipa') 579 | for key, value in file_dict.items(): 580 | from_dir = os.path.join(payload_dir, key) 581 | to_dir = os.path.join(payload_dir, file_dict['app'], value) 582 | if key != 'app': 583 | # try: 584 | # cmp = filecmp.cmp(from_dir, to_dir) 585 | # except FileNotFoundError: 586 | # print(f'new: {from_dir}') 587 | # print(f'cmp is {cmp}, move {key} from {from_dir} to {to_dir}') 588 | shutil.move(from_dir, to_dir) 589 | 590 | self.log.debug(f'{target}: Set access and modified date to 0 for reproducible zip files') 591 | for f in pathlib.Path(temp_dir).glob('**/*'): 592 | os.utime(f, (0, 0)) 593 | 594 | zip_args = ('zip', '-qrX', os.path.join(os.getcwd(), output), 'Payload') 595 | self.log.debug(f'{target}: Run zip: {zip_args}') 596 | try: 597 | subprocess.check_call(zip_args, cwd=temp_dir) 598 | except subprocess.CalledProcessError as err: 599 | self.log.error(f'{target}: {zip_args} {str(err)}') 600 | 601 | def on_message(message, data): 602 | ''' 603 | callback function for dump messages 604 | receives paths and copies them with scp 605 | ''' 606 | t = threading.currentThread() 607 | t.name = f'msg-{target}' 608 | try: 609 | payload = message['payload'] 610 | except KeyError: 611 | self.log.warning(f'{target}: No payload in message') 612 | self.log.debug(f'Message: {message}') 613 | return 614 | 615 | if 'info' in payload: 616 | self.log.debug(f"{target}: {payload['info']}") 617 | 618 | if 'warn' in payload: 619 | self.log.warning(f"{target}: {payload['warn']}") 620 | 621 | if 'dump' in payload: 622 | index = payload['path'].find('.app/') + 5 623 | file_dict[os.path.basename(payload['dump'])] = payload['path'][index:] 624 | 625 | with tqdm(unit="B", unit_scale=True, miniters=1, bar_format=bar_fmt, disable=disable_progress) as t: 626 | pr = progress_helper(t) 627 | with SCPClient(self.sshclient.get_transport(), socket_timeout=self.timeout, progress=pr) as scp: 628 | scp.get(payload['dump'], payload_dir + '/') 629 | 630 | chmod_dir = os.path.join(payload_dir, os.path.basename(payload['dump'])) 631 | chmod_args = ('chmod', '655', chmod_dir) 632 | try: 633 | subprocess.check_call(chmod_args) 634 | except subprocess.CalledProcessError as err: 635 | self.log.error(f'{target}: {chmod_args} {str(err)}') 636 | 637 | if 'app' in payload: 638 | with tqdm(unit="B", unit_scale=True, miniters=1, bar_format=bar_fmt, disable=disable_progress) as t: 639 | pr = progress_helper(t) 640 | with SCPClient(self.sshclient.get_transport(), socket_timeout=self.timeout, progress=pr) as scp: 641 | scp.get(payload['app'], payload_dir + '/', recursive=True) 642 | 643 | chmod_dir = os.path.join(payload_dir, os.path.basename(payload['app'])) 644 | chmod_args = ('chmod', '755', chmod_dir) 645 | try: 646 | subprocess.check_call(chmod_args) 647 | except subprocess.CalledProcessError as err: 648 | self.log.error(f'{target}: {chmod_args} {str(err)}') 649 | 650 | file_dict['app'] = os.path.basename(payload['app']) 651 | 652 | if 'done' in payload: 653 | self.finished.set() 654 | 655 | self.log.debug(f'{target}: Opening app') 656 | self.ssh_cmd(f'open {target}') 657 | time.sleep(0.1) 658 | 659 | # create frida session 660 | apps = self.frida_device.enumerate_applications() 661 | session = None 662 | for app in apps: 663 | if app.identifier == target: 664 | if app.pid == 0: 665 | self.log.error(f'{target}: Could not start app') 666 | return 667 | session = self.frida_device.attach(app.pid) 668 | 669 | # run script 670 | with open(dumpjs_path) as f: 671 | jsfile = f.read() 672 | script = session.create_script(jsfile) 673 | script.on('message', on_message) 674 | self.log.debug(f'{target}: Loading script') 675 | script.load() 676 | script.post('dump') 677 | 678 | success = False 679 | if self.finished.wait(timeout=timeout): 680 | if self.running: 681 | generate_ipa() 682 | self.log.debug(f'{target}: Dumping finished. Clean up temp dir {temp_dir}') 683 | 684 | success = True 685 | else: 686 | self.log.debug(f'{target}: Cancelling dump. Clean up temp dir {temp_dir}') 687 | else: 688 | self.log.error(f'{target}: Timeout of {timeout}s exceeded. Clean up temp dir {temp_dir}') 689 | 690 | shutil.rmtree(temp_dir) 691 | 692 | if session: 693 | session.detach() 694 | return success 695 | 696 | def bulk_decrypt(self, itunes_ids, timeout_per_MiB=0.5, parallel=3, output_directory='ipa_output', country='us'): 697 | ''' 698 | Installs apps, decrypts and uninstalls them 699 | In parallel! 700 | itunes_ids: list of int with the iTunes IDs 701 | ''' 702 | if type(itunes_ids[0]) != int: 703 | self.log.error('bulk_decrypt: list of int needed') 704 | return False 705 | total = len(itunes_ids) 706 | wait_for_install = [] # apps that are currently downloading and installing 707 | done = [] # apps that are uninstalled 708 | waited_time = 0 709 | while len(itunes_ids) > 0 or len(wait_for_install) > 0: 710 | self.log.debug(f'Done {len(done)}/{total}, installing: {len(wait_for_install)}') 711 | if len(itunes_ids) > 0 and len(wait_for_install) < parallel: 712 | # install app 713 | self.log.info(f'Installing, len: {len(wait_for_install)}') 714 | 715 | itunes_id = itunes_ids.pop() 716 | if self.already_dumped(itunes_id, output_directory): 717 | self.log.warning(f'{itunes_id}: Skipping, app is already dumped.') 718 | continue 719 | 720 | trackName, version, bundleId, fileSizeMiB, price, currency = itunes_info( 721 | itunes_id, log_level=self.log_level, country=country 722 | ) 723 | 724 | if not bundleId: 725 | self.log.warning(f'{itunes_id}: Skipping, app not found.') 726 | continue 727 | 728 | app = {'bundleId': bundleId, 'fileSizeMiB': fileSizeMiB, 'itunes_id': itunes_id, 'version': version} 729 | 730 | if price != 0: 731 | self.log.warning(f'{bundleId}: Skipping, app is not for free ({price} {currency})') 732 | continue 733 | 734 | if self.__is_installed(bundleId) is not False: 735 | self.log.info(f'{bundleId}: Skipping, app already installed') 736 | total -= 1 737 | # subprocess.check_output(['ideviceinstaller', '--uninstall', bundleId]) 738 | continue 739 | 740 | wait_for_install.append(app) 741 | self.install(itunes_id) 742 | self.log.info(f'{bundleId}: Waiting for download and installation to finish ({fileSizeMiB} MiB)') 743 | else: 744 | # check if an app installation has finished 745 | # if yes then dump app else wait for an install to finish 746 | # also check if a dump has finished. If yes then uninstall app 747 | 748 | install_finished = False 749 | to_download_size = 0 750 | for app in wait_for_install: 751 | if self.__is_installed(app['bundleId']) is not False: 752 | # dump app 753 | 754 | self.log.info( 755 | f"{app['bundleId']}: Download and installation finished. Opening app and starting dump" 756 | ) 757 | install_finished = True 758 | waited_time = 0 759 | # waited_time -= app['fileSizeMiB'] * timeout_per_MiB 760 | # if waited_time < 0: 761 | # waited_time = 0 762 | wait_for_install.remove(app) 763 | 764 | try: 765 | os.mkdir(output_directory) 766 | except FileExistsError: 767 | pass 768 | 769 | name = f"{app['itunes_id']}_{app['bundleId']}_{app['version']}.ipa" 770 | output = os.path.join(output_directory, name) 771 | timeout = self.timeout + app['fileSizeMiB'] // 2 772 | disable_progress = False if self.log_level == 'debug' else True 773 | 774 | self.dump_frida(app['bundleId'], output, timeout=timeout, disable_progress=disable_progress) 775 | # uninstall app after dump 776 | self.log.info(f"{app['bundleId']}: Uninstalling") 777 | if self.udid is None: 778 | subprocess.check_output(['ideviceinstaller', '--uninstall', app['bundleId']]) 779 | else: 780 | subprocess.check_output( 781 | ['ideviceinstaller', '--udid', self.udid, '--uninstall', app['bundleId']] 782 | ) 783 | done.append(app) 784 | else: 785 | # recalculate remaining download size 786 | to_download_size += app['fileSizeMiB'] 787 | 788 | # wait for an app to finish installation 789 | if install_finished is False: 790 | self.log.debug(f'Need to download {to_download_size} MiB') 791 | if waited_time > self.timeout + timeout_per_MiB * to_download_size: 792 | self.log.error( 793 | f'Timeout exceeded. Waited time: {waited_time}. Need to download: {to_download_size} MiB' 794 | ) 795 | self.log.debug(f'Wait for install queue: {wait_for_install}') 796 | return False 797 | else: 798 | waited_time += 1 799 | time.sleep(1) 800 | 801 | def install(self, itunes_id): 802 | ''' 803 | Opens app in appstore on device and simulates touch input to download and installs the app. 804 | If there is a cloud button then press that and done 805 | Else if there is a load button, press that and confirm with install button. 806 | return success 807 | ''' 808 | if not self.init_ssh_done: 809 | if not self.init_ssh(): 810 | return 1, '', '' 811 | if not self.init_images_done: 812 | if not self.init_images(): 813 | return False 814 | if not self.init_zxtouch_done: 815 | if not self.init_zxtouch(): 816 | return False 817 | # get rid of permission request popups 818 | while True: 819 | dissallow_xy = self.__match_image('dissallow.png') 820 | if dissallow_xy is not False: 821 | self.log.debug('Dissallow permission request') 822 | self.__tap(dissallow_xy, message='dissallow') 823 | time.sleep(0.1) 824 | else: 825 | break 826 | 827 | self.ssh_cmd(f'uiopen https://apps.apple.com/de/app/id{str(itunes_id)}') 828 | 829 | self.log.debug(f'ID {itunes_id}: Waiting for get or cloud button to appear') 830 | dl_btn_wait_time = 0 831 | while dl_btn_wait_time <= self.timeout: 832 | dl_btn_wait_time += 1 833 | time.sleep(1) 834 | dl_btn_xy = self.__match_image('get.png') 835 | if dl_btn_xy is False: 836 | dl_btn_xy = self.__match_image('cloud.png') 837 | if dl_btn_xy is False: 838 | continue 839 | else: 840 | # tap and done 841 | self.__tap(dl_btn_xy, 'cloud') 842 | return True 843 | else: 844 | self.__tap(dl_btn_xy, 'get') 845 | break 846 | 847 | if dl_btn_wait_time > self.timeout: 848 | self.log.warning(f'ID {itunes_id}: No download button found after {self.timeout}s') 849 | return False 850 | 851 | # tap and need to wait and confirm with install button 852 | self.__tap(dl_btn_xy, 'load') 853 | self.log.debug(f'ID {itunes_id}: Waiting for install button to appear') 854 | install_btn_wait_time = 0 855 | while install_btn_wait_time <= self.timeout: 856 | install_btn_wait_time += 1 857 | time.sleep(1) 858 | install_btn_xy = self.__match_image('install.png') 859 | if install_btn_xy is not False: 860 | self.__tap(install_btn_xy, 'install') 861 | return True 862 | 863 | self.log.warning(f'ID {itunes_id}: No install button found after {self.timeout}s') 864 | return False 865 | --------------------------------------------------------------------------------