├── ktx_quick.zip ├── README.md ├── SnapshotImageFinder.py ├── SnapshotTriage.py └── ccl_bplist.py /ktx_quick.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrignoni/iOS-Snapshot-Triage-Parser/HEAD/ktx_quick.zip -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iOS-Snapshot-Triage-Parser 2 | 3 | For details on how to use the scripts and macOS Automator quick action see the following blog post: 4 | https://abrignoni.blogspot.com/2019/09/ios-snapshots-triage-parser-working.html 5 | 6 | Script purpose and workflow: 7 | 1. Run SnapshotImageFinder.py to identify iOS snapshot images and extract them from a targeted iOS file system extraction directory. These files end with @3.ktx or @2.ktx. 8 | 9 | 2. Extract the macOS Automator quick action from the ktx_quick.zip file. With it convert all the .ktx files extracted in step #1 to .png format. 10 | 11 | 3. Run SnapshotTriage.py to extract, parse and match the images with the snapshot metadata contained in the extracted bplists from the applicationState.db datastore. This script accepts 2 parameters, the directory where the applicationState.db is located and the directory where the .png files are located. 12 | 13 | 4. After execution of the previews steps a triage report directory will be created containing the extracted bplists and iOS snapshot reports in HTML per app. 14 | -------------------------------------------------------------------------------- /SnapshotImageFinder.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from argparse import RawTextHelpFormatter 3 | from six.moves.configparser import RawConfigParser 4 | from shutil import copy 5 | import sys 6 | import io 7 | import os 8 | import glob 9 | 10 | parser = argparse.ArgumentParser(description="\ 11 | iOS Snapshot KTX File Finder\ 12 | \n\n Locate iOS snapshot KTX files." 13 | , prog='SnapshotKtxFinder.py' 14 | , formatter_class=RawTextHelpFormatter) 15 | parser.add_argument('data_dir_to_analyze',help="Path to Data Directory.") 16 | 17 | args = parser.parse_args() 18 | data_dir = args.data_dir_to_analyze 19 | 20 | foldername = ('FoundSnapshotImages') 21 | pathfound = 0 22 | path = os.getcwd() 23 | outpath = path + "/" + foldername 24 | count = 0 25 | pathlog =[] 26 | os.makedirs(outpath) 27 | print("\n--------------------------------------------------------------------------------------") 28 | print("iOS Snapshot KTX file finder") 29 | print("By: Alexis Brignoni | @AlexisBrignoni | abrignoni.com") 30 | print("Source directory to be searched: " + data_dir) 31 | print("\n--------------------------------------------------------------------------------------") 32 | print("") 33 | print('Searching for iOS Snapshot images.') 34 | print('Please wait...') 35 | 36 | for root, dirs, filenames in os.walk(data_dir): 37 | for f in filenames: 38 | if f.endswith('@3x.ktx'): 39 | pathfound = os.path.join(root, f) 40 | copy(pathfound, outpath) 41 | pathlog.insert(count, pathfound) 42 | #print(pathlog[count]) 43 | count = count + 1 44 | 45 | elif f.endswith('@2x.ktx'): 46 | pathfound = os.path.join(root, f) 47 | copy(pathfound, outpath) 48 | pathlog.insert(count, pathfound) 49 | #print(pathlog[count]) 50 | count = count + 1 51 | 52 | if pathfound == 0: 53 | print('') 54 | print('No snapshot ktx or png files available.') 55 | else: 56 | print('') 57 | print('Snapshot ktx files moved to: '+ outpath) 58 | print('Snapshot ktx files moved total: '+ str(count)) 59 | with open("SnapshotImageFinderPathsRreport.txt", "w") as output: 60 | output.write('iOS Snapshot KTX File Finder \n') 61 | output.write('By: Alexis Brignoni | @AlexisBrignoni | abrignoni.com \n') 62 | output.write('Snapshot KTX files moved to: '+ outpath+'\n') 63 | output.write('Snapshot ktx files moved total: '+ str(count)+'\n') 64 | output.write('\n') 65 | output.write('KTX file paths from source directory: \n') 66 | for item in pathlog: 67 | output.write('%s\n' % item) 68 | print('Copy log at: '+path+'\SnapshotImageFinderPathsRreport.txt') 69 | -------------------------------------------------------------------------------- /SnapshotTriage.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import argparse 3 | from argparse import RawTextHelpFormatter 4 | from six.moves.configparser import RawConfigParser 5 | import sys 6 | import ccl_bplist 7 | import plistlib 8 | import io 9 | import os 10 | import glob 11 | import sqlite3 12 | from shutil import copy 13 | from time import process_time 14 | 15 | parser = argparse.ArgumentParser(description="\ 16 | iOS Snapshot KTX Traige Parser\ 17 | \n\n Parse iOS snapshot plists and matching ktx files." 18 | , prog='SnapTriage.py' 19 | , formatter_class=RawTextHelpFormatter) 20 | parser.add_argument('data_dir_snaps',help="Path to the Snapshot images Directory") 21 | parser.add_argument('data_dir_appState',help="Path to the applicationState.db Directory..") 22 | 23 | args = parser.parse_args() 24 | data_dir = args.data_dir_snaps 25 | appState_dir = args.data_dir_appState 26 | count = 0 27 | pathfound = 0 28 | 29 | #create directories 30 | #foldername = str(int(datetime.datetime.now().timestamp())) 31 | foldername = ("iOSSnapshotTriageReports_" + datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')) 32 | 33 | #calculate timestamps 34 | unix = datetime.datetime(1970, 1, 1) 35 | cocoa = datetime.datetime(2001, 1, 1) 36 | delta = cocoa - unix 37 | 38 | for root, dirs, filenames in os.walk(appState_dir): 39 | for f in filenames: 40 | if f == "applicationState.db": 41 | pathfound = os.path.join(root, f) 42 | 43 | 44 | if pathfound == 0: 45 | print("No applicationState.db") 46 | else: 47 | path = os.getcwd() 48 | try: 49 | outpath = path + "/" + foldername 50 | os.mkdir(outpath) 51 | os.mkdir(outpath+"/ExtractedBplistsFirstLevel") 52 | os.mkdir(outpath+"/ExtractedBplistsSecondLevel") 53 | os.mkdir(outpath+"/Reports") 54 | os.mkdir(outpath+"/Reports/images") 55 | except OSError: 56 | print("Error making directories") 57 | 58 | 59 | 60 | print("\n--------------------------------------------------------------------------------------") 61 | print("iOS Snapshot Triage Parser.") 62 | print("Objective: Triage iOS Snapshot images.") 63 | print("By: Alexis Brignoni | @AlexisBrignoni | abrignoni.com") 64 | print("Processed images directory: " + data_dir) 65 | print("Snapshots database: " + appState_dir) 66 | print("\n--------------------------------------------------------------------------------------") 67 | print("") 68 | 69 | print("Database located at: "+pathfound) 70 | print("Please wait...") 71 | 72 | #connect sqlite databases 73 | #database = 'applicationState.db' 74 | db = sqlite3.connect(pathfound) 75 | cursor = db.cursor() 76 | 77 | cursor.execute('''SELECT 78 | application_identifier_tab.id, 79 | application_identifier_tab.application_identifier, 80 | kvs.value 81 | FROM kvs, application_identifier_tab, key_tab 82 | WHERE application_identifier_tab.id = kvs.application_identifier 83 | and key_tab.key = 'XBApplicationSnapshotManifest' 84 | and key_tab.id = kvs.key 85 | ''') 86 | 87 | all_rows = cursor.fetchall() 88 | 89 | for row in all_rows: 90 | bundleid = row[1] 91 | wbplist = row [2] 92 | print('Processing: '+bundleid) 93 | output_file = open('./'+foldername+'/ExtractedBplistsFirstLevel/'+bundleid+'.bplist', 'wb') #export from applicationState.db 94 | output_file.write(wbplist) 95 | output_file.close() 96 | 97 | g = open('./'+foldername+'/ExtractedBplistsFirstLevel/'+bundleid+'.bplist', 'rb') 98 | plistg = ccl_bplist.load(g) 99 | 100 | output_file = open('./'+foldername+'/ExtractedBplistsSecondLevel/'+bundleid+'.bplist', 'wb') #export from applicationState.db 101 | output_file.write(plistg) 102 | output_file.close() 103 | 104 | g = open('./'+foldername+'/ExtractedBplistsSecondLevel/'+bundleid+'.bplist', 'rb') 105 | plistg = ccl_bplist.load(g) 106 | long = len(plistg['$objects']) 107 | 108 | #start report 109 | h = open('./'+foldername+'/Reports/'+bundleid+'.html', 'w') #write report 110 | h.write('') 111 | h.write('

