Created
November 20, 2025 08:58
-
-
Save RunasSudo/7eb153d0adc0a42a1a43264d273faaeb to your computer and use it in GitHub Desktop.
Decrypts a password-protected Microsoft Access database (using Agile Encryption)
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 | |
| # Copyright (C) 2025 Lee Yingtong Li | |
| # | |
| # This program is free software: you can redistribute it and/or modify | |
| # it under the terms of the GNU Affero General Public License as published by | |
| # the Free Software Foundation, either version 3 of the License, or | |
| # (at your option) any later version. | |
| # | |
| # This program is distributed in the hope that it will be useful, | |
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| # GNU Affero General Public License for more details. | |
| # | |
| # You should have received a copy of the GNU Affero General Public License | |
| # along with this program. If not, see <https://www.gnu.org/licenses/>. | |
| import base64 | |
| from hashlib import sha512 | |
| import struct | |
| import sys | |
| import xml.etree.ElementTree as ET | |
| from Crypto.Cipher import AES, ARC4 | |
| # Get CLI args | |
| if len(sys.argv) < 4: | |
| print('Decrypts a password-protected Microsoft Access database (using Agile Encryption)') | |
| print() | |
| print('Usage: python decrypt_accdb.py /path/to/input.accdb /path/to/output.accdb password') | |
| input_filename = sys.argv[1] | |
| output_filename = sys.argv[2] | |
| db_password = sys.argv[3] | |
| # Constants, etc. | |
| PAGE_SIZE = 0x1000 | |
| class DatabaseFormatError(Exception): pass | |
| class DatabaseIntegrityError(Exception): pass | |
| class DecryptDatabaseError(Exception): pass | |
| with open(input_filename, 'rb') as input_file: | |
| # Read the header page | |
| header_page = input_file.read(PAGE_SIZE) | |
| # Validate the file format | |
| # https://web.archive.org/web/20230730041037/http://jabakobob.net/mdb/first-page.html | |
| # https://github.com/mdbtools/mdbtools/blob/dev/HACKING.md | |
| # Magic number for header page (database definition page) | |
| if header_page[0x0:0x4] != b'\x00\x01\x00\x00': | |
| raise DatabaseFormatError('Unexpected header page magic number - is this a Microsoft Access database?') | |
| # We only support the new ACE format | |
| if header_page[0x4:0x14] != b'Standard ACE DB\0': | |
| if header_page[0x4:0x14] == b'Standard Jet DB\0': | |
| raise NotImplementedError('Jet (MDB) database format is not supported') | |
| raise DatabaseFormatError('Unknown file format ID') | |
| # Decrypt encoding key with RC4 key | |
| # Pre-generate the cipher stream so we can easily reuse for decryption and encryption | |
| header_page_decrypted = bytearray(header_page) | |
| cipher = ARC4.new(b'\xc7\xda\x39\x6b') | |
| rc4_cipher_stream = cipher.decrypt(b'\0' * 0x80) | |
| header_page_decrypted[0x18:0x98] = bytes(a ^ b for a, b in zip(header_page[0x18:0x98], rc4_cipher_stream)) | |
| db_encoding_key = header_page_decrypted[0x3e:0x42] | |
| # Read EncryptionInfo stream | |
| # https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/87020a34-e73f-4139-99bc-bbdf6cf6fa55 | |
| (encryption_info_len,) = struct.unpack('<H', header_page[0x299:0x29b]) | |
| encryption_info = header_page[0x29b:0x29b+encryption_info_len] | |
| # Read EncryptionVersionInfo struct | |
| # https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/122d11d9-9aff-47bd-8ae8-2996bdda3bdd | |
| (v_major, v_minor) = struct.unpack('<HH', encryption_info[0x0:0x4]) | |
| # We only support Agile Encryption | |
| # https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/87020a34-e73f-4139-99bc-bbdf6cf6fa55 | |
| if v_major != 0x4 or v_minor != 0x4: | |
| if v_major == 0x1 and v_minor == 0x1: | |
| # https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/76aeedb0-4d59-487f-8bd8-fb6860a60df7 | |
| raise NotImplementedError('RC4 Encryption is not supported') | |
| if v_major in (0x2, 0x3, 0x4) and v_minor == 0x2: | |
| # https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/2895eba1-acb1-4624-9bde-2cdad3fea015 | |
| # https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/36cfb17f-9b15-4a9b-911a-f401f60b3991 | |
| raise NotImplementedError('Standard Encryption/RC4 CryptoAPI Encryption are not supported') | |
| if v_major in (0x3, 0x4) and v_minor == 0x3: | |
| # https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/a922e41e-63f2-4701-8521-7f5d221a7ce0 | |
| raise NotImplementedError('Extensible Encryption is not supported') | |
| raise DatabaseFormatError('Unknown encryption version - only Agile Encryption is supported') | |
| # Validate Reserved field | |
| if encryption_info[0x4:0x8] != b'\x40\x00\x00\x00': | |
| raise DatabaseFormatError('Invalid Reserved field of Agile Encryption EncryptionInfo') | |
| # Parse XmlEncryptionDescription | |
| xml_encryption_descriptor = ET.fromstring(encryption_info[0x8:].decode('utf-8')) | |
| # Check that there is one keyEncryptor and it is a password keyEncryptor | |
| key_encryptors = xml_encryption_descriptor.find('{http://schemas.microsoft.com/office/2006/encryption}keyEncryptors').findall('{http://schemas.microsoft.com/office/2006/encryption}keyEncryptor') | |
| if len(key_encryptors) != 1: | |
| raise NotImplementedError('More than 1 keyEncryptor is unsupported') | |
| key_encryptor = key_encryptors[0] | |
| if key_encryptor.get('uri') != 'http://schemas.microsoft.com/office/2006/keyEncryptor/password': | |
| raise NotImplementedError('Non-password keyEncryptor is unsupported') | |
| encrypted_key = key_encryptor.find('{http://schemas.microsoft.com/office/2006/keyEncryptor/password}encryptedKey') | |
| # Validate cipher for encrypted master key | |
| # Only AES-CBC with SHA512 is supported | |
| if encrypted_key.get('hashAlgorithm') != 'SHA512': | |
| raise NotImplementedError('Only SHA256 is supported') | |
| if encrypted_key.get('cipherAlgorithm') != 'AES': | |
| raise NotImplementedError('Only AES is supported') | |
| if encrypted_key.get('cipherChaining') != 'ChainingModeCBC': | |
| raise NotImplementedError('Only AES-CBC is supported') | |
| # Get cipher parameters for encrypted master key | |
| password_salt = base64.b64decode(encrypted_key.get('saltValue')) | |
| password_spin_count = int(encrypted_key.get('spinCount')) | |
| password_key_bits = int(encrypted_key.get('keyBits')) | |
| if str(len(password_salt)) != encrypted_key.get('saltSize'): | |
| raise DatabaseIntegrityError('Unexpected length of password saltValue') | |
| if password_key_bits % 8 != 0: | |
| raise DatabaseIntegrityError('Invalid number of password keyBits') | |
| def get_encryption_key_from_password(block_key): | |
| # Derives an AES-CBC encryption key from the password and block_key | |
| # https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/74d60145-a0f0-44be-99ce-c65d211b4eb7 | |
| # UTF-16LE as per https://sourceforge.net/p/jackcessencrypt/code/199/tree/trunk/src/main/java/com/healthmarketscience/jackcess/crypt/impl/OfficeCryptCodecHandler.java#l260 | |
| hash_n = sha512(password_salt + db_password.encode('utf-16le')).digest() | |
| for iterator in range(password_spin_count): | |
| iterator_bytes = struct.pack('<I', iterator) | |
| assert len(iterator_bytes) == 4 | |
| hash_n = sha512(iterator_bytes + hash_n).digest() | |
| hash_final = sha512(hash_n + block_key).digest() | |
| if len(hash_final) < password_key_bits//8: | |
| hash_final = hash_final + (b'0x36' * (password_key_bits//8 - len(hash_final))) | |
| if len(hash_final) > password_key_bits//8: | |
| hash_final = hash_final[:password_key_bits//8] | |
| return hash_final | |
| # Verify password | |
| # https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/a57cb947-554f-4e5e-b150-3f2978225e92 | |
| # encryptedVerifierHashInput | |
| cipher = AES.new(get_encryption_key_from_password(b'\xfe\xa7\xd2\x76\x3b\x4b\x9e\x79'), AES.MODE_CBC, iv=password_salt) | |
| verifier_hash_input = cipher.decrypt(base64.b64decode(encrypted_key.get('encryptedVerifierHashInput'))) | |
| # encryptedVerifierHashValue | |
| cipher = AES.new(get_encryption_key_from_password(b'\xd7\xaa\x0f\x6d\x30\x61\x34\x4e'), AES.MODE_CBC, iv=password_salt) | |
| verifier_hash_value = cipher.decrypt(base64.b64decode(encrypted_key.get('encryptedVerifierHashValue'))) | |
| # Verify the password | |
| if sha512(verifier_hash_input).digest() != verifier_hash_value: | |
| raise DecryptDatabaseError('Incorrect database password') | |
| # encryptedKeyValue | |
| cipher = AES.new(get_encryption_key_from_password(b'\x14\x6e\x0b\xe7\xab\xac\xd0\xd6'), AES.MODE_CBC, iv=password_salt) | |
| db_master_key = cipher.decrypt(base64.b64decode(encrypted_key.get('encryptedKeyValue'))) | |
| # Validate cipher for database encryption | |
| # Only AES-CBC with SHA512 is supported | |
| key_data = xml_encryption_descriptor.find('{http://schemas.microsoft.com/office/2006/encryption}keyData') | |
| if key_data.get('hashAlgorithm') != 'SHA512': | |
| raise NotImplementedError('Only SHA256 is supported') | |
| if key_data.get('cipherAlgorithm') != 'AES': | |
| raise NotImplementedError('Only AES is supported') | |
| if key_data.get('cipherChaining') != 'ChainingModeCBC': | |
| raise NotImplementedError('Only AES-CBC is supported') | |
| # Get cipher parameters for database encryption | |
| db_salt = base64.b64decode(key_data.get('saltValue')) | |
| db_block_size = int(key_data.get('blockSize')) | |
| if str(len(db_salt)) != key_data.get('saltSize'): | |
| raise DatabaseIntegrityError('Unexpected length of database saltValue') | |
| def get_iv(page_number): | |
| # Derives an AES-CBC IV from the page number | |
| # https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/11672b8c-b9f0-4bb7-9b2d-3f1695286436 | |
| # CONTRARY to https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/9e61da63-8ddb-4c0a-b25d-f85d990f44c8 we do not directly use the "zero-based segment number as a blockKey" | |
| # https://sourceforge.net/p/jackcessencrypt/code/199/tree/trunk/src/main/java/com/healthmarketscience/jackcess/crypt/impl/office/AgileEncryptionProvider.java#l135 | |
| page_number_bytes = struct.pack('<I', page_number) | |
| block_key = bytes((a ^ b for a, b in zip(page_number_bytes, db_encoding_key))) | |
| iv = sha512(db_salt + block_key).digest() | |
| if len(iv) < db_block_size: | |
| iv = iv + (b'\x36' * (db_block_size - len(iv))) | |
| if len(iv) > db_block_size: | |
| iv = iv[:db_block_size] | |
| return iv | |
| # Decrypt data! | |
| with open(output_filename, 'wb') as output_file: | |
| # Generate a new header page with encryption flags unset | |
| header_page_no_encryption = bytearray(header_page) | |
| # Set the bytes at 0x3e:0x42 to the equivalent bytes from RC4 cipherstream | |
| # So that when XORed, the bytes are zeroed - removing the encryption | |
| header_page_no_encryption[0x3e:0x42] = rc4_cipher_stream[0x3e-0x18:0x42-0x18] | |
| output_file.write(header_page_no_encryption) | |
| # Decrypt the subsequent pages | |
| page_num = 0 | |
| while True: | |
| page = input_file.read(PAGE_SIZE) | |
| page_num += 1 | |
| if not page: | |
| # End of file | |
| break | |
| # https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/9e61da63-8ddb-4c0a-b25d-f85d990f44c8 | |
| cipher = AES.new(db_master_key, AES.MODE_CBC, iv=get_iv(page_num)) | |
| payload = cipher.decrypt(page) | |
| # Payload is just the raw Access data | |
| output_file.write(payload) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment