From c8a643f54649574777e6b5d5f5d332160c8c72ea Mon Sep 17 00:00:00 2001
From: Cyrille Bagard <nocbos@gmail.com>
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