summaryrefslogtreecommitdiff
path: root/plugins/python
diff options
context:
space:
mode:
authorCyrille Bagard <nocbos@gmail.com>2019-07-01 19:38:13 (GMT)
committerCyrille Bagard <nocbos@gmail.com>2019-07-01 19:38:13 (GMT)
commitc8a643f54649574777e6b5d5f5d332160c8c72ea (patch)
tree6acc977c19203c8fd92b287c523910e5d47930ba /plugins/python
parent6e8544094334d134d51cd9ca549a7c75b2e8fdab (diff)
Added support for Android backup files.
Diffstat (limited to 'plugins/python')
-rw-r--r--plugins/python/Makefile.am2
-rw-r--r--plugins/python/abackup/Makefile.am10
-rw-r--r--plugins/python/abackup/__init__.py4
-rw-r--r--plugins/python/abackup/backup.py211
-rw-r--r--plugins/python/abackup/password.py83
-rw-r--r--plugins/python/abackup/plugin.py136
6 files changed, 445 insertions, 1 deletions
diff --git a/plugins/python/Makefile.am b/plugins/python/Makefile.am
index 1560913..40af827 100644
--- a/plugins/python/Makefile.am
+++ b/plugins/python/Makefile.am
@@ -1,2 +1,2 @@
-SUBDIRS = apkfiles checksec
+SUBDIRS = abackup apkfiles checksec
diff --git a/plugins/python/abackup/Makefile.am b/plugins/python/abackup/Makefile.am
new file mode 100644
index 0000000..1c8ae18
--- /dev/null
+++ b/plugins/python/abackup/Makefile.am
@@ -0,0 +1,10 @@
+
+abackupdir = $(pluginsdatadir)/python/abackup
+
+abackup_DATA = \
+ __init__.py \
+ backup.py \
+ password.py \
+ plugin.py
+
+EXTRA_DIST = $(abackup_DATA)
diff --git a/plugins/python/abackup/__init__.py b/plugins/python/abackup/__init__.py
new file mode 100644
index 0000000..72b936d
--- /dev/null
+++ b/plugins/python/abackup/__init__.py
@@ -0,0 +1,4 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+from abackup.plugin import AndroidBackupPlugin as AutoLoad
diff --git a/plugins/python/abackup/backup.py b/plugins/python/abackup/backup.py
new file mode 100644
index 0000000..e2599da
--- /dev/null
+++ b/plugins/python/abackup/backup.py
@@ -0,0 +1,211 @@
+
+# Chrysalide - Outil d'analyse de fichiers binaires
+# backup.py - gestionnaire du format de fichier des sauvegardes Android
+#
+# Copyright (C) 2019 Cyrille Bagard
+#
+# This file is part of Chrysalide.
+#
+# Chrysalide is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# Chrysalide is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+
+try:
+ from Crypto.Cipher import AES
+except:
+ AES = None
+
+import hashlib
+import io
+import pychrysalide
+from pychrysalide.analysis.contents import FileContent
+from pychrysalide.arch import vmpa
+import zlib
+
+
+PBKDF2_KEY_SIZE = 32
+
+
+class AndroidBackup():
+ """Reader for Android backups."""
+
+
+ def _read_lines(self, pos, lcount):
+ """Read lcount lines from a given pos in a binary content."""
+
+ count = 0
+
+ buf = ''
+
+ while count < lcount:
+
+ got = chr(self._content.read_u8(pos))
+
+ buf += got
+
+ if got == '\n':
+ count += 1
+
+ if pos.phys == self._content.size:
+ break
+
+ return buf
+
+
+ def __init__(self, content):
+ """Create an Android backup reader from a binary content."""
+
+ # Refs from https://android.googlesource.com/platform/frameworks/base.git/+/refs/heads/master/ :
+
+ # - versions : services/backup/java/com/android/server/backup/BackupManagerService.java#184
+ # - content : services/backup/java/com/android/server/backup/fullbackup/PerformAdbBackupTask.java#335
+
+ self._content = content
+
+ pos = vmpa(0, vmpa.VMPA_NO_VIRTUAL)
+
+ # Read the global file header
+
+ self._header = self._read_lines(pos, 4)
+
+ lines = self._header.split('\n')
+
+ if len(lines) != 5 or lines[0] != 'ANDROID BACKUP':
+ raise ValueError('Invalid header')
+
+ self._version = lines[1]
+
+ if not(lines[2] in ['0', '1']):
+ raise ValueError('Content should be compressed or uncompressed')
+
+ self._compressed = (lines[2] == '1')
+
+ if not(lines[3] in ['none', 'AES-256']):
+ raise ValueError('Encryption not supported!')
+
+ self._encrypted = (lines[3] == 'AES-256')
+
+ # Read the encryption header
+
+ if not(self._encrypted):
+
+ self._enc_header = None
+
+ self._user_password_salt = None
+ self._master_key_checksum_salt = None
+ self._pbkdf2_rounds_numer = None
+ self._user_key_iv = None
+ self._master_key_blob = None
+
+ else:
+
+ self._enc_header = self._read_lines(pos, 5)
+
+ lines = self._enc_header.split('\n')
+
+ if len(lines) != 6:
+ raise ValueError('Invalid encryption header')
+
+ self._user_password_salt = bytes.fromhex(lines[0])
+ self._master_key_checksum_salt = bytes.fromhex(lines[1])
+ self._pbkdf2_rounds_numer = int(lines[2])
+ self._user_key_iv = bytes.fromhex(lines[3])
+ self._master_key_blob = bytes.fromhex(lines[4])
+
+ self._backup_start = pos.phys
+
+
+ def is_encrypted(self):
+ """Tell if the backup is encrypted."""
+
+ return self._encrypted
+
+
+ def _convert_master_key(self, input_bytes):
+ """Convert a master key into an UTF-8 byte array."""
+
+ output = []
+
+ for byte in input_bytes:
+
+ if byte < ord(b'\x80'):
+ output.append(byte)
+ else:
+ output.append(ord('\xef') | (byte >> 12))
+ output.append(ord('\xbc') | ((byte >> 6) & ord('\x3f')))
+ output.append(ord('\x80') | (byte & ord('\x3f')))
+
+ return bytes(output)
+
+
+ def get_master_key(self, password):
+ """Get a verified master key and its IV for the backup encryption."""
+
+ if AES is None:
+ raise OSError('No AES support')
+
+ key = hashlib.pbkdf2_hmac('sha1', password.encode('utf-8'),
+ self._user_password_salt,
+ self._pbkdf2_rounds_numer, PBKDF2_KEY_SIZE)
+
+ alg = AES.new(key, AES.MODE_CBC, self._user_key_iv)
+
+ master_key = alg.decrypt(self._master_key_blob)
+
+ blob = io.BytesIO(master_key)
+
+ master_iv_length = ord(blob.read(1))
+ master_iv = blob.read(master_iv_length)
+ master_key_length = ord(blob.read(1))
+ master_key = blob.read(master_key_length)
+ master_key_checksum_length = ord(blob.read(1))
+ master_key_checksum = blob.read(master_key_checksum_length)
+
+ checksum = hashlib.pbkdf2_hmac('sha1', self._convert_master_key(master_key),
+ self._master_key_checksum_salt,
+ self._pbkdf2_rounds_numer, PBKDF2_KEY_SIZE)
+
+ if not master_key_checksum == checksum:
+ raise ValueError('Invalid decryption password')
+
+ return master_key, master_iv
+
+
+ def get_content(self, password=None):
+ """Extract the backup content as a simple tarball content."""
+
+ data = self._content.data[self._backup_start:]
+
+ if self._encrypted:
+
+ assert(password)
+
+ # Cf. https://f-o.org.uk/2017/decrypting-android-backups-with-python.html
+
+ master_key, master_iv = self.get_master_key(password)
+
+ alg = AES.new(master_key, AES.MODE_CBC, master_iv)
+
+ data = alg.decrypt(data)
+
+ if self._compressed:
+
+ d = zlib.decompressobj()
+ extracted = d.decompress(data)
+
+ else:
+
+ extracted = data
+
+ return extracted
diff --git a/plugins/python/abackup/password.py b/plugins/python/abackup/password.py
new file mode 100644
index 0000000..d1162e8
--- /dev/null
+++ b/plugins/python/abackup/password.py
@@ -0,0 +1,83 @@
+
+# Chrysalide - Outil d'analyse de fichiers binaires
+# password.py - lecture des mots de passe pour une sauvegarde chiffrée
+#
+# Copyright (C) 2019 Cyrille Bagard
+#
+# This file is part of Chrysalide.
+#
+# Chrysalide is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# Chrysalide is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+
+from pychrysalide.gui import core
+from gi.repository import GLib, Gtk
+from threading import Event
+
+
+class PasswordReader():
+ """Features for getting a backup password."""
+
+
+ @staticmethod
+ def read_password_from_console():
+ """Get the backup console from the console."""
+
+ password = input('Enter the password of the backup: ')
+
+ return password
+
+
+ @staticmethod
+ def _show_password_box(mutex, ref):
+
+ dlgbox = Gtk.MessageDialog(parent = core.get_editor_window(),
+ flags = Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
+ type = Gtk.MessageType.QUESTION,
+ buttons = Gtk.ButtonsType.OK_CANCEL,
+ message_format = 'The backup file is password protected. Please enter it here:')
+
+ dlgbox.set_title('Android backup password')
+
+ entry = Gtk.Entry()
+ entry.set_visibility(False)
+ entry.set_invisible_char("*")
+ entry.set_size_request(250, 0)
+
+ area = dlgbox.get_content_area()
+ area.pack_end(entry, False, False, 0)
+
+ dlgbox.show_all()
+ response = dlgbox.run()
+
+ if response == Gtk.ResponseType.OK:
+ ref['password'] = entry.get_text()
+
+ dlgbox.destroy()
+
+ mutex.set()
+
+
+ @staticmethod
+ def read_password_from_gui():
+ """Get the backup console from a dialog box."""
+
+ evt = Event()
+ ref = {}
+
+ GLib.idle_add(PasswordReader._show_password_box, evt, ref)
+
+ evt.wait()
+
+ return ref['password'] if 'password' in ref.keys() else None
diff --git a/plugins/python/abackup/plugin.py b/plugins/python/abackup/plugin.py
new file mode 100644
index 0000000..3c70f07
--- /dev/null
+++ b/plugins/python/abackup/plugin.py
@@ -0,0 +1,136 @@
+
+# Chrysalide - Outil d'analyse de fichiers binaires
+# plugin.py - point d'entrée pour le greffon assurant la gestion des sauvegardes Android
+#
+# Copyright (C) 2019 Cyrille Bagard
+#
+# This file is part of Chrysalide.
+#
+# Chrysalide is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# Chrysalide is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+
+import io
+import tarfile
+from pychrysalide import PluginModule
+from pychrysalide import core
+from pychrysalide.analysis.contents import EncapsulatedContent
+from pychrysalide.analysis.contents import MemoryContent
+from .backup import AndroidBackup
+from .password import PasswordReader
+
+
+class AndroidBackupPlugin(PluginModule):
+ """Open and process Android backup files."""
+
+
+ def __init__(self):
+ """Initialize the plugin for Chrysalide."""
+
+ interface = {
+
+ 'name' : 'AndroidBackup',
+ 'desc' : 'Add suppport for the Android backup file format',
+ 'version' : '0.1',
+
+ 'actions' : ( PluginModule.PGA_CONTENT_EXPLORER, )
+
+ }
+
+ super(AndroidBackupPlugin, self).__init__(**interface)
+
+
+ def handle_binary_content(self, action, content, wid, status):
+ """Process an operation on a binary content."""
+
+ assert(action == PluginModule.PGA_CONTENT_EXPLORER)
+
+ try:
+ backup = AndroidBackup(content)
+ except:
+ backup = None
+
+ if backup:
+
+ # Get the backup password, if required
+
+ encrypted = backup.is_encrypted()
+
+ if encrypted:
+
+ if 'password' in content.attributes.keys:
+
+ password = content.attributes['password']
+
+ else:
+
+ if core.is_batch_mode():
+ password = PasswordReader.read_password_from_console()
+ else:
+ password = PasswordReader.read_password_from_gui()
+
+ #content.attributes['password'] = password
+
+ if password:
+
+ try:
+ backup.get_master_key(password)
+ valid = True
+ except:
+ valid = False
+
+ else:
+ valid = False
+
+ else:
+
+ password = None
+
+ # Extract all the backup content
+
+ if not(encrypted) or valid:
+
+ tar_content = backup.get_content(password)
+
+ tar_stream = io.BytesIO(tar_content)
+
+ explorer = core.get_content_explorer()
+
+ try:
+
+ tf = tarfile.TarFile(fileobj=tar_stream, mode='r')
+
+ for ti in tf.getmembers():
+
+ if not(ti.type == tarfile.REGTYPE):
+ continue
+
+ fobj = tf.extractfile(ti)
+
+ data = fobj.read()
+
+ if len(data):
+
+ mem_content = MemoryContent(data)
+ encaps_content = EncapsulatedContent(content, ti.name, mem_content)
+
+ explorer.populate_group(wid, encaps_content)
+
+ except:
+
+ core.log_message(core.LMT_ERROR, 'The Android backup is corrupted')
+
+ else:
+
+ core.log_message(core.LMT_ERROR, 'Bad Android backup password')