|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# |
| 3 | +# config_decryptor_aes_with_iv_bpkdf2 |
| 4 | +# |
| 5 | +# Author: doomedraven |
| 6 | +# |
| 7 | +# MIT License |
| 8 | +# |
| 9 | +# Copyright (c) 2025 Jeff Archer |
| 10 | +# |
| 11 | +# Permission is hereby granted, free of charge, to any person obtaining a copy |
| 12 | +# of this software and associated documentation files (the "Software"), to deal |
| 13 | +# in the Software without restriction, including without limitation the rights |
| 14 | +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 15 | +# copies of the Software, and to permit persons to whom the Software is |
| 16 | +# furnished to do so, subject to the following conditions: |
| 17 | +# |
| 18 | +# The above copyright notice and this permission notice shall be included in all |
| 19 | +# copies or substantial portions of the Software. |
| 20 | +# |
| 21 | +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 22 | +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 23 | +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 24 | +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 25 | +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 26 | +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 27 | +# SOFTWARE. |
| 28 | +import re |
| 29 | +from base64 import b64decode |
| 30 | +from logging import getLogger, DEBUG |
| 31 | + |
| 32 | +from Cryptodome.Cipher import AES |
| 33 | +from Cryptodome.Cipher.AES import MODE_CBC as CBC |
| 34 | +from Cryptodome.Protocol.KDF import PBKDF2 |
| 35 | +from Cryptodome.Util.Padding import unpad |
| 36 | + |
| 37 | +from ...config_parser_exception import ConfigParserException |
| 38 | +from ..data_utils import bytes_to_int, decode_bytes |
| 39 | +from ..dotnetpe_payload import DotNetPEPayload |
| 40 | +from .config_decryptor import ConfigDecryptor, IncompatibleDecryptorException |
| 41 | + |
| 42 | +logger = getLogger(__name__) |
| 43 | + |
| 44 | + |
| 45 | +class ConfigDecryptorAESWithIV_pbkdf2(ConfigDecryptor): |
| 46 | + # Minimum length of valid ciphertext |
| 47 | + _MIN_CIPHERTEXT_LEN = 16 |
| 48 | + |
| 49 | + # Do not re.compile in-line replacement patterns |
| 50 | + _PATTERN_AES_SALT_ITER = re.compile(rb"\x72(.{4})\x1f(.)\x8d.{4}\x25\xd0(.{4})\x28") |
| 51 | + |
| 52 | + def __init__(self, payload: DotNetPEPayload) -> None: |
| 53 | + super().__init__(payload) |
| 54 | + try: |
| 55 | + self._get_aes_metadata() |
| 56 | + except Exception as e: |
| 57 | + raise IncompatibleDecryptorException(e) |
| 58 | + |
| 59 | + # Given an initialization vector and ciphertext, creates a Cipher |
| 60 | + # object with the AES key and specified IV and decrypts the ciphertext |
| 61 | + def _decrypt(self, iv: bytes, ciphertext: bytes) -> bytes: |
| 62 | + logger.debug( |
| 63 | + f"Decrypting {ciphertext} with key {self.key.hex()} and IV {iv.hex()}..." |
| 64 | + ) |
| 65 | + |
| 66 | + cipher = AES.new(self.key, mode=CBC, iv=iv) |
| 67 | + unpadded_text = "" |
| 68 | + |
| 69 | + try: |
| 70 | + unpadded_text = cipher.decrypt(ciphertext) |
| 71 | + unpadded_text = unpad(unpadded_text, AES.block_size) |
| 72 | + except Exception as e: |
| 73 | + logger.debug(ciphertext) |
| 74 | + raise ConfigParserException( |
| 75 | + f"Error decrypting ciphertext with IV {iv.hex()} and key {self.key.hex()} : {e}" |
| 76 | + ) |
| 77 | + logger.debug(f"Decryption result: {unpadded_text}") |
| 78 | + return unpadded_text |
| 79 | + |
| 80 | + # Decrypts encrypted config values with the provided cipher data |
| 81 | + def decrypt_encrypted_strings( |
| 82 | + self, encrypted_strings: dict[str, str] |
| 83 | + ) -> dict[str, str]: |
| 84 | + logger.debug("Decrypting encrypted strings...") |
| 85 | + decrypted_config_strings = {} |
| 86 | + for k, v in encrypted_strings.items(): |
| 87 | + # Leave empty strings as they are |
| 88 | + if len(v) == 0: |
| 89 | + logger.debug(f"Key: {k}, Value: {v}") |
| 90 | + decrypted_config_strings[k] = v |
| 91 | + continue |
| 92 | + |
| 93 | + # Check if base64-encoded string |
| 94 | + b64_exception = False |
| 95 | + try: |
| 96 | + decoded_val = b64decode(v) |
| 97 | + except Exception: |
| 98 | + b64_exception = True |
| 99 | + # If it was not base64-encoded, or if it is less than our min length |
| 100 | + # for ciphertext, leave the value as it is |
| 101 | + if b64_exception or len(decoded_val) < self._MIN_CIPHERTEXT_LEN: |
| 102 | + logger.debug(f"Key: {k}, Value: {v}") |
| 103 | + decrypted_config_strings[k] = v |
| 104 | + continue |
| 105 | + |
| 106 | + result, last_exc = None, None |
| 107 | + # Run through key candidates until suitable one found or failure |
| 108 | + |
| 109 | + try: |
| 110 | + result = decode_bytes(self._decrypt(self.iv, decoded_val)) |
| 111 | + except ConfigParserException as e: |
| 112 | + last_exc = e |
| 113 | + print("error", e) |
| 114 | + |
| 115 | + if result is None: |
| 116 | + logger.debug( |
| 117 | + f"Decryption failed for item {v}: {last_exc}; Leaving as original value..." |
| 118 | + ) |
| 119 | + result = v |
| 120 | + |
| 121 | + logger.debug(f"Key: {k}, Value: {result}") |
| 122 | + decrypted_config_strings[k] = result |
| 123 | + |
| 124 | + logger.debug("Successfully decrypted strings") |
| 125 | + return decrypted_config_strings |
| 126 | + |
| 127 | + # Identifies the initialization of the AES256 object in the payload and |
| 128 | + # sets the necessary values needed for decryption |
| 129 | + def _get_aes_metadata(self) -> None: |
| 130 | + logger.debug("Extracting AES metadata...") |
| 131 | + # Some payloads have multiple embedded salt values: |
| 132 | + # Find the one that is actually used for initialization |
| 133 | + for candidate in re.finditer(self._PATTERN_AES_SALT_ITER, self._payload.data): |
| 134 | + password, size, salt_rva = candidate.groups() |
| 135 | + |
| 136 | + try: |
| 137 | + self.salt = self._get_aes_salt(salt_rva, int.from_bytes(size)) |
| 138 | + password = self._payload.user_string_from_rva(bytes_to_int(password)) |
| 139 | + key = PBKDF2(password, self.salt, dkLen=48) |
| 140 | + self.iv = key[32:] |
| 141 | + self.key = key[:32] |
| 142 | + except ConfigParserException as cfe: |
| 143 | + logger.info( |
| 144 | + f"Initialization using salt candidate {hex(bytes_to_int(candidate.groups()[0]))} failed: {cfe}" |
| 145 | + ) |
| 146 | + continue |
| 147 | + |
| 148 | + # Extracts the AES salt from the payload, accounting for both hardcoded |
| 149 | + # salt byte arrays, and salts derived from hardcoded strings |
| 150 | + def _get_aes_salt(self, salt_rva: int, salt_size: int) -> bytes: |
| 151 | + logger.debug("Extracting AES salt value...") |
| 152 | + salt_strings_rva = bytes_to_int(salt_rva) |
| 153 | + salt = self._payload.byte_array_from_size_and_rva(salt_size, salt_strings_rva) |
| 154 | + logger.debug(f"Found salt value: {salt.hex()}") |
| 155 | + return salt |
0 commit comments