Decrypting Browser Passwords & Other "Secrets"

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 Secrets

I 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 Works

Figure 1

The 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."


So 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 Key

Let'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
        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[ \
            chromekey = encrypted_key
        print(" [*] Chromium encryption key not found or not usable; maybe older version")
    browser["chromekey"] = chromekey
    return chromekey

After 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.


   def _decrypt_passwords(self, browser):
        browser["passwords"] = passwords = {}
            db = sqlite3.connect(browser["password_file"])
            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)
                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.


def 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[ \

    return plaintext

Just 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.


When 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 going to be doing a happy dance all around the office. People reuse passwords. I would expect at least one recovered password can be used to further my access on the network. How do you think my pentest report would go if I can show the CEO that I was able to obtain her credit card number?

One last thing: I was surprised when I ran this tool on my local computer. I had switched to Brave a long time ago and it's been more than a year since I stopped letting the browser keep any passwords. The tool relentlessly searches for Chromium installs and it found my old Google Chrome install. That sample output above in Figure 2 is from that abandoned install. Make sure once you've secured your browser, head into Explorer and delete those older browser installs.

Learn more about our internal or "assumed compromise" test today. Our certified pentesters will analyze your network to provide detailed analysis and reporting you can use to further secure your network. We'll help you secure your browser passwords.


Kirby Angell is the CTO of Alertra, Inc. and a certified pentester. In addition he has written several articles on Python programming for magazines back when that was a thing. He contributed a chapter to the 1st edition of "The Quick Python Book" published by Manning. He was one of the first 10 Microsoft Certified Solution Developers. Ah the 90s...he probably still has a "Members Only" jacket. He is certified to teach firearms classes in Oklahoma and holds a black belt in mixed martial arts.