Found another new sample on MalwareBazaar (sha256:93c1733e9d5d2ecfc6e742308ef02d52e644f36867d2718015e90e966ff30ec4) which I took a look at.
What caught my attention was the encryption they place on their strings so I went deep-diving into it. Found out, how it decrypts the strings using an RSA-decryption with the corresponding private key in every file hardcoded for its’ strings.
What was strange was that many resulting strings were base64 which made no sense so I kept going…obviously xD
Further investigation brought me to an AES-decryption which used exactly these base64 strings. So I went for a hunt for interesting dotnet-dll calls like Connect() or similar on Sockets and usages of variables which sound familiar like ‘Host’ and ‘Port’ which I also successfully found.
I could encrypt the 2 places which are used for C2 communication and payload delivery.

I’ll post the scripts I did for it and later add a deeper dive into it, how I found out everything.
RSA decryption:
import base64
import os
import re
import traceback
import xml.etree.ElementTree as ET
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey.RSA import RsaKey
def rsa_from_xml(xml_str: str) -> RSA.RsaKey:
"""
Convert Microsoft-style RSA XML key into a PyCryptodome RSA key.
"""
root = ET.fromstring(xml_str)
def b64_to_int(tag):
node = root.find(tag)
if node is None:
return None
return int.from_bytes(base64.b64decode(node.text), byteorder="big")
n = b64_to_int("Modulus")
e = b64_to_int("Exponent")
d = b64_to_int("D")
p = b64_to_int("P")
q = b64_to_int("Q")
# Construct private key
key = RSA.construct((n, e, d, p, q))
return key
def decrypt_with_xml_key(rsa_key: RsaKey, ciphertext_b64: str) -> str:
cipher = PKCS1_OAEP.new(rsa_key) # <-- OAEP padding
ciphertext = base64.b64decode(ciphertext_b64)
plaintext = cipher.decrypt(ciphertext)
return plaintext.decode("utf-8")
def extract_rsa_key(file_content: str) -> str:
"""Extract the XML key from a line like `string text = "…";`"""
match = re.search(r'string\s+text\s*=\s*"(.+?)";', file_content, re.DOTALL)
if match:
return match.group(1)
return None
def extract_b64_matches(file_content: str) -> list:
"""Extract all first parameters from decrypt_rsa calls."""
# first renamed all the decryption functions of each class so all can be handled the same
return re.findall(r'decrypt_rsa\("([A-Za-z0-9+/=]+)"', file_content)
def process_directory(directory: str):
with open(os.path.join(directory, "outputs.txt"), "w") as file:
encryptions = []
for root_dir, _, files in os.walk(directory):
for filename in files:
filepath = os.path.join(root_dir, filename)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
# Extract RSA key
raw_xml = extract_rsa_key(content)
if not raw_xml:
continue
xml_key = raw_xml.replace(r'\r\n', '\n').replace(r'\"', '"')
rsa_key = rsa_from_xml(xml_key)
# Extract all base64 ciphertexts
b64_strings = extract_b64_matches(content)
for b64 in b64_strings:
try:
plaintext = decrypt_with_xml_key(rsa_key, b64)
print(f"{filename}: {plaintext}")
encryptions.append(f"{b64} => {plaintext}")
except Exception as e:
# print(f"{filename}: Failed to decrypt {b64} -> {e}")
continue
file.write("\n".join(encryptions))
if __name__ == "__main__":
directory_path = "/investigation/encryptedClasses/"
process_directory(directory_path)
After this there was a file which held the values. To then find out the URL’s and connections, I went manually searching for the parameter which was used in the connecting functions and pulled the base64values from the RSA encrypted strings file.
For the AES decryption, the algorithm took the first 16bytes of the contributed base64 string which is the IV key. The key itself was hardcoded and was always “8521”.
Here’s the code for the additional decryption logic:
import base64
from hashlib import md5
from Crypto.Cipher import AES
def decrypt_strings(input_b64: str) -> str:
key = "8521"
# Decode input
input_bytes = base64.b64decode(input_b64)
# Extract IV (first 16 bytes)
iv = input_bytes[:16]
# AES ciphertext is the rest
ciphertext = input_bytes[16:]
# Generate MD5 key from key
key = md5(key.encode("utf-8")).digest()
# Create AES cipher in CBC mode
cipher = AES.new(key, AES.MODE_CBC, iv)
# Decrypt
decrypted = cipher.decrypt(ciphertext)
# Remove PKCS7 padding
pad_len = decrypted[-1]
if isinstance(pad_len, str):
pad_len = ord(pad_len)
decrypted = decrypted[:-pad_len]
return decrypted.decode("utf-8")
if __name__ == "__main__":
encrypted_b64 = [
"wUjRMYQeV86c+SlSnhRPFtcnggAbEdBGZUf6yYk5oqEJwpvaRx9Zez6m1wBASwQ3rY1sGcFQiTMMREYvu3nuiA==",
"bMqWKzId9jnW0+0GXWoj2M2Z0C4+m9QTTdXGDruV8ms=",
"ENKRphQh63tdspRDhQHw1LUx6GmnDtaINPoXI3r4yvY=",
"t04Wloctcf6qW6eZRwdM2dqVCLKz76PBjxoliI4Lr8Y=",
"FRviEC0n/N4+2AwLUvqsG68H4biL3/f1MGMfyvTFpzc=",
"DJAkTf6YraQfNl5HPDYcvXX2I4UmS1KisMXADhVQdu4cPCmGbCd0+U54XoDOJVB87elmOHYRHnOy445t1QBagJWULRN/nPu7JMpekGaamn1CYpWtlRHcBuWGsjMkpp+KGICYlmHHc8EbDsrczyXkDTQAgvs6+k9CfLXkvni1iEVtGM5gI8AApqq7gsJSUjHPyVSU6zfOu0KHN6UGp2nlmigUEPzXmhtvh5M3CNFZuKbKjU4XP1Ib5WPtie/R8VYEtMB6gxDu/XOy8CMZEJ7fZ5DSr/ff8qt+3pPCVw2vtZRhsBmbOvr+4oYmO1PbaI6K9iFE5BTA9vcCwRauYrYfw9J5PnE8HHrTeF5UCVFRG7s6yePKfwVDTS5QbuE1DDBHiAL/NrXfPetShMV5nMI8b5cb+L+VZt5p6pyeOEnjLtKY3X7DtJxTa85cvmQvZJEYkMECNgXP19SqIKi63bTKkb9EyD+zaahlR4w6mJMoncYFGqNHWb24G+qOWTWb6zab00sM/HfIU3GAuTjaStFfmFGEME76rJkVCQwtapD8cdOA67TbBbNo56NCT4CjVZ9Yr7EgQZM5zpU14PtwO0Ix5i502RznAkls+4tR8FGyghiqQmS65LXGGY3EG4E02UnRNPnNOi9VCNAImX6GiQYXL/+Sm3nikIeMJi4Ho9ApQ7I="
]
for val in encrypted_b64:
plaintext = decrypt_strings(val)
print(plaintext)
The hardcoded b64 strings are the one’s extracted from the code. the first 5 represent a payload delivery url. and the last is a ‘|’ separated string where element at [2] and [3] are used and represent Host:port.
IOCs
| Type | IOC |
| Delivery URL | hxxp[://]68[.]154[.]1[.]115/CS/index[.]php?VS=V1[.]0&PL=SIM |
| C2 URL | cabunca.mypets.ws:443 |
Seeya next time!
Take care.
R4ruk
