From c8a643f54649574777e6b5d5f5d332160c8c72ea Mon Sep 17 00:00:00 2001 From: Cyrille Bagard Date: Mon, 1 Jul 2019 21:38:13 +0200 Subject: Added support for Android backup files. --- configure.ac | 1 + plugins/python/Makefile.am | 2 +- plugins/python/abackup/Makefile.am | 10 ++ plugins/python/abackup/__init__.py | 4 + plugins/python/abackup/backup.py | 211 +++++++++++++++++++++++++++++++++++++ plugins/python/abackup/password.py | 83 +++++++++++++++ plugins/python/abackup/plugin.py | 136 ++++++++++++++++++++++++ 7 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 plugins/python/abackup/Makefile.am create mode 100644 plugins/python/abackup/__init__.py create mode 100644 plugins/python/abackup/backup.py create mode 100644 plugins/python/abackup/password.py create mode 100644 plugins/python/abackup/plugin.py diff --git a/configure.ac b/configure.ac index 338f7d4..67019f2 100644 --- a/configure.ac +++ b/configure.ac @@ -456,6 +456,7 @@ AC_CONFIG_FILES([Makefile plugins/pychrysalide/gui/core/Makefile plugins/pychrysalide/mangling/Makefile plugins/python/Makefile + plugins/python/abackup/Makefile plugins/python/apkfiles/Makefile plugins/python/checksec/Makefile plugins/readdex/Makefile 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') -- cgit v0.11.2-87-g4458