iOS Snapshots Triage Report

') 112 | h.write('

Application: '+bundleid+'

') 113 | h.write('Data aggregated per following data source: '+pathfound) 114 | h.write('
') 115 | h.write('Press on the image to get full size') 116 | h.write('
') 117 | h.write ('') 118 | h.write('
') 119 | 120 | #opne table 121 | h.write('') 122 | for i in range (0, long): 123 | test = (plistg['$objects'][i]) 124 | try: 125 | if test.endswith('@3x.ktx'): 126 | h.write('') 127 | h.write('') 128 | h.write('') 144 | h.write('') 145 | #new html block 146 | #convert the ktx to jpg and add to html 147 | #print(test) 148 | 149 | except: 150 | pass 151 | 152 | try: 153 | if test.endswith('@2x.ktx'): 154 | h.write('') 155 | h.write('') 156 | h.write('') 172 | h.write('') 173 | #new html block 174 | #convert the ktx to jpg and add to html 175 | #print(test) 176 | #new html block 177 | #convert the ktx to jpg and add to html 178 | 179 | except: 180 | pass 181 | 182 | try: 183 | if test.endswith('.png'): 184 | h.write('') 185 | h.write('') 188 | h.write('') 189 | #new html block 190 | #convert the ktx to jpg and add to html 191 | #print(test) 192 | 193 | except: 194 | pass 195 | 196 | try: 197 | if test['NS.time']: 198 | dates = test['NS.time'] 199 | dia = str(dates) 200 | dias = (dia.rsplit('.', 1)[0]) 201 | timestamp = datetime.datetime.fromtimestamp(int(dias)) + delta 202 | 203 | h.write('') 204 | h.write('') 207 | h.write('') 208 | 209 | #print(timestamp) 210 | 211 | except: 212 | pass 213 | h.write('
Filename:
'+str(test)+'
') 129 | image = test.split('.') 130 | imagenew = image[0] 131 | path2 = os.getcwd() 132 | imagepath = (path+'/'+data_dir+'/'+imagenew+'.png') 133 | imageoutpath = (outpath+'/Reports/images') 134 | #print ('path2: '+path2) 135 | #print('image: '+imagepath) 136 | #print('imagefinal: '+imageoutpath) 137 | #print('foldername: '+foldername) 138 | copy(imagepath, imageoutpath) 139 | h.write('') 140 | h.write('') 142 | h.write('') 143 | h.write('
Filename:
'+str(test)+'
') 157 | image = test.split('.') 158 | imagenew = image[0] 159 | path2 = os.getcwd() 160 | imagepath = (path+'/'+data_dir+'/'+imagenew+'.png') 161 | imageoutpath = (outpath+'/Reports/images') 162 | #print ('path2: '+path2) 163 | #print('image: '+imagepath) 164 | #print('imagefinal: '+imageoutpath) 165 | #print('foldername: '+foldername) 166 | copy(imagepath, imageoutpath) 167 | h.write('') 168 | h.write('') 170 | h.write('') 171 | h.write('
') 186 | h.write('File not found on system.
'+str(test)+'') 187 | h.write('
') 205 | h.write(str(timestamp)) 206 | h.write('
') 214 | h.write('
') 215 | h.write('Script by: abrignoni.com') 216 | h.write('') 217 | h.close() 218 | count = count + 1 219 | print('Total of apps with processed snapshots: '+str(count)) 220 | -------------------------------------------------------------------------------- /ccl_bplist.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2012-2016, CCL Forensics 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | * Neither the name of the CCL Forensics nor the 13 | names of its contributors may be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL CCL FORENSICS BE LIABLE FOR ANY 20 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | """ 27 | 28 | import sys 29 | import os 30 | import struct 31 | import datetime 32 | 33 | __version__ = "0.21" 34 | __description__ = "Converts Apple binary PList files into a native Python data structure" 35 | __contact__ = "Alex Caithness" 36 | 37 | _object_converter = None 38 | def set_object_converter(function): 39 | """Sets the object converter function to be used when retrieving objects from the bplist. 40 | default is None (which will return objects in their raw form). 41 | A built in converter (ccl_bplist.NSKeyedArchiver_common_objects_convertor) which is geared 42 | toward dealling with common types in NSKeyedArchiver is available which can simplify code greatly 43 | when dealling with these types of files.""" 44 | if not hasattr(function, "__call__"): 45 | raise TypeError("function is not a function") 46 | global _object_converter 47 | _object_converter = function 48 | 49 | class BplistError(Exception): 50 | pass 51 | 52 | class BplistUID: 53 | def __init__(self, value): 54 | self.value = value 55 | 56 | def __repr__(self): 57 | return "UID: {0}".format(self.value) 58 | 59 | def __str__(self): 60 | return self.__repr__() 61 | 62 | def __decode_multibyte_int(b, signed=True): 63 | if len(b) == 1: 64 | fmt = ">B" # Always unsigned? 65 | elif len(b) == 2: 66 | fmt = ">h" 67 | elif len(b) == 3: 68 | if signed: 69 | return ((b[0] << 16) | struct.unpack(">H", b[1:])[0]) - ((b[0] >> 7) * 2 * 0x800000) 70 | else: 71 | return (b[0] << 16) | struct.unpack(">H", b[1:])[0] 72 | elif len(b) == 4: 73 | fmt = ">i" 74 | elif len(b) == 8: 75 | fmt = ">q" 76 | elif len(b) == 16: 77 | # special case for BigIntegers 78 | high, low = struct.unpack(">QQ", b) 79 | result = (high << 64) | low 80 | if high & 0x8000000000000000 and signed: 81 | result -= 0x100000000000000000000000000000000 82 | return result 83 | else: 84 | raise BplistError("Cannot decode multibyte int of length {0}".format(len(b))) 85 | 86 | if signed and len(b) > 1: 87 | return struct.unpack(fmt.lower(), b)[0] 88 | else: 89 | return struct.unpack(fmt.upper(), b)[0] 90 | 91 | def __decode_float(b, signed=True): 92 | if len(b) == 4: 93 | fmt = ">f" 94 | elif len(b) == 8: 95 | fmt = ">d" 96 | else: 97 | raise BplistError("Cannot decode float of length {0}".format(len(b))) 98 | 99 | if signed: 100 | return struct.unpack(fmt.lower(), b)[0] 101 | else: 102 | return struct.unpack(fmt.upper(), b)[0] 103 | 104 | def __decode_object(f, offset, collection_offset_size, offset_table): 105 | # Move to offset and read type 106 | #print("Decoding object at offset {0}".format(offset)) 107 | f.seek(offset) 108 | # A little hack to keep the script portable between py2.x and py3k 109 | if sys.version_info[0] < 3: 110 | type_byte = ord(f.read(1)[0]) 111 | else: 112 | type_byte = f.read(1)[0] 113 | #print("Type byte: {0}".format(hex(type_byte))) 114 | if type_byte == 0x00: # Null 0000 0000 115 | return None 116 | elif type_byte == 0x08: # False 0000 1000 117 | return False 118 | elif type_byte == 0x09: # True 0000 1001 119 | return True 120 | elif type_byte == 0x0F: # Fill 0000 1111 121 | raise BplistError("Fill type not currently supported at offset {0}".format(f.tell())) # Not sure what to return really... 122 | elif type_byte & 0xF0 == 0x10: # Int 0001 xxxx 123 | int_length = 2 ** (type_byte & 0x0F) 124 | int_bytes = f.read(int_length) 125 | return __decode_multibyte_int(int_bytes) 126 | elif type_byte & 0xF0 == 0x20: # Float 0010 nnnn 127 | float_length = 2 ** (type_byte & 0x0F) 128 | float_bytes = f.read(float_length) 129 | return __decode_float(float_bytes) 130 | elif type_byte & 0xFF == 0x33: # Date 0011 0011 131 | date_bytes = f.read(8) 132 | date_value = __decode_float(date_bytes) 133 | try: 134 | result = datetime.datetime(2001,1,1) + datetime.timedelta(seconds = date_value) 135 | except OverflowError: 136 | result = datetime.datetime.min 137 | return result 138 | elif type_byte & 0xF0 == 0x40: # Data 0100 nnnn 139 | if type_byte & 0x0F != 0x0F: 140 | # length in 4 lsb 141 | data_length = type_byte & 0x0F 142 | else: 143 | # A little hack to keep the script portable between py2.x and py3k 144 | if sys.version_info[0] < 3: 145 | int_type_byte = ord(f.read(1)[0]) 146 | else: 147 | int_type_byte = f.read(1)[0] 148 | if int_type_byte & 0xF0 != 0x10: 149 | raise BplistError("Long Data field definition not followed by int type at offset {0}".format(f.tell())) 150 | int_length = 2 ** (int_type_byte & 0x0F) 151 | int_bytes = f.read(int_length) 152 | data_length = __decode_multibyte_int(int_bytes, False) 153 | return f.read(data_length) 154 | elif type_byte & 0xF0 == 0x50: # ASCII 0101 nnnn 155 | if type_byte & 0x0F != 0x0F: 156 | # length in 4 lsb 157 | ascii_length = type_byte & 0x0F 158 | else: 159 | # A little hack to keep the script portable between py2.x and py3k 160 | if sys.version_info[0] < 3: 161 | int_type_byte = ord(f.read(1)[0]) 162 | else: 163 | int_type_byte = f.read(1)[0] 164 | if int_type_byte & 0xF0 != 0x10: 165 | raise BplistError("Long ASCII field definition not followed by int type at offset {0}".format(f.tell())) 166 | int_length = 2 ** (int_type_byte & 0x0F) 167 | int_bytes = f.read(int_length) 168 | ascii_length = __decode_multibyte_int(int_bytes, False) 169 | return f.read(ascii_length).decode("ascii") 170 | elif type_byte & 0xF0 == 0x60: # UTF-16 0110 nnnn 171 | if type_byte & 0x0F != 0x0F: 172 | # length in 4 lsb 173 | utf16_length = (type_byte & 0x0F) * 2 # Length is characters - 16bit width 174 | else: 175 | # A little hack to keep the script portable between py2.x and py3k 176 | if sys.version_info[0] < 3: 177 | int_type_byte = ord(f.read(1)[0]) 178 | else: 179 | int_type_byte = f.read(1)[0] 180 | if int_type_byte & 0xF0 != 0x10: 181 | raise BplistError("Long UTF-16 field definition not followed by int type at offset {0}".format(f.tell())) 182 | int_length = 2 ** (int_type_byte & 0x0F) 183 | int_bytes = f.read(int_length) 184 | utf16_length = __decode_multibyte_int(int_bytes, False) * 2 185 | return f.read(utf16_length).decode("utf_16_be") 186 | elif type_byte & 0xF0 == 0x80: # UID 1000 nnnn 187 | uid_length = (type_byte & 0x0F) + 1 188 | uid_bytes = f.read(uid_length) 189 | return BplistUID(__decode_multibyte_int(uid_bytes, signed=False)) 190 | elif type_byte & 0xF0 == 0xA0: # Array 1010 nnnn 191 | if type_byte & 0x0F != 0x0F: 192 | # length in 4 lsb 193 | array_count = type_byte & 0x0F 194 | else: 195 | # A little hack to keep the script portable between py2.x and py3k 196 | if sys.version_info[0] < 3: 197 | int_type_byte = ord(f.read(1)[0]) 198 | else: 199 | int_type_byte = f.read(1)[0] 200 | if int_type_byte & 0xF0 != 0x10: 201 | raise BplistError("Long Array field definition not followed by int type at offset {0}".format(f.tell())) 202 | int_length = 2 ** (int_type_byte & 0x0F) 203 | int_bytes = f.read(int_length) 204 | array_count = __decode_multibyte_int(int_bytes, signed=False) 205 | array_refs = [] 206 | for i in range(array_count): 207 | array_refs.append(__decode_multibyte_int(f.read(collection_offset_size), False)) 208 | return [__decode_object(f, offset_table[obj_ref], collection_offset_size, offset_table) for obj_ref in array_refs] 209 | elif type_byte & 0xF0 == 0xC0: # Set 1010 nnnn 210 | if type_byte & 0x0F != 0x0F: 211 | # length in 4 lsb 212 | set_count = type_byte & 0x0F 213 | else: 214 | # A little hack to keep the script portable between py2.x and py3k 215 | if sys.version_info[0] < 3: 216 | int_type_byte = ord(f.read(1)[0]) 217 | else: 218 | int_type_byte = f.read(1)[0] 219 | if int_type_byte & 0xF0 != 0x10: 220 | raise BplistError("Long Set field definition not followed by int type at offset {0}".format(f.tell())) 221 | int_length = 2 ** (int_type_byte & 0x0F) 222 | int_bytes = f.read(int_length) 223 | set_count = __decode_multibyte_int(int_bytes, signed=False) 224 | set_refs = [] 225 | for i in range(set_count): 226 | set_refs.append(__decode_multibyte_int(f.read(collection_offset_size), False)) 227 | return [__decode_object(f, offset_table[obj_ref], collection_offset_size, offset_table) for obj_ref in set_refs] 228 | elif type_byte & 0xF0 == 0xD0: # Dict 1011 nnnn 229 | if type_byte & 0x0F != 0x0F: 230 | # length in 4 lsb 231 | dict_count = type_byte & 0x0F 232 | else: 233 | # A little hack to keep the script portable between py2.x and py3k 234 | if sys.version_info[0] < 3: 235 | int_type_byte = ord(f.read(1)[0]) 236 | else: 237 | int_type_byte = f.read(1)[0] 238 | #print("Dictionary length int byte: {0}".format(hex(int_type_byte))) 239 | if int_type_byte & 0xF0 != 0x10: 240 | raise BplistError("Long Dict field definition not followed by int type at offset {0}".format(f.tell())) 241 | int_length = 2 ** (int_type_byte & 0x0F) 242 | int_bytes = f.read(int_length) 243 | dict_count = __decode_multibyte_int(int_bytes, signed=False) 244 | key_refs = [] 245 | #print("Dictionary count: {0}".format(dict_count)) 246 | for i in range(dict_count): 247 | key_refs.append(__decode_multibyte_int(f.read(collection_offset_size), False)) 248 | value_refs = [] 249 | for i in range(dict_count): 250 | value_refs.append(__decode_multibyte_int(f.read(collection_offset_size), False)) 251 | 252 | dict_result = {} 253 | for i in range(dict_count): 254 | #print("Key ref: {0}\tVal ref: {1}".format(key_refs[i], value_refs[i])) 255 | key = __decode_object(f, offset_table[key_refs[i]], collection_offset_size, offset_table) 256 | val = __decode_object(f, offset_table[value_refs[i]], collection_offset_size, offset_table) 257 | dict_result[key] = val 258 | return dict_result 259 | 260 | 261 | def load(f): 262 | """ 263 | Reads and converts a file-like object containing a binary property list. 264 | Takes a file-like object (must support reading and seeking) as an argument 265 | Returns a data structure representing the data in the property list 266 | """ 267 | # Check magic number 268 | if f.read(8) != b"bplist00": 269 | raise BplistError("Bad file header") 270 | 271 | # Read trailer 272 | f.seek(-32, os.SEEK_END) 273 | trailer = f.read(32) 274 | offset_int_size, collection_offset_size, object_count, top_level_object_index, offest_table_offset = struct.unpack(">6xbbQQQ", trailer) 275 | 276 | # Read offset table 277 | f.seek(offest_table_offset) 278 | offset_table = [] 279 | for i in range(object_count): 280 | offset_table.append(__decode_multibyte_int(f.read(offset_int_size), False)) 281 | 282 | return __decode_object(f, offset_table[top_level_object_index], collection_offset_size, offset_table) 283 | 284 | 285 | def NSKeyedArchiver_common_objects_convertor(o): 286 | """Built in converter function (suitable for submission to set_object_converter()) which automatically 287 | converts the following common data-types found in NSKeyedArchiver: 288 | NSDictionary/NSMutableDictionary; 289 | NSArray/NSMutableArray; 290 | NSSet/NSMutableSet 291 | NSString/NSMutableString 292 | NSDate 293 | $null strings""" 294 | # Conversion: NSDictionary 295 | if is_nsmutabledictionary(o): 296 | return convert_NSMutableDictionary(o) 297 | # Conversion: NSArray 298 | elif is_nsarray(o): 299 | return convert_NSArray(o) 300 | elif is_isnsset(o): 301 | return convert_NSSet(o) 302 | # Conversion: NSString 303 | elif is_nsstring(o): 304 | return convert_NSString(o) 305 | # Conversion: NSDate 306 | elif is_nsdate(o): 307 | return convert_NSDate(o) 308 | # Conversion: "$null" string 309 | elif isinstance(o, str) and o == "$null": 310 | return None 311 | # Fallback: 312 | else: 313 | return o 314 | 315 | def NSKeyedArchiver_convert(o, object_table): 316 | if isinstance(o, list): 317 | #return NsKeyedArchiverList(o, object_table) 318 | result = NsKeyedArchiverList(o, object_table) 319 | elif isinstance(o, dict): 320 | #return NsKeyedArchiverDictionary(o, object_table) 321 | result = NsKeyedArchiverDictionary(o, object_table) 322 | elif isinstance(o, BplistUID): 323 | #return NSKeyedArchiver_convert(object_table[o.value], object_table) 324 | result = NSKeyedArchiver_convert(object_table[o.value], object_table) 325 | else: 326 | #return o 327 | result = o 328 | 329 | if _object_converter: 330 | return _object_converter(result) 331 | else: 332 | return result 333 | 334 | 335 | class NsKeyedArchiverDictionary(dict): 336 | def __init__(self, original_dict, object_table): 337 | super(NsKeyedArchiverDictionary, self).__init__(original_dict) 338 | self.object_table = object_table 339 | 340 | def __getitem__(self, index): 341 | o = super(NsKeyedArchiverDictionary, self).__getitem__(index) 342 | return NSKeyedArchiver_convert(o, self.object_table) 343 | 344 | def get(self, key, default=None): 345 | return self[key] if key in self else default 346 | 347 | def values(self): 348 | for k in self: 349 | yield self[k] 350 | 351 | def items(self): 352 | for k in self: 353 | yield k, self[k] 354 | 355 | class NsKeyedArchiverList(list): 356 | def __init__(self, original_iterable, object_table): 357 | super(NsKeyedArchiverList, self).__init__(original_iterable) 358 | self.object_table = object_table 359 | 360 | def __getitem__(self, index): 361 | o = super(NsKeyedArchiverList, self).__getitem__(index) 362 | return NSKeyedArchiver_convert(o, self.object_table) 363 | 364 | def __iter__(self): 365 | for o in super(NsKeyedArchiverList, self).__iter__(): 366 | yield NSKeyedArchiver_convert(o, self.object_table) 367 | 368 | 369 | def deserialise_NsKeyedArchiver(obj, parse_whole_structure=False): 370 | """Deserialises an NSKeyedArchiver bplist rebuilding the structure. 371 | obj should usually be the top-level object returned by the load() 372 | function.""" 373 | 374 | # Check that this is an archiver and version we understand 375 | if not isinstance(obj, dict): 376 | raise TypeError("obj must be a dict") 377 | if "$archiver" not in obj or obj["$archiver"] not in ("NSKeyedArchiver", "NRKeyedArchiver"): 378 | raise ValueError("obj does not contain an '$archiver' key or the '$archiver' is unrecognised") 379 | if "$version" not in obj or obj["$version"] != 100000: 380 | raise ValueError("obj does not contain a '$version' key or the '$version' is unrecognised") 381 | 382 | object_table = obj["$objects"] 383 | if "root" in obj["$top"] and not parse_whole_structure: 384 | return NSKeyedArchiver_convert(obj["$top"]["root"], object_table) 385 | else: 386 | return NSKeyedArchiver_convert(obj["$top"], object_table) 387 | 388 | # NSMutableDictionary convenience functions 389 | def is_nsmutabledictionary(obj): 390 | if not isinstance(obj, dict): 391 | return False 392 | if "$class" not in obj.keys(): 393 | return False 394 | if obj["$class"].get("$classname") not in ("NSMutableDictionary", "NSDictionary"): 395 | return False 396 | if "NS.keys" not in obj.keys(): 397 | return False 398 | if "NS.objects" not in obj.keys(): 399 | return False 400 | 401 | return True 402 | 403 | def convert_NSMutableDictionary(obj): 404 | """Converts a NSKeyedArchiver serialised NSMutableDictionary into 405 | a straight dictionary (rather than two lists as it is serialised 406 | as)""" 407 | 408 | # The dictionary is serialised as two lists (one for keys and one 409 | # for values) which obviously removes all convenience afforded by 410 | # dictionaries. This function converts this structure to an 411 | # actual dictionary so that values can be accessed by key. 412 | 413 | if not is_nsmutabledictionary(obj): 414 | raise ValueError("obj does not have the correct structure for a NSDictionary/NSMutableDictionary serialised to a NSKeyedArchiver") 415 | keys = obj["NS.keys"] 416 | vals = obj["NS.objects"] 417 | 418 | # sense check the keys and values: 419 | if not isinstance(keys, list): 420 | raise TypeError("The 'NS.keys' value is an unexpected type (expected list; actual: {0}".format(type(keys))) 421 | if not isinstance(vals, list): 422 | raise TypeError("The 'NS.objects' value is an unexpected type (expected list; actual: {0}".format(type(vals))) 423 | if len(keys) != len(vals): 424 | raise ValueError("The length of the 'NS.keys' list ({0}) is not equal to that of the 'NS.objects ({1})".format(len(keys), len(vals))) 425 | 426 | result = {} 427 | for i,k in enumerate(keys): 428 | if k in result: 429 | raise ValueError("The 'NS.keys' list contains duplicate entries") 430 | result[k] = vals[i] 431 | 432 | return result 433 | 434 | # NSArray convenience functions 435 | def is_nsarray(obj): 436 | if not isinstance(obj, dict): 437 | return False 438 | if "$class" not in obj.keys(): 439 | return False 440 | if obj["$class"].get("$classname") not in ("NSArray", "NSMutableArray"): 441 | return False 442 | if "NS.objects" not in obj.keys(): 443 | return False 444 | 445 | return True 446 | 447 | def convert_NSArray(obj): 448 | if not is_nsarray(obj): 449 | raise ValueError("obj does not have the correct structure for a NSArray/NSMutableArray serialised to a NSKeyedArchiver") 450 | 451 | return obj["NS.objects"] 452 | 453 | # NSSet convenience functions 454 | def is_isnsset(obj): 455 | if not isinstance(obj, dict): 456 | return False 457 | if "$class" not in obj.keys(): 458 | return False 459 | if obj["$class"].get("$classname") not in ("NSSet", "NSMutableSet"): 460 | return False 461 | if "NS.objects" not in obj.keys(): 462 | return False 463 | 464 | return True 465 | 466 | def convert_NSSet(obj): 467 | if not is_isnsset(obj): 468 | raise ValueError("obj does not have the correct structure for a NSSet/NSMutableSet serialised to a NSKeyedArchiver") 469 | 470 | return list(obj["NS.objects"]) 471 | 472 | # NSString convenience functions 473 | def is_nsstring(obj): 474 | if not isinstance(obj, dict): 475 | return False 476 | if "$class" not in obj.keys(): 477 | return False 478 | if obj["$class"].get("$classname") not in ("NSString", "NSMutableString"): 479 | return False 480 | if "NS.string" not in obj.keys(): 481 | return False 482 | return True 483 | 484 | def convert_NSString(obj): 485 | if not is_nsstring(obj): 486 | raise ValueError("obj does not have the correct structure for a NSString/NSMutableString serialised to a NSKeyedArchiver") 487 | 488 | return obj["NS.string"] 489 | 490 | # NSDate convenience functions 491 | def is_nsdate(obj): 492 | if not isinstance(obj, dict): 493 | return False 494 | if "$class" not in obj.keys(): 495 | return False 496 | if obj["$class"].get("$classname") not in ("NSDate"): 497 | return False 498 | if "NS.time" not in obj.keys(): 499 | return False 500 | 501 | return True 502 | 503 | def convert_NSDate(obj): 504 | if not is_nsdate(obj): 505 | raise ValueError("obj does not have the correct structure for a NSDate serialised to a NSKeyedArchiver") 506 | 507 | return datetime.datetime(2001, 1, 1) + datetime.timedelta(seconds=obj["NS.time"]) 508 | --------------------------------------------------------------------------------