Skip to content
Draft

Session #1280

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions ileapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import scripts.report as report
import traceback
import sys
from pathlib import Path

import scripts.plugin_loader as plugin_loader

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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':
Expand Down
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ pyliblzfse

pytz
simplekml
cryptography
asn1crypto
sqlcipher3-wheels
122 changes: 122 additions & 0 deletions scripts/artifacts/session.py
Original file line number Diff line number Diff line change
@@ -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
115 changes: 115 additions & 0 deletions scripts/ilapfuncs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/**/'
Expand All @@ -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():
Expand All @@ -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 '''
Expand Down Expand Up @@ -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 []
Loading