def setUp(self): if MAS_APP: self.mas_app = Bundle.make(MAS_APP) else: self.mas_app = None # The Calculator.app app has been part of macOS for as long as I can think. # There is no risk this app is going anywhere. self.system_app = Bundle.make("/Applications/Calculator.app")
def folder_for_app(results_dir : str, app : Bundle) -> str: """Return the output directory for a certain application inside the user-specified results directory. Note that this function _does not_ touch the filesystem. Therefore, no directories are created as part of this functionality""" app_bundle_id = app.bundle_identifier() if app_bundle_id is '' or app_bundle_id is None: app_bundle_id = 'b.UNKNOWN' app_version = app.version() if app_version is '' or app_version is None: app_version = 'v.UNKNOWN' return os.path.join(results_dir, app_bundle_id, app_version)
def all_apps(at: str = "/Applications", mas_only: bool = False, sandboxed_only: bool = False): """ Returns all apps from a target folder :param at: The base folder where to search for applications :param mas_only: Whether to only consider applications from the Mac App Store :param sandboxed_only: Whether to only return sandboxed applications :return: Filepaths to applications fulfilling the criteria specified """ all_entries = [ os.path.join(at, x) for x in os.listdir(at) if x.endswith(".app") ] for entry in all_entries: try: app_bundle = Bundle.make(entry) if mas_only and not app_bundle.is_mas_app(): continue if sandboxed_only and not app_bundle.is_sandboxed(): continue yield entry except InvalidBundle: continue
def container_for_app(app): """ Returns the container directory used by the application or None if the container does not exist. :param app: The app for which to find the container directory. Note that valid arguments are both a filepath to the application and a bundle for that application :return: Filepath to the container or None, if the lookup failed. """ # Handle code that already has a bundle for an app if isinstance(app, Bundle): app_bundle = app elif isinstance(app, str): try: app_bundle = Bundle.make(app) except InvalidBundle: return None bid = app_bundle.bundle_identifier(normalized=True) # Verify the container exists. container_path = os.path.join(os.path.expanduser("~/Library/Containers/"), bid) if not os.path.exists(container_path): return None # Also verify that the metadata file is present, else the container is invalid and of # no use to other code container_metadata = os.path.join(container_path, "Container.plist") if not os.path.exists(container_metadata): return None return container_path
def run( self, apps_dir: str, out_dir: str, select: Selection = Selection.ALL, ) -> None: if not os.path.exists(apps_dir): print(f"Directory does not exist: {apps_dir}", file=sys.stderr) exit(1) exit_watcher = SignalIntelligence() self.logger.info(f"{self.name} starting") for app_dir in Driver.iterate_applications(apps_dir): if exit_watcher.should_exit: break app = Bundle.make(app_dir) if select == Selection.MAS and not app.is_mas_app(): continue app_out_dir = folder_for_app(out_dir, app) os.makedirs(app_out_dir, exist_ok=True) print(f"[ ] Analysing {app.filepath}") reset_cursor = "\r\033[1A[" result = self.analyse(app, app_out_dir) print(reset_cursor + result.colored) self.logger.info(f"{self.name} stopping")
def test_sub_frameworks(self): app = Bundle.make("/Applications/iTunes.app") sub_frameworks = app.sub_frameworks() self.assertEqual(1, len(sub_frameworks)) self.assertEqual("/Applications/iTunes.app/Contents/Frameworks/iPodUpdater.framework", sub_frameworks[0].filepath)
def extract_data(self, app: Bundle, result_path: str) -> bool: # Metadata is stored in a dictionary, which is later serialised to the disk. dependencies_metadata = dict() if not isinstance(app, Application): self.log_error("Supplied app {} is not an application".format( app.filepath)) return False app_base = os.path.realpath(app.filepath) + "/" executable = app.executable() app_dependencies = executable.application_libraries( ) if executable else [] for dependency in app_dependencies: dependency_bundle = Bundle.from_binary(dependency) # Relative path component from the underlying app to the dependency. dependency_rel = fs.path_remove_prefix(dependency, app_base) dependency_infos = None if dependency_bundle: # Use Info.plist from bundle dependency_infos = dependency_bundle.info_dictionary() else: # Try to instead use embedded information if lief.is_macho(dependency): dependency_infos = common.extract_embedded_info( lief_extensions.macho_parse_quick(dependency)) if dependency_infos: # Record entry along with further information dependencies_metadata[ dependency_rel] = DependenciesExtractor._extract_dependency_infos( dependency_infos) else: # Just record the path to the dependency dependencies_metadata[dependency_rel] = {} # Store the information to the filesystem with open(os.path.join(result_path, "dependencies.json"), "w") as outfile: json.dump(dependencies_metadata, outfile, indent=4) return True
def extract_data(self, app: Bundle, result_path: str) -> bool: executable_path = app.executable_path() if not os.path.exists(executable_path): self.log_error("Executable for {} {} could not be found.".format( app.bundle_type, app.filepath)) return False fs.copy(executable_path, os.path.join(result_path, "executable.bin")) return True
def extract_data(self, app: Bundle, result_path: str) -> bool: info_path = app.info_dictionary_path() if not os.path.exists(info_path): self.log_error("Info.plist for {} {} could not be found.".format( app.bundle_type, app.filepath)) return False fs.copy(info_path, os.path.join(result_path, "Info.plist")) return True
def _check_capabilities_internal_static(cls, app_bundle : Bundle, entitlement_keys : List[str]): """Checks an applications capabilities solely statically""" app_entitlements = app_bundle.entitlements() # Check whether there is a entitlement allowing the capability for key in entitlement_keys: if app_entitlements.get(key, False): return True return False
def extract_data(self, app: Bundle, result_path: str) -> bool: app_bundle_id = app.bundle_identifier() if not app.is_mas_app(): self.log_info( 'Application {} is not from the Mac App Store. Will not query iTunes for metadata.' .format(app_bundle_id)) return True metadata = itunes_api.lookup_metadata(bundleId=app_bundle_id) if not metadata: self.log_info('Metadata could not be extracted for {}'.format( app.filepath)) return True outpath = os.path.join(result_path, 'itunes_metadata.json') with open(outpath, "w") as outfile: json.dump(metadata, outfile, indent=4) return True
def test_sub_bundles(self): app = Bundle.make("/Applications/iTunes.app") sub_bundle_paths = [x.filepath for x in app.sub_bundles()] required_paths = [ "/Applications/iTunes.app/Contents/XPCServices/VisualizerService.xpc", "/Applications/iTunes.app/Contents/PlugIns/iTunesStorageExtension.appex", "/Applications/iTunes.app/Contents/MacOS/iTunesHelper.app" ] for path in required_paths: self.assertIn(path, sub_bundle_paths)
def _entitlements_can_be_parsed(app_bundle: Bundle) -> bool: """ Check whether an application's entitlements can be parsed by libsecinit. We only check part of the process, namely the parsing of entitlements via xpc_create_from_plist. :param app_bundle: Bundle for which to check whether the entitlements can be parsed :type app_bundle: Bundle :return: True, iff the entitlements of the main executable can be parsed, else false. """ # No entitlements, no problem # If the app contains no entitlements, entitlement validation cannot fail. if not app_bundle.has_entitlements(): return True exe_path = app_bundle.executable_path() raw_entitlements = Binary.get_entitlements(exe_path, raw=True) # Call the local xpc_vuln_checker program that does the actual checking. exit_code, _ = tool_named("xpc_vuln_checker")(input=raw_entitlements) return exit_code != 1
def extract_data(self, app: Bundle, result_path: str) -> bool: iap_path = app.path_for_resource( "Contents/Resources/InternetAccessPolicy.plist") if not iap_path: self.log_info("IAP for application {} does not exist.".format( app.filepath)) # This method _always_ returns True, because IAPs are optional and almost always # absent from applications return True else: self.log_info("IAP for application {} does exist.".format( app.filepath)) fs.copy(iap_path, os.path.join(result_path, "iap.plist")) return True
def sandbox_status(app_bundle: Bundle, logger: logging.Logger) -> Optional[int]: process = subprocess.Popen([app_bundle.executable_path()], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # Sandbox initialisation should be almost instant. If the application is still # running after a couple of seconds, the sandbox failed to initialise or is # not enabled at all. # We use 10 seconds as an arbitrary cutoff time. time.sleep(10) pid = str(process.pid) if process.poll() is not None: logger.error("Process terminated early: {}".format( app_bundle.executable_path())) return None sb_status = subprocess.run([tool_named("sandbox_status"), pid], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) process.kill() rx = re.compile(r'^Sandbox status for PID {} is (\d+)$'.format(pid)) m = rx.match(sb_status.stdout.decode().strip()) if m: return int(m.group(1)) logger.error( "`sandbox_status` did not return a status for executable at {}. Skipping." .format(app_bundle.executable_path())) return None
def check_app(cls, app_bundle : Bundle): try: static_sandbox = app_bundle.is_sandboxed() dynamic_sandbox = app_utils.init_sandbox(app_bundle, logger) sandbox_status = app_utils.sandbox_status(app_bundle, logger) if not static_sandbox and (dynamic_sandbox or sandbox_status == 1): logger.info("App Sandbox active though not enabled for app {}".format(app_bundle.filepath)) return (True, { 'static': static_sandbox, 'dynamic': dynamic_sandbox, 'status': sandbox_status, }) except: return False, dict()
def install_app(app_path: str, logger, output: str): """Install an app from the `app_path` into the `output` directory.""" app = Bundle.make(app_path) output_folder = os.path.join(folder_for_app(output, app), os.path.basename(app_path)) try: shutil.copytree(app_path, output_folder, symlinks=True) except FileExistsError: logger.error("Application already exists: {}. Skipping.".format(output_folder)) print('\r[' + colored('skip', 'yellow')) return except e: logger.error("Could not install application: {}".format(output_folder)) print('\r[' + colored('err ', 'red')) return logger.info("Installed application: {}".format(output_folder)) print('\r[' + colored(' ok ', 'green'))
def test_bundle_from_binary(self): # For single-file utility, this function should return None, because the file is not # part of any bundle self.assertIsNone(Bundle.from_binary("/bin/ls")) self.assertIsNone(Bundle.from_binary("/bin/ln")) # For binaries that are part of a bundle but not the main executable in such a bundle, # the function is also supposed to return None # Note: iTunes is used here, because it comes pre-installed on macOS # and it has the most complicated dependencies of any installed app, thus # it can be used for many different purposes self.assertIsNone(Bundle.from_binary("/Applications/iTunes.app/Contents/MacOS/iTunesASUHelper")) # For non-existent binaries, the function should throw a ValueError with self.assertRaises(ValueError): # This binary surely does not exist Bundle.from_binary("/bin/useful_test03837246") # If the main executable is supplied, the corresponding bundle should be found self.assertEqual( Bundle.from_binary( "/Applications/iTunes.app/Contents/MacOS/iTunes" ).filepath, "/Applications/iTunes.app") # This should also work for other types of bundles self.assertEqual( Bundle.from_binary( "/Applications/iTunes.app/Contents/Frameworks/iPodUpdater.framework/Versions/A/iPodUpdater" ).filepath, "/Applications/iTunes.app/Contents/Frameworks/iPodUpdater.framework" ) self.assertEqual( Bundle.from_binary( "/Applications/iTunes.app/Contents/PlugIns/iTunesStorageExtension.appex/Contents/MacOS/iTunesStorageExtension" ).filepath, "/Applications/iTunes.app/Contents/PlugIns/iTunesStorageExtension.appex" )
def extract_data(self, app: Bundle, result_path: str) -> bool: from extractors.executable import ExecutableExtractor from extractors.info import InfoExtractor # Find all XPC bundles that are inside the overall application bundle for sub_bundle in app.sub_bundles(): if not sub_bundle.bundle_type == BundleType.XPC_EXTENSION: continue # Create a new folder for the XPC bundle found previously. if not sub_bundle.has_bundle_identifier(): self.log_error( "{} found in {} @ {} does not have a bundle id.".format( sub_bundle.bundle_type, app.bundle_type, app.filepath)) # Abort on error return False # Create sub-directory for XPC bundles found. bundle_id = sub_bundle.bundle_identifier() xpc_result_path = os.path.join(result_path, bundle_id) os.mkdir(xpc_result_path) # Use ExecutableExtractor and InfoExtractor on the XPC bundle success = ExecutableExtractor().extract_data( sub_bundle, xpc_result_path) success &= InfoExtractor().extract_data(sub_bundle, xpc_result_path) # Abort immediately on failure. Overall extraction only works if every plugins could be extracted if not success: self.log_error( "Data extraction failed for xpc plugin extraction from {} @ {}. Aborting." .format(app.bundle_type, app.filepath)) # Cleanup: Delete directory shutil.rmtree(xpc_result_path) # Propagate errors return False return True
def process_app(app_path, info_extractors, logger, output, source_hint: str=None): """Process an app using the supplied `info_extractors` Log potentially relevant information to `logger` and return results at `output`. If supplied, the `source_hint` will be written to `output/source` """ app = Bundle.make(app_path) output_folder = folder_for_app(output, app) if os.path.exists(output_folder): logger.info( "Skipping processing of {} @ {}, because the app has already been processed.".format(app, app.filepath) ) return # Make basefolder os.makedirs(output_folder) try: # The source_hint can for example be used to store an application's identifier for # the third-party platform where the app was downloaded from (i.e macupdate) if source_hint is not None: with open(os.path.join(output_folder, 'source'), 'w') as source_file: source_file.write(source_hint) extraction_status = functools.reduce( lambda status, extractor: status & run_extractor(extractor, app, output_folder), info_extractors, True ) if not extraction_status: logger.info("Processing failed for {} @ {}".format(app, app.filepath)) except: logger.error( "Exception occurred during processing of {} @ {}".format(app, app.filepath) )
def analyse_app(app_path, produce_text = False): """ Analyses an app to provide answers to the questions posed in the comment at the beginning of this file. Returns a dictionary containing the responses. """ result = dict() try: app_bundle = Bundle.make(app_path) except: print(termcolor.colored("App processing failed. Make sure the supplied application is valid.", COLOR_NEGATIVE)) return # Run all analysers analysers = AbstractAppChecker.__subclasses__() # Check if sandbox enabled success, key, local_result = run_analyser(AppSandboxStatusChecker, app_bundle, produce_text) if not success: return result result[key] = local_result # If the sandbox is not enabled, all other checks are mood. if not local_result['dynamic']: return result for analyser in analysers: if analyser == AppSandboxStatusChecker: continue success, key, local_result = run_analyser(analyser, app_bundle, produce_text) if not success: return result result[key] = local_result return result
def test_make_bundle(self): # Check common applications self.assertEqual(Bundle.make("/Applications/Calculator.app").bundle_type, BundleType.APPLICATION) self.assertEqual(Bundle.make("/Applications/Safari.app").bundle_type, BundleType.APPLICATION) # Check framework self.assertEqual(Bundle.make("/System/Library/Frameworks/Accelerate.framework").bundle_type, BundleType.FRAMEWORK) # KEXTs, even though actual proper support is not implemented yet. self.assertEqual(Bundle.make("/System/Library/Extensions/AppleHWSensor.kext").bundle_type, BundleType.KEXT) # Check failure cases with self.assertRaises(InvalidBundle): Bundle.make("/System/Library/Frameworks/Kernel.framework")
def setUp(self): self.framework = Bundle.make( "/System/Library/Frameworks/DVDPlayback.framework")
def test_normalize_path(self): self.assertEqual(Bundle.normalize_path("/Applications/Xcode.app/"), "/Applications/Xcode.app") self.assertEqual(Bundle.normalize_path("/Applications/Xcode.app"), "/Applications/Xcode.app") self.assertEqual(Bundle.normalize_path("/Applications/Xcode.app/Test"), "/Applications/Xcode.app")
def test_get_sandbox_rules(self): bundle = Bundle.make("/Applications/Calculator.app") self.assertIsNotNone(app_utils.get_sandbox_rules(bundle))
def setUp(self): # The Calculator.app app has been part of macOS for as long as I can think. # There is no risk this app is going anywhere. self.app = Bundle.make("/Applications/Calculator.app")
def main(): logger.info("appxtractor starting") parser = argparse.ArgumentParser(description='Extract information from Mac Apps.') parser.add_argument('-i', '--input', required=True, help='The directory that contains the applications to analyse.') parser.add_argument('-t', '--type', default='app_folder', const='app_folder', nargs='?', choices=['app_folder', 'archive_folder'], help='''Process input folder as folder containing .app bundles or as folder full of archives containing .app bundles. Supported archives formats are zip, tar, gz and dmg. Default type: app_folder''') parser.add_argument('-o', '--output', required=True, help='Output directory: This directory shall also be passed to this program to update an existing output folder.') parser.add_argument('--all-apps', dest='all_apps', default=False, action='store_true', help='Analyse all apps. By default, only Mac AppStore apps are analysed.') parser.add_argument('--install-only', default=False, action='store_true', help='''Install archived applications into the output directory. This option only works with archive folders.''') args = parser.parse_args() if args.type != 'archive_folder' and args.install_only: print("Option '--install-only' is only supported for archive folders.", file=sys.stderr) exit(1) exit_watcher = SignalIntelligence() if args.install_only: print("[+] Installing apps from \"{}\" to \"{}\"".format(args.input, args.output)) print("[+] Press Ctrl+C to cancel installation\n") else: print("[+] Analysing apps at \"{}\"".format(args.input)) print("[+] Press Ctrl+C to cancel analysis (can later be resumed)\n") if args.type == 'app_folder': app_candidates = iterate_apps_folder(args.input) elif args.type == 'archive_folder': app_candidates = iterate_archived_apps_folder(args.input) else: assert False and 'Iteration type not supported.' for path, hint in app_candidates: if exit_watcher.should_exit: break if not Bundle.is_bundle(path): continue bundle = Bundle.make(path) if not bundle.is_mas_app() and not args.all_apps and not args.install_only: continue if args.install_only: print('[ ] Installing {}'.format(path), end='') install_app(app_path=path, logger=logger, output=args.output) else: print('[+] Processing {}'.format(path)) process_app(app_path=path, info_extractors=info_extractors, logger=logger, output=args.output, source_hint=hint) logger.info("appxtractor stopping")
def init_sandbox(app_bundle: Bundle, logger: logging.Logger, force_initialisation: bool = False) -> bool: """ Initialises the sandbox for a particular app bundle. :param app_bundle: The App for which to initialise the App Sandbox :param logger: Logger object used to record failure cases :param force_initialisation: Whether to overwrite / start initialisation even if metadata exists that indicates the sandbox has already been initialised :return: Boolean value indicating whether the sandbox was successfully initialised (or was already initialised) """ # Guarding against a few applications that ship with entitlements libsecinit cannot parse. if not _entitlements_can_be_parsed(app_bundle): return False # Super useful environment variable used by libsecinit. If this variable is set, the application # is terminated after its sandbox is initialised. init_sandbox_environ = { **os.environ, 'APP_SANDBOX_EXIT_AFTER_INIT': str(1) } app_container = container_for_app(app_bundle) if app_container is not None and not force_initialisation: if logger: logger.info( "Container directory already existed. Skipping sandbox initialisation." ) return True if logger: logger.info("Starting process {} to initialize sandbox.".format( app_bundle.executable_path())) process = subprocess.Popen([app_bundle.executable_path()], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=init_sandbox_environ) # Sandbox initialisation should be almost instant. If the application is still # running after a couple of seconds, the sandbox failed to initialise. # We use 10 seconds as an arbitrary cutoff time. try: process.wait(10) except subprocess.TimeoutExpired: process.kill() if logger: logger.error( "Sandbox was not initialised successfully for executable at {}. Skipping." .format(app_bundle.executable_path())) return False # Check that there now is an appropriate container if container_for_app(app_bundle) is None: if logger: logger.info("Sandbox initialisation for executable {} succeeded \ but no appropriate container metadata was created.".format( app_bundle.executable_path())) return False return True
def test_is_bundle(self): self.assertTrue(Bundle.is_bundle("/Applications/Calculator.app")) self.assertTrue(Bundle.is_bundle("/System/Library/Frameworks/WebKit.framework"))