Skip to content

Commit 7c62937

Browse files
Merge pull request #28 from doomedraven/another_dec
another decryptor
2 parents 487a1b9 + 7eb85aa commit 7c62937

File tree

2 files changed

+158
-0
lines changed

2 files changed

+158
-0
lines changed

src/rat_king_parser/config_parser/utils/decryptors/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2525
# SOFTWARE.
2626
from .config_decryptor import ConfigDecryptor, IncompatibleDecryptorException
27+
from .config_decryptor_aes_with_iv_pbkdf2 import ConfigDecryptorAESWithIV_pbkdf
2728
from .config_decryptor_aes_with_iv import ConfigDecryptorAESWithIV
2829
from .config_decryptor_decrypt_xor import ConfigDecryptorDecryptXOR
2930
from .config_decryptor_ecb import ConfigDecryptorECB
@@ -34,6 +35,7 @@
3435
ConfigDecryptor,
3536
IncompatibleDecryptorException,
3637
ConfigDecryptorAESWithIV,
38+
ConfigDecryptorAESWithIV_pbkdf,
3739
ConfigDecryptorECB,
3840
ConfigDecryptorDecryptXOR,
3941
ConfigDecryptorRandomHardcoded,
@@ -43,6 +45,7 @@
4345
# ConfigDecryptorPlaintext should always be the last fallthrough case
4446
SUPPORTED_DECRYPTORS = [
4547
ConfigDecryptorAESWithIV,
48+
ConfigDecryptorAESWithIV_pbkdf,
4649
ConfigDecryptorECB,
4750
ConfigDecryptorDecryptXOR,
4851
ConfigDecryptorRandomHardcoded,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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

Comments
 (0)