diff --git a/ileapp.py b/ileapp.py index 55d5c97fa..4a85163bb 100755 --- a/ileapp.py +++ b/ileapp.py @@ -7,6 +7,7 @@ import scripts.report as report import traceback import sys +from pathlib import Path import scripts.plugin_loader as plugin_loader @@ -314,6 +315,9 @@ def crunch_artifacts( logfunc('Objective: Triage iOS Full File System and iTunes Backup Extractions.') logfunc('By: Alexis Brignoni | @AlexisBrignoni | abrignoni.com') logfunc('By: Yogesh Khatri | @SwiftForensics | swiftforensics.com\n') + + + logdevinfo() seeker = None @@ -341,6 +345,31 @@ def crunch_artifacts( temp_file.close() return False + # Attempt to load a KeyChain + input_path_directory = Path(input_path) + if extracttype != 'fs': + logfunc('Checking for GrayKey keychain file in the same folder as the input "*keychain.plist\n') + input_path_directory = input_path_directory.parent + # Checks for file ending in keychain.plist in the root of the input folder + # If input is an archive (ZIP, TAR, GZ) the file needs to be in the same folder as the archive file + # If input is a Folder (FS) it needs to be contained in the root folder - Not sure if this should do something more + matching_files = list(input_path_directory.glob("*keychain.plist")) + + if matching_files: + logfunc(f'Found Keychain File: {matching_files[0]}') + iOS.load_keychain(matching_files[0]) + else: + logfunc(f'Did not find GrayKey keychain file. Make sure it is located in the folder: {input_path_directory}\n') + # If a GrayKey style keychain was not found alongside the Archive file, it may be a UFED extraction. + # Check inside the archive for the file: extra/KeychainDump/backup_keychain_v2.plist + keychain_search = "*/extra/KeychainDump/backup_keychain_v2.plist" + logfunc('Checking for UFED keychain file within the Input File\n') + logfunc(f'Checking for file at the path: {keychain_search}\n') + keychain_seeker_results = seeker.search(keychain_search, True) + if keychain_seeker_results: + logfunc("UFED Keychain Found") + iOS.load_ufed_keychain(keychain_seeker_results) + # Now ready to run # add lastBuild at the start except for iTunes backups if extracttype != 'itunes': diff --git a/requirements.txt b/requirements.txt index 48f16bb17..b2de31b27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,3 +27,6 @@ pyliblzfse pytz simplekml +cryptography +asn1crypto +sqlcipher3-wheels \ No newline at end of file diff --git a/scripts/artifacts/session.py b/scripts/artifacts/session.py new file mode 100644 index 000000000..b9544d110 --- /dev/null +++ b/scripts/artifacts/session.py @@ -0,0 +1,122 @@ +__artifacts_v2__ = { + "session": { + "name": "Session Chats", + "description": "Parses Session chats", + "author": "@snoop168", + "creation_date": "2025-06-18", + "last_update_date": "2025-06-18", + "requirements": "none", + "category": "Session", + "notes": "Place the GK Keychain in the same folder as the input file, or make sure the UFED keychain is in the Zip file", + "paths": ('*/mobile/Containers/Shared/AppGroup/*/database/Session.sqlite*', + '*/mobile/Containers/Data/Application/*/Library/Caches/*-thumbnails/thumbnail-*.jpg', + '*/mobile/Containers/Shared/AppGroup/*/Attachments/*'), + "output_types": "standard", # or ["html", "tsv", "timeline", "lava"] + "artifact_icon": "message-square", + "data_views": { + "chat": { + "threadDiscriminatorColumn": "Thread ID", + "threadLabelColumn": "Thread ID", + "textColumn": "Message", + "directionColumn": "Message Sent", + "directionSentValue": 1, + "timeColumn": "Message Timestamp", + "senderColumn": "Author", + "sentMessageLabelColumn": "Author", + "mediaColumn": "Attachments" + } + } + } +} + +import inspect +from scripts.ilapfuncs import convert_unix_ts_to_timezone, iOS +from scripts.ilapfuncs import artifact_processor, get_file_path, get_sqlite_cipher_db_records, logfunc, check_in_media + +@artifact_processor +def session(files_found, report_folder, seeker, wrap_text, timezone_offset): + source_path = get_file_path(files_found, "Session.sqlite") + data_list = [] + + if not source_path: + logfunc("Session.sqlite file not found") + return None, None, None + + kc = iOS.get_keychain() + found_key = False + for item in kc.get('genp'): + if item.get('acct') == b'GRDBDatabaseCipherKeySpec': + key = str(item['v_Data'].hex()) + found_key = True + query = ''' + SELECT + interaction.timestampMs, + interaction.body, + interaction.authorId, + interaction.variant, + interaction.wasRead, + interaction.threadId, + interaction.id, + profile.name, + interactionAttachment.attachmentId, + attachment.contentType, + attachment.sourceFilename + FROM interaction + LEFT JOIN profile ON interaction.authorId = profile.id + LEFT JOIN interactionAttachment ON interactionAttachment.interactionId = interaction.id + LEFT JOIN attachment on attachment.id = interactionAttachment.attachmentId + ''' + + data_headers = (('Message Timestamp', 'datetime'), 'Message', 'Author ID', 'Message Sent', + 'Message Read', 'Thread ID', 'Message ID', 'Author', ('Attachments', 'media')) + + db_records = get_sqlite_cipher_db_records(source_path, query, key, 32) + + for record in db_records: + artifact_info = inspect.stack()[0] + message_timestamp = convert_unix_ts_to_timezone(record[0], timezone_offset) + media_items = None + attach = record[8] + media_id = None + if attach: + if record[10]: + #Find the media using the sourceFilename [10] + media_id = check_in_media( + artifact_info, + report_folder, + seeker, + files_found, + f'{attach}/{record[10]}', + name=attach + ) + #if we havent found the media_id yet via the above method + if not media_id: + #first try to find the actual attachment + media_id = check_in_media( + artifact_info, + report_folder, + seeker, + files_found, + f'{attach}.mp4', #Be smarter here, we can figure out the actual ext testing for now + name=attach + ) + if not media_id: + #Still didnt find it, so now lets fall back to the thumbnail + for resolution in [1334, 450, 200]: + media_id = check_in_media( + artifact_info, + report_folder, + seeker, + files_found, + f'{attach}-thumbnails/thumbnail-{resolution}.jpg', + name=attach + ) + if media_id: + break + + data_list.append( + (message_timestamp, record[1], record[2], record[3], record[4], record[5], record[6], record[7], + media_id)) + if not found_key: + logfunc("Session Key wasn't found. Place the GK keychain file in the same folder as the input file or make sure the UFED keychain is located in the Zip file.") + return data_headers, data_list, source_path diff --git a/scripts/ilapfuncs.py b/scripts/ilapfuncs.py index d4cc12350..56ea1d700 100644 --- a/scripts/ilapfuncs.py +++ b/scripts/ilapfuncs.py @@ -18,6 +18,7 @@ from functools import lru_cache from pathlib import Path from urllib.parse import quote +from sqlcipher3 import dbapi2 as sqlcipher3 import scripts.artifact_report as artifact_report # common third party imports @@ -34,6 +35,9 @@ lava_insert_sqlite_media_item, lava_insert_sqlite_media_references, lava_get_media_references, \ lava_get_full_media_info +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from asn1crypto import core + os.path.basename = lru_cache(maxsize=None)(os.path.basename) thumbnail_root = '**/Media/PhotoData/Thumbnails/**/' @@ -44,8 +48,21 @@ icons = {} lava_only_artifacts = {} + +class InnerSequence(core.Sequence): + _fields = [ + ('key', core.UTF8String), + ('value', core.Any), + ] + +class KeychainItem(core.SetOf): + _child_spec = InnerSequence + class iOS: _version = None + _keyChain = None + + @staticmethod def get_version(): @@ -58,6 +75,70 @@ def set_version(os_version): if iOS._version is None: iOS._version = os_version + @staticmethod + def load_keychain(plist_path): + """Loads the keychain data from a plist file once.""" + if iOS._keyChain is None: + with open(plist_path, 'rb') as fp: + iOS._keyChain = plistlib.load(fp) + + @staticmethod + def load_ufed_keychain(plist_path): + """Loads the keychain data from a plist file once.""" + if iOS._keyChain is None: + with open(plist_path, 'rb') as fp: + iOS._keyChain = iOS.process_ufed_keychain(plistlib.load(fp)) + + @staticmethod + def get_keychain(): + """Returns the loaded keychain data.""" + return iOS._keyChain + + @staticmethod + def process_ufed_keychain(kcplist): + + def normalize_to_bytes(val): + if isinstance(val, str): + return val.encode('utf-8') + return val + + full_keychain = {} + for entry in kcplist['keychainEntries']: + ct = nska_deserialize.deserialize_plist_from_string(entry['data']['ciphertext']) + key = entry['data']['unwrappedKey'] + aesgcm_decrypter = AESGCM(key) + pt = aesgcm_decrypter.decrypt(ct['SFInitializationVector'], ct['SFCiphertext'] + ct['SFAuthenticationCode'], + None) + as1_result = KeychainItem.load(pt) + md_wrapping_key = kcplist['classKeyIdxToUnwrappedMetadataClassKey'][str(entry['classKeyIdx'])] + + metadata = entry['metadata'] + + metadata_wrapped_key = nska_deserialize.deserialize_plist_from_string(metadata['wrappedKey']) + metadata_key_decrypter = AESGCM(md_wrapping_key) + metadata_unwrapped_key = metadata_key_decrypter.decrypt(metadata_wrapped_key['SFInitializationVector'], + metadata_wrapped_key['SFCiphertext'] + + metadata_wrapped_key['SFAuthenticationCode'], None) + + metadata_ciphertext = nska_deserialize.deserialize_plist_from_string(metadata['ciphertext']) + metadata_decrypter = AESGCM(metadata_unwrapped_key) + decrypted_metadata = metadata_decrypter.decrypt(metadata_ciphertext['SFInitializationVector'], + metadata_ciphertext['SFCiphertext'] + metadata_ciphertext[ + 'SFAuthenticationCode'], None) + md_as1_result = KeychainItem.load(decrypted_metadata) + + full_entry = {'clas': entry['classKeyIdx'], 'rowid': entry['rowID']} + for item in as1_result: + full_entry[str(item['key'])] = normalize_to_bytes(item['value'].native) + + for item in md_as1_result: + full_entry[str(item['key'])] = normalize_to_bytes(item['value'].native) + + if entry['table'] not in full_keychain: + full_keychain[entry['table']] = [] + full_keychain[entry['table']].append(full_entry) + + return full_keychain class OutputParameters: '''Defines the parameters that are common for ''' @@ -1361,3 +1442,37 @@ def get_resolution_for_model_id(model_id: str): f"Warning! - Resolution not found for '{model_id}', contact developers to add resolution into the get_resolution_for_model_id function") return None +def open_sqlite_cipher_db_readonly(path, key, header_size=0): + '''Opens a sqlite db in read-only mode, so original db (and -wal/journal are intact)''' + try: + if path: + #path = get_sqlite_db_path(path) + #with sqlcipher3.connect(f"file:{path}?mode=ro", uri=True) as db: + with sqlcipher3.connect(path) as db: + key_pragma = f"PRAGMA key=\"x'{key}'\";" + db.execute(key_pragma) + db.execute(f"PRAGMA cipher_plaintext_header_size = {header_size};") + return db + except sqlcipher3.OperationalError as e: + logfunc(f"Error with {path}:") + logfunc(f" - {str(e)}") + return None + +def get_sqlite_cipher_db_records(path, query, key, header_size=0, attach_query=None): + db = open_sqlite_cipher_db_readonly(path, key, header_size) + if db: + db.row_factory = sqlcipher3.Row # For fetching columns by name + try: + cursor = db.cursor() + if attach_query: + cursor.execute(attach_query) + cursor.execute(query) + records = cursor.fetchall() + return records + except sqlcipher3.OperationalError as e: + logfunc(f"Error with {path}:") + logfunc(f" - {str(e)}") + except sqlcipher3.ProgrammingError as e: + logfunc(f"Error with {path}:") + logfunc(f" - {str(e)}") + return [] \ No newline at end of file