''' Copyright (c) 2017 Yogesh Khatri This file is part of mac_apt (macOS Artifact Parsing Tool). Usage or distribution of this software/code is subject to the terms of the MIT License. ''' import ast import biplist import logging import os import random import pytsk3 import shutil import stat import string import struct import sqlite3 import sys import tempfile import time import traceback from io import BytesIO from plugins.helpers.apfs_reader import * from plugins.helpers.darwin_path_generator import GetDarwinPath, GetDarwinPath2 from plugins.helpers.deserializer import process_nsa_plist from plugins.helpers.hfs_alt import HFSVolume from plugins.helpers.common import * from plugins.helpers.structs import * from plugins.helpers import decryptor if sys.platform == 'linux': from plugins.helpers.statx import statx log = logging.getLogger('MAIN.HELPERS.MACINFO') ''' Common data structures for plugins ''' class OutputParams: def __init__(self): self.output_path = '' self.write_csv = False self.write_sql = False self.write_xlsx = False self.xlsx_writer = None self.output_db_path = '' self.export_path = '' # For artifact source files self.export_path_rel = '' # Relative export path self.export_log_sqlite = None self.timezone = TimeZoneType.UTC class UserInfo: def __init__ (self): self.user_name = '' self.real_name = '' self.home_dir = '' self.UID = '' # retain as string self.UUID = '' self.GID = '' # retain as string self.pw_hint = '' self.password = '' self.creation_time = None self.deletion_time = None self.failed_login_count = 0 self.failed_login_timestamp = None self.last_login_timestamp = None self.password_last_set_time = None self.DARWIN_USER_DIR = '' #0 With DARWIN_USER_* folders, there may be one or more comma separated self.DARWIN_USER_TEMP_DIR = '' #T self.DARWIN_USER_CACHE_DIR = ''#C self._source = '' # Path of data source class HfsVolumeInfo: def __init__(self): #self.name = '' self.version = 0 self.last_mounted_version = '' self.date_created_local_time = None self.date_modified = None self.date_backup = None self.date_last_checked = None self.num_files = 0 self.num_folders = 0 self.block_size = 0 self.total_blocks = 0 self.free_blocks = 0 self.is_HFSX = False class NativeHfsParser: '''Native HFS+ parser - pure python implementation''' def __init__(self): self.initialized = False self.volume = None def Initialize(self, pytsk_img, offset): if not pytsk_img: return False try: log.debug('Initializing NativeHFSParser->HFSVolume Vol starts @ offset 0x{:X}'.format(offset)) self.volume = HFSVolume(pytsk_img, offset) self.initialized = True return True except ValueError as ex: log.exception('Could not initialize HFS volume class: '+ str(ex)) return False def GetVolumeInfo(self): if not self.initialized: raise ValueError("Volume not loaded (initialized)!") try: hfs_info = HfsVolumeInfo() header = self.volume.header hfs_info.is_HFSX = header.signature == 0x4858 hfs_info.block_size = header.blockSize hfs_info.version = 0 hfs_info.last_mounted_version = struct.unpack("<4s", struct.pack(">I", header.lastMountedVersion))[0].decode('utf-8', 'ignore') # ugly, is there a better way? hfs_info.date_created_local_time = CommonFunctions.ReadMacHFSTime(header.createDate) hfs_info.date_modified = CommonFunctions.ReadMacHFSTime(header.modifyDate) hfs_info.date_backup = CommonFunctions.ReadMacHFSTime(header.backupDate) hfs_info.date_last_checked = CommonFunctions.ReadMacHFSTime(header.checkedDate) hfs_info.num_files = header.fileCount hfs_info.num_folders = header.folderCount hfs_info.total_blocks = header.totalBlocks hfs_info.free_blocks = header.freeBlocks return hfs_info except ValueError as ex: log.exception("Failed to read HFS info") return None def GetExtendedAttribute(self, path, att_name): return self.volume.getXattr(path, att_name) def GetExtendedAttributes(self, path): return self.volume.getXattrsByPath(path) def GetFileSize(self, path, error=None): '''For a given file path, gets logical file size, or None if error''' try: return self.volume.GetFileSize(path) except ValueError as ex: log.debug ("NativeHFSParser->Exception from GetFileSize() " + str(ex)) return error def _GetSizeFromRec(self, k, v): '''For a file's catalog key & value , gets logical file size, or 0 if error''' try: return self.volume.GetFileSizeFromFileRecord(v) except ValueError as ex: name = getString(k) log.error ("NativeHFSParser->Exception from _GetSizeFromRec()" +\ "\nFilename=" + name + " CNID=" + str(v.data.fileID) +\ "\nException details: " + str(ex)) return 0 def Open(self, path): '''Open files, returns open file handle''' if not self.initialized: raise ValueError("Volume not loaded (initialized)!") try: log.debug("Trying to open file : " + path) size = self.GetFileSize(path) if size > 209715200: log.warning('File size > 200 MB. File size is {} bytes'.format(size)) f = tempfile.SpooledTemporaryFile(max_size=209715200) self.volume.readFile(path, f) f.seek(0) return f except (OSError, ValueError) as ex: log.exception("NativeHFSParser->Failed to open file {} Error was {}".format(path, str(ex))) return None def ExtractFile(self, path, extract_to_path): '''Extract file, returns True or False''' if not self.initialized: raise ValueError("Volume not loaded!") try: log.debug("Trying to export file : " + path + " to " + extract_to_path) with open(extract_to_path, "wb") as f: self.volume.readFile(path, f) f.close() return True except (ValueError, OSError) as ex: log.exception("NativeHFSParser->Failed to export file {} to {}".format(path, extract_to_path)) return False def GetFileMACTimes(self, file_path): ''' Returns dictionary {c_time, m_time, cr_time, a_time} where cr_time = created time and c_time = Last time inode/mft modified ''' try: return self.volume.GetFileMACTimes(file_path) except ValueError: log.exception('NativeHFSParser->Error trying to get MAC times') return { 'c_time':None, 'm_time':None, 'cr_time':None, 'a_time':None } def _GetFileMACTimesFromFileRecord(self, v): '''Return times from file's catalog record''' try: return self.volume.GetFileMACTimesFromFileRecord(v) except ValueError: log.exception('NativeHFSParser->Error trying to get MAC times') return { 'c_time':None, 'm_time':None, 'cr_time':None, 'a_time':None } def IsSymbolicLink(self, path): '''Check if a path is a symbolic link''' try: return self.volume.IsSymbolicLink(path) except ValueError: log.exception('NativeHFSParser->Failed trying to check for symbolic link') return False def IsValidFilePath(self, path): '''Check if a file path is valid, does not check for folders!''' try: return self.volume.IsValidFilePath(path) except ValueError: log.exception('NativeHFSParser->Failed trying to check valid file path') return False def IsValidFolderPath(self, path): '''Check if a folder path is valid''' try: return self.volume.IsValidFolderPath(path) except ValueError: log.exception('NativeHFSParser->Failed trying to check valid folder path') return False def GetUserAndGroupID(self, path): ''' Returns tuple (success, UID, GID) for object identified by path If failed to get values, success=False UID & GID are returned as strings ''' success, uid, gid = False, 0, 0 try: uid, gid = self.volume.GetUserAndGroupID(path) uid = str(uid) gid = str(gid) success = True except ValueError as ex: log.error("Exception trying to get uid & gid for " + path + ' Exception details: ' + str(ex)) return success, uid, gid def GetUserAndGroupIDForFile(self, path): ''' Returns tuple (success, UID, GID) for file identified by path If failed to get values, success=False UID & GID are returned as strings ''' return self.GetUserAndGroupID(path) def GetUserAndGroupIDForFolder(self, path): ''' Returns tuple (success, UID, GID) for folder identified by path If failed to get values, success=False UID & GID are returned as strings ''' return self.GetUserAndGroupID(path) def ListItemsInFolder(self, path='/', types_to_fetch=EntryType.FILES_AND_FOLDERS, include_dates=False): ''' Returns a list of files and/or folders in a list Format of list = [ { 'name':'got.txt', 'type':EntryType.FILES, 'size':10, 'dates': [] }, .. ] 'path' should be linux style using forward-slash like '/var/db/xxyy/file.tdc' ''' items = [] # List of dictionaries try: k,v = self.volume.catalogTree.getRecordFromPath(path) if k: if v.recordType == kHFSPlusFolderRecord: for k,v in self.volume.catalogTree.getFolderContents(v.data.folderID): if v.recordType in (kHFSPlusFolderRecord, kHFSPlusFileRecord): try: entry_type = EntryType.FILES if v.recordType == kHFSPlusFileRecord else EntryType.FOLDERS if types_to_fetch == EntryType.FILES_AND_FOLDERS: items.append( self._BuildFileListItemFromRecord(k, v, entry_type, include_dates) ) elif types_to_fetch == EntryType.FILES and entry_type == EntryType.FILES: items.append( self._BuildFileListItemFromRecord(k, v, entry_type, include_dates) ) elif types_to_fetch == EntryType.FOLDERS and entry_type == EntryType.FOLDERS: items.append( self._BuildFileListItemFromRecord(k, v, entry_type, include_dates) ) except Exception as ex: log.error("Error accessing file/folder record: " + str(ex)) else: log.error("Can't get dir listing as this is not a folder : " + path) else: log.error('Path not found : ' + path) except (KeyError, ValueError, TypeError, OSError): log.error('Error trying to get file list from folder: ' + path) log.exception('') return items def _BuildFileListItemFromRecord(self, k, v, entry_type, include_dates): name = getString(k) item = None if include_dates: item = { 'name':name, 'type':entry_type, 'size':self._GetSizeFromRec(k, v) if entry_type == EntryType.FILES else 0, 'dates': self._GetFileMACTimesFromFileRecord(v) } else: item = { 'name':name, 'type':entry_type, 'size':self._GetSizeFromRec(k, v) if entry_type == EntryType.FILES else 0 } return item class MacInfo: def __init__(self, output_params, password=''): #self.Partitions = {} # Dictionary of all partition objects returned from pytsk LATER! self.pytsk_image = None self.macos_FS = None # Just the FileSystem object (fs) from OSX partition self.macos_partition_start_offset = 0 # Container offset if APFS self.vol_info = None # disk_volumes self.output_params = output_params self.os_version = '0.0.0' self.os_build = '' self.os_friendly_name = 'No name yet!' self.users = [] self.hfs_native = NativeHfsParser() self.is_apfs = False self.use_native_hfs_parser = True # runtime platform self.is_windows = (os.name == 'nt') self.is_linux = (sys.platform == 'linux') # for encrypted volumes self.password = password # Public functions, plugins can use these def GetAbsolutePath(self, current_abs_path, dest_rel_path): '''Returns the absolute (full) path to a destination file/folder given the current location (path) and a relative path to the destination. This is for relative paths that start with . or .. ''' # This is for linux paths only if dest_rel_path in ('', '/'): return current_abs_path # Strip / at start and end of dest dest_rel_path = dest_rel_path.rstrip('/').lstrip('/') if current_abs_path[-1] != '/': current_abs_path += '/' curr_paths = current_abs_path.rstrip('/').lstrip('/').split('/') if len(curr_paths) == 1 and curr_paths[0] == '': curr_paths = [] rel_paths = dest_rel_path.split('/') curr_path_index = len(curr_paths) for x in rel_paths: if x == '.': pass elif x == '..': if curr_path_index == 0: raise ValueError('Relative path tried to go above root !') else: curr_path_index -= 1 curr_paths.pop() elif x == '': raise ValueError("Relative path had // , can't parse") else: curr_paths.append(x) curr_path_index += 1 final_path = '' for index, x in enumerate(curr_paths): final_path += '/' + x if index == curr_path_index: break if final_path == '': final_path = '/' return final_path def GetFileMACTimes(self, file_path): ''' Returns dictionary {c_time, m_time, cr_time, a_time} where cr_time = created time and c_time = Last time inode/mft modified ''' if self.use_native_hfs_parser: return self.hfs_native.GetFileMACTimes(file_path) times = { 'c_time':None, 'm_time':None, 'cr_time':None, 'a_time':None } try: tsk_file = self.macos_FS.open(file_path) times['c_time'] = CommonFunctions.ReadUnixTime(tsk_file.info.meta.ctime) times['m_time'] = CommonFunctions.ReadUnixTime(tsk_file.info.meta.mtime) times['cr_time'] = CommonFunctions.ReadUnixTime(tsk_file.info.meta.crtime) times['a_time'] = CommonFunctions.ReadUnixTime(tsk_file.info.meta.atime) except Exception as ex: log.exception('Error trying to get MAC times') return times def GetExtendedAttribute(self, path, att_name): if self.use_native_hfs_parser: return self.hfs_native.GetExtendedAttribute(path, att_name) def GetExtendedAttributes(self, path): if self.use_native_hfs_parser: return self.hfs_native.GetExtendedAttributes(path) def ExportFolder(self, artifact_path, subfolder_name, overwrite): '''Export an artifact folder to the output\Export\subfolder_name folder. This will export the entire folder and subfolders recursively. This does not export Xattr. Return value is boolean (False if it encountered any errors). ''' export_path = os.path.join(self.output_params.export_path, subfolder_name, os.path.basename(artifact_path)) # create folder try: if not os.path.exists(export_path): os.makedirs(export_path) except (KeyError, ValueError, TypeError, OSError) as ex: log.error ("Exception while creating Export folder " + export_path + "\n Is output folder Writeable?" + "Is it full? Perhaps the drive is disconnected? Exception Details: " + str(ex)) return False # recursively export files/folders try: return self._ExportFolder(artifact_path, export_path, overwrite) except (KeyError, ValueError, TypeError, OSError): log.exception('Exception while exporting folder ' + artifact_path) return False def _ExportFolder(self, artifact_path, export_path, overwrite): '''Exports files/folders from artifact_path to export_path recursively''' artifact_path = artifact_path.rstrip('/') entries = self.ListItemsInFolder(artifact_path, EntryType.FILES_AND_FOLDERS, True) ret = True for entry in entries: new_path = os.path.join(export_path, self._GetSafeFilename(entry['name'])) if entry['type'] == EntryType.FOLDERS: try: if not os.path.exists(new_path): os.mkdir(new_path) except: log.exception("Exception while creating Export folder " + export_path) ret = False continue ret &= self._ExportFolder(artifact_path + '/' + entry['name'], new_path, overwrite) else: # FILE if entry['size'] > 0: ret &= self._ExtractFile(artifact_path + '/' + entry['name'], new_path, entry['dates']) else: log.info('Skipping export of {} as filesize=0'.format(artifact_path + '/' + entry['name'])) return ret def ExportFile(self, artifact_path, subfolder_name, file_prefix='', check_for_sqlite_files=True, overwrite=False): '''Export an artifact (file) to the output\Export\subfolder_name folder. Ideally subfolder_name should be the name of the plugin. If 'overwrite' is set to True, it will not check for existing files. The default behaviour is to check and rename the newly exported file if there is a name collision. If this is an sqlite db, the -journal and -wal files will also be exported. The check for -journal and -wal can be skipped if check_for_sqlite_files=False It is much faster to skip the check if not needed. The Function returns False if it fails to export the file. ''' export_path = os.path.join(self.output_params.export_path, subfolder_name) # create folder try: if not os.path.exists(export_path): os.makedirs(export_path) except Exception as ex: log.error ("Exception while creating Export folder " + export_path + "\n Is output folder Writeable?" + "Is it full? Perhaps the drive is disconnected? Exception Details: " + str(ex)) return False # extract each file to temp folder out_filename = file_prefix + os.path.basename(artifact_path) out_filename = self._GetSafeFilename(out_filename) #filter filenames based on platform (Eg: Windows does not like ?<>/\:*"! in filenames) if overwrite: file_path = os.path.join(export_path, out_filename) else: file_path = CommonFunctions.GetNextAvailableFileName(os.path.join(export_path, out_filename)) if self._ExtractFile(artifact_path, file_path): if check_for_sqlite_files: jrn_file_path = file_path + "-journal" wal_file_path = file_path + "-wal" if self.IsValidFilePath(artifact_path + "-journal"): self._ExtractFile(artifact_path + "-journal", jrn_file_path) if self.IsValidFilePath(artifact_path + "-wal"): self._ExtractFile(artifact_path + "-wal", wal_file_path) return True return False def _ExtractFile(self, artifact_path, export_path, mac_times=None): '''Internal function, just export, no checks!''' if self.ExtractFile(artifact_path, export_path): if not mac_times: mac_times = self.GetFileMACTimes(artifact_path) export_path_rel = os.path.relpath(export_path, start=self.output_params.export_path) if self.is_windows: export_path_rel = export_path_rel.replace('\\', '/') self.output_params.export_log_sqlite.WriteRow([artifact_path, export_path_rel, mac_times['c_time'], mac_times['m_time'], mac_times['cr_time'], mac_times['a_time']]) return True else: log.info("Failed to export '" + artifact_path + "' to '" + export_path + "'") return False def DeserializeNsKeyedPlist(self, plist_file): '''Returns a deserialized version of an NSKeyedArchive plist''' deserialised_plist = process_nsa_plist('', plist_file) return deserialised_plist def ReadPlist(self, path, deserialize=False): '''Safely open and read a plist; returns tuple (True/False, plist/None, "error_message")''' log.debug("Trying to open plist file : " + path) error = '' try: f = self.Open(path) if f != None: try: log.debug("Trying to read plist file : " + path) plist = biplist.readPlist(f) if deserialize: try: f.seek(0) plist = self.DeserializeNsKeyedPlist(f) f.close() return (True, plist, '') except: f.close() error = 'Could not read deserialized plist: ' + path + " Error was : " + str(ex) else: f.close() return (True, plist, '') except biplist.InvalidPlistException as ex: try: # Perhaps this is manually edited or incorrectly formatted by a non-Apple utility # that has left whitespaces at the start of file before <?xml tag # This is assuming XML format! f.seek(0) data = f.read().decode('utf8', 'ignore') f.close() data = data.lstrip(" \r\n\t").encode('utf8', 'backslashreplace') if deserialize: try: temp_file = BytesIO(data) plist = self.DeserializeNsKeyedPlist(temp_file) temp_file.close() return (True, plist, '') except: error = 'Could not read deserialized plist: ' + path + " Error was : " + str(ex) else: plist = biplist.readPlistFromString(data) return (True, plist, '') except (biplist.InvalidPlistException, biplist.NotBinaryPlistException) as ex: error = 'Could not read plist: ' + path + " Error was : " + str(ex) except OSError as ex: error = 'OSError while reading plist: ' + path + " Error was : " + str(ex) else: error = 'Failed to open file' except Exception as ex: error = 'Exception from ReadPlist, trying to open file. Exception=' + str(ex) return (False, None, error) def IsSymbolicLink(self, path): '''Check if path represents a symbolic link''' if self.use_native_hfs_parser: return self.hfs_native.IsSymbolicLink(path) return False def ReadSymLinkTargetPath(self, path): '''Returns the target file/folder's path from the sym link path provided''' f = self.Open(path) if f: target_path = f.read() f.close() return target_path.rstrip(b'\0').decode('utf8', 'backslashreplace') return '' def IsValidFilePath(self, path): '''Check if a file path is valid, does not check for folders!''' if self.use_native_hfs_parser: return self.hfs_native.IsValidFilePath(path) try: valid_file = self.macos_FS.open(path) return True except Exception: pass return False def IsValidFolderPath(self, path): '''Check if a folder path is valid''' if self.use_native_hfs_parser: return self.hfs_native.IsValidFolderPath(path) try: valid_folder = self.macos_FS.open_dir(path) return True except Exception: pass return False def GetFileSize(self, path, error=None): '''For a given file path, gets logical file size, or None if error''' if self.use_native_hfs_parser: return self.hfs_native.GetFileSize(path) try: valid_file = self.macos_FS.open(path) return valid_file.info.meta.size except Exception as ex: log.debug (" Unknown exception from GetFileSize() " + str(ex) + " Perhaps file does not exist " + path) return error def ListItemsInFolder(self, path='/', types_to_fetch=EntryType.FILES_AND_FOLDERS, include_dates=False): ''' Returns a list of files and/or folders in a list Format of list = [ { 'name':'got.txt', 'type':EntryType.FILES, 'size':10, 'dates': {} }, .. ] 'path' should be linux style using forward-slash like '/var/db/xxyy/file.tdc' ''' if self.use_native_hfs_parser: return self.hfs_native.ListItemsInFolder(path, types_to_fetch, include_dates) items = [] # List of dictionaries try: dir = self.macos_FS.open_dir(path) for entry in dir: name = self._GetName(entry) if name == "": continue elif name == "." or name == "..": continue elif not self._IsValidFileOrFolderEntry(entry): continue # this filters for allocated files and folders only entry_type = EntryType.FOLDERS if entry.info.name.type == pytsk3.TSK_FS_NAME_TYPE_DIR else EntryType.FILES if include_dates: path_no_trailing_slash = path.rstrip('/') item = { 'name':name, 'type':entry_type, 'size':self._GetSize(entry), 'dates': self.GetFileMACTimes(path_no_trailing_slash + '/' + name) } else: item = { 'name':name, 'type':entry_type, 'size':self._GetSize(entry) } if types_to_fetch == EntryType.FILES_AND_FOLDERS: items.append( item ) elif types_to_fetch == EntryType.FILES and entry_type == EntryType.FILES: items.append( item ) elif types_to_fetch == EntryType.FOLDERS and entry_type == EntryType.FOLDERS: items.append( item ) except Exception as ex: if str(ex).find('tsk_fs_dir_open: path not found'): log.debug("Path not found : " + path) else: log.debug("Exception details:\n", exc_info=True) #traceback.print_exc() log.error("Failed to get dir info!") return items def Open(self, path): '''Open files less than 200 MB, returns open file handle''' if self.use_native_hfs_parser: return self.hfs_native.Open(path) try: log.debug("Trying to open file : " + path) tsk_file = self.macos_FS.open(path) size = tsk_file.info.meta.size if size > 209715200: raise ValueError('File size > 200 MB, use direct TSK file functions!') f = tempfile.SpooledTemporaryFile(max_size=209715200) BUFF_SIZE = 20 * 1024 * 1024 offset = 0 while offset < size: available_to_read = min(BUFF_SIZE, size - offset) data = tsk_file.read_random(offset, available_to_read) if not data: break offset += len(data) f.write(data) f.seek(0) return f except Exception as ex: if str(ex).find('tsk_fs_file_open: path not found:') > 0: log.error("Open() returned 'Path not found' error for path: {}".format(path)) elif str(ex).find('tsk_fs_attrlist_get: Attribute 4352 not found') > 0 or \ (str(ex).find('Read error: Invalid file offset') > 0 and self._IsFileCompressed(tsk_file)) or \ str(ex).find('Read error: Error in metadata') > 0: log.debug("Known TSK bug caused Error: Failed to open file {}".format(path)) log.debug("Trying to open with Native HFS parser") try: if not self.hfs_native.initialized: self.hfs_native.Initialize(self.pytsk_image, self.macos_partition_start_offset) return self.hfs_native.Open(path) except (OSError, ValueError): log.error("Failed to open file: " + path) log.debug("Exception details:\n", exc_info=True) else: log.exception("Failed to open file {}".format(path)) return None def ExtractFile(self, tsk_path, destination_path): '''Extract a file from image to provided destination path''' if self.use_native_hfs_parser: return self.hfs_native.ExtractFile(tsk_path, destination_path) try: tsk_file = self.macos_FS.open(tsk_path) size = tsk_file.info.meta.size BUFF_SIZE = 20 * 1024 * 1024 offset = 0 try: with open(destination_path, 'wb') as f: while offset < size: available_to_read = min(BUFF_SIZE, size - offset) try: data = tsk_file.read_random(offset, available_to_read) if not data: break offset += len(data) f.write(data) except Exception as ex: if str(ex).find('tsk_fs_attrlist_get: Attribute 4352 not found') > 0 or \ (str(ex).find('Read error: Invalid file offset') > 0 and self._IsFileCompressed(tsk_file)) or \ str(ex).find('Read error: Error in metadata') > 0: log.debug("Known TSK bug caused Error: Failed to read file {}".format(tsk_path)) log.debug("Trying to read with Native HFS parser") try: f.close() os.remove(destination_path) if not self.hfs_native.initialized: self.hfs_native.Initialize(self.pytsk_image, self.macos_partition_start_offset) return self.hfs_native.ExtractFile(tsk_path,destination_path) except Exception as ex2: log.error("Failed to export file: " + tsk_path) log.debug("Exception details:\n", exc_info=True) return False else: log.exception("Failed to read file {}".format(tsk_path)) return False f.flush() f.close() return True except Exception as ex: log.error (" Failed to create file for writing - " + destination_path + "\n" + str(ex)) log.debug("Exception details:", exc_info=True) except Exception as ex: if str(ex).find('tsk_fs_file_open: path not found:') > 0: log.debug("Open() returned 'Path not found' error for path: {}".format(tsk_path)) else: log.error("Failed to open/find file: " + tsk_path) return False def GetArrayFirstElement(self, array, error=''): '''Safely return zero'th element''' try: return array[0] except IndexError: pass return error def GetVersionDictionary(self): '''Returns macOS version as dictionary {major:10, minor:5 , micro:0}''' version_dict = { 'major':0, 'minor':0, 'micro':0 } info = self.os_version.split(".") try: version_dict['major'] = int(info[0]) try: version_dict['minor'] = int(info[1]) try: version_dict['micro'] = int(info[2]) except Exception: pass except Exception: pass except Exception: pass return version_dict def GetUserAndGroupIDForFolder(self, path): ''' Returns tuple (success, UID, GID) for folder identified by path If failed to get values, success=False UID & GID are returned as strings ''' success, uid, gid = False, 0, 0 try: path_dir = self.macos_FS.open_dir(path) uid = str(path_dir.info.fs_file.meta.uid) gid = str(path_dir.info.fs_file.meta.gid) success = True except Exception as ex: log.error("Exception trying to get uid & gid for folder " + path + ' Exception details: ' + str(ex)) return success, uid, gid def GetUserAndGroupIDForFile(self, path): ''' Returns tuple (success, UID, GID) for file identified by path If failed to get values, success=False UID & GID are returned as strings ''' success, uid, gid = False, 0, 0 try: path_file = self.macos_FS.open(path) uid = str(path_file.info.meta.uid) gid = str(path_file.info.meta.gid) success = True except Exception as ex: log.error("Exception trying to get uid & gid for file " + path + ' Exception details: ' + str(ex)) return success, uid, gid # Private (Internal) functions, plugins should not use these def _GetSafeFilename(self, name): ''' Removes illegal characters from filenames Eg: Windows does not like ?<>/\:*"! in filename ''' try: unsafe_chars = '?<>/\:*"!\r\n' if self.is_windows else '/' return ''.join([c for c in name if c not in unsafe_chars]) except: pass return "_error_no_name_" def _IsFileCompressed(self, tsk_file): '''For a pytsk3 file entry, determines if a file is compressed''' try: return int(tsk_file.info.meta.flags) & pytsk3.TSK_FS_META_FLAG_COMP except Exception as ex: log.error (" Unknown exception from _IsFileCompressed() " + str(ex)) return False def _GetSize(self, entry): '''For a pytsk3 file entry, gets logical file size, or 0 if error''' try: return entry.info.meta.size except Exception as ex: log.error (" Unknown exception from _GetSize() " + str(ex)) return 0 def _GetName(self, entry): '''Return utf8 filename from pytsk entry object''' try: return entry.info.name.name.decode("utf8", "ignore") except UnicodeError: #log.debug("UnicodeError getting name ") pass except Exception as ex: log.error (" Unknown exception from GetName:" + str(ex)) return "" def _CheckFileContents(self, f): f.seek(0) header = f.read(4) if len(header) == 4 and header == b'\0\0\0\0': log.error('File header was zeroed out. If the source is an E01 file, this may be a libewf problem.'\ ' Try to use a different version of libewf. Read more about this here:'\ ' https://github.com/ydkhatri/mac_apt/wiki/Known-issues-and-Workarounds') def _IsValidFileOrFolderEntry(self, entry): try: if entry.info.name.type == pytsk3.TSK_FS_NAME_TYPE_REG: return True elif entry.info.name.type == pytsk3.TSK_FS_NAME_TYPE_DIR: return True else: log.warning(" Found invalid entry - " + self._GetName(entry) + " " + str(entry.info.name.type) ) except Exception: log.error(" Unknown exception from _IsValidFileOrFolderEntry:" + self._GetName(entry)) log.debug("Exception details:\n", exc_info=True) return False def _GetDomainUserInfo(self): '''Populates self.users with data from /Users/''' log.debug('Trying to get domain profiles from /Users/') users_folder = self.ListItemsInFolder('/Users/', EntryType.FOLDERS) for folder in users_folder: folder_path = '/Users/' + folder['name'] success, uid, gid = self.GetUserAndGroupIDForFolder(folder_path) if success: found_user = False for user in self.users: if user.UID == uid: found_user = True break if found_user: continue else: target_user = UserInfo() self.users.append(target_user) target_user.UID = uid target_user.GID = gid #target_user.UUID = unknown target_user.home_dir = folder_path target_user.user_name = folder['name'] target_user.real_name = folder['name'] target_user._source = folder_path def _ReadPasswordPolicyData(self, password_policy_data, target_user): try: plist2 = biplist.readPlistFromString(password_policy_data[0]) target_user.failed_login_count = plist2.get('failedLoginCount', 0) target_user.failed_login_timestamp = plist2.get('failedLoginTimestamp', None) target_user.last_login_timestamp = plist2.get('lastLoginTimestamp', None) target_user.password_last_set_time = plist2.get('passwordLastSetTime', None) except (biplist.InvalidPlistException, biplist.NotBinaryPlistException): log.exception('Error reading password_policy_data embedded plist') def _ReadAccountPolicyData(self, account_policy_data, target_user): try: plist2 = biplist.readPlistFromString(account_policy_data[0]) target_user.creation_time = CommonFunctions.ReadUnixTime(plist2.get('creationTime', None)) target_user.failed_login_count = plist2.get('failedLoginCount', 0) target_user.failed_login_timestamp = CommonFunctions.ReadUnixTime(plist2.get('failedLoginTimestamp', None)) target_user.password_last_set_time = CommonFunctions.ReadUnixTime(plist2.get('passwordLastSetTime', None)) except (biplist.InvalidPlistException, biplist.NotBinaryPlistException): log.exception('Error reading password_policy_data embedded plist') def _GetUserInfo(self): '''Populates user info from plists under: /private/var/db/dslocal/nodes/Default/users/''' #TODO - make a better plugin that gets all user & group info users_path = '/private/var/db/dslocal/nodes/Default/users' user_plists = self.ListItemsInFolder(users_path, EntryType.FILES) for plist_meta in user_plists: if plist_meta['size'] > 0: try: user_plist_path = users_path + '/' + plist_meta['name'] f = self.Open(user_plist_path) if f!= None: self.ExportFile(user_plist_path, 'USERS', '', False) try: plist = biplist.readPlist(f) home_dir = self.GetArrayFirstElement(plist.get('home', '')) if home_dir != '': #log.info('{} : {}'.format(plist_meta['name'], home_dir)) if home_dir.startswith('/var/'): home_dir = '/private' + home_dir # in mac /var is symbolic link to /private/var target_user = UserInfo() self.users.append(target_user) target_user.UID = str(self.GetArrayFirstElement(plist.get('uid', ''))) target_user.GID = str(self.GetArrayFirstElement(plist.get('gid', ''))) target_user.UUID = self.GetArrayFirstElement(plist.get('generateduid', '')) target_user.home_dir = home_dir target_user.user_name = self.GetArrayFirstElement(plist.get('name', '')) target_user.real_name = self.GetArrayFirstElement(plist.get('realname', '')) target_user.pw_hint = self.GetArrayFirstElement(plist.get('hint', '')) target_user._source = user_plist_path os_version = self.GetVersionDictionary() if os_version['major'] == 10 and os_version['minor'] <= 9: # Mavericks & earlier password_policy_data = plist.get('passwordpolicyoptions', None) if password_policy_data == None: log.debug('Could not find passwordpolicyoptions for user {}'.format(target_user.user_name)) else: self._ReadPasswordPolicyData(password_policy_data, target_user) else: # 10.10 - Yosemite & higher account_policy_data = plist.get('accountPolicyData', None) if account_policy_data == None: pass #log.debug('Could not find accountPolicyData for user {}'.format(target_user.user_name)) else: self._ReadAccountPolicyData(account_policy_data, target_user) else: log.error('Did not find \'home\' in ' + plist_meta['name']) except (biplist.InvalidPlistException, biplist.NotBinaryPlistException): log.exception("biplist failed to read plist " + user_plist_path) self._CheckFileContents(f) f.close() except (OSError, KeyError, ValueError, IndexError, TypeError): log.exception ("Could not open/read plist " + user_plist_path) self._GetDomainUserInfo() self._GetDarwinFoldersInfo() # This probably does not apply to OSX < Mavericks ! def _GetDarwinFoldersInfo(self): '''Gets DARWIN_*_DIR paths by looking up folder permissions''' users_dir = self.ListItemsInFolder('/private/var/folders', EntryType.FOLDERS) for unknown1 in users_dir: unknown1_name = unknown1['name'] unknown1_dir = self.ListItemsInFolder('/private/var/folders/' + unknown1_name, EntryType.FOLDERS) for unknown2 in unknown1_dir: unknown2_name = unknown2['name'] path = '/private/var/folders/' + unknown1_name + "/" + unknown2_name success, uid, gid = self.GetUserAndGroupIDForFolder(path) if success: found_user = False for user in self.users: if user.UID == uid: if user.DARWIN_USER_DIR: log.warning('There is already a value in DARWIN_USER_DIR {}'.format(user.DARWIN_USER_DIR)) #Sometimes (rare), if UUID changes, there may be another folder upon restart for DARWIN_USER, we will just concatenate with comma. If you see this, it is more likely that another user with same UID existed prior. user.DARWIN_USER_DIR += ',' + path + '/0' user.DARWIN_USER_CACHE_DIR += ',' + path + '/C' user.DARWIN_USER_TEMP_DIR += ',' + path + '/T' else: user.DARWIN_USER_DIR = path + '/0' user.DARWIN_USER_CACHE_DIR = path + '/C' user.DARWIN_USER_TEMP_DIR = path + '/T' found_user = True break if not found_user: log.error('Could not find username for UID={} GID={}'.format(uid, gid)) def _GetSystemInfo(self): ''' Gets system version information''' try: log.debug("Trying to get system version from /System/Library/CoreServices/SystemVersion.plist") f = self.Open('/System/Library/CoreServices/SystemVersion.plist') if f != None: try: plist = biplist.readPlist(f) self.os_version = plist.get('ProductVersion', '') self.os_build = plist.get('ProductBuildVersion', '') if self.os_version != '': if self.os_version.startswith('10.10'): self.os_friendly_name = 'Yosemite' elif self.os_version.startswith('10.11'): self.os_friendly_name = 'El Capitan' elif self.os_version.startswith('10.12'): self.os_friendly_name = 'Sierra' elif self.os_version.startswith('10.13'): self.os_friendly_name = 'High Sierra' elif self.os_version.startswith('10.14'): self.os_friendly_name = 'Mojave' elif self.os_version.startswith('10.15'): self.os_friendly_name = 'Catalina' elif self.os_version.startswith('10.0'): self.os_friendly_name = 'Cheetah' elif self.os_version.startswith('10.1'): self.os_friendly_name = 'Puma' elif self.os_version.startswith('10.2'): self.os_friendly_name = 'Jaguar' elif self.os_version.startswith('10.3'): self.os_friendly_name = 'Panther' elif self.os_version.startswith('10.4'): self.os_friendly_name = 'Tiger' elif self.os_version.startswith('10.5'): self.os_friendly_name = 'Leopard' elif self.os_version.startswith('10.6'): self.os_friendly_name = 'Snow Leopard' elif self.os_version.startswith('10.7'): self.os_friendly_name = 'Lion' elif self.os_version.startswith('10.8'): self.os_friendly_name = 'Mountain Lion' elif self.os_version.startswith('10.9'): self.os_friendly_name = 'Mavericks' elif self.os_version.startswith('11.'): self.os_friendly_name = 'Big Sur' else: self.os_friendly_name = 'Unknown version!' log.info ('macOS version detected is: {} ({}) Build={}'.format(self.os_friendly_name, self.os_version, self.os_build)) f.close() return True except (biplist.InvalidPlistException, biplist.NotBinaryPlistException) as ex: log.error ("Could not get ProductVersion from plist. Is it a valid xml plist? Error=" + str(ex)) f.close() else: log.error("Could not open plist to get system version info!") except: log.exception("Unknown error from _GetSystemInfo()") return False class ApfsMacInfo(MacInfo): def __init__(self, output_params, password): super().__init__(output_params, password) self.apfs_container = None self.apfs_db = None self.apfs_db_path = '' self.apfs_sys_volume = None # New in 10.15, a System read-only partition self.apfs_data_volume = None # New in 10.15, a separate Data partition def UseCombinedVolume(self): self.macos_FS = ApfsSysDataLinkedVolume(self.apfs_sys_volume, self.apfs_data_volume) def CreateCombinedVolume(self): '''Returns True/False depending on whether system & data volumes could be combined successfully''' try: self.macos_FS = ApfsSysDataLinkedVolume(self.apfs_sys_volume, self.apfs_data_volume) apfs_parser = ApfsFileSystemParser(self.macos_FS, self.apfs_db) return apfs_parser.create_linked_volume_tables(self.apfs_sys_volume, self.apfs_data_volume, self.macos_FS.firmlinks_paths, self.macos_FS.firmlinks) except (ValueError, TypeError) as ex: log.exception('') log.error('Failed to create combined System + Data volume') return False def ReadApfsVolumes(self): '''Read volume information into an sqlite db''' decryption_key = None # Process Preboot volume first preboot_vol = self.apfs_container.preboot_volume if preboot_vol: apfs_parser = ApfsFileSystemParser(preboot_vol, self.apfs_db) apfs_parser.read_volume_records() preboot_vol.dbo = self.apfs_db # Process other volumes now for vol in self.apfs_container.volumes: vol.dbo = self.apfs_db if vol == preboot_vol: continue elif vol.is_encrypted and self.apfs_container.is_sw_encrypted: # For hardware encryption(T2), do nothing, it should have been acquired as decrypted.. if self.password == '': log.error(f'Skipping vol {vol.volume_name}. The vol is ENCRYPTED and user did not specify a password to decrypt it!' + f' If you know the password, run mac_apt again with the -p option to decrypt this volume.') continue uuid_folders = [] preboot_dir = preboot_vol.ListItemsInFolder('/') for items in preboot_dir: if len(items['name']) == 36: # UUID Named folder uuid_folders.append(items['name']) if len(uuid_folders) > 1: log.warning("There are more than 1 UUID like folders:\n" + str(uuid_folders) + "\nThe volume cannot be unencrypted by this script. Please contact the developers") elif len(uuid_folders) == 0: log.error("There are no UUID like folders in the Preboot volume! Decryption cannot continue") else: plist_path = uuid_folders[0] + "/var/db/CryptoUserInfo.plist" if preboot_vol.DoesFileExist(plist_path): plist_raw_data = preboot_vol.open(plist_path).readAll() plist_data = biplist.readPlistFromString(plist_raw_data) decryption_key = decryptor.EncryptedVol(vol, plist_data, self.password).decryption_key if decryption_key is None: log.error(f"No decryption key found. Did you enter the right password? Volume '{vol.volume_name}' cannot be decrypted!") else: log.debug(f"Starting decryption of filesystem, VEK={decryption_key.hex().upper()}") vol.encryption_key = decryption_key apfs_parser = ApfsFileSystemParser(vol, self.apfs_db) apfs_parser.read_volume_records() else: apfs_parser = ApfsFileSystemParser(vol, self.apfs_db) apfs_parser.read_volume_records() def GetFileMACTimes(self, file_path): '''Gets MACB and the 5th Index timestamp too''' times = { 'c_time':None, 'm_time':None, 'cr_time':None, 'a_time':None, 'i_time':None } try: apfs_file_meta = self.macos_FS.GetFileMetadataByPath(file_path) if apfs_file_meta: times['c_time'] = apfs_file_meta.changed times['m_time'] = apfs_file_meta.modified times['cr_time'] = apfs_file_meta.created times['a_time'] = apfs_file_meta.accessed times['i_time'] = apfs_file_meta.date_added else: log.debug('File not found in GetFileMACTimes() query!, path was ' + file_path) except Exception as ex: log.exception('Error trying to get MAC times') return times def IsSymbolicLink(self, path): return self.macos_FS.IsSymbolicLink(path) def IsValidFilePath(self, path): return self.macos_FS.DoesFileExist(path) def IsValidFolderPath(self, path): return self.macos_FS.DoesFolderExist(path) def GetExtendedAttribute(self, path, att_name): return self.macos_FS.GetExtendedAttribute(path, att_name) def GetExtendedAttributes(self, path): xattrs = {} apfs_xattrs = self.macos_FS.GetExtendedAttributes(path) return { att_name:att.data for att_name,att in apfs_xattrs.items() } def GetFileSize(self, full_path, error=None): try: apfs_file_meta = self.macos_FS.GetFileMetadataByPath(full_path) if apfs_file_meta: return apfs_file_meta.logical_size except Exception as ex: log.debug ("APFSMacInfo->Exception from GetFileSize() " + str(ex)) return error def Open(self, path): '''Open file and return a file-like object''' return self.macos_FS.open(path) def ExtractFile(self, tsk_path, destination_path): return self.macos_FS.CopyOutFile(tsk_path, destination_path) def _GetSize(self, entry): '''For file entry, gets logical file size, or 0 if error''' try: apfs_file_meta = self.macos_FS.GetFileMetadataByPath(path) if apfs_file_meta: return apfs_file_meta.logical_size except: pass return 0 def GetUserAndGroupIDForFile(self, path): return self._GetUserAndGroupID(path) def GetUserAndGroupIDForFolder(self, path): return self._GetUserAndGroupID(path) def _GetUserAndGroupID(self, path): ''' Returns tuple (success, UID, GID) for file/folder identified by path If failed to get values, success=False UID & GID are returned as strings ''' success, uid, gid = False, 0, 0 apfs_file_meta = self.macos_FS.GetFileMetadataByPath(path) if apfs_file_meta: uid = str(apfs_file_meta.uid) gid = str(apfs_file_meta.gid) success = True else: log.debug("Path not found in database (filesystem) : " + path) return success, uid, gid def ListItemsInFolder(self, path='/', types_to_fetch=EntryType.FILES_AND_FOLDERS, include_dates=False): '''Always returns dates ignoring the 'include_dates' parameter''' items = [] all_items = self.macos_FS.ListItemsInFolder(path) if all_items: if types_to_fetch == EntryType.FILES_AND_FOLDERS: items = [] #[dict(x) for x in all_items if x['type'] in ['File', 'Folder'] ] for x in all_items: if x['type'] in ('File', 'SymLink'): x['type'] = EntryType.FILES items.append(dict(x)) elif x['type'] == 'Folder': x['type'] = EntryType.FOLDERS items.append(dict(x)) elif types_to_fetch == EntryType.FILES: for x in all_items: if x['type'] in ('File', 'SymLink'): x['type'] = EntryType.FILES items.append(dict(x)) else: # Folders for x in all_items: if x['type'] == 'Folder': x['type'] = EntryType.FOLDERS items.append(dict(x)) return items class MountedFile(): # This class is a file-like object, its existence is due to # Xways Forensics bug with reading mounted files, which can't # handle f.read() , only f.read(size) works and size must not # go beyond end of file. This class ensures that part. def __init__(self): self.pos = 0 self.size = 0 self._file = None self.closed = True def _check_closed(self): if self.closed: raise ValueError("File is closed!") # file methods def open(self, file_path, mode='rb'): self.size = os.path.getsize(file_path) self._file = open(file_path, mode) self.closed = False return self def close(self): self.closed = True if self._file: self._file.close() def tell(self): return self.pos def seek(self, offset, whence=0): if self.closed: raise ValueError("seek of closed file") self._file.seek(offset, whence) self.pos = self._file.tell() def __iter__(self): return self def __next__(self): line = self.readline() if len(line) == 0: raise StopIteration return line def readline(self, size=None): ret = b'' original_file_pos = self.tell() stop_at_one_iteration = True lf_found = False if size == None: stop_at_one_iteration = False size = 1024 buffer = self.read(size) while buffer: new_line_pos = buffer.find(b'\n') if new_line_pos == -1: # not_found, add to line ret += buffer else: ret += buffer[0:new_line_pos + 1] lf_found = True self.seek(original_file_pos + len(ret)) if stop_at_one_iteration or lf_found: break buffer = self.read(size) return ret def readlines(self, sizehint=None): lines = [] line = self.readline() while line: lines.append(line) line = self.readline() return lines def read(self, size_to_read=None): if self.closed: raise ValueError("read of closed file") data = b'' if size_to_read == None: size_to_read = self.size - self.pos if size_to_read > 0: data = self._file.read(size_to_read) self.pos += len(data) elif self.pos >= self.size: # at or beyond EOF, nothing to read pass else: end_pos = self.pos + size_to_read if end_pos > self.size: size_to_read = self.size - self.pos if size_to_read > 0: data = self._file.read(size_to_read) self.pos += len(data) return data # TODO: Make this class more efficient, perhaps remove some extractions! class MountedMacInfo(MacInfo): def __init__(self, root_folder_path, output_params): super().__init__(output_params) self.macos_root_folder = root_folder_path # TODO: if os.name == 'nt' and len (root_folder_path) == 2 and root_folder_path[2] == ':': self.macos_root_folder += '\\' if self.is_linux: log.warning('Since this is a linux (mounted) system, there is no way for python to extract created_date timestamps. '\ 'This is a limitation of Python. Created timestamps shown/seen will actually be same as Last_Modified timestamps.') def BuildFullPath(self, path_in_image): ''' Takes path inside image as input and returns the full path on current volume Eg: Image mounted at D:\Images\mac_osx\ Path=\etc\hosts Return= D:\Images\mac_osx\etc\hosts ''' full_path = '' path = path_in_image # remove leading / for os.path.join() if path != '/' and path.startswith('/'): path = path[1:] if self.is_windows: path = path.replace('/', '\\') try: full_path = os.path.join(self.macos_root_folder, path) except Exception: log.error("Exception in BuildFullPath(), path was " + path_in_image) log.exception("Exception details") #log.debug("req={} final={}".format(path_in_image, full_path)) return full_path def _get_creation_time(self, local_path): if self.is_windows: return CommonFunctions.ReadUnixTime(os.path.getctime(local_path)) elif self.is_linux: try: t = statx(local_path).get_btime() # New Linux kernel 4+ has this ability except (OSError, ValueError) as ex: t = 0 # Old linux kernel that does not support statx if t != 0: return CommonFunctions.ReadUnixTime(t) else: # Either old linux or a version of FUSE that does not populates btime (current does not)! return CommonFunctions.ReadUnixTime(os.path.getmtime(local_path)) # Since this is not possible to fetch in Linux (using python)! else: return CommonFunctions.ReadUnixTime(os.stat(local_path).st_birthtime) def GetFileMACTimes(self, file_path): file_path = self.BuildFullPath(file_path) times = { 'c_time':None, 'm_time':None, 'cr_time':None, 'a_time':None } try: times['c_time'] = None if self.is_windows else CommonFunctions.ReadUnixTime(os.path.getctime(file_path)) times['m_time'] = CommonFunctions.ReadUnixTime(os.path.getmtime(file_path)) times['cr_time'] = self._get_creation_time(file_path) times['a_time'] = CommonFunctions.ReadUnixTime(os.path.getatime(file_path)) except OSError as ex: log.exception('Error trying to get MAC times') return times def IsSymbolicLink(self, path): try: return os.path.islink(self.BuildFullPath(path)) except OSError as ex: log.exception("Exception in IsSymbolicLink() for path : {} " + path) return False def IsValidFilePath(self, path): try: return os.path.lexists(self.BuildFullPath(path)) except OSError as ex: log.exception("Exception in IsValidFilePath() for path : {} " + path) return False def IsValidFolderPath(self, path): return self.IsValidFilePath(path) def _GetFileSizeNoPathMod(self, full_path, error=None): '''Simply calls os.path.getsize(), BEWARE-does not build full path!''' try: return os.path.getsize(full_path) except OSError as ex: log.error("Exception in _GetFileSizeNoPathMod() : " + str(ex)) return error def GetFileSize(self, full_path, error=None): '''Builds full path, then gets size''' try: return os.path.getsize(self.BuildFullPath(full_path)) except OSError as ex: log.debug("Exception in GetFileSize() : " + str(ex) + " Perhaps file does not exist: " + full_path) return error def GetUserAndGroupIDForFile(self, path): return self._GetUserAndGroupID(self.BuildFullPath(path)) def GetUserAndGroupIDForFolder(self, path): return self._GetUserAndGroupID(self.BuildFullPath(path)) def ListItemsInFolder(self, path='/', types_to_fetch=EntryType.FILES_AND_FOLDERS, include_dates=False): ''' Returns a list of files and/or folders in a list Format of list = [ {'name':'got.txt', 'type':EntryType.FILES, 'size':10}, .. ] 'path' should be linux style using forward-slash like '/var/db/xxyy/file.tdc' and starting at root / ''' items = [] # List of dictionaries try: mounted_path = self.BuildFullPath(path) dir = os.listdir(mounted_path) for entry in dir: # Exclude the mounted encase <file>.Stream which is uncompressed stream of file, # not needed as we have the actual file if entry.find('\xB7Stream') >= 0 or entry.find('\xB7Resource') >= 0: log.debug(f'Excluding {entry} as it is raw stream not FILE. If you think this should be included, let the developers know!') continue newpath = os.path.join(mounted_path, entry) entry_type = EntryType.FOLDERS if os.path.isdir(newpath) else EntryType.FILES item = { 'name':entry, 'type':entry_type, 'size':self._GetFileSizeNoPathMod(newpath, 0)} if include_dates: item['dates'] = self.GetFileMACTimes(path + '/' + entry) if types_to_fetch == EntryType.FILES_AND_FOLDERS: items.append( item ) elif types_to_fetch == EntryType.FILES and entry_type == EntryType.FILES: items.append( item ) elif types_to_fetch == EntryType.FOLDERS and entry_type == EntryType.FOLDERS: items.append( item ) except FileNotFoundError as ex: if str(ex).find('There are no more files') >= 0: # known windows issue on some empty folders!! '[WinError 18] There are no more files:...' pass else: log.debug("Path not found : " + mounted_path) except Exception as ex: log.exception('') if str(ex).find('cannot find the path specified'): log.debug("Path not found : " + mounted_path) else: log.debug("Problem accessing path : " + mounted_path) log.debug("Exception details:\n", exc_info=True) #traceback.print_exc() log.error("Failed to get dir info!") return items def ReadSymLinkTargetPath(self, path): '''Returns the target file/folder's path from the sym link path provided''' target_path = '' try: if not self.is_windows: target_path = os.readlink(self.BuildFullPath(path)) else: target_path = super().ReadSymLinkTargetPath(path) except: log.exception("Error resolving symlink : " + path) return target_path def Open(self, path): try: mounted_path = self.BuildFullPath(path) log.debug("Trying to open file : " + mounted_path) file = MountedFile().open(mounted_path, 'rb') return file except (OSError) as ex: log.exception("Error opening file : " + mounted_path) return None def ExtractFile(self, path_in_image, destination_path): source_file = self.Open(path_in_image) if source_file: size = self.GetFileSize(path_in_image) BUFF_SIZE = 20 * 1024 * 1024 offset = 0 try: with open(destination_path, 'wb') as f: while offset < size: available_to_read = min(BUFF_SIZE, size - offset) data = source_file.read(available_to_read) if not data: break offset += len(data) f.write(data) f.flush() except (OSError) as ex: log.exception ("Failed to create file for writing at " + destination_path) source_file.close() return False source_file.close() return True return False def _GetUserAndGroupID(self, path): ''' Returns tuple (success, UID, GID) for object identified by path. UID & GID are returned as strings. If failed to get values, success=False ''' success, uid, gid = False, 0, 0 try: stat = os.stat(path) uid = str(stat.st_uid) gid = str(stat.st_gid) success = True except OSError as ex: log.error("Exception trying to get uid & gid for file " + path + ' Exception details: ' + str(ex)) return success, uid, gid def _GetDarwinFoldersInfo(self): '''Gets DARWIN_*_DIR paths ''' if not self.is_windows: # Unix/Linux or Mac mounted disks should preserve UID/GID, so we can read it normally from the files. super()._GetDarwinFoldersInfo(self) return for user in self.users: if user.UUID != '' and user.UID not in ('', '-2', '1', '201'): # Users nobody, daemon, guest don't have one darwin_path = '/private/var/folders/' + GetDarwinPath2(user.UUID, user.UID) if not self.IsValidFolderPath(darwin_path): darwin_path = '/private/var/folders/' + GetDarwinPath(user.UUID, user.UID) if not self.IsValidFolderPath(darwin_path): if user.user_name.startswith('_') and user.UUID.upper().startswith('FFFFEEEE'): pass else: log.warning(f'Could not find DARWIN_PATH for user {user.user_name}, uid={user.UID}, uuid={user.UUID}') continue user.DARWIN_USER_DIR = darwin_path + '/0' user.DARWIN_USER_CACHE_DIR = darwin_path + '/C' user.DARWIN_USER_TEMP_DIR = darwin_path + '/T' def _GetDomainUserInfo(self): if not self.is_windows: # Unix/Linux or Mac mounted disks should preserve UID/GID, so we can read it normally from the files. super()._GetDomainUserInfo(self) return log.debug('Trying to get domain profiles from /Users/') domain_users = [] users_folder = self.ListItemsInFolder('/Users/', EntryType.FOLDERS) for folder in users_folder: folder_name = folder['name'] if folder_name in ('Shared', 'root'): continue found_user = False for user in self.users: if user.user_name == folder_name: found_user = True # Existing local user break if found_user: continue else: log.info(f'Found a domain user {folder_name} or deleted user?') target_user = UserInfo() domain_users.append(target_user) target_user.home_dir = '/Users/' + folder_name target_user.user_name = folder_name target_user.real_name = folder_name target_user._source = '/Users/' + folder_name if domain_users: known_darwin_paths = set() for user in self.users: if user.UID and user.UUID and not user.UID.startswith('-'): known_darwin_paths.add('/private/var/folders/' + GetDarwinPath(user.UUID, user.UID)) # They haven't been populated yet in user! known_darwin_paths.add('/private/var/folders/' + GetDarwinPath2(user.UUID, user.UID)) # try to get darwin_cache folders var_folders = self.ListItemsInFolder('/private/var/folders', EntryType.FOLDERS) for level_1 in var_folders: name_1 = level_1['name'] var_folders_level_2 = self.ListItemsInFolder(f'/private/var/folders/{name_1}', EntryType.FOLDERS) for level_2 in var_folders_level_2: darwin_path = f'/private/var/folders/{name_1}/' + level_2['name'] if darwin_path in known_darwin_paths: continue else: matched_darwin_path_to_user = False font_reg_db = darwin_path + '/C/com.apple.FontRegistry/fontregistry.user' if self.IsValidFilePath(font_reg_db): try: sqlite_wrapper = SqliteWrapper(self) db = sqlite_wrapper.connect(font_reg_db) if db: cursor = db.cursor() cursor.execute('SELECT path_column from dir_table WHERE domain_column=1') user_path = '' for row in cursor: user_path = row[0] break cursor.close() db.close() if user_path: if user_path.startswith('/Users/'): username = user_path.split('/')[2] for dom_user in domain_users: if dom_user.user_name == username: dom_user.DARWIN_USER_DIR = darwin_path + '/0' dom_user.DARWIN_USER_TEMP_DIR = darwin_path + '/T' dom_user.DARWIN_USER_CACHE_DIR = darwin_path + '/C' log.debug(f'Found darwin path for user {username}') matched_darwin_path_to_user = True # Try to get uid now. if self.IsValidFolderPath(dom_user.DARWIN_USER_DIR + '/com.apple.LaunchServices.dv'): for item in self.ListItemsInFolder(dom_user.DARWIN_USER_DIR + '/com.apple.LaunchServices.dv', EntryType.FILES): name = item['name'] if name.startswith('com.apple.LaunchServices.trustedsignatures-') and name.endswith('.db'): dom_user.UID = name[43:-3] break break else: log.error(f'user profile path was non-standard - {user_path}') else: log.error('Query did not yield any output!') if not matched_darwin_path_to_user: log.error(f'Could not find mapping for darwin folder {darwin_path} to user') except sqlite3.Error: log.exception(f'Error reading {font_reg_db}, Cannot map darwin folder to user profile!') else: log.error(f'Could not find {font_reg_db}, Cannot map darwin folder to user profile!') self.users.extend(domain_users) class MountedMacInfoSeperateSysData(MountedMacInfo): '''Same as MountedMacInfo, but takes into account two volumes (SYS, DATA) mounted separately''' def __init__(self, sys_root_folder_path, data_root_folder_path, output_params): super().__init__(sys_root_folder_path, output_params) self.sys_volume_folder = sys_root_folder_path # New in 10.15, a System read-only partition self.data_volume_folder = data_root_folder_path # New in 10.15, a separate Data partition self.firmlinks = {} self.firmlinks_paths =[] self.max_firmlink_depth = 0 self._ParseFirmlinks() def _ParseFirmlinks(self): '''Read the firmlink path mappings between System & Data volumes''' firmlink_file_path = '/usr/share/firmlinks' try: mounted_path = super().BuildFullPath(firmlink_file_path) log.debug("Trying to open file : " + mounted_path) f = open(mounted_path, 'rb') except (OSError) as ex: log.exception("Error opening file : " + mounted_path) raise ValueError('Fatal : Could not find/read Firmlinks file in System volume!') data = [x.decode('utf8') for x in f.read().split(b'\n')] for item in data: if item: source, dest = item.split('\t') self.firmlinks[source] = dest self.firmlinks_paths.append(source) depth = len(source[1:].split('/')) if depth > self.max_firmlink_depth: self.max_firmlink_depth = depth if source[1:] != dest: # Maybe this is the Beta version of Catalina, try prefix 'Device' if dest.startswith('Device/'): self.firmlinks[source] = dest[7:] else: log.warning("Firmlink not handled : Source='{}' Dest='{}'".format(source, dest)) #add one for /System/Volumes/Data / self.firmlinks['/System/Volumes/Data'] = '' self.firmlinks_paths.append('/System/Volumes/Data') f.close() def BuildFullPath(self, path_in_image): ''' Takes path inside image as input and returns the full path on current volume Eg: Image mounted at D:\Images\mac_osx\ Path=\etc\hosts Return= D:\Images\mac_osx\etc\hosts Takes into account firmlinks and accordingly switches to SYS or DATA volume. ''' if path_in_image == '/': return self.sys_volume_folder if path_in_image[-1] == '/': path_in_image = path_in_image[:-1] # remove trailing / path_parts = path_in_image[1:].split('/') path = '' vol_folder = self.sys_volume_folder for index, folder_name in enumerate(path_parts): log.debug("index={}, folder_name={}".format(index, folder_name)) if index >= self.max_firmlink_depth: break else: log.debug("Searched for {}".format('/' + '/'.join(path_parts[:index + 1]))) dest = self.firmlinks.get('/' + '/'.join(path_parts[:index + 1]), None) if dest != None: log.debug("FOUND**********") found_in_firmlink = True vol_folder = self.data_volume_folder path = dest if index + 1 < len(path_parts): rest_of_path = '/'.join(path_parts[index + 1:]) path += '/' + rest_of_path elif path == '': path = '/' full_path = '' if path == '': path = path_in_image if path.startswith('/'): path = path[1:] # Remove leading / if self.is_windows: path = path.replace('/', '\\') try: full_path = os.path.join(vol_folder, path) except Exception: log.error("Exception in BuildFullPath(), path was " + path_in_image) log.exception("Exception details") log.debug("req={} final={}".format(path_in_image, full_path)) return full_path class MountedIosInfo(MountedMacInfo): def __init__(self, root_folder_path, output_params): super().__init__(root_folder_path, output_params) def GetUserAndGroupIDForFile(self, path): raise NotImplementedError() def GetUserAndGroupIDForFolder(self, path): return NotImplementedError() def _GetUserAndGroupID(self, path): return NotImplementedError() def _GetDarwinFoldersInfo(self): '''Gets DARWIN_*_DIR paths, these do not exist on IOS''' return NotImplementedError() def _GetUserInfo(self): return NotImplementedError() def _GetSystemInfo(self): ''' Gets system version information''' try: log.debug("Trying to get system version from /System/Library/CoreServices/SystemVersion.plist") f = self.Open('/System/Library/CoreServices/SystemVersion.plist') if f != None: try: plist = biplist.readPlist(f) self.os_version = plist.get('ProductVersion', '') self.os_build = plist.get('ProductBuildVersion', '') self.os_friendly_name = plist.get('ProductName', '') log.info ('iOS version detected is: {} ({}) Build={}'.format(self.os_friendly_name, self.os_version, self.os_build)) f.close() return True except (biplist.InvalidPlistException, biplist.NotBinaryPlistException) as ex: log.error ("Could not get ProductVersion from plist. Is it a valid xml plist? Error=" + str(ex)) f.close() else: log.error("Could not open plist to get system version info!") except: log.exception("Unknown error from _GetSystemInfo()") return False class SqliteWrapper: ''' Wrapper class for sqlite operations This is to extract the sqlite db and related files to disk before it can be opened. When object is destroyed, it will delete these temp files. Plugins can use this class and use the SqliteWrapper.connect() function to get a connection object. All other sqlite objects can be normally retrieved through SqliteWrapper.sqlite3. Use a new instance of SqliteWrapper for every database processed. WARNING: Keep this object/ref alive till you are using the db. And don't forget to call db.close() when you are done. ''' def __init__(self, mac_info): self.mac_info = mac_info self.sqlite3 = sqlite3 self.db_file_path = '' self.jrn_file_path = '' self.wal_file_path = '' self.db_file_path_temp = '' self.jrn_file_path_temp = '' self.wal_file_path_temp = '' self.db_temp_file = None self.shm_temp_file = None self.wal_temp_file = None self.folder_temp_path = os.path.join(mac_info.output_params.output_path, "Temp" + ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(4))) def _ExtractFiles(self): # create temp folder try: if not os.path.exists(self.folder_temp_path): os.makedirs(self.folder_temp_path) except Exception as ex: log.error ("Exception in _ExtractFiles(). Is ouput folder Writeable? Is it full? Perhaps the drive is disconnected? Exception Details: " + str(ex)) return False # extract each file to temp folder self.db_file_path_temp = os.path.join(self.folder_temp_path, os.path.basename(self.db_file_path)) self.jrn_file_path_temp = os.path.join(self.folder_temp_path, os.path.basename(self.jrn_file_path)) self.wal_file_path_temp = os.path.join(self.folder_temp_path, os.path.basename(self.wal_file_path)) self.db_temp_file = self.mac_info.ExtractFile(self.db_file_path, self.db_file_path_temp) if self.mac_info.IsValidFilePath(self.jrn_file_path): self.shm_temp_file = self.mac_info.ExtractFile(self.jrn_file_path, self.jrn_file_path_temp) if self.mac_info.IsValidFilePath(self.wal_file_path): self.wal_temp_file = self.mac_info.ExtractFile(self.wal_file_path, self.wal_file_path_temp) return True def _is_valid_sqlite_file(self, path): '''Checks file header for valid sqlite db''' ret = False with open (path, 'rb') as f: if f.read(16) == b'SQLite format 3\0': ret = True return ret def __getattr__(self, attr): if attr == 'connect': def hooked(path): # Get 'database' variable self.db_file_path = path self.jrn_file_path = path + "-journal" self.wal_file_path = path + "-wal" if self._ExtractFiles(): log.debug('Trying to extract and read db: ' + path) if self._is_valid_sqlite_file(self.db_file_path_temp): result = self.sqlite3.connect(self.db_file_path_temp) # TODO -> Why are exceptions not being raised here when bad paths are sent? else: log.error('File is not an SQLITE db or it is corrupted!') result = None else: result = None return result return hooked else: return attr def _remove_readonly(self, func, path, excinfo): os.chmod(path, stat.S_IWRITE) func(path) def __del__(self): '''Close all file handles and delete all files & temp folder''' # Sometimes a delay may be needed, lets try at least 3 times before failing. deleted = False count = 0 ex_str = '' while (not deleted) and (count < 3): count += 1 try: shutil.rmtree(self.folder_temp_path, onerror=self._remove_readonly) deleted = True except Exception as ex: ex_str = "Exception while deleting temp files/folders: " + str(ex) time.sleep(0.3) if not deleted: log.debug(ex_str)