Skip to content

Commit e6e72e0

Browse files
authored
Extract Android backups, yield devices instead of just echoing (#80)
* Extract Android backups, yield devices instead of just echoing This moves the functionality towards automatic config generation out of backups. The extraction depends on code which is not available in pypi, and requires some changes to be useful for us: bluec0re/android-backup-tools#1 * use logging for the database class, fix linting mistakes and some small changes based on code review * Add a fork of python-backup-tools for backup extraction Forked and modified from https://github.com/bluec0re/android-backup-tools
1 parent a6a8ecd commit e6e72e0

File tree

2 files changed

+294
-34
lines changed

2 files changed

+294
-34
lines changed

mirobo/android_backup.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
# This is a fork from original work by BlueC0re <coding@bluec0re.eu>
4+
# https://github.com/bluec0re/android-backup-tools
5+
# License: GPLv3
6+
import tarfile
7+
import zlib
8+
import enum
9+
import io
10+
import pickle
11+
import os
12+
import binascii
13+
14+
try:
15+
from Crypto.Cipher import AES
16+
from Crypto.Protocol.KDF import PBKDF2
17+
from Crypto import Random
18+
except ImportError:
19+
AES = None
20+
21+
22+
class CompressionType(enum.IntEnum):
23+
NONE = 0
24+
ZLIB = 1
25+
26+
27+
class EncryptionType(enum.Enum):
28+
NONE = 'none'
29+
AES256 = 'AES-256'
30+
31+
32+
class AndroidBackup:
33+
def __init__(self, fname=None):
34+
if fname:
35+
self.open(fname)
36+
37+
def open(self, fname, mode='rb'):
38+
self.fp = open(fname, mode)
39+
40+
def close(self):
41+
self.fp.close()
42+
43+
def parse(self):
44+
self.fp.seek(0)
45+
magic = self.fp.readline()
46+
assert magic == b'ANDROID BACKUP\n'
47+
self.version = int(self.fp.readline().strip())
48+
self.compression = CompressionType(int(self.fp.readline().strip()))
49+
self.encryption = EncryptionType(self.fp.readline().strip().decode())
50+
51+
def is_encrypted(self):
52+
return self.encryption == EncryptionType.AES256
53+
54+
def _decrypt(self, enc, password):
55+
if AES is None:
56+
raise ImportError("PyCrypto required")
57+
58+
user_salt, enc = enc.split(b'\n', 1)
59+
user_salt = binascii.a2b_hex(user_salt)
60+
ck_salt, enc = enc.split(b'\n', 1)
61+
ck_salt = binascii.a2b_hex(ck_salt)
62+
rounds, enc = enc.split(b'\n', 1)
63+
rounds = int(rounds)
64+
iv, enc = enc.split(b'\n', 1)
65+
iv = binascii.a2b_hex(iv)
66+
master_key, enc = enc.split(b'\n', 1)
67+
master_key = binascii.a2b_hex(master_key)
68+
69+
user_key = PBKDF2(password, user_salt, dkLen=256//8, count=rounds)
70+
cipher = AES.new(user_key,
71+
mode=AES.MODE_CBC,
72+
IV=iv)
73+
74+
master_key = list(cipher.decrypt(master_key))
75+
l = master_key.pop(0)
76+
master_iv = bytes(master_key[:l])
77+
master_key = master_key[l:]
78+
l = master_key.pop(0)
79+
mk = bytes(master_key[:l])
80+
master_key = master_key[l:]
81+
l = master_key.pop(0)
82+
master_ck = bytes(master_key[:l])
83+
84+
# gen checksum
85+
86+
# double encode utf8
87+
utf8mk = self.encode_utf8(mk)
88+
calc_ck = PBKDF2(utf8mk, ck_salt, dkLen=256//8, count=rounds)
89+
assert calc_ck == master_ck
90+
91+
cipher = AES.new(mk,
92+
mode=AES.MODE_CBC,
93+
IV=master_iv)
94+
95+
dec = cipher.decrypt(enc)
96+
pad = dec[-1]
97+
98+
return dec[:-pad]
99+
100+
@staticmethod
101+
def encode_utf8(mk):
102+
utf8mk = mk.decode('raw_unicode_escape')
103+
utf8mk = list(utf8mk)
104+
for i in range(len(utf8mk)):
105+
c = ord(utf8mk[i])
106+
# fix java encoding (add 0xFF00 to non ascii chars)
107+
if 0x7f < c < 0x100:
108+
c += 0xff00
109+
utf8mk[i] = chr(c)
110+
return ''.join(utf8mk).encode('utf-8')
111+
112+
def _encrypt(self, dec, password):
113+
if AES is None:
114+
raise ImportError("PyCrypto required")
115+
116+
master_key = Random.get_random_bytes(32)
117+
master_salt = Random.get_random_bytes(64)
118+
user_salt = Random.get_random_bytes(64)
119+
master_iv = Random.get_random_bytes(16)
120+
user_iv = Random.get_random_bytes(16)
121+
rounds = 10000
122+
123+
l = len(dec)
124+
pad = 16 - (l % 16)
125+
dec += bytes([pad] * pad)
126+
cipher = AES.new(master_key, IV=master_iv, mode=AES.MODE_CBC)
127+
enc = cipher.encrypt(dec)
128+
129+
master_ck = PBKDF2(self.encode_utf8(master_key),
130+
master_salt, dkLen=256//8, count=rounds)
131+
132+
user_key = PBKDF2(password,
133+
user_salt, dkLen=256//8, count=rounds)
134+
135+
master_dec = b"\x10" + master_iv + b"\x20" + master_key + b"\x20" + master_ck
136+
l = len(master_dec)
137+
pad = 16 - (l % 16)
138+
master_dec += bytes([pad] * pad)
139+
cipher = AES.new(user_key, IV=user_iv, mode=AES.MODE_CBC)
140+
master_enc = cipher.encrypt(master_dec)
141+
142+
enc = binascii.b2a_hex(user_salt).upper() + b"\n" + \
143+
binascii.b2a_hex(master_salt).upper() + b"\n" + \
144+
str(rounds).encode() + b"\n" + \
145+
binascii.b2a_hex(user_iv).upper() + b"\n" + \
146+
binascii.b2a_hex(master_enc).upper() + b"\n" + enc
147+
148+
return enc
149+
150+
def read_data(self, password):
151+
"""Reads from the file and returns a TarFile object."""
152+
data = self.fp.read()
153+
154+
if self.encryption == EncryptionType.AES256:
155+
if password is None:
156+
raise Exception("Password need to be provided to extract encrypted archives")
157+
data = self._decrypt(data, password)
158+
159+
if self.compression == CompressionType.ZLIB:
160+
data = zlib.decompress(data, zlib.MAX_WBITS)
161+
162+
tar = tarfile.TarFile(fileobj=io.BytesIO(data))
163+
return tar
164+
165+
def unpack(self, target_dir=None, password=None):
166+
tar = self.read_data(password)
167+
168+
members = tar.getmembers()
169+
170+
if target_dir is None:
171+
target_dir = os.path.basename(self.fp.name) + '_unpacked'
172+
pickle_fname = os.path.basename(self.fp.name) + '.pickle'
173+
if not os.path.exists(target_dir):
174+
os.mkdir(target_dir)
175+
176+
tar.extractall(path=target_dir)
177+
178+
with open(pickle_fname, 'wb') as fp:
179+
pickle.dump(members, fp)
180+
181+
def list(self, password=None):
182+
tar = self.read_tar(password)
183+
return tar.list()
184+
185+
def pack(self, fname, password=None):
186+
target_dir = os.path.basename(fname) + '_unpacked'
187+
pickle_fname = os.path.basename(fname) + '.pickle'
188+
189+
data = io.BytesIO()
190+
tar = tarfile.TarFile(name=fname,
191+
fileobj=data,
192+
mode='w',
193+
format=tarfile.PAX_FORMAT)
194+
195+
with open(pickle_fname, 'rb') as fp:
196+
members = pickle.load(fp)
197+
198+
os.chdir(target_dir)
199+
for member in members:
200+
if member.isreg():
201+
tar.addfile(member, open(member.name, 'rb'))
202+
else:
203+
tar.addfile(member)
204+
205+
tar.close()
206+
207+
data.seek(0)
208+
if self.compression == CompressionType.ZLIB:
209+
data = zlib.compress(data.read())
210+
if self.encryption == EncryptionType.AES256:
211+
data = self._encrypt(data, password)
212+
213+
with open(fname, 'wb') as fp:
214+
fp.write(b'ANDROID BACKUP\n')
215+
fp.write('{}\n'.format(self.version).encode())
216+
fp.write('{:d}\n'.format(self.compression).encode())
217+
fp.write('{}\n'.format(self.encryption.value).encode())
218+
219+
fp.write(data)
220+
221+
def __exit__(self, *args, **kwargs):
222+
self.close()
223+
224+
def __enter__(self):
225+
self.parse()
226+
return self

mirobo/extract_tokens.py

Lines changed: 68 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,33 @@
1+
import logging
12
import click
2-
import tarfile
33
import tempfile
44
import sqlite3
55
from Crypto.Cipher import AES
66
from pprint import pformat as pf
7+
import attr
8+
from .android_backup import AndroidBackup
9+
10+
logging.basicConfig(level=logging.INFO)
11+
_LOGGER = logging.getLogger(__name__)
12+
13+
14+
@attr.s
15+
class DeviceConfig:
16+
name = attr.ib()
17+
mac = attr.ib()
18+
ip = attr.ib()
19+
token = attr.ib()
20+
model = attr.ib()
721

822

923
class BackupDatabaseReader:
10-
def __init__(self, dump_all=False, dump_raw=False):
11-
self.dump_all = dump_all
24+
def __init__(self, dump_raw=False):
1225
self.dump_raw = dump_raw
1326

1427
@staticmethod
1528
def dump_raw(dev):
1629
raw = {k: dev[k] for k in dev.keys()}
17-
click.echo(pf(raw))
30+
_LOGGER.info(pf(raw))
1831

1932
@staticmethod
2033
def decrypt_ztoken(ztoken):
@@ -29,7 +42,7 @@ def decrypt_ztoken(ztoken):
2942
return token.decode()
3043

3144
def read_apple(self):
32-
click.echo("Reading tokens from Apple DB")
45+
_LOGGER.info("Reading tokens from Apple DB")
3346
c = self.conn.execute("SELECT * FROM ZDEVICE WHERE ZTOKEN IS NOT '';")
3447
for dev in c.fetchall():
3548
if self.dump_raw:
@@ -39,11 +52,12 @@ def read_apple(self):
3952
model = dev['ZMODEL']
4053
name = dev['ZNAME']
4154
token = BackupDatabaseReader.decrypt_ztoken(dev['ZTOKEN'])
42-
if ip or self.dump_all:
43-
click.echo("%s\n\tModel: %s\n\tIP address: %s\n\tToken: %s\n\tMAC: %s" % (name, model, ip, token, mac))
55+
56+
config = DeviceConfig(name=name, mac=mac, ip=ip, model=model, token=token)
57+
yield config
4458

4559
def read_android(self):
46-
click.echo("Reading tokens from Android DB")
60+
_LOGGER.info("Reading tokens from Android DB")
4761
c = self.conn.execute("SELECT * FROM devicerecord WHERE token IS NOT '';")
4862
for dev in c.fetchall():
4963
if self.dump_raw:
@@ -53,57 +67,77 @@ def read_android(self):
5367
model = dev['model']
5468
name = dev['name']
5569
token = dev['token']
56-
if ip or self.dump_all:
57-
click.echo("%s\n\tModel: %s\n\tIP address: %s\n\tToken: %s\n\tMAC: %s" % (name, model, ip, token, mac))
5870

59-
def dump_to_file(self, fp):
60-
fp.open()
61-
self.db.seek(0) # go to the beginning
62-
click.echo("Saving db to %s" % fp)
63-
fp.write(self.db.read())
71+
config = DeviceConfig(name=name, ip=ip, mac=mac,
72+
model=model, token=token)
73+
yield config
6474

6575
def read_tokens(self, db):
6676
self.db = db
77+
_LOGGER.info("Reading database from %s" % db)
6778
self.conn = sqlite3.connect(db)
79+
6880
self.conn.row_factory = sqlite3.Row
6981
with self.conn:
7082
is_android = self.conn.execute(
7183
"SELECT name FROM sqlite_master WHERE type='table' AND name='devicerecord';").fetchone() is not None
7284
is_apple = self.conn.execute(
7385
"SELECT name FROM sqlite_master WHERE type='table' AND name='ZDEVICE'").fetchone() is not None
7486
if is_android:
75-
self.read_android()
87+
yield from self.read_android()
7688
elif is_apple:
77-
self.read_apple()
89+
yield from self.read_apple()
7890
else:
79-
click.echo("Error, unknown database type!")
91+
_LOGGER.error("Error, unknown database type!")
8092

8193

8294
@click.command()
8395
@click.argument('backup')
84-
@click.option('--write-to-disk', type=click.File('wb'), help='writes sqlite3 db to a file for debugging')
85-
@click.option('--dump-all', is_flag=True, default=False, help='dump devices without ip addresses')
96+
@click.option('--write-to-disk', type=click.File('wb'),
97+
help='writes sqlite3 db to a file for debugging')
98+
@click.option('--password', type=str,
99+
help='password if the android database is encrypted')
100+
@click.option('--dump-all', is_flag=True, default=False,
101+
help='dump devices without ip addresses')
86102
@click.option('--dump-raw', is_flag=True, help='dumps raw rows')
87-
def main(backup, write_to_disk, dump_all, dump_raw):
103+
def main(backup, write_to_disk, password, dump_all, dump_raw):
88104
"""Reads device information out from an sqlite3 DB.
89-
If the given file is a .tar file, the file will be extracted
90-
and the database automatically located (out of Android backups).
105+
If the given file is an Android backup (.ab), the database
106+
will be extracted automatically.
107+
If the given file is an iOS backup, the tokens will be
108+
extracted (and decrypted if needed) automatically.
91109
"""
92-
reader = BackupDatabaseReader(dump_all, dump_raw)
93-
if backup.endswith(".tar"):
110+
111+
reader = BackupDatabaseReader(dump_raw)
112+
if backup.endswith(".ab"):
94113
DBFILE = "apps/com.xiaomi.smarthome/db/miio2.db"
95-
with tarfile.open(backup) as f:
96-
click.echo("Opened %s" % backup)
97-
db = f.extractfile(DBFILE)
98-
with tempfile.NamedTemporaryFile() as fp:
99-
click.echo("Extracting to %s" % fp.name)
114+
with AndroidBackup(backup) as f:
115+
tar = f.read_data(password)
116+
try:
117+
db = tar.extractfile(DBFILE)
118+
except KeyError as ex:
119+
click.echo("Unable to extract the database file %s: %s" % (DBFILE, ex))
120+
return
121+
if write_to_disk:
122+
file = write_to_disk
123+
else:
124+
file = tempfile.NamedTemporaryFile()
125+
with file as fp:
126+
click.echo("Saving database to %s" % fp.name)
100127
fp.write(db.read())
101-
if write_to_disk:
102-
reader.dump_to_file(write_to_disk)
103128

104-
reader.read_tokens(fp.name)
129+
devices = list(reader.read_tokens(fp.name))
105130
else:
106-
reader.read_tokens(backup)
131+
devices = list(reader.read_tokens(backup))
132+
133+
for dev in devices:
134+
if dev.ip or dump_all:
135+
click.echo("%s\n"
136+
"\tModel: %s\n"
137+
"\tIP address: %s\n"
138+
"\tToken: %s\n"
139+
"\tMAC: %s" % (dev.name, dev.model,
140+
dev.ip, dev.token, dev.mac))
107141

108142

109143
if __name__ == "__main__":

0 commit comments

Comments
 (0)