ios_backup_extractor 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.md +339 -0
- data/README.md +41 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/exe/ios +107 -0
- data/ios_backup_extractor.gemspec +29 -0
- data/lib/ios_backup_extractor.rb +42 -0
- data/lib/ios_backup_extractor/backup_retriever.rb +40 -0
- data/lib/ios_backup_extractor/info_plist.rb +41 -0
- data/lib/ios_backup_extractor/keybag.rb +108 -0
- data/lib/ios_backup_extractor/mbdb.rb +88 -0
- data/lib/ios_backup_extractor/raw_backup.rb +183 -0
- data/lib/ios_backup_extractor/raw_backup10.rb +85 -0
- data/lib/ios_backup_extractor/raw_backup4.rb +52 -0
- data/lib/ios_backup_extractor/version.rb +3 -0
- metadata +176 -0
data/exe/ios
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'ios_backup_extractor'
|
4
|
+
require 'optparse'
|
5
|
+
|
6
|
+
NauktisUtils::Logging.logger.level = Logger::WARN
|
7
|
+
retreiver = IosBackupExtractor::BackupRetriever.new
|
8
|
+
|
9
|
+
options = {}
|
10
|
+
backup_options = {}
|
11
|
+
OptionParser.new do |opts|
|
12
|
+
opts.banner = 'Usage: ios [options] [location]'
|
13
|
+
opts.separator 'If location is not provided, the default backup location will be searched'
|
14
|
+
|
15
|
+
opts.separator ''
|
16
|
+
opts.separator 'Operations:'
|
17
|
+
|
18
|
+
opts.on('-l', '--list', 'Prints a list of backups') do
|
19
|
+
options[:operation] = :list
|
20
|
+
end
|
21
|
+
|
22
|
+
opts.on('-d', '--detail', 'Prints a detailed list of backups') do
|
23
|
+
options[:operation] = :details
|
24
|
+
end
|
25
|
+
|
26
|
+
opts.on('--archive DESTINATION', 'Archives (tar) backups to specified destination') do |q|
|
27
|
+
options[:operation] = :archive
|
28
|
+
dest = File.expand_path(q)
|
29
|
+
raise "#{dest} does not exist" unless File.directory?(dest)
|
30
|
+
options[:destination] = dest
|
31
|
+
end
|
32
|
+
|
33
|
+
opts.on('--extract DESTINATION', 'Extracts backups to specified destination') do |q|
|
34
|
+
options[:operation] = :extract
|
35
|
+
dest = File.expand_path(q)
|
36
|
+
raise "#{dest} does not exist" unless File.directory?(dest)
|
37
|
+
options[:destination] = dest
|
38
|
+
end
|
39
|
+
|
40
|
+
# Options
|
41
|
+
opts.separator 'Flags:'
|
42
|
+
|
43
|
+
opts.on('--raw', 'Creates a raw archive instead of renaming files', 'Use with --archive') do
|
44
|
+
options[:backup_type] = :raw
|
45
|
+
end
|
46
|
+
|
47
|
+
opts.on('--home', 'Only save the HomeDomain') do
|
48
|
+
backup_options[:name] = 'home'
|
49
|
+
backup_options[:domain_filter] = /HomeDomain/i
|
50
|
+
end
|
51
|
+
|
52
|
+
opts.on('--media', 'Only saves well know media locations') do
|
53
|
+
backup_options[:name] = 'media'
|
54
|
+
backup_options[:domain_filter] = /CameraRollDomain|MediaDomain|AppDomain-net\.whatsapp\.WhatsApp/i
|
55
|
+
end
|
56
|
+
|
57
|
+
opts.on('--password PASSWORD', 'Password for encrypted backups') do |q|
|
58
|
+
backup_options[:password] = q.to_s
|
59
|
+
end
|
60
|
+
|
61
|
+
opts.on('--serial SERIAL', 'Target a specific device by its serial number') do |q|
|
62
|
+
options[:serial_number] = q.to_s.strip
|
63
|
+
end
|
64
|
+
|
65
|
+
opts.on('-v', '--verbose', 'Verbose logging') do
|
66
|
+
NauktisUtils::Logging.logger.level = Logger::INFO
|
67
|
+
end
|
68
|
+
|
69
|
+
opts.on('--veryverbose', 'Very verbose logging') do
|
70
|
+
NauktisUtils::Logging.logger.level = Logger::DEBUG
|
71
|
+
end
|
72
|
+
|
73
|
+
opts.on('-h', '--help', 'Prints this help') do
|
74
|
+
puts opts
|
75
|
+
exit
|
76
|
+
end
|
77
|
+
end.parse!
|
78
|
+
|
79
|
+
if ARGV.size > 0
|
80
|
+
retreiver.search_in(ARGV[0])
|
81
|
+
else
|
82
|
+
retreiver.search
|
83
|
+
end
|
84
|
+
|
85
|
+
backups = retreiver.backups.sort_by! { |b| b.info_plist.last_backup_date }
|
86
|
+
# Only perform the action for the selected backup (or all)
|
87
|
+
if options[:serial_number]
|
88
|
+
backups.select! { |b| b.info_plist.serial_number == options[:serial_number] }
|
89
|
+
end
|
90
|
+
|
91
|
+
backups.each do |backup|
|
92
|
+
case options[:operation]
|
93
|
+
when :list
|
94
|
+
puts backup
|
95
|
+
when :details
|
96
|
+
backup.info_plist.details
|
97
|
+
puts '-' * 40
|
98
|
+
when :extract
|
99
|
+
backup.extract_to(options[:destination], backup_options)
|
100
|
+
when :archive
|
101
|
+
if options[:backup_type] == 'raw'
|
102
|
+
backup.archive_raw(options[:destination], backup_options)
|
103
|
+
else
|
104
|
+
backup.archive_to(options[:destination], backup_options)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'ios_backup_extractor/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'ios_backup_extractor'
|
8
|
+
spec.version = IosBackupExtractor::VERSION
|
9
|
+
spec.authors = ['Nauktis']
|
10
|
+
spec.email = ['nauktis@users.noreply.github.com']
|
11
|
+
|
12
|
+
spec.summary = %q{Ruby script to extract iOS backups.}
|
13
|
+
spec.description = %q{Ruby script to extract iOS backups.}
|
14
|
+
spec.homepage = 'https://github.com/Nauktis/ios_backup_extractor'
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
17
|
+
spec.bindir = 'exe'
|
18
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
|
21
|
+
spec.add_dependency 'nauktis_utils'
|
22
|
+
spec.add_dependency 'activesupport'
|
23
|
+
spec.add_dependency 'aes_key_wrap'
|
24
|
+
spec.add_dependency 'CFPropertyList'
|
25
|
+
spec.add_dependency 'sqlite3'
|
26
|
+
spec.add_development_dependency 'bundler', '~> 1.10'
|
27
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
28
|
+
spec.add_development_dependency 'rspec'
|
29
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'digest/sha1'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
require 'active_support'
|
5
|
+
require 'active_support/core_ext/numeric'
|
6
|
+
require 'aes_key_wrap'
|
7
|
+
require 'cfpropertylist'
|
8
|
+
require 'nauktis_utils'
|
9
|
+
require 'sqlite3'
|
10
|
+
|
11
|
+
require 'ios_backup_extractor/version'
|
12
|
+
require 'ios_backup_extractor/info_plist'
|
13
|
+
require 'ios_backup_extractor/mbdb'
|
14
|
+
require 'ios_backup_extractor/keybag'
|
15
|
+
require 'ios_backup_extractor/raw_backup'
|
16
|
+
require 'ios_backup_extractor/raw_backup4'
|
17
|
+
require 'ios_backup_extractor/raw_backup10'
|
18
|
+
require 'ios_backup_extractor/backup_retriever'
|
19
|
+
|
20
|
+
module IosBackupExtractor
|
21
|
+
|
22
|
+
##
|
23
|
+
# Returns the number of files contained in +directory+
|
24
|
+
|
25
|
+
def self.file_count(directory)
|
26
|
+
count = 0
|
27
|
+
Find.find(File.expand_path(directory)) do |path|
|
28
|
+
count += 1 unless FileTest.directory?(path)
|
29
|
+
end
|
30
|
+
count
|
31
|
+
end
|
32
|
+
|
33
|
+
# TODO Move helpers somewhere else.
|
34
|
+
def self.plist_data_to_hash(data)
|
35
|
+
CFPropertyList.native_types(CFPropertyList::List.new(data: data).value)
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.plist_file_to_hash(file)
|
39
|
+
file = NauktisUtils::FileBrowser.ensure_valid_file(file)
|
40
|
+
CFPropertyList.native_types(CFPropertyList::List.new(:file => file).value)
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module IosBackupExtractor
|
2
|
+
class BackupRetriever
|
3
|
+
include NauktisUtils::Logging
|
4
|
+
attr_reader :backups
|
5
|
+
|
6
|
+
def search
|
7
|
+
search_in(mobilesync)
|
8
|
+
end
|
9
|
+
|
10
|
+
def search_in(directory)
|
11
|
+
@backups = []
|
12
|
+
directory = NauktisUtils::FileBrowser.ensure_valid_directory(directory)
|
13
|
+
logger.debug('Retriever') {"Retrieving iDevice backup files in #{directory}."}
|
14
|
+
NauktisUtils::FileBrowser.each_file(directory) do |path|
|
15
|
+
if File.basename(path) == 'Info.plist'
|
16
|
+
infos = InfoPlist.new(path)
|
17
|
+
continue unless infos.has? InfoPlist::PRODUCT_VERSION
|
18
|
+
major = infos.versions.first
|
19
|
+
if major <= 3
|
20
|
+
raise 'iOS 3 backups are not supported'
|
21
|
+
elsif major < 10
|
22
|
+
@backups << RawBackup4.new(File.dirname(path))
|
23
|
+
else
|
24
|
+
@backups << RawBackup10.new(File.dirname(path))
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
self
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
def mobilesync
|
33
|
+
if RUBY_PLATFORM =~ /darwin/
|
34
|
+
"#{ENV['HOME']}/Library/Application Support/MobileSync/Backup"
|
35
|
+
else
|
36
|
+
"#{ENV['APPDATA']}/Apple Computer/MobileSync/Backup/"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
class InfoPlist
|
2
|
+
DEVICE_NAME = 'Device Name'
|
3
|
+
DISPLAY_NAME = 'Display Name'
|
4
|
+
IMEI = 'IMEI'
|
5
|
+
ITUNES_VERSION = 'iTunes Version'
|
6
|
+
LAST_BACKUP_DATE = 'Last Backup Date'
|
7
|
+
PRODUCT_TYPE = 'Product Type'
|
8
|
+
PRODUCT_VERSION = 'Product Version'
|
9
|
+
SERIAL_NUMBER = 'Serial Number'
|
10
|
+
|
11
|
+
TAGS = [DEVICE_NAME, DISPLAY_NAME, IMEI, ITUNES_VERSION, LAST_BACKUP_DATE, PRODUCT_TYPE, PRODUCT_VERSION, SERIAL_NUMBER]
|
12
|
+
|
13
|
+
def initialize(file)
|
14
|
+
raise 'Info.plist does not exist' unless File.exist?(file)
|
15
|
+
@infos = IosBackupExtractor.plist_file_to_hash(file)
|
16
|
+
end
|
17
|
+
|
18
|
+
TAGS.each do |tag|
|
19
|
+
define_method(tag.gsub(/\s+/, '_').downcase.to_sym) do
|
20
|
+
@infos.fetch(tag)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def versions
|
25
|
+
product_version.scan(/\d+/).map {|i| i.to_i}
|
26
|
+
end
|
27
|
+
|
28
|
+
def has?(key)
|
29
|
+
@infos.has_key?(key)
|
30
|
+
end
|
31
|
+
|
32
|
+
def details
|
33
|
+
TAGS.each do |tag|
|
34
|
+
puts "#{tag}: #{@infos.fetch(tag)}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_s
|
39
|
+
"#{last_backup_date} - #{device_name} - #{serial_number} (#{product_type} iOS #{product_version})"
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
module IosBackupExtractor
|
2
|
+
class Keybag
|
3
|
+
include NauktisUtils::Logging
|
4
|
+
KEYBAG_TYPES = ['System', 'Backup', 'Escrow', 'OTA (icloud)']
|
5
|
+
CLASSKEY_TAGS = %w(UUID CLAS WRAP WPKY KTYP PBKY)
|
6
|
+
WRAP_DEVICE = 1
|
7
|
+
WRAP_PASSCODE = 2
|
8
|
+
|
9
|
+
def initialize(data, version_major, version_minor)
|
10
|
+
@version_major = version_major
|
11
|
+
@version_minor = version_minor
|
12
|
+
parse_binary_blob(data)
|
13
|
+
end
|
14
|
+
|
15
|
+
def parse_binary_blob(data)
|
16
|
+
@class_keys = {}
|
17
|
+
@attributes = {}
|
18
|
+
current_class = {}
|
19
|
+
loop_tlv_blocks(data) do |tag, value|
|
20
|
+
if value.size == 4
|
21
|
+
value = value.unpack('L>')[0]
|
22
|
+
end
|
23
|
+
if tag == 'TYPE'
|
24
|
+
@type = value & 0x3FFFFFFF # Ignore the flags
|
25
|
+
raise "Error: Keybag type #{@type} > 3" if @type > 3
|
26
|
+
logger.debug(self.class){"Keybag of type #{KEYBAG_TYPES[@type]}"}
|
27
|
+
end
|
28
|
+
@uuid = value if tag == 'UUID' and @uuid.nil?
|
29
|
+
@wrap = value if tag == 'WRAP' and @wrap.nil?
|
30
|
+
current_class = {} if tag == 'UUID' # New class starts by the UUID tag.
|
31
|
+
current_class[tag] = value if CLASSKEY_TAGS.include?(tag)
|
32
|
+
@class_keys[current_class['CLAS'] & 0xF] = current_class if current_class.has_key?('CLAS')
|
33
|
+
@attributes[tag] = value
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def unlock_backup_keybag_with_passcode(password)
|
38
|
+
raise 'This is not a backup keybag' unless @type == 1 or @type == 2
|
39
|
+
unwrap_class_keys(get_passcode_key_from_passcode(password))
|
40
|
+
end
|
41
|
+
|
42
|
+
def unwrap_class_keys(passcodekey)
|
43
|
+
@class_keys.each_value do |classkey|
|
44
|
+
k = classkey['WPKY']
|
45
|
+
if classkey['WRAP'] & WRAP_PASSCODE > 0
|
46
|
+
k = AESKeyWrap.unwrap!(classkey['WPKY'].to_s, passcodekey)
|
47
|
+
classkey['KEY'] = k
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def unwrap_key_for_class(protection_class, persistent_key)
|
53
|
+
raise "Keybag key #{protection_class} missing or locked" unless @class_keys.has_key?(protection_class) and @class_keys[protection_class].has_key?('KEY')
|
54
|
+
raise 'Invalid key length' unless persistent_key.length == 0x28
|
55
|
+
AESKeyWrap.unwrap!(persistent_key, @class_keys[protection_class]['KEY'])
|
56
|
+
end
|
57
|
+
|
58
|
+
def get_passcode_key_from_passcode(password)
|
59
|
+
raise 'This is not a backup/icloud keybag' unless @type == 1 or @type == 3
|
60
|
+
|
61
|
+
if @version_major == 10 && @version_minor < 2
|
62
|
+
return OpenSSL::PKCS5.pbkdf2_hmac_sha1(password, @attributes['SALT'], @attributes['ITER'], 32)
|
63
|
+
end
|
64
|
+
|
65
|
+
# Version >= 10.2
|
66
|
+
digest = OpenSSL::Digest::SHA256.new
|
67
|
+
len = digest.digest_length
|
68
|
+
kek = OpenSSL::PKCS5.pbkdf2_hmac(password, @attributes['DPSL'], @attributes['DPIC'], len, digest)
|
69
|
+
OpenSSL::PKCS5.pbkdf2_hmac_sha1(kek, @attributes['SALT'], @attributes['ITER'], 32)
|
70
|
+
end
|
71
|
+
|
72
|
+
##
|
73
|
+
# Creates a new Keybag
|
74
|
+
|
75
|
+
def self.create_with_backup_manifest(manifest, password, version_major, version_minor)
|
76
|
+
kb = Keybag.new(manifest['BackupKeyBag'], version_major, version_minor)
|
77
|
+
kb.unlock_backup_keybag_with_passcode(password)
|
78
|
+
kb
|
79
|
+
end
|
80
|
+
|
81
|
+
##
|
82
|
+
# Prints information about the Keybag
|
83
|
+
|
84
|
+
def print_info
|
85
|
+
puts '== Keybag'
|
86
|
+
puts "Keybag type: #{KEYBAG_TYPES[@type]} keybag (#{@type})"
|
87
|
+
puts "Keybag version: #{@attributes['VERS']}"
|
88
|
+
puts "Keybag iterations: #{@attributes['ITER']}, iv=#{@attributes['SALT'].unpack('H*')[0]}"
|
89
|
+
puts "Keybag UUID: #{@uuid.unpack('H*')[0]}"
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
##
|
95
|
+
# Parses a binary blob and extracts the tags and data.
|
96
|
+
|
97
|
+
def loop_tlv_blocks(data)
|
98
|
+
i = 0
|
99
|
+
while i + 8 <= data.size do
|
100
|
+
tag = data[i...(i+4)].to_s
|
101
|
+
length = data[(i+4)...(i+8)].unpack('L>')[0]
|
102
|
+
value = data[(i+8)...(i+8+length)]
|
103
|
+
yield(tag, value)
|
104
|
+
i += 8 + length
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module IosBackupExtractor
|
2
|
+
class MBDB
|
3
|
+
include NauktisUtils::Logging
|
4
|
+
|
5
|
+
def initialize(manifest_location)
|
6
|
+
parse(NauktisUtils::FileBrowser.ensure_valid_file(manifest_location))
|
7
|
+
end
|
8
|
+
|
9
|
+
def files
|
10
|
+
@files
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
# Retreive an integer (big-endian) from the current offset in the data.
|
15
|
+
# Adjust the offset after.
|
16
|
+
def get_integer(size)
|
17
|
+
value = 0
|
18
|
+
size.times do
|
19
|
+
value = (value << 8) + @data[@offset].ord
|
20
|
+
@offset += 1
|
21
|
+
end
|
22
|
+
value
|
23
|
+
end
|
24
|
+
|
25
|
+
# Retreive a string from the current offset in the data.
|
26
|
+
# Adjust the offset after.
|
27
|
+
def get_string
|
28
|
+
if @data[@offset] == 0xFF.chr and @data[@offset + 1] == 0xFF.chr
|
29
|
+
@offset += 2
|
30
|
+
''
|
31
|
+
else
|
32
|
+
length = get_integer(2)
|
33
|
+
value = @data[@offset...(@offset + length)]
|
34
|
+
@offset += length
|
35
|
+
value
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Parse the manifest file
|
40
|
+
def parse(filename)
|
41
|
+
# Set-up
|
42
|
+
@files = Array.new
|
43
|
+
@total_size = 0
|
44
|
+
@data = File.open(filename, 'rb') { |f| f.read }
|
45
|
+
@offset = 0
|
46
|
+
raise 'This does not look like an MBDB file' if @data[0...4] != 'mbdb'
|
47
|
+
@offset = 6 # We skip the header mbdb\5\0
|
48
|
+
# Actual parsing
|
49
|
+
while @offset < @data.size
|
50
|
+
info = Hash.new
|
51
|
+
info[:start_offset] = @offset
|
52
|
+
info[:domain] = get_string # Domain name
|
53
|
+
info[:file_path] = get_string # File path
|
54
|
+
info[:link_target] = get_string # Absolute path for Symbolic Links
|
55
|
+
info[:data_hash] = get_string
|
56
|
+
info[:encryption_key] = get_string
|
57
|
+
info[:mode] = get_integer(2)
|
58
|
+
info[:type] = '?'
|
59
|
+
info[:type] = 'l' if (info[:mode] & 0xE000) == 0xA000 # Symlink
|
60
|
+
info[:type] = '-' if (info[:mode] & 0xE000) == 0x8000 # File
|
61
|
+
info[:type] = 'd' if (info[:mode] & 0xE000) == 0x4000 # Directory
|
62
|
+
@offset += 8 # We skip the inode numbers (uint64).
|
63
|
+
info[:user_id] = get_integer(4)
|
64
|
+
info[:group_id] = get_integer(4)
|
65
|
+
info[:mtime] = get_integer(4) # File last modified time in Epoch format
|
66
|
+
info[:atime] = get_integer(4) # File last accessed time in Epoch format
|
67
|
+
info[:ctime] = get_integer(4) # File created time in Epoch format
|
68
|
+
info[:file_size] = get_integer(8)
|
69
|
+
info[:protection_class] = get_integer(1)
|
70
|
+
info[:properties_number] = get_integer(1)
|
71
|
+
info[:properties] = Hash.new
|
72
|
+
info[:properties_number].times do
|
73
|
+
propname = get_string
|
74
|
+
propvalue = get_string
|
75
|
+
info[:properties][propname] = propvalue
|
76
|
+
end
|
77
|
+
# Compute the ID of the file.
|
78
|
+
fullpath = info[:domain] + '-' + info[:file_path]
|
79
|
+
info[:file_id] = Digest::SHA1.hexdigest(fullpath)
|
80
|
+
# We add the file to the list of files.
|
81
|
+
@files << info
|
82
|
+
# We accumulate the total size
|
83
|
+
@total_size += info[:file_size]
|
84
|
+
end
|
85
|
+
logger.debug('Manifest Parser') {"#{@files.size.to_s(:delimited)} entries in the Manifest. Total size: #{@total_size.to_s(:human_size)}."}
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|